From 5ce0e848a33046070b0592a786ec844ed4b476af Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 14 Apr 2026 15:06:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(memos):=20=EC=84=A0=ED=83=9D=EC=A0=81=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9,=20=ED=88=B4=EB=B0=94=20=EB=B2=84=ED=8A=BC,?= =?UTF-8?q?=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=ED=95=80=20=ED=8E=BC?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메모 입력/편집: - 선택적 제목 토글 (기본 숨김, "제목" 버튼으로 활성화) - 툴바 버튼: 체크리스트/굵게/제목 (모바일에서 마크다운 수동 입력 불필요) - 편집 모드에도 동일 툴바 대시보드 핀 메모: - 클릭 시 /memos 이동 대신 인라인 펼침/접힘 (details) - 제목이 있으면 제목 표시, 없으면 첫 줄 - 펼치면 마크다운 렌더링된 본문 + "메모함에서 보기" 링크 Backend: - MemoCreate/MemoUpdate에 선택적 title 파라미터 복원 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/memos.py | 6 +- frontend/src/routes/+page.svelte | 45 +++-- frontend/src/routes/memos/+page.svelte | 233 ++++++++++++++++--------- 3 files changed, 193 insertions(+), 91 deletions(-) diff --git a/app/api/memos.py b/app/api/memos.py index c70e165..1809e49 100644 --- a/app/api/memos.py +++ b/app/api/memos.py @@ -69,11 +69,13 @@ async def _enqueue_ai_stages(session: AsyncSession, document_id: int): class MemoCreate(BaseModel): content: str + title: str | None = None # 선택적 제목 (없으면 첫 줄 자동 생성) ask_includable: bool = True class MemoUpdate(BaseModel): content: str + title: str | None = None # 명시 제목 변경 (None이면 자동 생성) class ArchiveSet(BaseModel): @@ -146,7 +148,7 @@ async def create_memo( file_format="md", file_size=len(content.encode("utf-8")), file_type="note", - title=_auto_title(content), + title=body.title.strip() if body.title and body.title.strip() else _auto_title(content), extracted_text=content, review_status="approved", source_channel="memo", @@ -245,7 +247,7 @@ async def update_memo( doc.extracted_text = content doc.file_hash = _content_hash(content) doc.file_size = len(content.encode("utf-8")) - doc.title = _auto_title(content) + doc.title = body.title.strip() if body.title and body.title.strip() else _auto_title(content) doc.user_tags = _parse_hashtags(content) # stale AI 데이터 즉시 초기화 diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 3fd506c..259f8b4 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -14,6 +14,17 @@ import EmptyState from '$lib/components/ui/EmptyState.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; import { Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight } from 'lucide-svelte'; + import { marked } from 'marked'; + import DOMPurify from 'dompurify'; + + marked.use({ gfm: true }); + function renderMdSimple(text) { + return DOMPurify.sanitize(marked(text), { + USE_PROFILES: { html: true }, + FORBID_TAGS: ['style', 'script'], + FORBID_ATTR: ['onerror', 'onclick'], + }); + } let summary = $derived($dashboardSummary); let loading = $derived(summary === null); @@ -114,20 +125,28 @@ {:else if summary} - + {#if pinnedMemos.length > 0}
{#each pinnedMemos as memo (memo.id)} - - - - {memo.content?.split('\n')[0] || '메모'} - - +
+ + + + {memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80) + ? memo.title + : memo.content?.split('\n')[0] || '메모'} + + + +
+
+ {@html renderMdSimple(memo.content || '')} +
+ 메모함에서 보기 → +
+
{/each} {#if pinnedMemos.length >= 3} 더보기 → @@ -304,4 +323,8 @@ diff --git a/frontend/src/routes/memos/+page.svelte b/frontend/src/routes/memos/+page.svelte index 7364cbf..95bab6c 100644 --- a/frontend/src/routes/memos/+page.svelte +++ b/frontend/src/routes/memos/+page.svelte @@ -4,7 +4,7 @@ import { addToast } from '$lib/stores/toast'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; - import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore } from 'lucide-svelte'; + import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, ChevronDown, ChevronRight } 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'; @@ -16,16 +16,13 @@ // task list 줄 패턴 const TASK_RE = /^(\s*- \[)([ x])(\].*)$/; - // 렌더링 — #태그 링크 + task checkbox에 data-task-index 부여 function renderMd(text) { - // #태그를 클릭 가능한 링크로 변환 const withTags = text.replace( /(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})/g, '#$1' ); let html = marked(withTags); - // task checkbox에 data-task-index 부여 let taskIdx = 0; html = html.replace( //gi, @@ -46,7 +43,6 @@ }); } - // task toggle — n번째 task item 줄만 토글 function toggleTask(content, taskIndex) { const lines = content.split('\n'); let ti = 0; @@ -70,11 +66,15 @@ // 빠른 입력 let newContent = $state(''); + let newTitle = $state(''); + let showTitle = $state(false); let submitting = $state(false); + let inputRef = $state(null); // 인라인 편집 let editingId = $state(null); let editContent = $state(''); + let editTitle = $state(''); let saving = $state(false); // 필터 @@ -106,13 +106,17 @@ if (!content) return; submitting = true; try { + const body = { content }; + if (newTitle.trim()) body.title = newTitle.trim(); const memo = await api('/memos/', { method: 'POST', - body: JSON.stringify({ content }), + body: JSON.stringify(body), }); memos = [memo, ...memos]; total += 1; newContent = ''; + newTitle = ''; + showTitle = false; addToast('success', '메모 생성됨'); } catch (err) { addToast('error', '메모 생성 실패'); @@ -121,29 +125,98 @@ } } + // ─── 툴바: textarea에 텍스트 삽입 ─── + function insertAtCursor(textarea, text) { + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const before = newContent.slice(0, start); + const after = newContent.slice(end); + + // 줄 시작에 삽입해야 하는 경우 + const needsNewline = start > 0 && before[before.length - 1] !== '\n'; + const prefix = needsNewline ? '\n' : ''; + + newContent = before + prefix + text + after; + // 커서 위치 조정 + requestAnimationFrame(() => { + const pos = start + prefix.length + text.length; + textarea.focus(); + textarea.setSelectionRange(pos, pos); + }); + } + + function addCheckbox() { + const ta = document.querySelector('[data-memo-input]'); + insertAtCursor(ta, '- [ ] '); + } + + function addHeading() { + const ta = document.querySelector('[data-memo-input]'); + insertAtCursor(ta, '## '); + } + + function addBold() { + const ta = document.querySelector('[data-memo-input]'); + if (!ta) return; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const selected = newContent.slice(start, end); + if (selected) { + newContent = newContent.slice(0, start) + `**${selected}**` + newContent.slice(end); + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(start + 2, end + 2); + }); + } else { + insertAtCursor(ta, '**텍스트**'); + } + } + + // 편집 모드 툴바 + function editInsertAtCursor(text) { + const ta = document.querySelector('[data-edit-input]'); + if (!ta) return; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const before = editContent.slice(0, start); + const after = editContent.slice(end); + const needsNewline = start > 0 && before[before.length - 1] !== '\n'; + const prefix = needsNewline ? '\n' : ''; + editContent = before + prefix + text + after; + requestAnimationFrame(() => { + const pos = start + prefix.length + text.length; + ta.focus(); + ta.setSelectionRange(pos, pos); + }); + } + function startEdit(memo) { editingId = memo.id; editContent = memo.content || ''; + editTitle = memo.title || ''; } function cancelEdit() { editingId = null; editContent = ''; + editTitle = ''; } async function saveEdit(memoId) { saving = true; try { + const body = { content: editContent }; + if (editTitle.trim()) body.title = editTitle.trim(); const updated = await api(`/memos/${memoId}`, { method: 'PATCH', - body: JSON.stringify({ content: editContent }), + body: JSON.stringify(body), }); memos = memos.map((m) => (m.id === memoId ? updated : m)); editingId = null; addToast('success', '메모 수정됨'); } catch (err) { addToast('error', '메모 수정 실패'); - // 실패 시 편집 상태 유지 (내용 손실 방지) } finally { saving = false; } @@ -182,7 +255,6 @@ method: 'PATCH', body: JSON.stringify({ archived }), }); - // 아카이브/복원 시 현재 뷰에서 제거 memos = memos.filter((m) => m.id !== memoId); total -= 1; addToast('success', archived ? '아카이브됨' : '복원됨'); @@ -200,19 +272,15 @@ } } - // 체크박스 클릭 핸들러 (이벤트 위임) async function handleCheckboxClick(e, memo) { const target = e.target; if (target.tagName !== 'INPUT' || target.type !== 'checkbox') return; - e.preventDefault(); const taskIndex = parseInt(target.dataset.taskIndex, 10); if (isNaN(taskIndex)) return; const oldContent = memo.content; const newCont = toggleTask(oldContent, taskIndex); - - // optimistic update memo.content = newCont; memos = [...memos]; @@ -223,7 +291,6 @@ }); memos = memos.map((m) => (m.id === memo.id ? updated : m)); } catch (err) { - // 롤백 memo.content = oldContent; memos = [...memos]; addToast('error', '체크박스 변경 실패'); @@ -261,9 +328,7 @@ e.preventDefault(); saveEdit(memoId); } - if (e.key === 'Escape') { - cancelEdit(); - } + if (e.key === 'Escape') { cancelEdit(); } } function formatTime(dateStr) { @@ -293,7 +358,7 @@ {showArchived ? 'bg-accent/15 text-accent' : 'text-dim hover:text-text hover:bg-surface'}" > - {showArchived ? '아카이브' : '아카이브'} + 아카이브 {#if total > 0} {total}개 @@ -301,7 +366,7 @@
- + {#if activeTag}
필터: @@ -309,22 +374,54 @@ onclick={clearTag} class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-accent/15 text-accent hover:bg-accent/25 transition-colors" > - #{activeTag} - + #{activeTag}
{/if} - + {#if !showArchived} + + {#if showTitle} + + {/if} + -
+ + +
+
+ + + + + +
+ +
+
+ Ctrl+Enter / Esc
{:else} - + + {#if memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)} +

{memo.title}

+ {/if}
{#if editingId !== memo.id} - {#if memo.user_tags?.length || memo.ai_tags?.length}
{#each memo.user_tags || [] as tag} - + {/each} {#each memo.ai_tags || [] as tag} - + {/each}
{/if} -
{formatTime(memo.created_at)} {#if !memo.ask_includable} - - AI 제외 - + AI 제외 {/if}
-
{#if !showArchived} - {/if} - {#if !showArchived} - - + + {:else} - + {/if} - +
{/if} @@ -474,7 +552,6 @@ {/each}
- {#if total > 20}