diff --git a/app/(main)/activities/page.tsx b/app/(main)/activities/page.tsx index 2a55d16..f34f085 100644 --- a/app/(main)/activities/page.tsx +++ b/app/(main)/activities/page.tsx @@ -1,106 +1,45 @@ import type { Metadata } from "next"; import { SessionsSection } from "@/components/activities/sessions-section"; import { PageHeader } from "@/components/layout/page-header"; +import { activities } from "@/lib/activities"; import { getSessions } from "@/lib/data"; export const metadata: Metadata = { - title: "Activities", - description: - "정기 세션, 팀 연습, 대회 참가로 이루어진 모르고리즘의 활동을 소개합니다.", + title: "활동", + description: "정기 세션, 팀 연습, 대회 참가로 이루어진 모르고리즘의 활동을 소개합니다.", }; -const activities = [ - { - number: "01", - label: "WEEKLY SESSION", - title: "정기 세션", - description: - "매주 진행되는 알고리즘 세션. 난이도별 트랙으로 나뉘어 문제를 풀고, 풀이를 공유하고 리뷰합니다.", - details: [ - { label: "주기", value: "매주" }, - { label: "형식", value: "주제별 발표와 문제풀이 리뷰" }, - { label: "주제", value: "DP, 그래프, 문자열, 수학 등" }, - ], - }, - { - number: "02", - label: "TEAM PRACTICE", - title: "팀 연습", - description: - "ICPC 대회를 대비한 3인 1팀 연습. 실전과 동일한 환경에서 팀 커뮤니케이션과 전략을 다듬습니다.", - details: [ - { label: "주기", value: "매주 토요일" }, - { label: "구분", value: "Div 1, Div 2 구분 운영" }, - { label: "문제 셋", value: "Codeforces, AtCoder 기출 활용" }, - ], - }, - { - number: "03", - label: "COMPETITIONS", - title: "대회 참가", - description: - "ICPC, SCPC, UCPC 등 국내외 주요 프로그래밍 대회에 참가하며 실전 경험을 쌓습니다.", - details: [{ label: "주요 대회", value: "ICPC, SUAPC, SCPC, UCPC" }], - }, -]; - export default function ActivitiesPage() { const sessions = getSessions(); return ( -
+
- - {activities.map((item, index) => ( -
-
-
- - {item.number} - - - {item.label} - -

- {item.title} -

+
+ {activities.map((activity) => ( +
+
+ {activity.eyebrow} +

{activity.title}

-
-

- {item.description} -

-
- {item.details.map((detail) => ( -
-

- {detail.label} -

-

- {detail.value} -

+
+

{activity.description}

+
+ {activity.details.map((detail) => ( +
+
{detail.label}
+
{detail.value}
))} -
+
-
-
- ))} - +
+ ))} +
); diff --git a/app/(main)/awards/page.tsx b/app/(main)/awards/page.tsx index 11749ae..5582b4a 100644 --- a/app/(main)/awards/page.tsx +++ b/app/(main)/awards/page.tsx @@ -4,26 +4,22 @@ import { PageHeader } from "@/components/layout/page-header"; import { getAwards, getAwardStats } from "@/lib/data"; export const metadata: Metadata = { - title: "Awards", - description: - "모르고리즘 동아리원들이 국내외 알고리즘 대회에서 거둔 수상 기록입니다.", + title: "수상 기록", + description: "모르고리즘 동아리원들이 국내외 알고리즘 대회에서 거둔 수상 기록입니다.", }; export default function AwardsPage() { const awards = getAwards(); const stats = getAwardStats(); - const meta = stats.years - ? `${stats.total} RECORDS // ${stats.years.min}–${stats.years.max}` - : undefined; + const meta = stats.years ? `${stats.total} records · ${stats.years.min}–${stats.years.max}` : undefined; return ( -
+
diff --git a/app/(main)/donate/page.tsx b/app/(main)/donate/page.tsx index 5b01036..afba342 100644 --- a/app/(main)/donate/page.tsx +++ b/app/(main)/donate/page.tsx @@ -1,37 +1,38 @@ import type { Metadata } from "next"; +import { PageHeader } from "@/components/layout/page-header"; import { DonationCard } from "@/components/main/donation-card"; export const metadata: Metadata = { - title: "Donate", + title: "후원", description: "모르고리즘 후원 안내입니다.", }; export default function DonatePage() { const donationAccount = process.env.DONATION_ACCOUNT?.trim(); - const accountHolder = - process.env.DONATION_ACCOUNT_HOLDER?.trim() || "모르고리즘"; + const accountHolder = process.env.DONATION_ACCOUNT_HOLDER?.trim() || "모르고리즘"; const contactUrl = process.env.DONATION_CONTACT_URL?.trim(); return ( - ); } diff --git a/app/(main)/join/page.tsx b/app/(main)/join/page.tsx index 0fb8d80..ae5deda 100644 --- a/app/(main)/join/page.tsx +++ b/app/(main)/join/page.tsx @@ -1,9 +1,10 @@ import type { Metadata } from "next"; import { Clock, ExternalLink } from "lucide-react"; +import { PageHeader } from "@/components/layout/page-header"; import { Button } from "@/components/ui/button"; export const metadata: Metadata = { - title: "Join", + title: "지원", description: "모르고리즘 신입 동아리원 모집 안내입니다.", }; @@ -26,62 +27,39 @@ export default function JoinPage() { const joinFormUrl = process.env.JOIN_FORM_URL?.trim(); return ( -
-
-
- - 05 // RECRUITMENT - -

- Join the -
- Monolith. -

-
- {sections.map((section, index) => ( -
-

- {section.title} -

-

{section.body}

-
- ))} -
+
+ +
+
+ {sections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))}
- -
-

지원하기

-

- 아래 버튼을 눌러 구글폼 지원서를 작성해 주세요. 간단한 자기소개와 - 알고리즘 관심 분야를 적어 주시면 됩니다. -

+
+
+ Apply +

지원서 작성

+

+ 구글폼으로 받습니다. 간단한 자기소개와 알고리즘 관심 분야를 적어 주시면 됩니다. +

+
{joinFormUrl ? ( - ) : ( -
+
)} -

- 모집 기간이 아닌 경우 다음 모집 시 안내드리겠습니다. -

+

모집 기간이 아니면 다음 모집 때 안내드립니다.

diff --git a/app/(main)/members/page.tsx b/app/(main)/members/page.tsx index c2acc05..b7620e4 100644 --- a/app/(main)/members/page.tsx +++ b/app/(main)/members/page.tsx @@ -1,17 +1,13 @@ import type { Metadata } from "next"; import { PageHeader } from "@/components/layout/page-header"; import { MemberRow } from "@/components/members/member-row"; -import { - getMemberAwardCount, - getMemberByHandle, - getRosters, -} from "@/lib/data"; +import { getMemberAwardCount, getMemberByHandle, getRosters } from "@/lib/data"; import type { Roster } from "@/lib/schema"; import { getSolvedacProfiles, type SolvedacProfile } from "@/lib/solvedac"; export const metadata: Metadata = { - title: "Members", - description: "모르고리즘 동아리원 명단입니다.", + title: "운영진", + description: "모르고리즘을 운영하는 사람들입니다.", }; function semesterTitle(roster: Roster): string { @@ -21,35 +17,26 @@ function semesterTitle(roster: Roster): string { export default async function MembersPage() { const rosters = getRosters(); const [current, ...past] = rosters; - const profiles = current - ? await getSolvedacProfiles(current.handles) - : new Map(); + const profiles = current ? await getSolvedacProfiles(current.handles) : new Map(); return ( -
+
- {!current && ( -

- 아직 등록된 동아리원이 없습니다. -

- )} + {!current &&

아직 등록된 운영진이 없습니다.

} {current && ( -
-

- 현재 동아리원 - - {semesterTitle(current)} - +
+

+ 현재 운영진 + {semesterTitle(current)}

-
+
{current.handles.map((handle) => { const member = getMemberByHandle(handle); if (!member) return null; @@ -67,27 +54,19 @@ export default async function MembersPage() { )} {past.length > 0 && ( -
-

이전 학기

-
+
+

이전 운영진

+
{past.map((roster) => (
-

- {semesterTitle(roster)} - {roster.handles.length}명 +

+ {semesterTitle(roster)} · {roster.handles.length}명

-
+
{roster.handles.map((handle) => { const member = getMemberByHandle(handle); if (!member) return null; - return ( - - ); + return ; })}
diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx index fc02d82..85d434e 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -1,21 +1,35 @@ -import { AboutSection } from "@/components/main/about-section"; -import { GalleryCarouselSection } from "@/components/main/gallery-carousel-section"; +import { ActivitiesSection } from "@/components/main/activities-section"; import { HeroSection } from "@/components/main/hero-section"; +import { JoinBand } from "@/components/main/join-band"; +import { MomentsSection } from "@/components/main/moments-section"; import { RecentAwardsSection } from "@/components/main/recent-awards-section"; -import { getMembers, getRecentAwards } from "@/lib/data"; +import { StatsBand } from "@/components/main/stats-band"; +import { + getAllHandles, + getAwardStats, + getPeopleCount, + getRecentAwards, +} from "@/lib/data"; import { getSolvedacProfiles, sumSolvedCount } from "@/lib/solvedac"; export default async function MainPage() { - const profiles = await getSolvedacProfiles( - getMembers().map((member) => member.handle) - ); + const handles = getAllHandles(); + const profiles = await getSolvedacProfiles(handles); + const stats = getAwardStats(); return (
- - - - + + + + + +
); } diff --git a/app/fonts.ts b/app/fonts.ts new file mode 100644 index 0000000..0a42f00 --- /dev/null +++ b/app/fonts.ts @@ -0,0 +1,18 @@ +import { Archivo, Noto_Sans_KR } from "next/font/google"; + +/** 영문 워드마크, 숫자, 라벨용. 폭을 125%로 넓혀 쓴다(globals.css의 .wide). */ +export const archivo = Archivo({ + variable: "--font-archivo", + subsets: ["latin"], + weight: "variable", + axes: ["wdth"], + display: "swap", +}); + +/** 한글 제목과 본문. */ +export const notoSansKr = Noto_Sans_KR({ + variable: "--font-noto-sans-kr", + subsets: ["latin"], + weight: ["400", "500", "700"], + display: "swap", +}); diff --git a/app/globals.css b/app/globals.css index 19375b0..8193b05 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,8 +2,6 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@custom-variant dark (&:is(.dark *)); - @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -23,157 +21,77 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - - /* Custom surface colors from design */ + --color-chart-1: var(--primary); + --color-chart-2: var(--balloon-yellow); + --color-chart-3: var(--ac); + --color-chart-4: var(--outline); + --color-chart-5: var(--foreground); + --color-sidebar: var(--surface); + --color-sidebar-foreground: var(--foreground); + --color-sidebar-primary: var(--primary); + --color-sidebar-primary-foreground: var(--primary-foreground); + --color-sidebar-accent: var(--surface-2); + --color-sidebar-accent-foreground: var(--foreground); + --color-sidebar-border: var(--border); + --color-sidebar-ring: var(--ring); + + /* 사이트 고유 토큰 */ --color-surface: var(--surface); - --color-surface-container: var(--surface-container); - --color-surface-container-low: var(--surface-container-low); - --color-surface-container-high: var(--surface-container-high); - --color-on-surface-variant: var(--on-surface-variant); - --color-outline: var(--outline-color); - --color-outline-variant: var(--outline-variant); - --color-tertiary: var(--tertiary); - - --font-sans: var(--app-font-sans); - --font-headline: "Manrope", var(--app-font-sans); - --font-label: "Inter", var(--app-font-sans); - --font-mono: var(--font-geist-mono); - - --radius-sm: 0px; - --radius-md: 0px; - --radius-lg: 0px; - --radius-xl: 0px; - - --shadow-2xs: 0 1px 3px 0px rgb(0 0 0 / 0.05); - --shadow-xs: 0 1px 3px 0px rgb(0 0 0 / 0.05); - --shadow-sm: 0 1px 3px 0px rgb(0 0 0 / 0.10), 0 1px 2px -1px rgb(0 0 0 / 0.10); - --shadow: 0 1px 3px 0px rgb(0 0 0 / 0.10), 0 1px 2px -1px rgb(0 0 0 / 0.10); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); - --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); - --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + --color-surface-2: var(--surface-2); + --color-sky-top: var(--sky-top); + --color-outline: var(--outline); + --color-balloon-blue: var(--balloon-blue); + --color-balloon-yellow: var(--balloon-yellow); + --color-ac: var(--ac); + + --font-sans: var(--font-noto-sans-kr), "Apple SD Gothic Neo", "Pretendard", sans-serif; + --font-display: var(--font-archivo), "Helvetica Neue", Arial, sans-serif; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 24px; } :root { - --app-font-sans: "Manrope", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; - - /* Light editorial palette */ - --tertiary: #d48a00; - --background: #f5f5f5; - --foreground: #111111; + /* 하늘색에서 흰색으로 내려가는 밝은 화면. 잉크는 짙은 네이비. */ + --background: #ffffff; + --foreground: #0f1a2e; --card: #ffffff; - --card-foreground: #111111; + --card-foreground: #0f1a2e; --popover: #ffffff; - --popover-foreground: #111111; - --primary: #0077a3; + --popover-foreground: #0f1a2e; + --primary: #1e6fd9; --primary-foreground: #ffffff; - --secondary: #e8e8e8; - --secondary-foreground: #005f82; - --muted: #e8e8e8; - --muted-foreground: #5a6a70; - --accent: #e8e8e8; - --accent-foreground: #334b52; - --destructive: #c0392b; - --border: rgba(60, 73, 78, 0.15); - --input: rgba(60, 73, 78, 0.15); - --ring: #0077a3; - --chart-1: #0077a3; - --chart-2: #005f82; - --chart-3: #d48a00; - --chart-4: #b87200; - --chart-5: #00a8e0; - --radius: 0px; - - --surface: #f5f5f5; - --surface-container: #ffffff; - --surface-container-low: #efefef; - --surface-container-high: #e0e0e0; - --on-surface-variant: #334b52; - --outline-color: #7a8d93; - --outline-variant: #c0ccce; - - --sidebar: #efefef; - --sidebar-foreground: #111111; - --sidebar-primary: #0077a3; - --sidebar-primary-foreground: #ffffff; - --sidebar-accent: #ffffff; - --sidebar-accent-foreground: #111111; - --sidebar-border: rgba(60, 73, 78, 0.15); - --sidebar-ring: #0077a3; -} - -.dark { - /* Dark editorial palette — #131313 base, #9de2ff primary, #ffab27 tertiary */ - --tertiary: #ffab27; - --background: #131313; - --foreground: #e2e2e2; - --card: #1f1f1f; - --card-foreground: #e2e2e2; - --popover: #1f1f1f; - --popover-foreground: #e2e2e2; - --primary: #9de2ff; - --primary-foreground: #003545; - --secondary: #1b1b1b; - --secondary-foreground: #9dcee3; - --muted: #1b1b1b; - --muted-foreground: #869399; - --accent: #1b1b1b; - --accent-foreground: #bbc8d0; - --destructive: #ffb4ab; - --border: rgba(60, 73, 78, 0.2); - --input: rgba(60, 73, 78, 0.3); - --ring: #9de2ff; - --chart-1: #9de2ff; - --chart-2: #9dcee3; - --chart-3: #ffd098; - --chart-4: #ffb95a; - --chart-5: #5ed4ff; - - --surface: #131313; - --surface-container: #1f1f1f; - --surface-container-low: #1b1b1b; - --surface-container-high: #2a2a2a; - --on-surface-variant: #bbc8d0; - --outline-color: #869399; - --outline-variant: #3c494e; - - --sidebar: #1b1b1b; - --sidebar-foreground: #e2e2e2; - --sidebar-primary: #9de2ff; - --sidebar-primary-foreground: #003545; - --sidebar-accent: #1f1f1f; - --sidebar-accent-foreground: #e2e2e2; - --sidebar-border: rgba(60, 73, 78, 0.2); - --sidebar-ring: #9de2ff; + --secondary: #edf3fc; + --secondary-foreground: #0f1a2e; + --muted: #f6f9fe; + --muted-foreground: #4a5a75; + --accent: #edf3fc; + --accent-foreground: #0f1a2e; + --destructive: #d7263d; + --border: #e4eaf3; + --input: #d9e2ef; + --ring: #1e6fd9; + --radius: 12px; + + --surface: #f6f9fe; + --surface-2: #edf3fc; + --sky-top: #e3eeff; + --outline: #8a97ad; + --balloon-blue: #1e6fd9; + --balloon-yellow: #ffd23f; + --ac: #34c759; } -::-webkit-scrollbar { - width: 4px; +/* Archivo 가변 폰트를 넓게 쓴다. 영문 워드마크와 라벨에 붙인다. */ +@utility wide { + font-stretch: 125%; } -::-webkit-scrollbar-track { - background: var(--background); -} - -::-webkit-scrollbar-thumb { - background: var(--outline-variant); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--primary); +::selection { + background-color: var(--balloon-yellow); + color: var(--foreground); } html { @@ -186,12 +104,125 @@ html { } body { - font-family: "Manrope", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; - @apply bg-background text-foreground; + @apply bg-background text-foreground font-sans antialiased; + } +} + +/* ───────────────────────────────────────────────────────────── + Liquid Glass + 애플 HIG 기준: 콘텐츠 위에 떠 있는 "네비게이션 레이어"에만 쓴다. + 콘텐츠(목록, 표, 카드, 사진)에는 쓰지 않고, 유리 위에 유리를 겹치지 않는다. + 변형은 둘. Regular는 기본 UI 표면, Clear는 사진과 3D 위의 작은 컨트롤. + ───────────────────────────────────────────────────────────── */ +@layer components { + .glass { + position: relative; + isolation: isolate; + -webkit-backdrop-filter: blur(16px) saturate(180%); + backdrop-filter: blur(16px) saturate(180%); + background: rgb(255 255 255 / 0.55); + border: 1px solid rgb(255 255 255 / 0.65); + /* 위쪽 안쪽 선이 유리 두께, 아래 그림자가 떠 있는 느낌을 만든다. */ + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.85), + inset 0 -1px 0 rgb(15 26 46 / 0.04), + 0 8px 24px rgb(15 26 46 / 0.1); + } + + /* 가장자리에서 빛이 걸리는 스페큘러 림. */ + .glass::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + z-index: 0; + background: linear-gradient( + 140deg, + rgb(255 255 255 / 0.7) 0%, + rgb(255 255 255 / 0) 32%, + rgb(255 255 255 / 0) 68%, + rgb(255 255 255 / 0.45) 100% + ); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + padding: 1px; + } + + .glass > * { + position: relative; + z-index: 1; + } + + /* Clear. 사진이나 3D 위에 얹는 작은 컨트롤 전용. + 투명도가 높은 대신 글자가 묻히지 않도록 어두운 디밍 레이어를 함께 깐다. */ + .glass-clear { + -webkit-backdrop-filter: blur(10px) saturate(200%) brightness(1.06); + backdrop-filter: blur(10px) saturate(200%) brightness(1.06); + background: rgb(255 255 255 / 0.3); + border-color: rgb(255 255 255 / 0.55); + } + + /* 틴트. 애플 지침대로 주요 액션 하나에만 쓴다. */ + .glass-tint { + background: rgb(15 26 46 / 0.88); + border-color: rgb(255 255 255 / 0.28); + color: #ffffff; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.32), + 0 8px 24px rgb(15 26 46 / 0.24); + } + + .glass-tint::before { + background: linear-gradient( + 140deg, + rgb(255 255 255 / 0.45) 0%, + rgb(255 255 255 / 0) 34%, + rgb(255 255 255 / 0) 66%, + rgb(255 255 255 / 0.22) 100% + ); + } + + /* 굴절. backdrop-filter의 url() 참조는 Chrome 계열만 지원한다. + 지원하지 않는 브라우저는 위의 흐림만 보이고 레이아웃은 같다. */ + @supports (backdrop-filter: url(#glass-regular)) { + .glass { + -webkit-backdrop-filter: blur(14px) saturate(180%) url(#glass-regular); + backdrop-filter: blur(14px) saturate(180%) url(#glass-regular); + } + .glass-clear { + -webkit-backdrop-filter: blur(8px) saturate(200%) brightness(1.06) url(#glass-clear); + backdrop-filter: blur(8px) saturate(200%) brightness(1.06) url(#glass-clear); + } + } + + /* 투명도 줄이기를 켠 사람에게는 불투명한 면으로 바꾼다. + 애플도 이 설정에서 유리를 불투명하게 처리한다. */ + @media (prefers-reduced-transparency: reduce) { + .glass { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: #ffffff; + border-color: var(--border); + } + .glass::before { + display: none; + } + .glass-clear { + background: #ffffff; + } + .glass-tint { + background: var(--foreground); + } } - ::selection { - background-color: var(--primary); - color: var(--primary-foreground); + /* 대비 높이기에서는 테두리를 또렷하게. */ + @media (prefers-contrast: more) { + .glass { + background: rgb(255 255 255 / 0.92); + border-color: var(--foreground); + } } } diff --git a/app/layout.tsx b/app/layout.tsx index 0ebc763..3b2bcad 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,29 +1,11 @@ import type { Metadata } from "next"; -import { Manrope, Inter } from "next/font/google"; -import { Geist_Mono } from "next/font/google"; -import { ThemeProvider } from "@/components/layout/theme-provider"; +import { GlassFilters } from "@/components/ui/glass-filters"; +import { archivo, notoSansKr } from "@/app/fonts"; import { siteUrl } from "@/lib/site"; import "./globals.css"; -const manrope = Manrope({ - variable: "--font-manrope", - subsets: ["latin"], - weight: ["200", "400", "700", "800"], -}); - -const inter = Inter({ - variable: "--font-inter", - subsets: ["latin"], - weight: ["400", "700"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - const description = - "연세대학교 알고리즘 동아리 모르고리즘입니다. 문제 해결, 팀 연습, 대회 참가를 통해 함께 성장합니다."; + "연세대학교 알고리즘 동아리 모르고리즘입니다. 매주 모여 문제를 풀고, 팀으로 대회에 나갑니다."; export const metadata: Metadata = { metadataBase: new URL(siteUrl()), @@ -47,20 +29,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - {children} - + + + + {children} ); diff --git a/app/not-found.tsx b/app/not-found.tsx index 2d0047c..9438993 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -7,22 +7,13 @@ export default function NotFound() { return (
-
- - 404 // NOT FOUND - -

- Wrong Answer. -

-

- 요청하신 페이지를 찾을 수 없습니다. 주소를 다시 확인하거나 홈으로 - 돌아가 주세요. +

+ 404 +

Wrong Answer.

+

+ 요청하신 페이지를 찾을 수 없습니다. 주소를 다시 확인하거나 홈으로 돌아가 주세요.

-
diff --git a/components/activities/sessions-section.tsx b/components/activities/sessions-section.tsx index a25b4ab..52c4f14 100644 --- a/components/activities/sessions-section.tsx +++ b/components/activities/sessions-section.tsx @@ -1,58 +1,37 @@ import { ExternalLink } from "lucide-react"; import type { Session } from "@/lib/schema"; -const COLUMNS = ["Date", "Topic", "Presenter", "Link"]; +const COLUMNS = ["날짜", "주제", "발제", "자료"]; export function SessionsSection({ sessions }: { sessions: Session[] }) { if (sessions.length === 0) return null; return ( -
- - SESSION LOG - -

- 정기 세션 기록 -

+
+
+ Session log +

정기 세션 기록

+
- + {COLUMNS.map((column) => ( - + ))} {sessions.map((session) => ( - - + + - + diff --git a/components/awards/awards-explorer.tsx b/components/awards/awards-explorer.tsx index 71a1d63..1f37f40 100644 --- a/components/awards/awards-explorer.tsx +++ b/components/awards/awards-explorer.tsx @@ -1,13 +1,13 @@ "use client"; import { useState, type ReactNode } from "react"; -import { Button } from "@/components/ui/button"; import { groupAwardsByYear, type CompetitionCount } from "@/lib/awards"; import type { Award } from "@/lib/schema"; import { profileUrl } from "@/lib/solvedac"; +import { cn } from "@/lib/utils"; const ALL = "all"; -const COLUMNS = ["Event", "Award", "Name", "Team"]; +const COLUMNS = ["대회", "상", "이름", "팀"]; type AwardsExplorerProps = { awards: Award[]; @@ -16,27 +16,16 @@ type AwardsExplorerProps = { export function AwardsExplorer({ awards, competitions }: AwardsExplorerProps) { const [selected, setSelected] = useState(ALL); - const filtered = - selected === ALL - ? awards - : awards.filter((award) => award.competition === selected); + const filtered = selected === ALL ? awards : awards.filter((award) => award.competition === selected); const groups = groupAwardsByYear(filtered); if (awards.length === 0) { - return ( -

- 아직 등록된 수상 기록이 없습니다. -

- ); + return

아직 등록된 수상 기록이 없습니다.

; } return ( -
-
+
+
setSelected(ALL)}> 전체 {awards.length} @@ -46,72 +35,59 @@ export function AwardsExplorer({ awards, competitions }: AwardsExplorerProps) { active={selected === competition.name} onClick={() => setSelected(competition.name)} > - {competition.name}{" "} - {competition.count} + {competition.name} {competition.count} ))}
- {groups.length === 0 && ( -

해당 대회의 수상 기록이 없습니다.

- )} + {groups.length === 0 &&

해당 대회의 수상 기록이 없습니다.

} - {groups.map((group) => ( - - ))} +
+ {groups.map((group) => ( + + ))} +
); } -function FilterChip({ - active, - onClick, - children, -}: { - active: boolean; - onClick: () => void; - children: ReactNode; -}) { +function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) { return ( - + ); } function YearTable({ year, awards }: { year: number; awards: Award[] }) { const headingId = `awards-${year}`; return ( -
-

+
+

{year}

- {column} - {column}
{session.date}
{session.date} {session.topic} - {session.presenter} - {session.presenter} {session.link && ( - + )}
- - - + + + - + {COLUMNS.map((column) => ( - ))} @@ -119,27 +95,20 @@ function YearTable({ year, awards }: { year: number; awards: Award[] }) { {awards.map((award) => ( - + - + - ))} diff --git a/components/hero/balloon-hero.tsx b/components/hero/balloon-hero.tsx new file mode 100644 index 0000000..bc16dba --- /dev/null +++ b/components/hero/balloon-hero.tsx @@ -0,0 +1,80 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { archivo } from "@/app/fonts"; + +const BalloonScene = dynamic(() => import("@/components/hero/balloon-scene"), { + ssr: false, + loading: () => , +}); + +/** WebGL이 준비되기 전이나 쓸 수 없을 때 보이는 정지 화면. */ +function StaticWordmark() { + return ( + + ); +} + +const subscribe = () => () => {}; + +function useMountedAndMotion() { + const mounted = useSyncExternalStore(subscribe, () => true, () => false); + const [reduced, setReduced] = useState(false); + const [narrow, setNarrow] = useState(false); + useEffect(() => { + const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const width = window.matchMedia("(max-width: 768px)"); + const update = () => { + setReduced(motion.matches); + setNarrow(width.matches); + }; + update(); + motion.addEventListener("change", update); + width.addEventListener("change", update); + return () => { + motion.removeEventListener("change", update); + width.removeEventListener("change", update); + }; + }, []); + return { mounted, reduced, narrow }; +} + +export function BalloonHero() { + const { mounted, reduced, narrow } = useMountedAndMotion(); + const [solved, setSolved] = useState(0); + const fontFamily = archivo.style.fontFamily; + + return ( + <> +
+ {mounted ? ( + setSolved((n) => n + 1)} + /> + ) : ( + + )} +
+ {!reduced && ( +
+
+ )} + + ); +} diff --git a/components/hero/balloon-scene.tsx b/components/hero/balloon-scene.tsx new file mode 100644 index 0000000..0a6cb0c --- /dev/null +++ b/components/hero/balloon-scene.tsx @@ -0,0 +1,402 @@ +"use client"; + +import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber"; +import { useEffect, useMemo, useRef, useState } from "react"; +import * as THREE from "three"; + +type Kind = { glass: boolean; color: number }; + +// 대부분 유리, 두 개만 색이 있는 광택 풍선. +const KINDS: Kind[] = [ + { glass: true, color: 0xffffff }, + { glass: false, color: 0x1e6fd9 }, + { glass: true, color: 0xcfe3ff }, + { glass: true, color: 0xbff0e6 }, + { glass: false, color: 0xffd23f }, + { glass: true, color: 0xe6dcff }, + { glass: true, color: 0xffffff }, + { glass: true, color: 0xfff3c4 }, + { glass: true, color: 0xffd9e0 }, + { glass: true, color: 0xcfe3ff }, +]; + +// 로고타입 위에 흩어 놓는 자리. 가운데는 살짝 비워 글자가 보이게. +const SLOTS: [number, number, number][] = [ + [-6.2, 1.4, 0.5], + [-3.6, -0.9, 1.2], + [-1.4, 2.2, -0.8], + [0.6, -0.2, 1.8], + [2.4, 2.4, 0.2], + [4.4, -1.0, 0.9], + [6.4, 1.2, -0.4], + [-4.8, 3.2, -1.6], + [3.8, 3.4, -1.4], + [-0.8, -2.6, -0.6], +]; + +const WORD_PLANE_Z = -2.5; +const WORD_CANVAS = { width: 4096, height: 720 }; +const WORD_RATIO = WORD_CANVAS.height / WORD_CANVAS.width; + +function gradientCanvas(width: number, height: number, top: string, bottom: string) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return canvas; + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, top); + gradient.addColorStop(1, bottom); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + return canvas; +} + +/** 물방울 모양 풍선. 구를 위아래로 늘리고 아래를 살짝 좁힌다. */ +function makeBalloonGeometry() { + const geometry = new THREE.SphereGeometry(1, 72, 56); + const position = geometry.attributes.position; + for (let i = 0; i < position.count; i++) { + const x = position.getX(i); + const y = position.getY(i); + const z = position.getZ(i); + const k = y < 0 ? 1 + y * 0.22 : 1 + y * 0.04; + position.setXYZ(i, x * k, y * 1.18, z * k); + } + geometry.computeVertexNormals(); + return geometry; +} + +function makeStringGeometry() { + const points: THREE.Vector3[] = []; + for (let s = 0; s <= 8; s++) { + const t = s / 8; + points.push( + new THREE.Vector3( + Math.sin(t * 3.1) * 0.18 * t, + -1.3 - t * 3.2, + Math.cos(t * 2.3) * 0.12 * t + ) + ); + } + return new THREE.TubeGeometry(new THREE.CatmullRomCurve3(points), 24, 0.014, 6, false); +} + +/** 배경 그라데이션과 환경맵. 유리가 이 배경을 굴절시키고 광택에 하늘이 비친다. */ +function SceneSetup() { + const background = useMemo(() => { + const texture = new THREE.CanvasTexture(gradientCanvas(4, 512, "#E3EEFF", "#FFFFFF")); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; + }, []); + const environment = useMemo(() => { + const face = (top: string, bottom: string) => gradientCanvas(64, 64, top, bottom); + const texture = new THREE.CubeTexture([ + face("#ffffff", "#a9c8f5"), + face("#ffffff", "#a9c8f5"), + face("#ffffff", "#ffffff"), + face("#8db7f0", "#4d86d8"), + face("#ffffff", "#a9c8f5"), + face("#ffffff", "#a9c8f5"), + ]); + texture.needsUpdate = true; + texture.colorSpace = THREE.SRGBColorSpace; + return texture; + }, []); + useEffect( + () => () => { + background.dispose(); + environment.dispose(); + }, + [background, environment] + ); + return ( + <> + + + + ); +} + +/** 뒤쪽 판에 그린 MOLGORITHM 로고타입. 화면 폭의 92%에 맞춘다. */ +function drawWordmark(fontFamily: string): THREE.CanvasTexture { + const canvas = document.createElement("canvas"); + canvas.width = WORD_CANVAS.width; + canvas.height = WORD_CANVAS.height; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#B9D3F7"; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + const stretchable = ctx as CanvasRenderingContext2D & { fontStretch?: string }; + if ("fontStretch" in stretchable) stretchable.fontStretch = "expanded"; + ctx.font = `600 560px ${fontFamily}, "Helvetica Neue", Arial, sans-serif`; + const text = "MOLGORITHM"; + const measured = ctx.measureText(text).width; + const scale = Math.min(1, 3900 / measured); + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.scale(scale, scale); + ctx.fillText(text, 0, 20); + ctx.restore(); + } + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 8; + return texture; +} + +function Wordmark({ fontFamily }: { fontFamily: string }) { + const camera = useThree((state) => state.camera); + const viewport = useThree((state) => state.viewport); + const size = useThree((state) => state.size); + const [texture, setTexture] = useState(null); + + useEffect(() => { + let cancelled = false; + let current: THREE.CanvasTexture | null = null; + const apply = () => { + if (cancelled) return; + current?.dispose(); + current = drawWordmark(fontFamily); + setTexture(current); + }; + const fonts = document.fonts; + const ready = fonts?.load ? fonts.load(`600 100px ${fontFamily}`) : Promise.resolve(); + ready.then(apply, apply); + fonts?.ready.then(apply, () => {}); + return () => { + cancelled = true; + current?.dispose(); + }; + }, [fontFamily]); + + const width = useMemo(() => { + const current = viewport.getCurrentViewport(camera, new THREE.Vector3(0, 0.35, WORD_PLANE_Z), size); + return current.width * 0.92; + }, [camera, viewport, size]); + + if (!texture) return null; + return ( + + + + + ); +} + +/** 마우스 위치에 따라 카메라를 살짝 움직인다. */ +function Rig({ reduced }: { reduced: boolean }) { + useFrame((state) => { + if (reduced) return; + const camera = state.camera; + camera.position.x += (state.pointer.x * 0.9 - camera.position.x) * 0.04; + camera.position.y += (0.2 - state.pointer.y * 0.6 - camera.position.y) * 0.04; + camera.lookAt(0, 0.2, 0); + }); + return null; +} + +/** 인덱스에서 0~1 사이 값을 만든다. 렌더마다 같은 값이 나와야 하므로 Math.random을 쓰지 않는다. */ +function seeded(index: number, salt: number): number { + const x = Math.sin(index * 127.1 + salt * 311.7) * 43758.5453; + return x - Math.floor(x); +} + +type BalloonProps = { + index: number; + kind: Kind; + slot: [number, number, number]; + reduced: boolean; + geometry: THREE.BufferGeometry; + knotGeometry: THREE.BufferGeometry; + stringGeometry: THREE.BufferGeometry; + stringMaterial: THREE.Material; + onPop: () => void; +}; + +function Balloon({ index, kind, slot, reduced, geometry, knotGeometry, stringGeometry, stringMaterial, onPop }: BalloonProps) { + const group = useRef(null); + const motion = useRef({ + phase: seeded(index, 1) * Math.PI * 2, + speed: 0.5 + seeded(index, 2) * 0.4, + amp: 0.25 + seeded(index, 3) * 0.25, + rot: (seeded(index, 4) - 0.5) * 0.3, + scale: 0.75 + seeded(index, 5) * 0.55, + pop: 0, + rising: false, + }); + + const material = useMemo(() => { + if (kind.glass) { + return new THREE.MeshPhysicalMaterial({ + color: kind.color, + roughness: 0.02, + metalness: 0, + transmission: 0.985, + thickness: 1.6, + ior: 1.5, + clearcoat: 1, + clearcoatRoughness: 0.03, + attenuationColor: new THREE.Color(kind.color), + attenuationDistance: 6, + iridescence: 0.28, + iridescenceIOR: 1.3, + iridescenceThicknessRange: [120, 420], + envMapIntensity: 1.9, + specularIntensity: 1.1, + }); + } + return new THREE.MeshPhysicalMaterial({ + color: kind.color, + roughness: 0.16, + metalness: 0, + clearcoat: 1, + clearcoatRoughness: 0.08, + envMapIntensity: 1.2, + }); + }, [kind]); + useEffect(() => () => material.dispose(), [material]); + + useEffect(() => { + group.current?.scale.setScalar(motion.current.scale); + }, []); + + useFrame((state) => { + const g = group.current; + if (!g) return; + const m = motion.current; + const t = state.clock.elapsedTime; + if (m.pop > 0) { + m.pop += 0.08; + g.scale.setScalar(Math.max(0, 1 - m.pop) * m.scale); + if (m.pop >= 1) { + m.pop = 0; + m.rising = true; + g.position.y = -9; + g.scale.setScalar(m.scale * 0.2); + } + return; + } + if (m.rising) { + g.position.y += 0.06; + const k = Math.min(1, g.scale.x / m.scale + 0.03); + g.scale.setScalar(k * m.scale); + if (g.position.y >= slot[1]) { + g.position.y = slot[1]; + m.rising = false; + } + return; + } + if (reduced) return; + g.position.y = slot[1] + Math.sin(t * m.speed + m.phase) * m.amp; + g.position.x = slot[0] + Math.sin(t * m.speed * 0.6 + m.phase) * 0.12; + g.rotation.z = Math.sin(t * m.speed * 0.8 + m.phase) * 0.08 + m.rot * 0.3; + g.rotation.y = t * 0.15 + m.phase; + }); + + const handleClick = (event: ThreeEvent) => { + event.stopPropagation(); + const m = motion.current; + if (m.pop > 0 || m.rising) return; + m.pop = 1; + onPop(); + }; + + return ( + + + + + + ); +} + +function Balloons({ reduced, count, onPop }: { reduced: boolean; count: number; onPop: () => void }) { + const size = useThree((state) => state.size); + const ratio = size.width / size.height; + const scale = ratio < 1 ? 0.62 : ratio < 1.4 ? 0.8 : 1; + const geometry = useMemo(() => makeBalloonGeometry(), []); + const knotGeometry = useMemo(() => new THREE.ConeGeometry(0.16, 0.22, 24), []); + const stringGeometry = useMemo(() => makeStringGeometry(), []); + const stringMaterial = useMemo( + () => new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.55 }), + [] + ); + useEffect( + () => () => { + geometry.dispose(); + knotGeometry.dispose(); + stringGeometry.dispose(); + stringMaterial.dispose(); + }, + [geometry, knotGeometry, stringGeometry, stringMaterial] + ); + + return ( + + {SLOTS.slice(0, count).map((slot, index) => ( + + ))} + + ); +} + +type BalloonSceneProps = { + fontFamily: string; + reduced: boolean; + count?: number; + onPop: () => void; +}; + +export default function BalloonScene({ fontFamily, reduced, count = SLOTS.length, onPop }: BalloonSceneProps) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx index 4f6cadb..f8ecd82 100644 --- a/components/layout/footer.tsx +++ b/components/layout/footer.tsx @@ -1,47 +1,33 @@ import Link from "next/link"; const footerLinks = [ - { href: "/join", label: "JOIN", external: false }, - { href: "/donate", label: "DONATE", external: false }, - { href: "https://github.com/yonsei-molgorithm", label: "GITHUB", external: true }, + { href: "/join", label: "지원", external: false }, + { href: "/donate", label: "후원", external: false }, + { href: "https://github.com/yonsei-molgorithm", label: "GitHub", external: true }, ]; -const linkClass = - "font-label text-[10px] uppercase tracking-widest text-outline transition-colors hover:text-foreground"; +const linkClass = "text-sm text-muted-foreground transition-colors hover:text-foreground"; export function Footer() { return ( -
+
-
-
MOLGORITHM
-
-

- 서울특별시 서대문구 연세로 50 제1공학관 A527 -

-

- - yonseimolgorithm@gmail.com - -

+
+ MOLGORITHM +
+ 서울특별시 서대문구 연세로 50 제1공학관 A527 + + yonseimolgorithm@gmail.com +
-

- © {new Date().getFullYear()} MOLGORITHM. YONSEI ALGORITHM CLUB. +

+ © {new Date().getFullYear()} MOLGORITHM · YONSEI ALGORITHM CLUB

+ {column}
- - {award.month}월 + + {String(award.month).padStart(2, "0")} {award.competition} {award.award}{award.award} + {award.team} - {award.note && ( - - {award.note} - - )} + {award.note && {award.note}}