Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ bun scripts/og-images.ts <임시 폴더> # 유형 공유 페이지 og:image

**TanStack Start** on **Cloudflare Workers**, D1 + Drizzle, 달레 UI(`docs/stack.md`).

- 화면은 `src/routes/` 의 파일 기반 라우트다. 설문을 여는 버튼("설문 시작하기")은 회차 페이지(`/<회차>`)에만 두고, 홈과 유형 공유 페이지의 참여 버튼은 회차 페이지로 보낸다. 설문 진행 화면을 뺀 모든 화면에 `SiteHeader`·`SiteFooter` 를 둔다(헤더의 "결과" 메뉴는 예시가 아닌 결과가 생기면 저절로 나온다). 응답 완료·이미 응답·마감 화면은 `$edition.survey.tsx` 의 `Finished` 하나로 그린다.
- 화면은 `src/routes/` 의 파일 기반 라우트다. 설문을 여는 버튼("설문 시작하기")은 회차 페이지(`/<회차>`)에만 두고, 홈, 유형 공유 페이지, 헤더의 참여 버튼은 회차 페이지로 보낸다. 설문 진행 화면을 뺀 모든 화면에 `SiteHeader`·`SiteFooter` 를 둔다(헤더의 "결과" 메뉴는 예시가 아닌 결과가 생기면, "참여하기"는 응답을 받는 회차가 있으면 저절로 나온다). 응답 완료·이미 응답·마감 화면은 `$edition.survey.tsx` 의 `Finished` 하나로 그린다.
- 마감은 회차의 `closesAt` 으로 정한다. 화면과 서버 함수가 함께 본다. 회차의 `opensAt` 전(`isBeta`)에는 종료 화면에 "다시 답하기"가 나온다.
- 익명 설문이다. 로그인이 없고, 응답자를 알아볼 수 있는 값은 스키마에 칸을 두지 않는 것으로 지킨다. 개발자 유형도 브라우저에서만 계산한다. 서버와 브라우저에 무엇을 두는지는 `docs/data-model.md`.
- 결과 화면은 `src/results/<회차>.json` 만 읽고 원본 응답을 모른다(집계 규칙은 `docs/data-model.md`). TanStack Charts(`src/components/ResultChart.tsx`)로 그리고, 보기 기준은 `?by=` 에 남는다. 차트 색은 dataviz 검증 스크립트를 통과한 값이다.
Expand Down
2 changes: 1 addition & 1 deletion docs/stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Tally 같은 설문 도구 대신 직접 만든다. 응답 화면을 달레 UI
- `worker-configuration.d.ts` — `bun run cf-typegen`. 저장소를 새로 받으면 `bun run test` 전에 한 번 돌린다(`tsconfig.json` 이 이 파일을 읽는다). 시크릿 타입은 `wrangler types` 가 모르므로 `src/env.d.ts` 에 더한다.
- **Worker 바인딩**은 서버 코드에서 `import { env } from 'cloudflare:workers'` 로 읽는다.
- **타입 검사 범위:** `tsconfig.json` 이 `*.test.ts` 와 `scripts/` 를 뺀다. `bun test` 는 타입을 보지 않으므로, 테스트와 스크립트의 타입 오류는 실행해야 드러난다.
- **스타일:** 달레 UI 컴포넌트를 먼저 쓰고, 배치는 인라인 `style` 에 달레 UI CSS 변수(`var(--colors-…)`, `var(--spacing-…)`)로 한다. 별도 CSS 프레임워크는 없다.
- **스타일:** 달레 UI 컴포넌트를 먼저 쓰고, 배치는 인라인 `style` 에 달레 UI CSS 변수(`var(--colors-…)`, `var(--spacing-…)`)로 한다. 별도 CSS 프레임워크는 없다. 여러 화면이 같이 쓰는 제목 단계, 첫 화면 액자, 주 행동 링크, 브랜드 그라데이션은 `src/lib/brand.ts` 에 둔다. 인라인으로 할 수 없는 `:hover`, `:focus-visible`, 화면 너비별 규칙만 컴포넌트 안의 `<style>` 로 둔다(`QuestionStep`, `ADMIN_CSS` 등).
- **경로 별칭:** `@/` 는 `src/` 다(`tsconfig.json`, `vite-tsconfig-paths`).
- **유형 이미지:** `scripts/type-images.ts` 가 OpenAI 이미지 API 로 만들어 `public/types/` 에 커밋한다. 빌드나 실행 때는 API 를 부르지 않는다.
- **유형 미리보기 이미지:** `scripts/og-images.ts` 가 유형 일러스트에 설문 이름과 유형 이름을 얹어 `public/og/<회차>/` 에 만든다. 일러스트는 유형 카드에도 쓰여서 글자를 넣지 않고 따로 둔다. 회차 제목이나 유형 이름을 바꾸면 다시 돌린다. 글자는 설치된 폰트에 기대지 않도록 `@resvg/resvg-js` 에 폰트 파일(Pretendard)을 직접 넘겨 그린다. 폰트는 저장소에 넣지 않고 실행할 때 받는다.
Expand Down
21 changes: 11 additions & 10 deletions src/components/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,26 @@ import { scaleLinear } from '@tanstack/charts/scales/linear'
import { tooltip } from '@tanstack/charts/tooltip'
import { Heading, Text } from 'daleui'
import { useMemo } from 'react'
import { BRAND } from '@/components/ResultChart'
import { BRAND, BRAND_SOFT } from '@/components/ResultChart'
import { koDate } from '@/lib/date'
import type { AdminSummary } from '@/server/admin'

