diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e6f27f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# 배포 주소. metadata, sitemap, robots에 쓴다. Vercel에서는 https://<도메인> 으로 넣는다. +NEXT_PUBLIC_SITE_URL="http://localhost:3000" + +# Join 페이지의 지원서 버튼. 비우면 "현재 모집 기간이 아닙니다"가 뜬다. +JOIN_FORM_URL="" + +# Donate 페이지. DONATION_ACCOUNT를 비우면 "추후 안내 예정"이 뜬다. +DONATION_ACCOUNT="" +DONATION_ACCOUNT_HOLDER="모르고리즘" +DONATION_CONTACT_URL="" + +# 시트 이관 스크립트(pnpm import:sheets) 전용. 사이트 런타임에서는 쓰지 않는다. +AWARDS_CSV_URL="https://docs.google.com/spreadsheets/d/e/2PACX-1vRXpG6fjoUMIXmVplKESYaDgvysfBsW2_5qG-Rm6A01Jl2tzn_mswQ854QT9E7MQMkiGT_kSd_Fh-wa/pub?output=csv" +MEMBERS_CSV_URL="https://docs.google.com/spreadsheets/d/e/2PACX-1vRCh4wuYA0nTMZ2Awef0SAQlU4XBRqrc67-XM9ES8rt2ByKrNESiOheZXzWA3PvIi0moK6_KUAc-1bi/pub?output=csv" diff --git a/README.md b/README.md index e215bc4..8557d34 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,116 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Molgorithm -## Getting Started +연세대학교 알고리즘 동아리 모르고리즘 공식 홈페이지입니다. Next.js 16 App Router, React 19, Tailwind CSS v4, shadcn/ui로 만들었습니다. -First, run the development server: +## 개발 ```bash -npm run dev -# or -yarn dev -# or +pnpm install +cp .env.example .env # 값은 필요한 것만 채우면 됩니다 pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +`http://localhost:3000`에서 확인합니다. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +| 명령 | 설명 | +|---|---| +| `pnpm dev` | 개발 서버 | +| `pnpm build` | 프로덕션 빌드. 데이터 검증도 이때 함께 돕니다 | +| `pnpm lint` | ESLint (경고 0) | +| `pnpm typecheck` | TypeScript 검사 | +| `pnpm test` | vitest | +| `pnpm check:data` | `data/*.json` 검증 | +| `pnpm import:sheets` | Google Sheets에서 `data/*.json`을 다시 생성 | -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## 데이터 수정 -## Learn More +수상, 동아리원, 세션 기록은 `data/` 아래 JSON 파일이 원본입니다. 파일을 고친 뒤 `pnpm check:data`로 검증하고 PR을 올리면 Vercel이 자동으로 배포합니다. 형식이 틀리면 빌드가 실패하므로 잘못된 데이터가 배포되지 않습니다. -To learn more about Next.js, take a look at the following resources: +### `data/members.json` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +핸들(solved.ac 아이디)이 키입니다. 한 사람은 한 번만 적습니다. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```json +{ "handle": "rammma", "name": "이하람" } +``` + +### `data/rosters.json` + +학기별 명단입니다. 새 학기가 시작되면 항목 하나를 추가합니다. 핸들은 `members.json`에 있어야 합니다. 가장 최근 학기가 Members 페이지의 "현재 동아리원"으로 표시됩니다. + +```json +{ "year": 2026, "semester": 1, "handles": ["dlguq0107", "rammma"] } +``` + +### `data/awards.json` + +수상 기록입니다. `id`는 겹치지 않는 정수이고, `members`의 `handle`은 있으면 Members 페이지에서 수상 횟수로 집계됩니다. 외부 참가자는 `handle` 없이 이름만 적습니다. + +```json +{ + "id": 83, + "year": 2026, + "month": 8, + "competition": "SUAPC", + "award": "금상 / #1", + "team": "팀 이름", + "note": "연세대학교", + "members": [{ "name": "이협", "handle": "dlguq0107" }, { "name": "외부인" }] +} +``` + +`award`에 `#12`, `12위`, 또는 숫자만 있으면 같은 달 안에서 그 순서로 정렬됩니다. + +### `data/sessions.json` + +정기 세션 기록입니다. 비어 있으면 Activities 페이지에 섹션이 나타나지 않습니다. + +```json +{ "date": "2026-03-07", "topic": "세그먼트 트리", "presenter": "이하람", "link": "" } +``` -## Deploy on Vercel +### Google Sheets에서 다시 가져오기 -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +예전에 시트를 원본으로 쓰던 시절의 이관 도구입니다. `.env`의 `AWARDS_CSV_URL`, `MEMBERS_CSV_URL`을 읽어 `members.json`, `rosters.json`, `awards.json`을 덮어씁니다. `sessions.json`은 건드리지 않습니다. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +pnpm import:sheets +``` + +## 환경 변수 + +`.env.example`을 참고합니다. 빌드에 필수인 값은 없습니다. + +| 변수 | 용도 | +|---|---| +| `NEXT_PUBLIC_SITE_URL` | 배포 주소. metadata, sitemap, robots | +| `JOIN_FORM_URL` | 지원서 구글폼 링크. 비우면 모집 기간이 아니라고 표시 | +| `DONATION_ACCOUNT` | 후원 계좌. 비우면 "추후 안내 예정" | +| `DONATION_ACCOUNT_HOLDER` | 예금주 (기본 "모르고리즘") | +| `DONATION_CONTACT_URL` | 후원 문의 링크 | +| `AWARDS_CSV_URL`, `MEMBERS_CSV_URL` | 이관 스크립트 전용 | + +## 배포 + +Vercel에 연결하면 `main`에 푸시할 때마다 배포됩니다. 환경 변수는 Vercel 프로젝트 설정에서 넣습니다. 홈의 풀이 수 카운터와 Members 페이지의 티어 배지는 solved.ac 값을 하루에 한 번 갱신하고, 나머지 페이지는 정적입니다. + +## 사진 + +홈의 히어로 배경과 갤러리(`components/main/gallery-carousel-section.tsx`)는 `public/images/`의 실제 활동 사진을 씁니다. 사진을 추가하려면 파일을 `public/images/`에 넣고 `galleryImages` 배열에 `src`, `alt`, `caption`을 한 항목 더 적습니다. 가로 1400px 안팎, 500KB 이하로 줄여서 넣는 편이 좋습니다. + +## 구조 + +``` +app/(main)/ 페이지 (home, awards, activities, members, join, donate) +components/layout/ Header, Footer, PageHeader, 테마 +components/main/ 홈 섹션 +components/awards/ 수상 표와 필터 +components/members/ 멤버 행과 티어 배지 +components/activities/ 세션 기록 +lib/schema.ts zod 스키마와 타입 +lib/data.ts 데이터 진입점 +lib/awards.ts 정렬, 그룹, 통계 +lib/solvedac.ts solved.ac 조회 +data/ 데이터 원본 (JSON) +scripts/ 시트 이관과 데이터 검증 +``` diff --git a/app/(main)/activities/page.tsx b/app/(main)/activities/page.tsx index 19c6d8d..2a55d16 100644 --- a/app/(main)/activities/page.tsx +++ b/app/(main)/activities/page.tsx @@ -1,71 +1,107 @@ -import Image from "next/image"; +import type { Metadata } from "next"; +import { SessionsSection } from "@/components/activities/sessions-section"; +import { PageHeader } from "@/components/layout/page-header"; +import { getSessions } from "@/lib/data"; + +export const metadata: Metadata = { + title: "Activities", + description: + "정기 세션, 팀 연습, 대회 참가로 이루어진 모르고리즘의 활동을 소개합니다.", +}; const activities = [ { - label: "WEEKLY // 매주", + number: "01", + label: "WEEKLY SESSION", title: "정기 세션", description: "매주 진행되는 알고리즘 세션. 난이도별 트랙으로 나뉘어 문제를 풀고, 풀이를 공유하고 리뷰합니다.", - image: - "https://images.unsplash.com/photo-1515879218367-8466d910auj7?w=600", + details: [ + { label: "주기", value: "매주" }, + { label: "형식", value: "주제별 발표와 문제풀이 리뷰" }, + { label: "주제", value: "DP, 그래프, 문자열, 수학 등" }, + ], }, { - label: "SATURDAY // 팀연습", + number: "02", + label: "TEAM PRACTICE", title: "팀 연습", description: "ICPC 대회를 대비한 3인 1팀 연습. 실전과 동일한 환경에서 팀 커뮤니케이션과 전략을 다듬습니다.", - image: - "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=600", + details: [ + { label: "주기", value: "매주 토요일" }, + { label: "구분", value: "Div 1, Div 2 구분 운영" }, + { label: "문제 셋", value: "Codeforces, AtCoder 기출 활용" }, + ], }, { - label: "AD-HOC // 수시", + number: "03", + label: "COMPETITIONS", title: "대회 참가", description: "ICPC, SCPC, UCPC 등 국내외 주요 프로그래밍 대회에 참가하며 실전 경험을 쌓습니다.", - image: - "https://images.unsplash.com/photo-1504639725590-34d0984388bd?w=600", + details: [{ label: "주요 대회", value: "ICPC, SUAPC, SCPC, UCPC" }], }, ]; export default function ActivitiesPage() { + const sessions = getSessions(); + return ( -
-
- - 03 // THE CURRICULUM - -

