Skip to content

Commit 9dbb15a

Browse files
Aprasaksclaude
andcommitted
feat: 메인페이지 리디자인 — 인트로 텍스트 + 파티클 + 배경음악 (#51)
- HeroSection 제거, 미니멀 인트로 텍스트 중앙 배치 - 금빛 파티클 애니메이션 (ParticleCanvas) - 배경음악 전역 토글 (MusicProvider + MusicToggle) - 헤더 메뉴 폰트 크기·색상 개선, 모바일 동기화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 43e7e5e commit 9dbb15a

8 files changed

Lines changed: 228 additions & 39 deletions

File tree

public/audio/background-music.mp3

18.9 MB
Binary file not shown.

src/app/layout.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import Script from 'next/script';
55
import './globals.css';
66
import Header from '@/components/layout/Header';
77
import Footer from '@/components/layout/Footer';
8+
import ParticleCanvas from '@/components/home/ParticleCanvas';
9+
import { MusicProvider } from '@/components/layout/MusicProvider';
810

911
const geistSans = Geist({
1012
variable: '--font-geist-sans',
@@ -76,16 +78,19 @@ export default function RootLayout({
7678
alt=""
7779
fill
7880
sizes="100vw"
79-
className="object-cover blur-xs brightness-[0.35]"
81+
className="object-cover brightness-[0.55] blur-[1px]"
8082
priority
8183
/>
8284
<div className="absolute inset-y-0 left-0 w-32 bg-linear-to-r from-black to-transparent" />
8385
<div className="absolute inset-y-0 right-0 w-32 bg-linear-to-l from-black to-transparent" />
8486
</div>
8587
</div>
86-
<Header />
87-
{children}
88-
<Footer />
88+
<MusicProvider>
89+
<ParticleCanvas />
90+
<Header />
91+
{children}
92+
<Footer />
93+
</MusicProvider>
8994
</body>
9095
</html>
9196
);

src/app/page.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
11
import type { Metadata } from 'next';
2-
import HeroSection from '@/components/home/hero-section';
2+
import Link from 'next/link';
33

44
export const metadata: Metadata = {
55
alternates: { canonical: 'https://dechive.dev' },
66
};
77

88
export default function Home() {
99
return (
10-
<main className="relative w-full px-4 sm:px-6 overflow-hidden">
11-
<div className="flex items-center min-h-[calc(100vh-64px)]">
12-
<HeroSection />
10+
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-20 text-center">
11+
<div className="flex flex-col items-center gap-8">
12+
{/* 구분선 */}
13+
<div className="h-px w-10 bg-zinc-700" />
14+
15+
{/* 인트로 */}
16+
<h1 className="text-3xl font-extrabold leading-snug tracking-tight sm:text-4xl lg:text-5xl">
17+
<span className="text-zinc-400">생각이 기록이 되는 순간</span><br />
18+
<span className="text-white">의미를 가진다.</span>
19+
</h1>
20+
21+
{/* 구분선 */}
22+
<div className="h-px w-10 bg-zinc-700" />
23+
24+
{/* 서고 입장 */}
25+
<Link
26+
href="/archive"
27+
className="rounded-full border border-zinc-600 px-8 py-2.5 text-sm font-medium text-zinc-300 backdrop-blur-sm transition-all hover:border-zinc-400 hover:text-white active:scale-95"
28+
>
29+
서고 입장 →
30+
</Link>
1331
</div>
1432
</main>
1533
);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use client';
2+
3+
import { useEffect, useRef } from 'react';
4+
5+
interface Particle {
6+
x: number;
7+
y: number;
8+
size: number;
9+
speedY: number;
10+
speedX: number;
11+
opacity: number;
12+
opacitySpeed: number;
13+
}
14+
15+
function createParticle(width: number, height: number): Particle {
16+
return {
17+
x: Math.random() * width,
18+
y: Math.random() * height - height,
19+
size: Math.random() * 1.5 + 0.5,
20+
speedY: Math.random() * 0.4 + 0.1,
21+
speedX: (Math.random() - 0.5) * 0.2,
22+
opacity: 0,
23+
opacitySpeed: Math.random() * 0.003 + 0.001,
24+
};
25+
}
26+
27+
export default function ParticleCanvas() {
28+
const canvasRef = useRef<HTMLCanvasElement>(null);
29+
30+
useEffect(() => {
31+
const canvas = canvasRef.current;
32+
if (!canvas) return;
33+
const ctx = canvas.getContext('2d');
34+
if (!ctx) return;
35+
36+
let animId: number;
37+
const PARTICLE_COUNT = 60;
38+
const particles: Particle[] = [];
39+
40+
function resize() {
41+
if (!canvas) return;
42+
canvas.width = window.innerWidth;
43+
canvas.height = window.innerHeight;
44+
}
45+
46+
resize();
47+
window.addEventListener('resize', resize);
48+
49+
for (let i = 0; i < PARTICLE_COUNT; i++) {
50+
const p = createParticle(canvas.width, canvas.height);
51+
p.y = Math.random() * canvas.height; // 초기엔 화면 전체에 분산
52+
particles.push(p);
53+
}
54+
55+
function draw() {
56+
if (!canvas || !ctx) return;
57+
58+
ctx.clearRect(0, 0, canvas.width, canvas.height);
59+
60+
for (const p of particles) {
61+
p.y += p.speedY;
62+
p.x += p.speedX;
63+
p.opacity = Math.min(p.opacity + p.opacitySpeed, 0.55);
64+
65+
// 화면 밖으로 나가면 위에서 다시
66+
if (p.y > canvas.height + 10) {
67+
const fresh = createParticle(canvas.width, canvas.height);
68+
Object.assign(p, fresh);
69+
}
70+
71+
ctx.beginPath();
72+
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
73+
ctx.fillStyle = `rgba(220, 185, 120, ${p.opacity})`;
74+
ctx.fill();
75+
}
76+
77+
animId = requestAnimationFrame(draw);
78+
}
79+
80+
draw();
81+
82+
return () => {
83+
cancelAnimationFrame(animId);
84+
window.removeEventListener('resize', resize);
85+
};
86+
}, []);
87+
88+
return (
89+
<canvas
90+
ref={canvasRef}
91+
className="pointer-events-none fixed inset-0 z-10"
92+
/>
93+
);
94+
}

src/components/home/hero-section.tsx

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,56 @@
11
import Link from 'next/link';
22
import Image from 'next/image';
3-
import { getAllPosts } from '@/lib/posts';
43

54
export default function HeroSection() {
6-
const totalPosts = getAllPosts('ko').length;
7-
85
return (
9-
<section className="relative w-full overflow-hidden rounded-3xl border border-white/10 px-12 py-28 md:px-20 md:py-36">
6+
<section className="relative w-full overflow-hidden rounded-3xl">
107
{/* 배경 이미지 */}
118
<div className="pointer-events-none absolute inset-0 z-0 select-none">
129
<Image
1310
src="/images/coded-library.webp"
1411
alt=""
1512
fill
1613
sizes="100vw"
17-
className="object-cover object-bottom brightness-[0.55]"
14+
className="object-cover object-center brightness-[0.75]"
1815
priority
19-
quality={75}
16+
quality={80}
2017
/>
21-
{/* 텍스트 가독성 오버레이 */}
22-
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-transparent" />
23-
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
18+
<div className="absolute inset-0 bg-gradient-to-r from-black/70 via-black/40 to-black/20" />
19+
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950/90 via-transparent to-transparent" />
2420
</div>
2521

2622
{/* 콘텐츠 */}
27-
<div className="relative z-20 flex flex-col gap-8 max-w-3xl">
28-
<p className="text-xs font-semibold tracking-[0.2em] text-zinc-500 uppercase">
29-
Demian
30-
</p>
31-
32-
<h1 className="text-4xl font-extrabold leading-tight tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl">
33-
지식은 기록하는 순간<br />
34-
<span className="text-zinc-400">가치가 됩니다.</span>
35-
</h1>
23+
<div className="relative z-20 flex min-h-[500px] flex-col justify-center gap-12 px-10 py-16 md:flex-row md:items-center md:justify-between md:px-16 md:py-20">
3624

37-
<p className="text-sm leading-relaxed text-zinc-400 max-w-md">
38-
기술·철학·사유의 흔적을 정제하고 기록하는 무한서고.
39-
<span className="ml-2 text-zinc-600">{totalPosts}편 수록</span>
40-
</p>
25+
{/* 헤드라인 */}
26+
<div className="flex flex-col gap-4">
27+
{/* 앰버 액센트 라인 */}
28+
<div className="h-px w-12" style={{ background: 'linear-gradient(to right, #c9963a, transparent)' }} />
29+
<h1 className="text-4xl font-extrabold leading-tight tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl">
30+
지식은 기록하는 순간<br />
31+
<span className="text-zinc-300">가치가 됩니다.</span>
32+
</h1>
33+
</div>
4134

42-
<div className="flex items-center gap-8 pt-2">
35+
{/* 버튼 */}
36+
<div className="flex shrink-0 flex-col gap-3">
37+
{/* 메인 버튼 — 황금빛 앰버 */}
4338
<Link
4439
href="/archive"
45-
className="text-sm font-medium text-zinc-200 transition-colors hover:text-white"
40+
className="inline-flex items-center justify-center rounded-xl px-10 py-4 text-sm font-bold text-zinc-950 whitespace-nowrap transition-all hover:brightness-110 active:scale-95"
41+
style={{ background: 'linear-gradient(135deg, #d4a843, #c9963a)' }}
4642
>
4743
서고 입장 →
4844
</Link>
45+
{/* 서브 버튼 — 유리 */}
4946
<Link
5047
href="/about"
51-
className="text-sm text-zinc-500 transition-colors hover:text-zinc-300"
48+
className="inline-flex items-center justify-center rounded-xl border border-white/20 bg-white/10 px-10 py-4 text-sm font-medium text-zinc-200 backdrop-blur-md transition-all hover:bg-white/15 hover:border-white/30 hover:text-white whitespace-nowrap"
5249
>
5350
소개 보기
5451
</Link>
5552
</div>
53+
5654
</div>
5755
</section>
5856
);

src/components/layout/Header.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Link from 'next/link';
66
import { usePathname } from 'next/navigation';
77
import { Menu, X } from 'lucide-react';
88
import LangToggle from './LangToggle';
9+
import MusicToggle from './MusicToggle';
910

1011
const NAV_ITEMS = [
1112
{ name: 'Archive', href: '/archive' },
@@ -41,10 +42,10 @@ export default function Header() {
4142
<li key={item.name}>
4243
<Link
4344
href={item.href}
44-
className={`text-sm font-medium tracking-tight transition-colors ${
45+
className={`text-base font-semibold tracking-tight transition-colors ${
4546
pathname === item.href
46-
? 'text-zinc-100'
47-
: 'text-zinc-500 hover:text-zinc-100'
47+
? 'text-white'
48+
: 'text-zinc-300 hover:text-white'
4849
}`}
4950
>
5051
{item.name}
@@ -56,8 +57,10 @@ export default function Header() {
5657

5758
{/* 우측: 언어 토글 + 햄버거 */}
5859
<div className="flex h-8 items-center justify-end gap-4" style={{ minWidth: '7rem' }}>
59-
<div className="hidden md:flex">
60+
<div className="hidden md:flex items-center gap-3">
6061
<LangToggle />
62+
<span className="text-zinc-700">·</span>
63+
<MusicToggle />
6164
</div>
6265
<button
6366
className="relative z-[110] text-zinc-500 transition-colors hover:text-zinc-100 md:hidden"
@@ -90,10 +93,14 @@ export default function Header() {
9093
</Link>
9194
))}
9295

93-
{/* 구분선 + 언어 토글 */}
96+
{/* 구분선 + 언어 토글 + 음악 */}
9497
<div className="flex flex-col items-center gap-4">
9598
<div className="h-px w-12 bg-zinc-800" />
96-
<LangToggle />
99+
<div className="flex items-center gap-3">
100+
<LangToggle />
101+
<span className="text-zinc-700">·</span>
102+
<MusicToggle />
103+
</div>
97104
</div>
98105
</nav>
99106
</div>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client';
2+
3+
import { createContext, useContext, useEffect, useRef, useState } from 'react';
4+
5+
interface MusicContextValue {
6+
playing: boolean;
7+
toggle: () => void;
8+
}
9+
10+
const MusicContext = createContext<MusicContextValue>({ playing: false, toggle: () => {} });
11+
12+
export function MusicProvider({ children }: { children: React.ReactNode }) {
13+
const audioRef = useRef<HTMLAudioElement | null>(null);
14+
const [playing, setPlaying] = useState(false);
15+
16+
useEffect(() => {
17+
const audio = new Audio('/audio/background-music.mp3');
18+
audio.loop = true;
19+
audio.volume = 0.3;
20+
audioRef.current = audio;
21+
22+
return () => {
23+
audio.pause();
24+
audio.src = '';
25+
};
26+
}, []);
27+
28+
function toggle() {
29+
const audio = audioRef.current;
30+
if (!audio) return;
31+
if (playing) {
32+
audio.pause();
33+
setPlaying(false);
34+
} else {
35+
audio.play();
36+
setPlaying(true);
37+
}
38+
}
39+
40+
return (
41+
<MusicContext.Provider value={{ playing, toggle }}>
42+
{children}
43+
</MusicContext.Provider>
44+
);
45+
}
46+
47+
export function useMusicContext() {
48+
return useContext(MusicContext);
49+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use client';
2+
3+
import { Volume2, VolumeX } from 'lucide-react';
4+
import { useMusicContext } from './MusicProvider';
5+
6+
export default function MusicToggle() {
7+
const { playing, toggle } = useMusicContext();
8+
9+
return (
10+
<button
11+
onClick={toggle}
12+
aria-label={playing ? '음악 끄기' : '음악 켜기'}
13+
className={`transition-colors ${playing ? 'text-zinc-100' : 'text-zinc-600 hover:text-zinc-400'}`}
14+
>
15+
{playing ? <Volume2 size={15} /> : <VolumeX size={15} />}
16+
</button>
17+
);
18+
}

0 commit comments

Comments
 (0)