// 인라인 스타일로는 못 하는 것(:focus-visible, :hover, 화면 너비별 규칙, 탭의 활성 표시)만 둔다.
// 탭의 활성 표시를 인라인으로 덧씌우면 React 가 지울 때 축약 속성의 색까지 지워 비활성 탭에 밑줄이 남는다.
export const ADMIN_CSS = `
.admin-panel { display: flex; flex-direction: column; border: 1px solid var(--colors-border-neutral); border-radius: var(--radii-lg); }
.admin-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--spacing-16); }
.admin-stat { padding: var(--spacing-20); gap: var(--spacing-8); }
.admin-stat-label { font-size: 0.875rem; font-weight: 500; color: var(--colors-fg-neutral-placeholder); }
.admin-stat-value { font-size: 2.25rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
.admin-tab { display: inline-flex; align-items: center; min-height: 48px; padding: 0 var(--spacing-12); margin-bottom: -1px; border-bottom: 2px solid transparent; color: var(--colors-fg-neutral-placeholder); font-weight: 500; text-decoration: none; }
.admin-panel { display: flex; flex-direction: column; border-radius: 12px; background: var(--colors-app-bg); }
.admin-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--spacing-12); }
.admin-stat { padding: 18px var(--spacing-20); gap: var(--spacing-8); }
.admin-stat-label { font-size: 0.8125rem; font-weight: 600; color: var(--colors-fg-neutral-placeholder); }
.admin-stat-value { font-size: 2rem; font-weight: 800; line-height: 1.1; color: var(--colors-fg-neutral-active); font-variant-numeric: tabular-nums; }
.admin-tab { display: inline-flex; align-items: center; min-height: 44px; padding: 0 var(--spacing-12); margin-bottom: -1px; border-bottom: 2px solid transparent; color: var(--colors-fg-neutral-placeholder); font-weight: 500; text-decoration: none; }
.admin-tab[aria-current='page'] { color: var(--colors-fg-brand); border-bottom-color: var(--colors-bg-solid-brand); font-weight: 600; }
.admin-tab:focus-visible, .admin-sort:focus-visible, .admin-expand:focus-visible, .admin-picker summary:focus-visible { outline: 2px solid var(--colors-border-brand-focus); outline-offset: 2px; border-radius: var(--radii-sm); }

/* 원본 응답 표. 칸 폭을 화면에 맞추고(table-layout: fixed) 긴 답은 줄바꿈해서 가로 스크롤이 생기지 않게 한다. */
.admin-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 0.875rem; line-height: 1.5; }
.admin-table th, .admin-table td { padding: var(--spacing-8) var(--spacing-12); text-align: left; vertical-align: top; overflow-wrap: anywhere; border-bottom: 1px solid var(--colors-bg-neutral-hover); }
.admin-table th { font-weight: 600; color: var(--colors-fg-neutral-placeholder); border-bottom-color: var(--colors-border-neutral); }
.admin-table th { font-size: 0.8125rem; font-weight: 700; color: var(--colors-fg-neutral-placeholder); border-bottom-color: var(--colors-bg-neutral-active); }
.admin-table tbody tr:hover { background: var(--colors-bg-brand); }
.admin-table tr.admin-open, .admin-table tr.admin-detail { background: var(--colors-bg-brand); }
.admin-table tr.admin-open td { border-bottom: 0; }
Expand All @@ -34,7 +34,7 @@ export const ADMIN_CSS = `
.admin-expand svg { transition: transform 0.15s; }
.admin-expand[aria-expanded='true'] svg { transform: rotate(90deg); }
.admin-picker { position: relative; }
.admin-picker summary { display: inline-flex; align-items: center; min-height: 40px; padding: 0 var(--spacing-12); border: 1px solid var(--colors-border-neutral); border-radius: var(--radii-md); cursor: pointer; font-weight: 500; }
.admin-picker summary { display: inline-flex; align-items: center; min-height: 44px; padding: 0 var(--spacing-12); border: 1px solid var(--colors-border-neutral-active); background: var(--colors-app-bg); list-style: none; border-radius: var(--radii-md); cursor: pointer; font-weight: 500; }
.admin-picker-panel { position: absolute; z-index: 1; top: calc(100% + var(--spacing-4)); left: 0; width: min(360px, calc(100vw - 48px)); max-height: 60vh; overflow-y: auto; padding: var(--spacing-12) var(--spacing-16); display: flex; flex-direction: column; gap: var(--spacing-12); background: var(--colors-app-bg); border: 1px solid var(--colors-border-neutral); border-radius: var(--radii-md); box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }
.admin-picker fieldset { margin: 0; padding: 0; border: 0; display: flex; flex-direction: column; gap: var(--spacing-8); }
.admin-picker legend { padding: 0 0 var(--spacing-4); font-size: 0.875rem; font-weight: 600; color: var(--colors-fg-brand); }
Expand Down Expand Up @@ -88,7 +88,7 @@ export function AdminDaily({ daily, closesAt }: { daily: AdminSummary['daily'];
const definition = useMemo(
() =>
defineChart({
marks: [barY(rows, { x: 'date', y: 'count', fill: BRAND, inset: 1, radius: 2 })],
marks: [barY(rows, { x: 'date', y: 'count', fill: (d) => (d.date === today ? BRAND : BRAND_SOFT), inset: 1, radius: 2 })],
scales: {
x: { scale: () => scaleBand<string>().padding(0.15), axis: { ticks: { values: days.filter((_, i) => i % 7 === 0), format: shortDate } } },
y: { scale: scaleLinear().domain([0, step * 3]), grid: true, axis: { ticks: { values: yTicks, format: (v: number) => v.toLocaleString() } } },
Expand All @@ -112,6 +112,7 @@ export function AdminDaily({ daily, closesAt }: { daily: AdminSummary['daily'];
</Heading>
<Text size="sm" muted>
한국 시간 기준{closesAt ? ` · ${koDate(last)} 마감` : ''}
{daily.length > 0 && ` · 가장 많은 날 ${koDate(peak.date)} ${peak.count.toLocaleString()}건`}
</Text>
</div>
{days.length === 0 ? (
Expand Down
49 changes: 34 additions & 15 deletions src/components/DevTypeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Heading, Text } from 'daleui'
import { Heading, Icon, Text } from 'daleui'
import type { DevType } from '@/lib/devType'

export function DevTypeCard({ type, label }: { type: DevType; label: string }) {
Expand All @@ -7,12 +7,12 @@ export function DevTypeCard({ type, label }: { type: DevType; label: string }) {
aria-label="개발자 유형"
style={{
width: '100%',
maxWidth: 560,
boxSizing: 'border-box',
overflow: 'hidden',
borderRadius: 'var(--radii-lg)',
borderRadius: 24,
border: '1px solid var(--colors-border-brand)',
background: 'var(--colors-app-bg)',
boxShadow: '0 24px 48px -24px rgb(83 51 225 / 0.28)',
}}
>
<img
Expand All @@ -22,31 +22,50 @@ export function DevTypeCard({ type, label }: { type: DevType; label: string }) {
height={630}
style={{ display: 'block', width: '100%', height: 'auto' }}
/>
<div style={{ padding: 'var(--spacing-24) var(--spacing-24) var(--spacing-32)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)' }}>
<div style={{ padding: '28px 32px 32px', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-12)' }}>
<Text size="sm" weight="semibold" tone="brand">
{label}
</Text>
<Heading level={2} size={2}>
<Heading level={2} wordBreak="cjk" style={{ fontSize: 'clamp(2rem, 1.5vw + 1.5rem, 2.5rem)', fontWeight: 800, letterSpacing: '-0.03em' }}>
{type.name}
</Heading>
<Text size="lg" style={{ lineHeight: 1.6 }}>
{type.description}
</Text>
<div
style={{
marginTop: 'var(--spacing-16)',
padding: 'var(--spacing-16) var(--spacing-20)',
borderRadius: 'var(--radii-md)',
background: 'var(--colors-bg-brand)',
marginTop: 'var(--spacing-12)',
padding: '20px 22px',
borderRadius: 14,
background: 'var(--colors-bg-brand-hover)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-4)',
gap: 14,
alignItems: 'flex-start',
}}
>
<Text size="sm" weight="semibold" tone="brand">
2027년 커리어 한 줄
</Text>
<Text style={{ lineHeight: 1.5 }}>{type.fortune}</Text>
<span
aria-hidden="true"
style={{
display: 'inline-flex',
flex: 'none',
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 10,
background: 'var(--colors-app-bg)',
}}
>
<Icon name="star" size="sm" tone="brand" />
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)' }}>
<Text size="sm" weight="bold" tone="brand">
2027년 커리어 한 줄
</Text>
<Text size="lg" style={{ lineHeight: 1.6 }}>
{type.fortune}
</Text>
</div>
</div>
</div>
</section>
Expand Down
10 changes: 6 additions & 4 deletions src/components/DevTypeGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ export function DevTypeGrid({ current, currentLabel }: { current?: string; curre
key={t.slug}
style={{
overflow: 'hidden',
borderRadius: 'var(--radii-md)',
borderRadius: 'var(--radii-lg)',
background: 'var(--colors-app-bg)',
border: mine ? '2px solid var(--colors-border-brand)' : '1px solid var(--colors-bg-neutral-hover)',
border: mine ? '2px solid var(--colors-border-brand-active)' : '1px solid var(--colors-bg-brand-active)',
}}
>
<img src={`/types/${t.slug}.jpg`} alt="" width={1200} height={630} loading="lazy" style={{ display: 'block', width: '100%', height: 'auto' }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-8)', alignItems: 'center', padding: 'var(--spacing-12) var(--spacing-16)' }}>
<Text weight="semibold">{t.name}</Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-8)', alignItems: 'center', padding: mine ? '15px 19px' : 'var(--spacing-16) var(--spacing-20)' }}>
<Text size="lg" weight="bold">
{t.name}
</Text>
{mine && currentLabel && <Tag tone="brand">{currentLabel}</Tag>}
</div>
</li>
Expand Down
77 changes: 49 additions & 28 deletions src/components/EditionList.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,78 @@
import { Link } from '@tanstack/react-router'
import { Heading, Tag, Text } from 'daleui'
import { EDITIONS, isBeta, isClosed } from '@/editions'
import { Tag, Text } from 'daleui'
import { EDITIONS, isClosed } from '@/editions'
import { koDate as day } from '@/lib/date'
import { RESULTS } from '@/results'

// 모든 회차를 최근 것부터. 회차마다 지금 할 수 있는 일(참여, 결과 보기)로 바로 간다.
const action: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
minHeight: 44,
padding: '0 var(--spacing-16)',
borderRadius: 'var(--radii-md)',
border: '1px solid var(--colors-border-neutral-active)',
color: 'var(--colors-fg-neutral)',
fontWeight: 600,
textDecoration: 'none',
}
const primary: React.CSSProperties = { ...action, border: 0, background: 'var(--colors-bg-solid-brand)', color: 'var(--colors-fg-solid-brand)' }

// 모든 회차를 최근 것부터. 회차마다 지금 할 수 있는 일(참여, 결과 보기)로 간다. 설문은 회차 페이지에서 연다.
export function EditionList() {
return (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column' }}>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 'var(--spacing-12)' }}>
{EDITIONS.map((e) => {
const result = RESULTS[e.id]
const open = e.survey && !isClosed(e)
const note = result
? `${result.total.toLocaleString()}명이 답했어요`
: open
? e.opensAt && e.closesAt
? `${day(e.opensAt)} – ${day(e.closesAt)}`
: '응답을 받고 있어요'
: e.resultsAt
? `${day(e.resultsAt)}에 결과를 공개해요`
: '결과를 준비하고 있어요'
return (
<li
key={e.id}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--spacing-16)',
gap: 'var(--spacing-16) var(--spacing-24)',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--spacing-24) 0',
borderTop: '1px solid var(--colors-bg-neutral-hover)',
padding: 'var(--spacing-24) 28px',
borderRadius: 'var(--radii-lg)',
border: `1px solid ${open ? 'var(--colors-border-brand)' : 'var(--colors-bg-neutral-hover)'}`,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)' }}>
<div style={{ flex: '1 1 320px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-8)', alignItems: 'center' }}>
<Heading level={3} size={5}>
<Link to="/$edition" params={{ edition: e.id }} style={{ color: 'inherit' }}>
{e.title}
</Link>
</Heading>
{open && <Tag tone="success">응답 받는 중</Tag>}
{e.sample && <Tag tone="warning">예시</Tag>}
<Text size="sm" muted>
{note}
</Text>
</div>
<Text size="sm" muted>
{result
? `${result.total.toLocaleString()}명이 답했어요`
: open
? e.closesAt ? `${day(e.closesAt)}까지 응답을 받아요` : '응답을 받고 있어요'
: e.resultsAt ? `${day(e.resultsAt)}에 결과를 공개해요` : '결과를 준비하고 있어요'}
</Text>
<Link
to="/$edition"
params={{ edition: e.id }}
style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--colors-fg-neutral-active)', textDecoration: 'none' }}
>
{e.title}
</Link>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-24)' }}>
{open && (
<Link to="/$edition/survey" params={{ edition: e.id }} reloadDocument={isBeta(e)}>
참여하기
</Link>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-8)' }}>
{(result || (e.survey && e.resultsAt)) && (
<Link to="/$edition/results" params={{ edition: e.id }}>
<Link to="/$edition/results" params={{ edition: e.id }} style={action}>
{result ? '결과 보기' : '결과 안내'}
</Link>
)}
{open && (
<Link to="/$edition" params={{ edition: e.id }} style={primary}>
참여하기
</Link>
)}
</div>
</li>
)
Expand Down
Loading
Loading