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 @@ -39,7 +39,7 @@ bun scripts/og-images.ts <임시 폴더> # 유형 공유 페이지 og:image
- 결과 화면은 `src/results/<회차>.json` 만 읽고 원본 응답을 모른다(집계 규칙은 `docs/data-model.md`). TanStack Charts(`src/components/ResultChart.tsx`)로 그리고, 보기 기준은 `?by=` 에 남는다. 차트 색은 dataviz 검증 스크립트를 통과한 값이다.
- 선택지에 `code: other`(기타)가 있는 문항은 "기타"를 고르면 직접 적을 수 있다. 다음 회차 선택지를 고르는 데 쓴다(`docs/research/2026-11-trend-tracking.md`).
- 제출은 Turnstile 로 막는다. 위젯은 마지막 문항 화면에만 띄운다 — 토큰이 5분이면 만료된다. 서버(`submitResponse`)는 저장 전에 `verifyTurnstile`(`src/lib/turnstile.ts`)로 `success`·action `survey_submit`·호스트 이름을 확인하고, 하나라도 어긋나면 거절한다(fail closed). 허용 호스트는 `wrangler.jsonc` 의 `TURNSTILE_HOSTNAMES`(운영에는 localhost 를 넣지 않는다)이고, 점으로 시작하는 항목(`.survey.dalestudy.com`)은 미리보기 주소를 위해 하위 도메인을 모두 받는다.
- 운영진은 `/admin` 에서 회차별 응답 수와 원본 응답을 읽기만 한다. 고치거나 지우는 기능과 SQL 입력창은 두지 않는다. 인증은 직접 만들지 않고 Cloudflare Access 가 `/admin` 을 잠근다. Access 는 잠근 경로로 가는 요청에만 토큰을 붙이므로 데이터는 서버 함수가 아니라 `/admin/api/*`(회차 요약 `summary`, 원본 한 페이지 `responses`)가 주고, 요청마다 `verifyAccessToken`(`src/lib/access.ts`)으로 확인해 하나라도 어긋나면 거절한다(fail closed). 로컬에서는 토큰이 없어 늘 거절되므로 미리보기에서 확인한다. 접근 대상은 처리방침과 맞물린다(`docs/operations.md`).
- 운영진은 `/admin` 에서 회차별 응답 수와 원본 응답을 읽기만 한다. 고치거나 지우는 기능과 SQL 입력창은 두지 않는다. 인증은 직접 만들지 않고 Cloudflare Access 가 `/admin` 을 잠근다. Access 는 잠근 경로로 가는 요청에만 토큰을 붙이므로 데이터는 서버 함수가 아니라 `/admin/api/*`(회차 요약 `summary`, 원본 전체 `responses`)가 주고, 요청마다 `verifyAccessToken`(`src/lib/access.ts`)으로 확인해 하나라도 어긋나면 거절한다(fail closed). 원본 응답은 회차 단위로 한 번에 받아 브라우저에서 거르고 정렬한다. 로컬에서는 토큰이 없어 늘 거절되므로 미리보기에서 확인한다. 접근 대상은 처리방침과 맞물린다(`docs/operations.md`).

