Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7d4ca5c
chore: approve pnpm build scripts for pnpm 11
rammmaa Sep 5, 2026
15869e6
docs: add spec for completing pages and json data layer
rammmaa Sep 5, 2026
0e7550a
docs: add implementation plan for completing pages
rammmaa Sep 5, 2026
3353723
chore: add zod, next-themes, vitest, tsx
rammmaa Sep 5, 2026
9eca6d8
feat: parse rank from award label
rammmaa Sep 5, 2026
f497b2c
feat: add zod schemas for members, rosters, awards, sessions
rammmaa Sep 5, 2026
2cf5d3b
feat: add award sorting, grouping and stats helpers
rammmaa Sep 5, 2026
0fdc274
feat: add sheet-to-json transform for members and awards
rammmaa Sep 5, 2026
8ab4d86
feat: add solved.ac profile fetch and tier helpers
rammmaa Sep 5, 2026
a165eb7
feat: move awards and members data to json with schema validation
rammmaa Sep 5, 2026
94717c6
feat: add light/dark theme and replace hard-coded colors with tokens
rammmaa Sep 5, 2026
5a898a2
feat: awards page with competition filter and year tables
rammmaa Sep 5, 2026
1216ea2
feat: members page grouped by semester with boj links and solved.ac t…
rammmaa Sep 5, 2026
14495c5
feat: redesign activities page and add session log section
rammmaa Sep 5, 2026
7a5c1ab
feat: refresh home, join and donate pages
rammmaa Sep 5, 2026
f88d898
docs: rewrite readme and add 404, sitemap, robots
rammmaa Sep 5, 2026
b179967
fix: call molgorithm a club, not an academic society
rammmaa Sep 5, 2026
18cc2aa
feat: use real club photos in hero and gallery
rammmaa Sep 5, 2026
ff719ad
feat: link handles to solved.ac and show solved problem count in hero
rammmaa Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"
120 changes: 100 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/ 시트 이관과 데이터 검증
```
120 changes: 78 additions & 42 deletions app/(main)/activities/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
className="py-32 px-6 md:px-12 bg-[#e2e2e2] text-[#131313]"
id="activities"
>
<div className="mb-24">
<span className="font-label text-[#006782] tracking-widest uppercase text-xs mb-4 block">
03 // THE CURRICULUM
</span>
<h2 className="font-headline text-4xl md:text-6xl font-bold tracking-tighter">
활동 소개
</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{activities.map((item) => (
<div
key={item.title}
className="group relative aspect-[3/4] overflow-hidden bg-[#131313] flex flex-col justify-end p-8"
>
<Image
src={item.image}
alt={item.title}
fill
sizes="(min-width: 1024px) 33vw, 100vw"
className="object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:scale-105 transition-all duration-700"
/>
<div className="relative z-10 text-[#e2e2e2]">
<span className="font-label text-[10px] tracking-widest text-[#9de2ff] mb-2 block">
<section className="bg-surface" id="activities">
<PageHeader
label="03 // THE CURRICULUM"
title="Activities"
labelColor="tertiary"
description="한 학기 동안 동아리원들이 함께하는 세 가지 활동입니다."
/>

{activities.map((item, index) => (
<div
key={item.title}
className={
index % 2 === 0
? "border-t border-outline-variant/20 bg-surface px-6 py-24 md:px-12"
: "border-t border-outline-variant/20 bg-surface-container-low px-6 py-24 md:px-12"
}
>
<div className="grid grid-cols-1 items-start gap-16 lg:grid-cols-[1fr_2fr]">
<div>
<span className="mb-4 block font-headline text-7xl font-extrabold text-primary/10 md:text-8xl">
{item.number}
</span>
<span className="mb-2 block font-label text-[10px] tracking-widest text-primary">
{item.label}
</span>
<h3 className="font-headline text-3xl font-bold mb-4">
<h2 className="font-headline text-3xl font-bold md:text-4xl">
{item.title}
</h3>
<p className="text-sm opacity-70">{item.description}</p>
</h2>
</div>
<div>
<p className="mb-12 text-lg leading-relaxed text-on-surface-variant">
{item.description}
</p>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-3">
{item.details.map((detail) => (
<div
key={detail.label}
className="border-l-2 border-primary/20 pl-6"
>
<p className="mb-2 font-label text-[10px] uppercase tracking-widest text-outline">
{detail.label}
</p>
<p className="text-sm font-bold text-foreground">
{detail.value}
</p>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
))}

<SessionsSection sessions={sessions} />
</section>
);
}
90 changes: 25 additions & 65 deletions app/(main)/awards/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="py-32 px-6 md:px-12 bg-surface" id="awards">
<div className="mb-16 flex flex-col md:flex-row justify-between md:items-end gap-4">
<div>
<span className="font-label text-primary tracking-widest uppercase text-xs mb-4 block">
02 // VERIFIED STATS
</span>
<h2 className="font-headline text-4xl md:text-6xl font-bold tracking-tighter">
Achievement Log
</h2>
</div>
<div className="text-right hidden md:block">
<p className="font-label text-xs text-outline tracking-widest">
DATABASE // 2020–2026
</p>
</div>
</div>

<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-outline-variant/30">
<th className="py-6 font-label text-[10px] tracking-[0.2em] uppercase text-outline">
Year
</th>
<th className="py-6 font-label text-[10px] tracking-[0.2em] uppercase text-outline">
Event
</th>
<th className="py-6 font-label text-[10px] tracking-[0.2em] uppercase text-outline">
Award
</th>
<th className="py-6 font-label text-[10px] tracking-[0.2em] uppercase text-outline">
Name
</th>
<th className="py-6 font-label text-[10px] tracking-[0.2em] uppercase text-outline">
Team
</th>
</tr>
</thead>
<tbody>
{awards.map((award) => (
<tr
key={award.순번}
className="border-b border-outline-variant/10 hover:bg-surface-container-low transition-colors"
>
<td className="py-8 text-primary font-bold">
{award.연도}.{award.월}
</td>
<td className="py-8">{award.내용}</td>
<td className="py-8">{award.상명}</td>
<td className="py-8">{award.이름}</td>
<td className="py-8 text-outline">{award.팀명}</td>
</tr>
))}
</tbody>
</table>
</div>
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 && (
<p className="mt-14 text-center text-outline">
수상 데이터를 불러오는 중입니다...
</p>
)}
return (
<section className="bg-surface" id="awards">
<PageHeader
label="02 // VERIFIED STATS"
title="Awards"
labelColor="tertiary"
meta={meta}
description="ICPC, SUAPC, SCPC, UCPC 등 국내외 대회에서 모르고리즘 동아리원들이 거둔 성과입니다. 이름을 누르면 solved.ac 프로필로 이동합니다."
/>
<AwardsExplorer awards={awards} competitions={stats.competitions} />
</section>
);
}
Loading