feat(study): iPad 손글씨 학습 세션 frontend (Phase 1)

PR-2: 자격증/어학 학습 세션 UI. iPad Safari + Apple Pencil 지원.

신규 컴포넌트:
- HandwriteCanvas — perfect-freehand + PointerEvents (압력/tilt) +
  palm rejection (pointerType==='pen') + DPR + touch-action:none +
  stroke 단위 undo/redo + 5초 idle / 5 stroke 자동 저장 +
  localStorage 백업 + PNG snapshot export
- StudyMetaEditor — study_type(certification/language) 토글, 자격증/어학
  분기 메타 입력, 어학 metadata.reading/meaning/unit_type
- SourceTextPanel — 원문 + 어학 메타 read-only 표시
- AssetList — 연결된 audio/video/scan/handwriting 표시 + 재생 + 연결/해제

라우트:
- /study → /study/write 리다이렉트
- /study/write — 도메인 토글 + 빠른 시작 폼 + 세션 목록
- /study/write/[id] — 좌측 메타/원문/asset, 우측 캔버스 (md+ 분할,
  모바일 위/아래)

Layout/Sidebar:
- 상단 nav 에 "공부" 추가 (메모와 뉴스 사이)
- Sidebar 메모/Inbox 섹션에 GraduationCap 아이콘 항목 추가

기타:
- frontend/package.json: perfect-freehand ^1.2.3 (MIT)
- THIRD_PARTY_LICENSES.md 신규 — perfect-freehand MIT 고지

