From 561feb58bc1d62a49851d62cdba1a73f704399e9 Mon Sep 17 00:00:00 2001 From: rammmaa Date: Sat, 5 Sep 2026 20:22:12 +0900 Subject: [PATCH 1/6] feat: light theme tokens with archivo wide and noto sans kr --- app/fonts.ts | 18 ++ app/globals.css | 210 ++++++------------- app/layout.tsx | 40 +--- components/layout/theme-provider.tsx | 11 - components/layout/theme-toggle.tsx | 49 ----- components/main/about-section.tsx | 61 ------ components/main/gallery-carousel-section.tsx | 78 ------- package.json | 3 + pnpm-lock.yaml | 190 ++++++++++++++++- 9 files changed, 275 insertions(+), 385 deletions(-) create mode 100644 app/fonts.ts delete mode 100644 components/layout/theme-provider.tsx delete mode 100644 components/layout/theme-toggle.tsx delete mode 100644 components/main/about-section.tsx delete mode 100644 components/main/gallery-carousel-section.tsx 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..5245d32 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; -} - -::-webkit-scrollbar { - width: 4px; + --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-track { - background: var(--background); +/* Archivo 가변 폰트를 넓게 쓴다. 영문 워드마크와 라벨에 붙인다. */ +@utility wide { + font-stretch: 125%; } -::-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,6 @@ html { } body { - font-family: "Manrope", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; - @apply bg-background text-foreground; - } - - ::selection { - background-color: var(--primary); - color: var(--primary-foreground); + @apply bg-background text-foreground font-sans antialiased; } } diff --git a/app/layout.tsx b/app/layout.tsx index 0ebc763..4d9d2d3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,29 +1,10 @@ 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 { 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,21 +28,8 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - {children} - - + + {children} ); } diff --git a/components/layout/theme-provider.tsx b/components/layout/theme-provider.tsx deleted file mode 100644 index 8613a5f..0000000 --- a/components/layout/theme-provider.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { ThemeProvider as NextThemesProvider } from "next-themes"; -import type { ComponentProps } from "react"; - -export function ThemeProvider({ - children, - ...props -}: ComponentProps) { - return {children}; -} diff --git a/components/layout/theme-toggle.tsx b/components/layout/theme-toggle.tsx deleted file mode 100644 index 3725969..0000000 --- a/components/layout/theme-toggle.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { useSyncExternalStore } from "react"; -import { Moon, Sun } from "lucide-react"; -import { useTheme } from "next-themes"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -const subscribe = () => () => {}; - -export function ThemeToggle({ className }: { className?: string }) { - const mounted = useSyncExternalStore( - subscribe, - () => true, - () => false - ); - const { resolvedTheme, setTheme } = useTheme(); - const isDark = resolvedTheme === "dark"; - - return ( - - ); -} diff --git a/components/main/about-section.tsx b/components/main/about-section.tsx deleted file mode 100644 index 7d324be..0000000 --- a/components/main/about-section.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Brain, Code2, Trophy, type LucideIcon } from "lucide-react"; - -const items = [ - { - Icon: Brain, - title: "생각한다", - description: - "알고리즘의 핵심은 사고입니다. 매주 정기 세션에서 문제를 분석하고, 최적의 접근법을 설계하며, 논리적 사고력을 키웁니다.", - }, - { - Icon: Code2, - title: "푼다", - description: - "생각을 코드로 구현합니다. 난이도별 트랙에서 문제를 풀고, 서로의 풀이를 리뷰하며 더 나은 해법을 찾아갑니다.", - }, - { - Icon: Trophy, - title: "경쟁한다", - description: - "ICPC, SCPC, UCPC 등 국내외 프로그래밍 대회에 팀을 이루어 참가합니다. 실전 경험을 통해 한계를 시험합니다.", - }, -] satisfies Array<{ - Icon: LucideIcon; - title: string; - description: string; -}>; - -export function AboutSection() { - return ( -
-
- - 01 // PHILOSOPHY - -

- 생각하고, -
- 풀고, 경쟁한다. -

-
-
- {items.map(({ Icon, title, description }, index) => ( -
-
- ))} -
-
- ); -} diff --git a/components/main/gallery-carousel-section.tsx b/components/main/gallery-carousel-section.tsx deleted file mode 100644 index bac8e7b..0000000 --- a/components/main/gallery-carousel-section.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, -} from "@/components/ui/carousel"; - -const galleryImages = [ - { - src: "/images/icpc-apac-2024.jpg", - alt: "2024 ICPC 아시아 태평양 챔피언십 무대 앞에서 포즈를 취한 동아리원 여섯 명", - caption: "2024 ICPC Asia Pacific Championship, 하노이", - }, - { - src: "/images/session-group.jpg", - alt: "강의실 화이트보드 앞에 모인 정기 세션 참가자들", - caption: "정기 세션 단체 사진", - }, - { - src: "/images/graduation-banner.jpg", - alt: "C++ 코드로 졸업을 축하하는 모르고리즘 현수막", - caption: "졸업 축하 현수막, graduation.cpp", - }, - { - src: "/images/yonsei-contest-poster.jpg", - alt: "모르고리즘이 출제한 연세대학교 프로그래밍 경시대회 포스터", - caption: "연세대학교 프로그래밍 경시대회 포스터", - }, -]; - -const carouselOptions = { align: "start", loop: true } as const; - -export function GalleryCarouselSection() { - return ( - - ); -} diff --git a/package.json b/package.json index cd07e82..79cc56d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@react-three/fiber": "^9.7.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", @@ -25,6 +26,7 @@ "react-dom": "19.2.3", "shadcn": "^3.8.5", "tailwind-merge": "^3.5.0", + "three": "^0.185.1", "tw-animate-css": "^1.4.0", "zod": "^4.5.4" }, @@ -34,6 +36,7 @@ "@types/papaparse": "^5.5.2", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "^0.185.4", "eslint": "^9", "eslint-config-next": "16.1.6", "papaparse": "^5.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c389cd..d75a7a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@base-ui/react': specifier: ^1.2.0 version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-three/fiber': + specifier: ^9.7.0 + version: 9.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -44,6 +47,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + three: + specifier: ^0.185.1 + version: 0.185.1 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -66,6 +72,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: ^0.185.4 + version: 0.185.4 eslint: specifier: ^9 version: 9.39.3(jiti@2.6.1)(supports-color@7.2.0) @@ -252,6 +261,9 @@ packages: '@types/react': optional: true + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dotenvx/dotenvx@1.52.0': resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} hasBin: true @@ -1511,6 +1523,31 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-three/fiber@9.7.0': + resolution: {integrity: sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + '@rolldown/binding-android-arm-eabi@1.2.6': resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1721,6 +1758,9 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1750,15 +1790,29 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/three@0.185.4': + resolution: {integrity: sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2075,6 +2129,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -2100,6 +2157,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2629,6 +2689,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -2835,6 +2898,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3046,6 +3112,11 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3317,6 +3388,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3700,6 +3774,15 @@ packages: '@types/react': optional: true + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -3978,6 +4061,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -3995,6 +4083,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4355,6 +4446,24 @@ packages: zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -4578,6 +4687,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@dimforge/rapier3d-compat@0.12.0': {} + '@dotenvx/dotenvx@1.52.0': dependencies: commander: 11.1.0 @@ -5758,6 +5869,26 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-three/fiber@9.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-use-measure: 2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.3) + three: 0.185.1 + use-sync-external-store: 1.6.0(react@19.2.3) + zustand: 5.0.15(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true @@ -5892,6 +6023,8 @@ snapshots: minimatch: 10.2.4 path-browserify: 1.0.1 + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -5922,14 +6055,31 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 + '@types/stats.js@0.17.4': {} + '@types/statuses@2.0.6': {} + '@types/three@0.185.4': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/validate-npm-package-name@4.0.2': {} + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5999,7 +6149,7 @@ snapshots: debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.4 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -6260,6 +6410,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.0: {} body-parser@2.2.2(supports-color@7.2.0): @@ -6297,6 +6449,11 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -6993,6 +7150,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -7191,6 +7350,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -7377,6 +7538,13 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.3): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.3 + transitivePeerDependencies: + - '@types/react' + jiti@2.6.1: {} jose@6.1.3: {} @@ -7576,6 +7744,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -8011,6 +8181,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-use-measure@2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} recast@0.23.11: @@ -8424,6 +8600,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + suspend-react@0.1.3(react@19.2.3): + dependencies: + react: 19.2.3 + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -8434,6 +8614,8 @@ snapshots: tapable@2.3.0: {} + three@0.185.1: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -8787,3 +8969,9 @@ snapshots: zod@3.25.76: {} zod@4.5.4: {} + + zustand@5.0.15(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) From 199b0c02118d5058d9feec85a0e18c9c3747d362 Mon Sep 17 00:00:00 2001 From: rammmaa Date: Sat, 5 Sep 2026 20:22:12 +0900 Subject: [PATCH 2/6] feat: glass balloon hero with react-three-fiber --- components/hero/balloon-hero.tsx | 80 ++++++ components/hero/balloon-scene.tsx | 399 ++++++++++++++++++++++++++++++ components/main/hero-section.tsx | 72 ++---- 3 files changed, 497 insertions(+), 54 deletions(-) create mode 100644 components/hero/balloon-hero.tsx create mode 100644 components/hero/balloon-scene.tsx diff --git a/components/hero/balloon-hero.tsx b/components/hero/balloon-hero.tsx new file mode 100644 index 0000000..f79bb15 --- /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..5aa3571 --- /dev/null +++ b/components/hero/balloon-scene.tsx @@ -0,0 +1,399 @@ +"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.04, + metalness: 0, + transmission: 0.96, + thickness: 2.2, + ior: 1.45, + clearcoat: 1, + clearcoatRoughness: 0.05, + attenuationColor: new THREE.Color(kind.color), + attenuationDistance: 2.5, + envMapIntensity: 1.5, + specularIntensity: 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/main/hero-section.tsx b/components/main/hero-section.tsx index 08f9d46..7725c4f 100644 --- a/components/main/hero-section.tsx +++ b/components/main/hero-section.tsx @@ -1,63 +1,27 @@ -import Image from "next/image"; -import { Network } from "lucide-react"; +import { BalloonHero } from "@/components/hero/balloon-hero"; -type HeroSectionProps = { - solvedCount?: number; -}; - -export function HeroSection({ solvedCount }: HeroSectionProps) { +export function HeroSection() { return (
- 2024 ICPC 아시아 태평양 챔피언십에 참가한 모르고리즘 동아리원들 -
); From 11e1334546f386b3f5a1fb7b7176fc68e0878f96 Mon Sep 17 00:00:00 2001 From: rammmaa Date: Sat, 5 Sep 2026 20:22:12 +0900 Subject: [PATCH 3/6] feat: redesign pages around the balloon hero --- app/(main)/activities/page.tsx | 107 +++---------- app/(main)/awards/page.tsx | 18 +-- app/(main)/donate/page.tsx | 43 +++--- app/(main)/join/page.tsx | 72 ++++----- app/(main)/members/page.tsx | 65 +++----- app/(main)/page.tsx | 23 ++- app/not-found.tsx | 21 +-- components/activities/sessions-section.tsx | 47 ++---- components/awards/awards-explorer.tsx | 101 +++++-------- components/layout/footer.tsx | 44 ++---- components/layout/header.tsx | 165 ++++++++------------- components/layout/page-header.tsx | 39 ++--- components/main/activities-section.tsx | 34 +++++ components/main/donation-card.tsx | 88 +++-------- components/main/join-band.tsx | 23 +++ components/main/moments-section.tsx | 55 +++++++ components/main/recent-awards-section.tsx | 80 +++++----- components/members/member-row.tsx | 26 +--- components/members/tier-badge.tsx | 10 +- lib/activities.ts | 47 ++++++ 20 files changed, 483 insertions(+), 625 deletions(-) create mode 100644 components/main/activities-section.tsx create mode 100644 components/main/join-band.tsx create mode 100644 components/main/moments-section.tsx create mode 100644 lib/activities.ts 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..267be2f 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -1,21 +1,18 @@ -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 { getSolvedacProfiles, sumSolvedCount } from "@/lib/solvedac"; - -export default async function MainPage() { - const profiles = await getSolvedacProfiles( - getMembers().map((member) => member.handle) - ); +import { getAwardStats, getRecentAwards } from "@/lib/data"; +export default function MainPage() { return (
- - - - + + + + +
); } 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/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 ( -
+ {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}}