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:
@@ -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.
|
||||||
|
```
|
||||||
Generated
+8
-1
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user