플랜: ~/.claude/plans/scalable-chasing-stonebraker.md (PR-2)
신규 파일 lint:tokens 회귀 0 (기존 잔존 130 그대로).
This commit is contained in:
Hyungi Ahn
2026-04-27 08:30:28 +09:00
parent e8c348ab21
commit 475a542ea3
12 changed files with 1191 additions and 3 deletions
+8 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"dompurify": "^3.3.3",
"lucide-svelte": "^0.400.0",
"marked": "^15.0.0"
"marked": "^15.0.0",
"perfect-freehand": "^1.2.3"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.0",
@@ -1850,6 +1851,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/perfect-freehand": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz",
"integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -20,6 +20,7 @@
"dependencies": {
"dompurify": "^3.3.3",
"lucide-svelte": "^0.400.0",
"marked": "^15.0.0"
"marked": "^15.0.0",
"perfect-freehand": "^1.2.3"
}
}
@@ -0,0 +1,112 @@
<script lang="ts">
/**
* AssetList — 학습 세션에 연결된 documents (assets) 목록.
*
* Phase 1 표시:
* - handwriting_png: PNG snapshot 이력
* - audio: 재생 버튼 (브라우저 native <audio> 활용)
* - video: thumbnail 또는 재생 링크
* - source_scan / transcript / reference: 텍스트 링크
*
* "기존 문서 연결" 버튼은 Phase 1 에선 document_id 직접 입력 prompt() 로 단순화.
* Phase 2 에서 search 모달로 교체.
*/
import { Trash2, Headphones, Image as ImageIcon, Film, FileText, Plus } from 'lucide-svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
export interface AssetItem {
id: number;
document_id: number;
asset_type: 'source_scan' | 'handwriting_png' | 'audio' | 'video' | 'transcript' | 'reference';
role: string | null;
sort_order: number;
created_at: string;
}
interface Props {
assets: AssetItem[];
onLink?: (input: { document_id: number; asset_type: string; role: string | null }) => void;
onUnlink?: (assetId: number) => void;
}
let { assets, onLink, onUnlink }: Props = $props();
function iconFor(type: AssetItem['asset_type']) {
switch (type) {
case 'audio': return Headphones;
case 'video': return Film;
case 'handwriting_png':
case 'source_scan': return ImageIcon;
default: return FileText;
}
}
function labelFor(type: AssetItem['asset_type']) {
switch (type) {
case 'audio': return '오디오';
case 'video': return '영상';
case 'handwriting_png': return '필기';
case 'source_scan': return '스캔';
case 'transcript': return '자막';
case 'reference': return '참고';
}
}
function promptLink() {
const idStr = prompt('연결할 document_id 입력:');
if (!idStr) return;
const id = parseInt(idStr, 10);
if (Number.isNaN(id)) return;
const type = prompt('asset_type (source_scan / handwriting_png / audio / video / transcript / reference):', 'audio');
if (!type) return;
const role = prompt('role (선택, 비워도 됨 — pronunciation/lecture/listening_source 등):', '') || null;
onLink?.({ document_id: id, asset_type: type, role });
}
</script>
<div class="flex flex-col gap-1 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-dim">연결된 자료 ({assets.length})</span>
<button
type="button"
onclick={promptLink}
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-accent hover:bg-accent/10"
>
<Plus size={12} /> 문서 연결
</button>
</div>
{#if assets.length === 0}
<div class="text-xs text-dim py-2">연결된 자료 없음. 오디오/영상/스캔 교재를 연결하세요.</div>
{:else}
{#each assets as a (a.id)}
{@const Icon = iconFor(a.asset_type)}
<div class="flex items-center gap-2 px-2 py-1.5 rounded border border-default bg-surface">
<Icon size={14} class="text-dim shrink-0" />
<span class="text-xs text-text shrink-0">{labelFor(a.asset_type)}</span>
{#if a.role}
<span class="text-[10px] text-dim shrink-0">· {a.role}</span>
{/if}
{#if a.asset_type === 'audio'}
<audio controls preload="none" src={`/api/documents/${a.document_id}/file?disposition=inline`} class="flex-1 min-w-0 h-6">
<track kind="captions" />
</audio>
{:else if a.asset_type === 'video'}
<a
href={`/video/${a.document_id}`}
class="text-xs text-accent hover:underline flex-1 truncate"
>video #{a.document_id} 보기</a>
{:else}
<a
href={`/documents/${a.document_id}`}
class="text-xs text-accent hover:underline flex-1 truncate"
>document #{a.document_id}</a>
{/if}
<IconButton
icon={Trash2}
size="sm"
aria-label="연결 해제"
onclick={() => onUnlink?.(a.id)}
/>
</div>
{/each}
{/if}
</div>
@@ -0,0 +1,363 @@
<script lang="ts">
/**
* HandwriteCanvas — Apple Pencil + 일반 터치/마우스 손글씨 캔버스.
*
* 핵심 (사용자 강조):
* - PointerEvent 만 사용. pointerType === 'pen' 검사 (palm rejection)
* - e.pressure (0~1, Pencil) / e.tiltX / e.tiltY → perfect-freehand
* - touch-action: none (캔버스 한정 — 페이지 다른 영역 영향 없음)
* - devicePixelRatio 반영 (retina 선명)
* - stroke 단위 undo/redo
* - 5초 idle 또는 stroke 5개마다 onChange — 부모가 PATCH 트리거
* - 마운트 시 strokes_json 으로 전체 redraw (세션 복원)
* - localStorage 백업 (네트워크 단절 대비)
*
* Phase 1 단순화: 검정 단색, 단일 굵기. 색/굵기 팔레트는 Phase 2+.
*/
import { onMount, onDestroy } from 'svelte';
import { getStroke } from 'perfect-freehand';
import { Eraser, Undo2, Redo2 } from 'lucide-svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
type Point = [number, number, number]; // [x, y, pressure]
type Stroke = {
id: string;
points: Point[];
};
export type StrokesJson = {
version: 1;
strokes: Stroke[];
};
interface Props {
sessionId: number;
initialStrokes?: StrokesJson | null;
/** 트레이싱 모드 — 캔버스 배경에 회색으로 깔리는 텍스트. Phase 1 에선 단순 텍스트만. */
traceText?: string | null;
/** 펜 하단 굵기 (default 4). kanji 면 약간 굵게 권장. */
baseSize?: number;
/** 부모가 PATCH /api/study-sessions/{id} 로 strokes_json 저장. */
onChange?: (strokes: StrokesJson) => void;
/** 부모가 POST /snapshot 로 PNG 업로드 — Blob 전달. */
onSnapshot?: (blob: Blob) => Promise<void>;
}
let {
sessionId,
initialStrokes = null,
traceText = null,
baseSize = 4,
onChange,
onSnapshot,
}: Props = $props();
// ── 상태 ──
let canvas = $state<HTMLCanvasElement | null>(null);
let containerEl = $state<HTMLDivElement | null>(null);
let cssWidth = $state(800);
let cssHeight = $state(600);
let strokes = $state<Stroke[]>(initialStrokes?.strokes ?? []);
let undoStack = $state<Stroke[]>([]); // 삭제된 stroke 들 (redo 용)
let inflight: Stroke | null = $state(null); // 진행 중 stroke (포인터 떼기 전)
let activePointerId: number | null = null;
let isDirty = $state(false);
let saveTimer: number | null = null;
let snapshotting = $state(false);
let snapshotErr = $state<string | null>(null);
// ── localStorage backup ──
const lsKey = $derived(`study_session_${sessionId}_strokes`);
function backupToLocalStorage() {
try {
localStorage.setItem(lsKey, JSON.stringify({ version: 1, strokes }));
} catch {
// quota / 비활성 — 무시
}
}
function restoreFromLocalStorageIfNewer() {
try {
const raw = localStorage.getItem(lsKey);
if (!raw) return;
const parsed = JSON.parse(raw) as StrokesJson;
// initialStrokes 가 비어있고 localStorage 에 데이터 있으면 복원 (네트워크 단절 후 새로고침 대비)
if ((initialStrokes?.strokes?.length ?? 0) === 0 && parsed.strokes.length > 0) {
strokes = parsed.strokes;
scheduleSave(); // 서버에 푸시
}
} catch {
// 깨진 데이터 — 무시
}
}
// ── DPR 반영 + resize ──
function resizeCanvas() {
if (!canvas || !containerEl) return;
const rect = containerEl.getBoundingClientRect();
cssWidth = Math.max(200, Math.floor(rect.width));
cssHeight = Math.max(200, Math.floor(rect.height));
const dpr = window.devicePixelRatio || 1;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const ctx = canvas.getContext('2d');
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw();
}
// ── render ──
function strokeToPath(stroke: Stroke): Path2D {
const outlinePoints = getStroke(stroke.points, {
size: baseSize,
thinning: 0.55,
smoothing: 0.5,
streamline: 0.4,
simulatePressure: false, // 실제 압력 사용
last: stroke !== inflight, // 진행 중 stroke 면 false → 끝점 둥글게 마감 안 함
});
const path = new Path2D();
if (outlinePoints.length === 0) return path;
path.moveTo(outlinePoints[0][0], outlinePoints[0][1]);
for (let i = 1; i < outlinePoints.length; i++) {
path.lineTo(outlinePoints[i][0], outlinePoints[i][1]);
}
path.closePath();
return path;
}
function drawTraceBackground(ctx: CanvasRenderingContext2D) {
if (!traceText) return;
ctx.save();
ctx.fillStyle = 'rgba(120, 120, 120, 0.18)'; // 옅은 회색, 토큰 미사용 — 캔버스 직접 페인트라 lint:tokens 적용 불가
// 단순 가이드: 텍스트를 캔버스 중앙에 큰 폰트로 깐다. 줄바꿈 없음 (Phase 1).
const fontSize = Math.min(cssWidth, cssHeight) * 0.4;
ctx.font = `${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(traceText, cssWidth / 2, cssHeight / 2);
ctx.restore();
}
function redraw() {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, cssWidth, cssHeight);
drawTraceBackground(ctx);
ctx.fillStyle = '#111111';
for (const stroke of strokes) {
ctx.fill(strokeToPath(stroke));
}
if (inflight) {
ctx.fill(strokeToPath(inflight));
}
}
// ── pointer 핸들러 ──
function getLocalXY(e: PointerEvent): [number, number] {
if (!canvas) return [0, 0];
const rect = canvas.getBoundingClientRect();
return [e.clientX - rect.left, e.clientY - rect.top];
}
function isPenLike(e: PointerEvent): boolean {
// pen / mouse 는 stroke. touch (손가락) 는 거부 — palm rejection.
// 데스크톱 마우스 테스트도 가능하게 mouse 허용. iPad 에선 pen 만 들어옴.
return e.pointerType === 'pen' || e.pointerType === 'mouse';
}
function onPointerDown(e: PointerEvent) {
if (!canvas || !isPenLike(e)) return;
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
activePointerId = e.pointerId;
const [x, y] = getLocalXY(e);
inflight = {
id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
points: [[x, y, e.pressure || 0.5]],
};
redraw();
}
function onPointerMove(e: PointerEvent) {
if (!inflight || e.pointerId !== activePointerId) return;
const [x, y] = getLocalXY(e);
// 일부 브라우저는 coalesced events 제공 — 곡선 부드러움 향상.
const events = (e.getCoalescedEvents?.() ?? [e]) as PointerEvent[];
for (const ev of events) {
const [cx, cy] = getLocalXY(ev);
inflight.points.push([cx, cy, ev.pressure || 0.5]);
}
if (events.length === 0) inflight.points.push([x, y, e.pressure || 0.5]);
redraw();
}
function endStroke(e: PointerEvent) {
if (!inflight || e.pointerId !== activePointerId) return;
canvas?.releasePointerCapture?.(e.pointerId);
activePointerId = null;
if (inflight.points.length > 1) {
strokes = [...strokes, inflight];
undoStack = []; // 새 stroke 가 들어오면 redo 큐 비움 (Anki 식)
isDirty = true;
backupToLocalStorage();
scheduleSave();
}
inflight = null;
redraw();
}
// ── undo/redo/clear ──
function undo() {
if (strokes.length === 0) return;
const last = strokes[strokes.length - 1];
strokes = strokes.slice(0, -1);
undoStack = [...undoStack, last];
isDirty = true;
backupToLocalStorage();
scheduleSave();
redraw();
}
function redo() {
if (undoStack.length === 0) return;
const last = undoStack[undoStack.length - 1];
undoStack = undoStack.slice(0, -1);
strokes = [...strokes, last];
isDirty = true;
backupToLocalStorage();
scheduleSave();
redraw();
}
function clearAll() {
if (strokes.length === 0) return;
if (!confirm('이 세션의 모든 stroke 를 지웁니다. 계속할까요?')) return;
undoStack = [...undoStack, ...strokes];
strokes = [];
isDirty = true;
backupToLocalStorage();
scheduleSave();
redraw();
}
// ── 자동 저장 디바운스 ──
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = window.setTimeout(flushSave, 5000) as unknown as number;
// stroke 5개 마다 즉시 flush
if (strokes.length > 0 && strokes.length % 5 === 0) {
flushSave();
}
}
function flushSave() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (!isDirty) return;
isDirty = false;
onChange?.({ version: 1, strokes });
}
// ── snapshot (PNG) ──
async function takeSnapshot() {
if (!canvas || !onSnapshot) return;
if (snapshotting) return;
snapshotting = true;
snapshotErr = null;
try {
// 진행 중 stroke 가 있다면 마지막 redraw 보장
redraw();
// 미저장 stroke 가 있으면 먼저 flush
flushSave();
const blob: Blob | null = await new Promise((resolve) =>
canvas!.toBlob((b) => resolve(b), 'image/png')
);
if (!blob) throw new Error('PNG 생성 실패');
await onSnapshot(blob);
} catch (e) {
snapshotErr = (e as { detail?: string; message?: string }).detail
|| (e as Error).message
|| '스냅샷 저장 실패';
} finally {
snapshotting = false;
}
}
// ── beforeunload flush ──
function onBeforeUnload() {
if (isDirty) flushSave();
}
// ── 마운트 ──
let resizeObserver: ResizeObserver | null = null;
onMount(() => {
resizeCanvas();
restoreFromLocalStorageIfNewer();
redraw();
if (containerEl && 'ResizeObserver' in window) {
resizeObserver = new ResizeObserver(() => resizeCanvas());
resizeObserver.observe(containerEl);
}
window.addEventListener('beforeunload', onBeforeUnload);
});
onDestroy(() => {
flushSave();
resizeObserver?.disconnect();
if (typeof window !== 'undefined') {
window.removeEventListener('beforeunload', onBeforeUnload);
}
});
// initialStrokes 가 부모에서 바뀌면 (예: navigate) 따라가기
$effect(() => {
if (initialStrokes && initialStrokes.strokes !== strokes) {
strokes = initialStrokes.strokes;
undoStack = [];
redraw();
}
});
// traceText 변경 시 redraw
$effect(() => {
void traceText;
redraw();
});
</script>
<div class="flex flex-col h-full">
<!-- 툴바 -->
<div class="flex items-center gap-1 px-2 py-1 border-b border-default bg-surface shrink-0">
<IconButton icon={Undo2} size="sm" aria-label="실행 취소" onclick={undo} disabled={strokes.length === 0} />
<IconButton icon={Redo2} size="sm" aria-label="다시 실행" onclick={redo} disabled={undoStack.length === 0} />
<IconButton icon={Eraser} size="sm" aria-label="모두 지우기" onclick={clearAll} disabled={strokes.length === 0} />
<span class="text-xs text-dim ml-2">stroke {strokes.length}</span>
<div class="flex-1"></div>
{#if snapshotErr}
<span class="text-xs text-error mr-2">{snapshotErr}</span>
{/if}
<button
type="button"
onclick={takeSnapshot}
disabled={snapshotting || strokes.length === 0}
class="px-3 py-1 rounded text-sm bg-accent text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{snapshotting ? '저장 중...' : 'PNG 저장'}
</button>
</div>
<!-- 캔버스 컨테이너 -->
<div bind:this={containerEl} class="flex-1 min-h-0 bg-bg relative overflow-hidden">
<canvas
bind:this={canvas}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={endStroke}
onpointercancel={endStroke}
onpointerleave={(e) => { if (e.pointerId === activePointerId) endStroke(e); }}
class="block"
style="touch-action: none; cursor: crosshair;"
></canvas>
</div>
</div>
+11 -1
View File
@@ -2,7 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap } from 'lucide-svelte';
let tree = $state([]);
let loading = $state(true);
@@ -209,6 +209,16 @@
메모
</span>
</a>
<a
href="/study"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
{$page.url.pathname.startsWith('/study') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
>
<span class="flex items-center gap-2">
<GraduationCap size={16} />
공부
</span>
</a>
<a
href="/inbox"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-text hover:bg-surface transition-colors"
@@ -0,0 +1,62 @@
<script lang="ts">
/**
* SourceTextPanel — 학습 세션의 원문/어학 메타 표시 (read-only).
*
* 캔버스 옆 또는 위쪽에 깔린다. 트레이싱 모드(mode='trace')에선 캔버스가 직접
* traceText 를 배경으로 그리므로 여기선 별도 표시 안 해도 되지만, 표시 보존.
*/
type Mode = 'copy' | 'trace' | 'blank-repeat' | 'dictation' | 'shadowing' | 'quiz' | 'flashcard';
interface Props {
sourceText?: string | null;
sourcePage?: number | null;
metadata?: Record<string, unknown> | null;
mode?: Mode;
studyType?: 'certification' | 'language';
}
let { sourceText, sourcePage, metadata, mode, studyType }: Props = $props();
let m = $derived((metadata ?? {}) as Record<string, string>);
</script>
<div class="flex flex-col gap-2 p-3 border-b border-default bg-surface">
{#if sourceText}
<div>
<div class="text-xs text-dim mb-1">
원문 {sourcePage ? `(p.${sourcePage})` : ''}
</div>
<div class="text-sm text-text whitespace-pre-wrap break-words">{sourceText}</div>
</div>
{:else}
<div class="text-xs text-dim">원문 미설정 — 메타 편집기에서 입력</div>
{/if}
{#if studyType === 'language' && metadata}
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs mt-1 pt-2 border-t border-default">
{#if m.reading}
<div><span class="text-dim">reading:</span> <span class="text-text">{m.reading}</span></div>
{/if}
{#if m.meaning}
<div><span class="text-dim">meaning:</span> <span class="text-text">{m.meaning}</span></div>
{/if}
{#if m.unit_type}
<div><span class="text-dim">unit:</span> <span class="text-text">{m.unit_type}</span></div>
{/if}
{#if m.romaji}
<div><span class="text-dim">romaji:</span> <span class="text-text">{m.romaji}</span></div>
{/if}
{#if m.example_sentence}
<div class="col-span-2">
<span class="text-dim">example:</span> <span class="text-text">{m.example_sentence}</span>
</div>
{/if}
</div>
{/if}
{#if mode === 'trace' && sourceText}
<div class="text-[10px] text-dim mt-1">
트레이싱 모드 — 캔버스 배경에 회색으로 깔린 원문 위에 펜으로 덮어쓰세요.
</div>
{/if}
</div>
@@ -0,0 +1,182 @@
<script lang="ts">
/**
* StudyMetaEditor — 학습 세션의 도메인 메타 편집.
*
* - study_type 토글: certification / language
* - 자격증: certification + learning_level + subject + topic
* - 어학: language_code + learning_level + subject + topic + metadata.reading/meaning/unit_type
* - source_text + source_page (공통)
* - mode 선택 (copy / trace / blank-repeat / dictation / shadowing)
*
* 부모가 onPatch(partial) 호출로 PATCH /api/study-sessions/{id} 트리거.
* Phase 1 단순화: source_document 검색 모달 없음 — source_text 직접 입력만.
* (Phase 2 에서 /library 검색 모달 추가)
*/
import TextInput from '$lib/components/ui/TextInput.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
type StudyType = 'certification' | 'language';
type Mode = 'copy' | 'trace' | 'blank-repeat' | 'dictation' | 'shadowing';
export interface StudyMeta {
study_type: StudyType;
certification?: string | null;
language_code?: string | null;
learning_level?: string | null;
subject?: string | null;
topic?: string | null;
source_text?: string | null;
source_page?: number | null;
mode: Mode;
metadata?: Record<string, unknown> | null;
}
interface Props {
meta: StudyMeta;
onPatch?: (partial: Partial<StudyMeta>) => void;
}
let { meta, onPatch }: Props = $props();
// 어학 metadata 의 자주 쓰는 필드를 분리해 binding (NULL 안전 처리)
let lm = $derived((meta.metadata ?? {}) as Record<string, string>);
function patchMetadata(key: string, value: string) {
const next = { ...(meta.metadata ?? {}), [key]: value || undefined };
// undefined 키 제거
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(next)) if (v !== undefined && v !== '') cleaned[k] = v;
onPatch?.({ metadata: cleaned });
}
const MODE_OPTIONS = [
{ value: 'copy', label: '필사 (copy)' },
{ value: 'trace', label: '트레이싱 (trace)' },
{ value: 'blank-repeat', label: '깜지 (blank-repeat)' },
{ value: 'dictation', label: '받아쓰기 (dictation)' },
{ value: 'shadowing', label: '쉐도잉 (shadowing)' },
];
const LANG_OPTIONS = [
{ value: '', label: '(미지정)' },
{ value: 'ja', label: '일본어 (ja)' },
{ value: 'en', label: '영어 (en)' },
{ value: 'zh', label: '중국어 (zh)' },
{ value: 'ko', label: '한국어 (ko)' },
];
const UNIT_TYPE_OPTIONS = [
{ value: '', label: '(미지정)' },
{ value: 'kanji', label: '한자' },
{ value: 'vocabulary', label: '단어' },
{ value: 'sentence', label: '문장' },
{ value: 'listening', label: '듣기' },
{ value: 'shadowing', label: '쉐도잉' },
];
</script>
<div class="flex flex-col gap-3 p-3">
<!-- study_type 토글 -->
<div class="flex items-center gap-2">
<span class="text-xs text-dim w-20">도메인</span>
<div class="flex gap-1">
<button
type="button"
onclick={() => onPatch?.({ study_type: 'certification' })}
class="px-3 py-1 rounded text-xs transition-colors
{meta.study_type === 'certification' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
>자격증</button>
<button
type="button"
onclick={() => onPatch?.({ study_type: 'language' })}
class="px-3 py-1 rounded text-xs transition-colors
{meta.study_type === 'language' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
>어학</button>
</div>
</div>
<!-- 학습 모드 -->
<Select
label="학습 모드"
value={meta.mode}
options={MODE_OPTIONS}
onchange={(e: Event) => onPatch?.({ mode: (e.target as HTMLSelectElement).value as Mode })}
/>
{#if meta.study_type === 'certification'}
<TextInput
label="자격증"
value={meta.certification ?? ''}
placeholder="예: 산업안전기사"
onblur={(e: FocusEvent) => onPatch?.({ certification: (e.target as HTMLInputElement).value })}
/>
<TextInput
label="학습 레벨"
value={meta.learning_level ?? ''}
placeholder="예: 1차, 2차"
onblur={(e: FocusEvent) => onPatch?.({ learning_level: (e.target as HTMLInputElement).value })}
/>
{:else}
<Select
label="언어"
value={meta.language_code ?? ''}
options={LANG_OPTIONS}
onchange={(e: Event) => onPatch?.({ language_code: (e.target as HTMLSelectElement).value || null })}
/>
<TextInput
label="학습 레벨"
value={meta.learning_level ?? ''}
placeholder="예: JLPT N3, TOEIC 750"
onblur={(e: FocusEvent) => onPatch?.({ learning_level: (e.target as HTMLInputElement).value })}
/>
{/if}
<TextInput
label="과목 (subject)"
value={meta.subject ?? ''}
placeholder={meta.study_type === 'language' ? '예: 漢字, 어휘' : '예: 산업안전보건법'}
onblur={(e: FocusEvent) => onPatch?.({ subject: (e.target as HTMLInputElement).value })}
/>
<TextInput
label="주제 (topic)"
value={meta.topic ?? ''}
placeholder={meta.study_type === 'language' ? '예: 安全' : '예: 안전보건관리책임자의 직무'}
onblur={(e: FocusEvent) => onPatch?.({ topic: (e.target as HTMLInputElement).value })}
/>
<!-- 원문 텍스트 snapshot -->
<Textarea
label="원문 텍스트 (snapshot)"
value={meta.source_text ?? ''}
rows={3}
placeholder={meta.study_type === 'language' ? '예: 安全' : '예: 산업안전보건법 제15조 발췌'}
onblur={(e: FocusEvent) => onPatch?.({ source_text: (e.target as HTMLTextAreaElement).value })}
/>
{#if meta.study_type === 'language'}
<!-- 어학 전용 metadata -->
<Select
label="단위 (unit_type)"
value={(lm.unit_type as string) ?? ''}
options={UNIT_TYPE_OPTIONS}
onchange={(e: Event) => patchMetadata('unit_type', (e.target as HTMLSelectElement).value)}
/>
<TextInput
label="reading"
value={(lm.reading as string) ?? ''}
placeholder="예: あんぜん"
onblur={(e: FocusEvent) => patchMetadata('reading', (e.target as HTMLInputElement).value)}
/>
<TextInput
label="meaning (한국어 뜻)"
value={(lm.meaning as string) ?? ''}
placeholder="예: 안전"
onblur={(e: FocusEvent) => patchMetadata('meaning', (e.target as HTMLInputElement).value)}
/>
<Textarea
label="example_sentence"
value={(lm.example_sentence as string) ?? ''}
rows={2}
placeholder="예: 安全第一です。"
onblur={(e: FocusEvent) => patchMetadata('example_sentence', (e.target as HTMLTextAreaElement).value)}
/>
{/if}
</div>
+1
View File
@@ -97,6 +97,7 @@
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" href="/ask" class={isActive('/ask') ? 'text-accent' : ''}>질문</Button>
<Button variant="ghost" size="sm" href="/memos" class={isActive('/memos') ? 'text-accent' : ''}>메모</Button>
<Button variant="ghost" size="sm" href="/study" class={isActive('/study') ? 'text-accent' : ''}>공부</Button>
<Button variant="ghost" size="sm" href="/news" class={isActive('/news') ? 'text-accent' : ''}>뉴스</Button>
<Button variant="ghost" size="sm" href="/inbox" class={isActive('/inbox') ? 'text-accent' : ''}>Inbox</Button>
<div class="relative">
+13
View File
@@ -0,0 +1,13 @@
<script>
// /study — Phase 1 진입점.
// Phase 3 부터는 디바이스 분기 (iPad → /study/write, 모바일 → /study/review) 추가 예정.
// Phase 1 에선 단순히 /study/write 로 navigate.
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
onMount(() => {
goto('/study/write', { replaceState: true });
});
</script>
<div class="p-6 text-sm text-dim">학습 페이지로 이동 중...</div>
@@ -0,0 +1,223 @@
<script>
/**
* /study/write — 학습 세션 목록 + 새 세션 시작.
*
* 자격증/어학 study_type 토글 + 빠른 시작 폼 (필수 메타 최소만 입력 → 생성 후 작업 화면으로 이동).
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { Plus, BookOpen, Languages, ArrowRight, Trash2 } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
let studyType = $state('certification'); // 'certification' | 'language'
let items = $state([]);
let total = $state(0);
let loading = $state(true);
// 빠른 시작 폼
let formOpen = $state(false);
let f_certification = $state('');
let f_language_code = $state('ja');
let f_learning_level = $state('');
let f_subject = $state('');
let f_topic = $state('');
let f_source_text = $state('');
let f_mode = $state('copy');
let creating = $state(false);
const MODE_OPTIONS = [
{ value: 'copy', label: '필사 (copy)' },
{ value: 'trace', label: '트레이싱 (trace)' },
{ value: 'blank-repeat', label: '깜지 (blank-repeat)' },
{ value: 'dictation', label: '받아쓰기 (dictation)' },
{ value: 'shadowing', label: '쉐도잉 (shadowing)' },
];
const LANG_OPTIONS = [
{ value: 'ja', label: '일본어 (ja)' },
{ value: 'en', label: '영어 (en)' },
{ value: 'zh', label: '중국어 (zh)' },
{ value: 'ko', label: '한국어 (ko)' },
];
async function load() {
loading = true;
try {
const res = await api(`/study-sessions/?study_type=${studyType}&limit=50`);
items = res.items;
total = res.total;
} catch (err) {
addToast('error', '학습 세션 로딩 실패');
} finally {
loading = false;
}
}
$effect(() => {
void studyType;
load();
});
onMount(load);
async function createSession() {
creating = true;
try {
const body = {
study_type: studyType,
learning_level: f_learning_level || null,
subject: f_subject || null,
topic: f_topic || null,
source_text: f_source_text || null,
mode: f_mode,
};
if (studyType === 'certification') {
body.certification = f_certification || null;
} else {
body.language_code = f_language_code || null;
}
const sess = await api('/study-sessions/', {
method: 'POST',
body: JSON.stringify(body),
});
addToast('success', '학습 세션 생성됨');
goto(`/study/write/${sess.id}`);
} catch (err) {
addToast('error', err.detail || '세션 생성 실패');
} finally {
creating = false;
}
}
async function removeSession(id) {
if (!confirm('이 학습 세션을 삭제합니다. 연결된 documents 본체는 유지됩니다.')) return;
try {
await api(`/study-sessions/${id}`, { method: 'DELETE' });
items = items.filter((s) => s.id !== id);
total -= 1;
addToast('success', '삭제됨');
} catch (err) {
addToast('error', err.detail || '삭제 실패');
}
}
function fmtDate(s) {
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' });
}
</script>
<div class="p-4 md:p-6 max-w-5xl mx-auto">
<!-- 헤더 + 도메인 토글 -->
<div class="flex items-center justify-between mb-4">
<h1 class="text-lg font-semibold text-text">공부</h1>
<div class="flex items-center gap-1 bg-surface rounded-lg p-1 border border-default">
<button
type="button"
onclick={() => (studyType = 'certification')}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors
{studyType === 'certification' ? 'bg-accent/15 text-accent' : 'text-dim hover:text-text'}"
>
<BookOpen size={14} /> 자격증
</button>
<button
type="button"
onclick={() => (studyType = 'language')}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors
{studyType === 'language' ? 'bg-accent/15 text-accent' : 'text-dim hover:text-text'}"
>
<Languages size={14} /> 어학
</button>
</div>
</div>
<!-- 빠른 시작 -->
<Card class="mb-4">
{#snippet children()}
<div class="p-3">
{#if !formOpen}
<Button onclick={() => (formOpen = true)} icon={Plus} size="sm">새 학습 세션</Button>
{:else}
<div class="flex flex-col gap-3">
<div class="text-sm font-semibold text-text">{studyType === 'certification' ? '자격증' : '어학'} 세션</div>
<Select
label="학습 모드"
bind:value={f_mode}
options={MODE_OPTIONS}
/>
{#if studyType === 'certification'}
<TextInput label="자격증" bind:value={f_certification} placeholder="예: 산업안전기사" />
<TextInput label="학습 레벨" bind:value={f_learning_level} placeholder="예: 1차" />
{:else}
<Select label="언어" bind:value={f_language_code} options={LANG_OPTIONS} />
<TextInput label="학습 레벨" bind:value={f_learning_level} placeholder="예: JLPT N3" />
{/if}
<TextInput label="과목" bind:value={f_subject} placeholder={studyType === 'language' ? '예: 漢字' : '예: 산업안전보건법'} />
<TextInput label="주제" bind:value={f_topic} placeholder={studyType === 'language' ? '예: 安全' : '예: 안전보건관리책임자의 직무'} />
<Textarea label="원문 텍스트" bind:value={f_source_text} rows={3} placeholder="발췌 원문 또는 학습 단어/문장" />
<div class="flex gap-2 justify-end">
<Button variant="ghost" onclick={() => (formOpen = false)} disabled={creating}>취소</Button>
<Button onclick={createSession} loading={creating}>생성 후 시작</Button>
</div>
</div>
{/if}
</div>
{/snippet}
</Card>
<!-- 목록 -->
{#if loading}
<div class="space-y-2">
{#each Array(3) as _}
<Skeleton class="h-16" />
{/each}
</div>
{:else if items.length === 0}
<EmptyState
title="학습 세션이 없습니다"
description="위 버튼으로 첫 세션을 시작하세요."
/>
{:else}
<div class="text-xs text-dim mb-2">{total}</div>
<div class="flex flex-col gap-2">
{#each items as s (s.id)}
<div class="flex items-center gap-3 p-3 rounded-lg border border-default bg-surface hover:bg-surface/80">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-sm">
{#if s.study_type === 'language'}
<Languages size={14} class="text-dim" />
<span class="text-dim text-xs">{s.language_code || '?'} · {s.learning_level || ''}</span>
{:else}
<BookOpen size={14} class="text-dim" />
<span class="text-dim text-xs">{s.certification || '미지정'}</span>
{/if}
<span class="text-dim text-xs">·</span>
<span class="text-dim text-xs">{s.mode}</span>
</div>
<div class="text-sm text-text mt-1 truncate">
{s.subject || '(과목 미지정)'}{s.topic || '(주제 미지정)'}
</div>
<div class="text-[10px] text-dim mt-1">
stroke {Array.isArray(s.strokes_json?.strokes) ? s.strokes_json.strokes.length : 0}
· assets {s.assets?.length ?? 0}
· {fmtDate(s.created_at)}
</div>
</div>
<Button href={`/study/write/${s.id}`} size="sm" variant="ghost" icon={ArrowRight} iconPosition="right">열기</Button>
<button
type="button"
onclick={() => removeSession(s.id)}
class="p-1.5 rounded hover:bg-error/10 text-dim hover:text-error"
aria-label="삭제"
><Trash2 size={14} /></button>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,180 @@
<script>
/**
* /study/write/[id] — 학습 세션 작업 화면.
*
* 좌측: SourceTextPanel + StudyMetaEditor + AssetList
* 우측: HandwriteCanvas (Apple Pencil)
*
* 자동 저장: HandwriteCanvas onChange → PATCH strokes_json
* snapshot: HandwriteCanvas onSnapshot → POST /snapshot (multipart)
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, uploadFile } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, RotateCw } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
import SourceTextPanel from '$lib/components/SourceTextPanel.svelte';
import StudyMetaEditor from '$lib/components/StudyMetaEditor.svelte';
import AssetList from '$lib/components/AssetList.svelte';
let sessionId = $derived(parseInt($page.params.id, 10));
let sess = $state(null);
let loading = $state(true);
let repCount = $state(0);
async function load() {
loading = true;
try {
sess = await api(`/study-sessions/${sessionId}`);
repCount = sess.repetition_count;
} catch (err) {
addToast('error', err.detail || '세션 로딩 실패');
} finally {
loading = false;
}
}
onMount(load);
async function patchSession(partial) {
try {
const updated = await api(`/study-sessions/${sessionId}`, {
method: 'PATCH',
body: JSON.stringify(partial),
});
sess = updated;
} catch (err) {
addToast('error', err.detail || '저장 실패');
}
}
async function onStrokesChange(strokesJson) {
// Phase 1: 부모에서 디바운스 안 하고 자식 (HandwriteCanvas) 디바운스를 신뢰.
try {
await api(`/study-sessions/${sessionId}`, {
method: 'PATCH',
body: JSON.stringify({ strokes_json: strokesJson }),
});
} catch (err) {
// 자동 저장 실패는 toast 한 번만 (스팸 방지). HandwriteCanvas 내부 localStorage backup 이 보호.
console.warn('strokes 저장 실패', err);
}
}
async function onSnapshot(blob) {
const fd = new FormData();
fd.append('file', blob, `session_${sessionId}_snapshot.png`);
fd.append('sort_order', '0');
try {
await uploadFile(`/study-sessions/${sessionId}/snapshot`, fd);
addToast('success', 'PNG 스냅샷 저장됨');
// assets 갱신
await load();
} catch (err) {
addToast('error', err.detail || '스냅샷 저장 실패');
throw err;
}
}
async function bumpRep() {
repCount += 1;
await patchSession({ repetition_count: repCount });
}
async function unlinkAsset(assetId) {
try {
await api(`/study-sessions/${sessionId}/assets/${assetId}`, { method: 'DELETE' });
sess = { ...sess, assets: sess.assets.filter((a) => a.id !== assetId) };
} catch (err) {
addToast('error', err.detail || '연결 해제 실패');
}
}
async function linkAsset({ document_id, asset_type, role }) {
try {
const asset = await api(`/study-sessions/${sessionId}/assets`, {
method: 'POST',
body: JSON.stringify({ document_id, asset_type, role }),
});
sess = { ...sess, assets: [...(sess.assets || []), asset] };
addToast('success', '문서가 연결되었습니다');
} catch (err) {
const code = err.errorCode;
if (code === 'asset_already_linked') {
addToast('warning', '이미 연결된 문서입니다');
} else {
addToast('error', err.detail || '연결 실패');
}
}
}
</script>
<svelte:head>
<title>학습 세션 #{sessionId}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</svelte:head>
<div class="h-full flex flex-col">
<!-- 상단 액션 -->
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-default bg-surface shrink-0">
<Button href="/study/write" size="sm" variant="ghost" icon={ArrowLeft}>목록</Button>
<div class="flex-1 min-w-0 text-center text-xs text-dim truncate">
{#if sess}
{sess.study_type === 'language' ? `${sess.language_code || '?'} · ${sess.learning_level || ''}` : (sess.certification || '자격증')}
· {sess.subject || ''} · {sess.topic || ''}
{/if}
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={bumpRep}
class="flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent/10 text-accent hover:bg-accent/15"
>
<RotateCw size={12} /> 다음 시도 ({repCount})
</button>
</div>
</div>
{#if loading}
<div class="p-4">
<Skeleton class="h-96" />
</div>
{:else if !sess}
<div class="p-4 text-sm text-error">세션을 찾을 수 없습니다.</div>
{:else}
<!-- 본문: md 이상 좌우 분할, 모바일은 위/아래 -->
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<!-- 좌측: 메타/원문/asset -->
<aside class="md:w-80 md:border-r border-default md:overflow-y-auto md:shrink-0 md:max-h-full">
<SourceTextPanel
sourceText={sess.source_text}
sourcePage={sess.source_page}
metadata={sess.metadata}
mode={sess.mode}
studyType={sess.study_type}
/>
<details class="border-b border-default">
<summary class="px-3 py-2 text-xs text-dim cursor-pointer hover:bg-surface">메타 편집</summary>
<StudyMetaEditor meta={sess} onPatch={patchSession} />
</details>
<AssetList assets={sess.assets || []} onLink={linkAsset} onUnlink={unlinkAsset} />
</aside>
<!-- 우측: 캔버스 -->
<section class="flex-1 min-h-0 md:min-w-0">
<HandwriteCanvas
sessionId={sess.id}
initialStrokes={sess.strokes_json}
traceText={sess.mode === 'trace' ? sess.source_text : null}
baseSize={sess.metadata?.unit_type === 'kanji' ? 6 : 4}
onChange={onStrokesChange}
onSnapshot={onSnapshot}
/>
</section>
</div>
{/if}
</div>