## 작업 방식

Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Tally 같은 설문 도구 대신 직접 만든다. 응답 화면을 달레 UI
| DB | D1 + Drizzle | Workers 바인딩으로 바로 쓰고, 마이그레이션은 drizzle-kit 이 만든다 | `src/db/`, `drizzle/` |
| UI | 달레 UI (`daleui`) | 커뮤니티 디자인 시스템 | 컴포넌트 전반 |
| 차트 | TanStack Charts | 결과 화면, 관리자 화면의 날짜별 응답 | `src/components/ResultChart.tsx`, `src/components/Admin.tsx` |
| 표 | TanStack Table | 관리자 화면에서 원본 응답을 거르고 정렬한다. 열이 많아 가로로 펼치지 않고 보이는 열을 고른다 | `src/routes/admin.raw.tsx` |
| 봇 방지 | Turnstile | 로그인 없이 자동 제출을 막는다 | `src/lib/turnstile.ts`, `src/components/Turnstile.tsx` |
| 운영자 인증 | Cloudflare Access | 로그인 코드를 들여오지 않고 GitHub 팀으로 잠근다 | `src/lib/access.ts`, `src/routes/admin_.api.$part.ts` |
| 문항 원본 | YAML, Bun 내장 파서 | 사람이 읽고 고치기 쉽고, 파서 의존성이 없다 | `questions/`, `scripts/questions.ts` |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@tanstack/charts": "1.2.0",
"@tanstack/react-router": "^1.170.38",
"@tanstack/react-start": "^1.168.56",
"@tanstack/react-table": "9.3.1",
"daleui": "^1.5.0",
"drizzle-orm": "^0.45.2",
"react": "^19.3.0",
Expand Down
40 changes: 29 additions & 11 deletions src/components/Admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ import { BRAND } from '@/components/ResultChart'
import { koDate } from '@/lib/date'
import type { AdminSummary } from '@/server/admin'

// 원본 목록과 상세가 위아래로 쌓이는 너비. 화면(CSS)과 응답을 고를 때 스크롤(JS)이 같은 값을 본다.
export const STACKED = '(max-width: 900px)'

// 인라인 스타일로는 못 하는 것(:focus-visible, 화면 너비별 규칙, 탭의 활성 표시)만 둔다.
// 인라인 스타일로는 못 하는 것(: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); }
Expand All @@ -22,18 +19,39 @@ export const ADMIN_CSS = `
.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-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-row:focus-visible { outline: 2px solid var(--colors-border-brand-focus); outline-offset: -2px; }
.admin-raw { display: grid; grid-template-columns: minmax(0, 420px) minmax(0, 1fr); gap: var(--spacing-16); align-items: start; }
.admin-back { display: none; }
.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 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; }
.admin-table tr.admin-detail > td { padding: var(--spacing-8) var(--spacing-16) var(--spacing-20) 5.5rem; }
.admin-sort, .admin-expand { display: inline-flex; align-items: center; gap: var(--spacing-4); min-height: 32px; padding: 0; border: 0; background: none; font: inherit; color: inherit; text-align: left; cursor: pointer; }
.admin-expand { font-weight: 600; color: var(--colors-fg-brand); font-variant-numeric: tabular-nums; }
.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-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); }

@media (max-width: 640px) {
.admin-stats { gap: var(--spacing-8); }
.admin-stat { padding: var(--spacing-12); gap: var(--spacing-4); }
.admin-stat-label { font-size: 0.75rem; }
.admin-stat-value { font-size: 1.5rem; }
}
@media ${STACKED} {
.admin-raw { grid-template-columns: minmax(0, 1fr); }
.admin-back { display: inline-flex; }
/* 휴대폰에서는 행을 카드처럼 쌓고, 머리글은 정렬 버튼 줄로 남긴다 */
.admin-table, .admin-table tbody, .admin-table tr, .admin-table td { display: block; width: auto; }
.admin-table thead tr { display: flex; flex-wrap: wrap; gap: 0 var(--spacing-12); padding: var(--spacing-4) var(--spacing-12); border-bottom: 1px solid var(--colors-border-neutral); }
.admin-table thead th { width: auto !important; padding: 0; border: 0; }
.admin-table tbody tr { padding: var(--spacing-8) 0; border-bottom: 1px solid var(--colors-bg-neutral-hover); }
.admin-table td { padding: var(--spacing-2) var(--spacing-12); border: 0; }
.admin-table td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; font-size: 0.75rem; color: var(--colors-fg-neutral-placeholder); }
.admin-table tr.admin-detail > td { padding: var(--spacing-8) var(--spacing-12) var(--spacing-12); }
}
`

Expand Down
Loading
Loading