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
+34
View File
@@ -0,0 +1,34 @@
# Third Party Licenses
본 프로젝트는 다음 오픈소스를 사용합니다.
## perfect-freehand
- License: **MIT**
- Repository: https://github.com/steveruizok/perfect-freehand
- Used by: `frontend/src/lib/components/HandwriteCanvas.svelte` — Apple Pencil 압력/tilt
를 반영한 손글씨 stroke 렌더링.
```
MIT License
Copyright (c) 2021 Stephen Ruiz Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+8 -1
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"lucide-svelte": "^0.400.0", "lucide-svelte": "^0.400.0",
"marked": "^15.0.0" "marked": "^15.0.0",
"perfect-freehand": "^1.2.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.0.0", "@sveltejs/adapter-node": "^5.0.0",
@@ -1850,6 +1851,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -20,6 +20,7 @@
"dependencies": { "dependencies": {
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"lucide-svelte": "^0.400.0", "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 { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; 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 tree = $state([]);
let loading = $state(true); let loading = $state(true);
@@ -209,6 +209,16 @@
메모 메모
</span> </span>
</a> </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 <a
href="/inbox" href="/inbox"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-text hover:bg-surface transition-colors" 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"> <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="/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="/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="/news" class={isActive('/news') ? 'text-accent' : ''}>뉴스</Button>
<Button variant="ghost" size="sm" href="/inbox" class={isActive('/inbox') ? 'text-accent' : ''}>Inbox</Button> <Button variant="ghost" size="sm" href="/inbox" class={isActive('/inbox') ? 'text-accent' : ''}>Inbox</Button>
<div class="relative"> <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>