- 활동 소개 -

-
-
- {activities.map((item) => ( -
- {item.title} -
- +
+ + + {activities.map((item, index) => ( +
+
+
+ + {item.number} + + {item.label} -

+

{item.title} -

-

{item.description}

+ +
+
+

+ {item.description} +

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

+ {detail.label} +

+

+ {detail.value} +

+
+ ))} +
- ))} -
+
+ ))} + +
); } diff --git a/app/(main)/awards/page.tsx b/app/(main)/awards/page.tsx index 700128c..11749ae 100644 --- a/app/(main)/awards/page.tsx +++ b/app/(main)/awards/page.tsx @@ -1,71 +1,31 @@ -import { getAwards } from "@/lib/data"; +import type { Metadata } from "next"; +import { AwardsExplorer } from "@/components/awards/awards-explorer"; +import { PageHeader } from "@/components/layout/page-header"; +import { getAwards, getAwardStats } from "@/lib/data"; -export default async function AwardsPage() { - const awards = await getAwards(); +export const metadata: Metadata = { + title: "Awards", + description: + "모르고리즘 동아리원들이 국내외 알고리즘 대회에서 거둔 수상 기록입니다.", +}; - return ( -
-
-
- - 02 // VERIFIED STATS - -

- Achievement Log -

-
-
-

- DATABASE // 2020–2026 -

-
-
- -
- - - - - - - - - - - - {awards.map((award) => ( - - - - - - - - ))} - -
- Year - - Event - - Award - - Name - - Team -
- {award.연도}.{award.월} - {award.내용}{award.상명}{award.이름}{award.팀명}
-
+export default function AwardsPage() { + const awards = getAwards(); + const stats = getAwardStats(); + const meta = stats.years + ? `${stats.total} RECORDS // ${stats.years.min}–${stats.years.max}` + : undefined; - {awards.length === 0 && ( -

- 수상 데이터를 불러오는 중입니다... -

- )} + return ( +
+ +
); } diff --git a/app/(main)/donate/page.tsx b/app/(main)/donate/page.tsx index 44849d6..5b01036 100644 --- a/app/(main)/donate/page.tsx +++ b/app/(main)/donate/page.tsx @@ -1,5 +1,11 @@ +import type { Metadata } from "next"; import { DonationCard } from "@/components/main/donation-card"; +export const metadata: Metadata = { + title: "Donate", + description: "모르고리즘 후원 안내입니다.", +}; + export default function DonatePage() { const donationAccount = process.env.DONATION_ACCOUNT?.trim(); const accountHolder = @@ -14,9 +20,9 @@ export default function DonatePage() { 06 // SUPPORT -

+

후원 안내 -

+

모르고리즘의 대회 참가비, 서버 운영비, 스터디 교재비 등을 후원해 주세요. 여러분의 후원이 다음 세대의 알고리즘 인재를 키웁니다. diff --git a/app/(main)/join/page.tsx b/app/(main)/join/page.tsx index 6ac8a0d..0fb8d80 100644 --- a/app/(main)/join/page.tsx +++ b/app/(main)/join/page.tsx @@ -1,100 +1,87 @@ +import type { Metadata } from "next"; import { Clock, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; +export const metadata: Metadata = { + title: "Join", + description: "모르고리즘 신입 동아리원 모집 안내입니다.", +}; + +const sections = [ + { + title: "지원 자격", + body: "연세대학교 학부생 또는 대학원생으로 C++, Python, Java 등 프로그래밍 언어에 기초적인 이해가 있는 분이면 누구나 지원 가능합니다.", + }, + { + title: "모집 일정", + body: "매년 3월과 9월, 학기 초에 모집합니다. 정확한 모집 일정은 추후 공지됩니다.", + }, + { + title: "선발 방식", + body: "서류 지원으로만 선발합니다. 별도의 코딩 테스트나 면접은 없습니다.", + }, +]; + export default function JoinPage() { const joinFormUrl = process.env.JOIN_FORM_URL?.trim(); return ( -

-
+
+
- + 05 // RECRUITMENT -

+

Join the
Monolith. -

-
-
-

- 지원 자격 -

-

- 연세대학교 학부생 또는 대학원생으로 C++, Python, Java 등 - 프로그래밍 언어에 기초적인 이해가 있는 분이면 누구나 지원 - 가능합니다. -

-
-
-

- 모집 일정 -

-

- 매년 3월과 9월, 학기 초에 모집합니다. 정확한 모집 일정은 추후 - 공지됩니다. -

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

+ {section.title} +

+

{section.body}

+
+ ))}
-
-

- 가입 절차 -

-
-
- - 01 - -
-
서류 지원
-

- 간단한 자기소개와 알고리즘 관심 분야를 작성해 주세요. -

-
-
-
- - 02 - -
-
코딩 테스트
-

- 기초적인 알고리즘 문제 풀이 능력을 확인합니다. -

-
-
-
- - 03 - -
-
면접
-

- 학회 활동에 대한 열정과 협업 능력을 확인하는 면접입니다. -

-
-
-
+
+

지원하기

+

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

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

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

diff --git a/app/(main)/members/page.tsx b/app/(main)/members/page.tsx index 03d15e6..c2acc05 100644 --- a/app/(main)/members/page.tsx +++ b/app/(main)/members/page.tsx @@ -1,38 +1,99 @@ -import { getMembers } from "@/lib/data"; +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 type { Roster } from "@/lib/schema"; +import { getSolvedacProfiles, type SolvedacProfile } from "@/lib/solvedac"; + +export const metadata: Metadata = { + title: "Members", + description: "모르고리즘 동아리원 명단입니다.", +}; + +function semesterTitle(roster: Roster): string { + return `${roster.year}년 ${roster.semester}학기`; +} export default async function MembersPage() { - const members = await getMembers(); + const rosters = getRosters(); + const [current, ...past] = rosters; + const profiles = current + ? await getSolvedacProfiles(current.handles) + : new Map(); return ( -
-
- - 04 // THE NODES - -

- Members -

-
-
- {members.map((member) => ( -
- - {member.이름} - - - @{member.핸들} +
+ + + {!current && ( +

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

+ )} + + {current && ( +
+

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

+
+ {current.handles.map((handle) => { + const member = getMemberByHandle(handle); + if (!member) return null; + return ( + + ); + })}
- ))} -
+
+ )} - {members.length === 0 && ( -

- 멤버 데이터를 불러오는 중입니다... -

+ {past.length > 0 && ( +
+

이전 학기

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

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

+
+ {roster.handles.map((handle) => { + const member = getMemberByHandle(handle); + if (!member) return null; + return ( + + ); + })} +
+
+ ))} +
+
)}
); diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx index 4241f6a..fc02d82 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -1,12 +1,20 @@ -import { HeroSection } from "@/components/main/hero-section"; import { AboutSection } from "@/components/main/about-section"; import { GalleryCarouselSection } from "@/components/main/gallery-carousel-section"; +import { HeroSection } from "@/components/main/hero-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) + ); -export default function MainPage() { return (
- + +
); diff --git a/app/globals.css b/app/globals.css index 8dd1641..19375b0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -70,6 +70,52 @@ :root { --app-font-sans: "Manrope", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; + /* Light editorial palette */ + --tertiary: #d48a00; + --background: #f5f5f5; + --foreground: #111111; + --card: #ffffff; + --card-foreground: #111111; + --popover: #ffffff; + --popover-foreground: #111111; + --primary: #0077a3; + --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; @@ -95,9 +141,7 @@ --chart-3: #ffd098; --chart-4: #ffb95a; --chart-5: #5ed4ff; - --radius: 0px; - /* Surface system */ --surface: #131313; --surface-container: #1f1f1f; --surface-container-low: #1b1b1b; @@ -121,15 +165,15 @@ } ::-webkit-scrollbar-track { - background: #131313; + background: var(--background); } ::-webkit-scrollbar-thumb { - background: #353535; + background: var(--outline-variant); } ::-webkit-scrollbar-thumb:hover { - background: #9de2ff; + background: var(--primary); } html { @@ -147,7 +191,7 @@ html { } ::selection { - background-color: #9de2ff; - color: #003545; + background-color: var(--primary); + color: var(--primary-foreground); } -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index 0b3dde3..0ebc763 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,8 @@ 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 { siteUrl } from "@/lib/site"; import "./globals.css"; const manrope = Manrope({ @@ -20,10 +22,23 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +const description = + "연세대학교 알고리즘 동아리 모르고리즘입니다. 문제 해결, 팀 연습, 대회 참가를 통해 함께 성장합니다."; + export const metadata: Metadata = { - title: "Molgorithm — 연세대학교 알고리즘 학회", - description: - "연세대학교 알고리즘 학회 모르고리즘입니다. 문제 해결, 팀 연습, 대회 참가를 통해 함께 성장합니다.", + metadataBase: new URL(siteUrl()), + title: { + default: "Molgorithm — 연세대학교 알고리즘 동아리", + template: "%s | Molgorithm", + }, + description, + openGraph: { + type: "website", + locale: "ko_KR", + siteName: "Molgorithm", + title: "Molgorithm — 연세대학교 알고리즘 동아리", + description, + }, }; export default function RootLayout({ @@ -34,9 +49,19 @@ export default function RootLayout({ return ( - {children} + + + {children} + + ); } diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..2d0047c --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import { Footer } from "@/components/layout/footer"; +import { Header } from "@/components/layout/header"; +import { Button } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+
+
+ + 404 // NOT FOUND + +

+ Wrong Answer. +

+

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

+ +
+
+
+ ); +} diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..b536992 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,9 @@ +import type { MetadataRoute } from "next"; +import { siteUrl } from "@/lib/site"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { userAgent: "*", allow: "/" }, + sitemap: `${siteUrl()}/sitemap.xml`, + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..f4f2f4f --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from "next"; +import { siteUrl } from "@/lib/site"; + +const routes = ["", "/awards", "/activities", "/members", "/join", "/donate"]; + +export default function sitemap(): MetadataRoute.Sitemap { + const base = siteUrl(); + const lastModified = new Date(); + return routes.map((route) => ({ + url: `${base}${route}`, + lastModified, + })); +} diff --git a/components/activities/sessions-section.tsx b/components/activities/sessions-section.tsx new file mode 100644 index 0000000..a25b4ab --- /dev/null +++ b/components/activities/sessions-section.tsx @@ -0,0 +1,66 @@ +import { ExternalLink } from "lucide-react"; +import type { Session } from "@/lib/schema"; + +const COLUMNS = ["Date", "Topic", "Presenter", "Link"]; + +export function SessionsSection({ sessions }: { sessions: Session[] }) { + if (sessions.length === 0) return null; + + return ( +
+ + SESSION LOG + +

+ 정기 세션 기록 +

+
+ + + + {COLUMNS.map((column) => ( + + ))} + + + + {sessions.map((session) => ( + + + + + + + ))} + +
+ {column} +
{session.date}{session.topic} + {session.presenter} + + {session.link && ( + + + )} +
+
+
+ ); +} diff --git a/components/awards/awards-explorer.tsx b/components/awards/awards-explorer.tsx new file mode 100644 index 0000000..71a1d63 --- /dev/null +++ b/components/awards/awards-explorer.tsx @@ -0,0 +1,175 @@ +"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"; + +const ALL = "all"; +const COLUMNS = ["Event", "Award", "Name", "Team"]; + +type AwardsExplorerProps = { + awards: Award[]; + competitions: CompetitionCount[]; +}; + +export function AwardsExplorer({ awards, competitions }: AwardsExplorerProps) { + const [selected, setSelected] = useState(ALL); + const filtered = + selected === ALL + ? awards + : awards.filter((award) => award.competition === selected); + const groups = groupAwardsByYear(filtered); + + if (awards.length === 0) { + return ( +

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

+ ); + } + + return ( +
+
+ setSelected(ALL)}> + 전체 {awards.length} + + {competitions.map((competition) => ( + setSelected(competition.name)} + > + {competition.name}{" "} + {competition.count} + + ))} +
+ + {groups.length === 0 && ( +

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

+ )} + + {groups.map((group) => ( + + ))} +
+ ); +} + +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} +

+
+ + + + + + + + + + {COLUMNS.map((column) => ( + + ))} + + + + {awards.map((award) => ( + + + + + + + ))} + +
+ {column} +
+ + {award.month}월 + + {award.competition} + {award.award} + + + {award.team} + {award.note && ( + + {award.note} + + )} +
+
+
+ ); +} + +function MemberNames({ members }: { members: Award["members"] }) { + return ( + <> + {members.map((member, index) => ( + + {index > 0 && ", "} + {member.handle ? ( + + {member.name} + + ) : ( + member.name + )} + + ))} + + ); +} diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx index 66c6883..4f6cadb 100644 --- a/components/layout/footer.tsx +++ b/components/layout/footer.tsx @@ -1,27 +1,56 @@ 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 }, +]; + +const linkClass = + "font-label text-[10px] uppercase tracking-widest text-outline transition-colors hover:text-foreground"; + export function Footer() { return ( -