diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..813e0bf --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -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. +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ac90fe..6cffc8f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 9738654..32c6403 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/lib/components/AssetList.svelte b/frontend/src/lib/components/AssetList.svelte new file mode 100644 index 0000000..819ddc6 --- /dev/null +++ b/frontend/src/lib/components/AssetList.svelte @@ -0,0 +1,112 @@ + + +
+
+ 연결된 자료 ({assets.length}) + +
+ {#if assets.length === 0} +
연결된 자료 없음. 오디오/영상/스캔 교재를 연결하세요.
+ {:else} + {#each assets as a (a.id)} + {@const Icon = iconFor(a.asset_type)} +
+ + {labelFor(a.asset_type)} + {#if a.role} + · {a.role} + {/if} + {#if a.asset_type === 'audio'} + + {:else if a.asset_type === 'video'} + video #{a.document_id} 보기 + {:else} + document #{a.document_id} + {/if} + onUnlink?.(a.id)} + /> +
+ {/each} + {/if} +
diff --git a/frontend/src/lib/components/HandwriteCanvas.svelte b/frontend/src/lib/components/HandwriteCanvas.svelte new file mode 100644 index 0000000..8314ca6 --- /dev/null +++ b/frontend/src/lib/components/HandwriteCanvas.svelte @@ -0,0 +1,363 @@ + + +
+ +
+ + + + stroke {strokes.length} +
+ {#if snapshotErr} + {snapshotErr} + {/if} + +
+ + +
+ { if (e.pointerId === activePointerId) endStroke(e); }} + class="block" + style="touch-action: none; cursor: crosshair;" + > +
+
diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 9be1f3d..d36750f 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -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 @@ 메모 + + + + 공부 + + + /** + * 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 | null; + mode?: Mode; + studyType?: 'certification' | 'language'; + } + let { sourceText, sourcePage, metadata, mode, studyType }: Props = $props(); + + let m = $derived((metadata ?? {}) as Record); + + +
+ {#if sourceText} +
+
+ 원문 {sourcePage ? `(p.${sourcePage})` : ''} +
+
{sourceText}
+
+ {:else} +
원문 미설정 — 메타 편집기에서 입력
+ {/if} + + {#if studyType === 'language' && metadata} +
+ {#if m.reading} +
reading: {m.reading}
+ {/if} + {#if m.meaning} +
meaning: {m.meaning}
+ {/if} + {#if m.unit_type} +
unit: {m.unit_type}
+ {/if} + {#if m.romaji} +
romaji: {m.romaji}
+ {/if} + {#if m.example_sentence} +
+ example: {m.example_sentence} +
+ {/if} +
+ {/if} + + {#if mode === 'trace' && sourceText} +
+ 트레이싱 모드 — 캔버스 배경에 회색으로 깔린 원문 위에 펜으로 덮어쓰세요. +
+ {/if} +
diff --git a/frontend/src/lib/components/StudyMetaEditor.svelte b/frontend/src/lib/components/StudyMetaEditor.svelte new file mode 100644 index 0000000..78b4ad9 --- /dev/null +++ b/frontend/src/lib/components/StudyMetaEditor.svelte @@ -0,0 +1,182 @@ + + +
+ +
+ 도메인 +
+ + +
+
+ + + onPatch?.({ language_code: (e.target as HTMLSelectElement).value || null })} + /> + onPatch?.({ learning_level: (e.target as HTMLInputElement).value })} + /> + {/if} + + onPatch?.({ subject: (e.target as HTMLInputElement).value })} + /> + onPatch?.({ topic: (e.target as HTMLInputElement).value })} + /> + + +