feat(memos): 선택적 제목, 툴바 버튼, 대시보드 핀 펼침

메모 입력/편집:
- 선택적 제목 토글 (기본 숨김, "제목" 버튼으로 활성화)
- 툴바 버튼: 체크리스트/굵게/제목 (모바일에서 마크다운 수동 입력 불필요)
- 편집 모드에도 동일 툴바

대시보드 핀 메모:
- 클릭 시 /memos 이동 대신 인라인 펼침/접힘 (details)
- 제목이 있으면 제목 표시, 없으면 첫 줄
- 펼치면 마크다운 렌더링된 본문 + "메모함에서 보기" 링크

Backend:
- MemoCreate/MemoUpdate에 선택적 title 파라미터 복원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-14 15:06:43 +09:00
parent deb5c1b704
commit 5ce0e848a3
3 changed files with 193 additions and 91 deletions
+4 -2
View File
@@ -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 데이터 즉시 초기화
+34 -11
View File
@@ -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 | null>($dashboardSummary);
let loading = $derived(summary === null);
@@ -114,20 +125,28 @@
{:else if summary}
<!-- ═══ 2. 핀 고정 메모 (조건부) ═══ -->
<!-- ═══ 2. 핀 고정 메모 (조건부, 펼침/접힘) ═══ -->
{#if pinnedMemos.length > 0}
<div class="mb-5 space-y-1.5">
{#each pinnedMemos as memo (memo.id)}
<a
href="/memos"
class="flex items-center gap-2.5 px-3 py-2 bg-surface border border-default/50 rounded-lg
hover:bg-surface-hover transition-colors text-sm group"
>
<Pin size={13} class="text-accent shrink-0" />
<span class="text-text truncate group-hover:text-accent transition-colors">
{memo.content?.split('\n')[0] || '메모'}
</span>
</a>
<details class="group/pin">
<summary class="flex items-center gap-2.5 px-3 py-2 bg-surface border border-default/50 rounded-lg
hover:bg-surface-hover transition-colors text-sm cursor-pointer select-none list-none">
<Pin size={13} class="text-accent shrink-0" />
<span class="text-text truncate flex-1">
{memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)
? memo.title
: memo.content?.split('\n')[0] || '메모'}
</span>
<ChevronRight size={13} class="text-dim shrink-0 transition-transform group-open/pin:rotate-90" />
</summary>
<div class="mt-1 px-3 py-2.5 bg-surface/50 border border-default/30 rounded-lg text-sm text-text">
<div class="prose prose-sm max-w-none memo-content-pin">
{@html renderMdSimple(memo.content || '')}
</div>
<a href="/memos" class="text-[11px] text-accent hover:underline mt-2 inline-block">메모함에서 보기 →</a>
</div>
</details>
{/each}
{#if pinnedMemos.length >= 3}
<a href="/memos" class="text-[11px] text-accent hover:underline pl-8">더보기 →</a>
@@ -304,4 +323,8 @@
<style>
details[open] .details-chevron { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
.memo-content-pin :global(p) { margin: 0.2em 0; }
.memo-content-pin :global(ul), .memo-content-pin :global(ol) { margin: 0.2em 0; padding-left: 1.5em; }
.memo-content-pin :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.memo-content-pin :global(a) { color: var(--accent); }
</style>
+155 -78
View File
@@ -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,
'<a href="/memos?tag=$1" class="text-accent hover:underline">#$1</a>'
);
let html = marked(withTags);
// task checkbox에 data-task-index 부여
let taskIdx = 0;
html = html.replace(
/<input\s+(checked\s+)?disabled\s+type="checkbox"\s*\/?>/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'}"
>
<Archive size={12} />
{showArchived ? '아카이브' : '아카이브'}
아카이브
</button>
{#if total > 0}
<span class="text-sm text-dim">{total}</span>
@@ -301,7 +366,7 @@
</div>
</div>
<!-- 태그 필터 표시 -->
<!-- 태그 필터 -->
{#if activeTag}
<div class="flex items-center gap-2 mb-3">
<span class="text-sm text-dim">필터:</span>
@@ -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}
<X size={12} />
#{activeTag} <X size={12} />
</button>
</div>
{/if}
<!-- 빠른 입력 (아카이브 뷰에서는 숨김) -->
<!-- ═══ 빠른 입력 ═══ -->
{#if !showArchived}
<Card class="mb-5">
<!-- 선택적 제목 -->
{#if showTitle}
<input
type="text"
bind:value={newTitle}
placeholder="제목 (선택)"
class="w-full bg-transparent text-text text-sm font-medium outline-none placeholder:text-dim mb-2 pb-2 border-b border-default/50"
/>
{/if}
<textarea
data-memo-input
bind:value={newContent}
onkeydown={handleKeydown}
placeholder="메모 입력... (Ctrl+Enter 저장, #태그, - [ ] 체크리스트)"
placeholder="메모 입력... (Ctrl+Enter 저장)"
class="w-full h-20 bg-transparent text-text text-sm resize-none outline-none placeholder:text-dim"
></textarea>
<div class="flex justify-end mt-1.5">
<!-- 툴바 + 저장 -->
<div class="flex items-center justify-between mt-1.5 pt-1.5 border-t border-default/30">
<div class="flex items-center gap-0.5">
<button onclick={addCheckbox} class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="체크리스트">
<ListChecks size={16} />
</button>
<button onclick={addBold} class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="굵게">
<Bold size={16} />
</button>
<button onclick={addHeading} class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="제목">
<Heading size={16} />
</button>
<span class="w-px h-4 bg-default/50 mx-1"></span>
<button
onclick={() => (showTitle = !showTitle)}
class="px-1.5 py-1 rounded text-[11px] transition-colors
{showTitle ? 'text-accent bg-accent/10' : 'text-dim hover:text-text hover:bg-surface'}"
title="제목 추가"
>
제목
</button>
</div>
<Button
variant="primary"
size="sm"
@@ -338,12 +435,10 @@
</Card>
{/if}
<!-- 메모 피드 -->
<!-- ═══ 메모 피드 ═══ -->
{#if loading}
{#each Array(3) as _}
<div class="mb-3">
<Skeleton class="h-24 w-full rounded-card" />
</div>
<div class="mb-3"><Skeleton class="h-24 w-full rounded-card" /></div>
{/each}
{:else if memos.length === 0}
<EmptyState>
@@ -360,7 +455,6 @@
<div class="space-y-2.5">
{#each memos as memo (memo.id)}
<Card padded={false} class="group relative {showArchived ? 'opacity-70' : ''}">
<!-- 핀 뱃지 -->
{#if memo.pinned && !showArchived}
<div class="absolute top-2 right-2">
<span class="text-xs text-accent"><Pin size={12} /></span>
@@ -369,22 +463,41 @@
<div class="px-4 py-3">
{#if editingId === memo.id}
<!-- 인라인 편집 모드 -->
<!-- ═══ 인라인 편집 모드 ═══ -->
<input
type="text"
bind:value={editTitle}
placeholder="제목 (선택)"
class="w-full bg-transparent text-text text-sm font-medium outline-none placeholder:text-dim mb-2 pb-1 border-b border-default/50"
/>
<textarea
data-edit-input
bind:value={editContent}
onkeydown={(e) => handleEditKeydown(e, memo.id)}
class="w-full h-32 bg-bg border border-default rounded-md px-3 py-2 text-sm text-text resize-none outline-none focus:border-accent"
placeholder="메모 내용..."
></textarea>
<!-- 편집 툴바 -->
<div class="flex items-center justify-between mt-2">
<span class="text-[11px] text-dim">Ctrl+Enter 저장 / Esc 취소</span>
<div class="flex gap-2">
<div class="flex items-center gap-0.5">
<button onclick={() => editInsertAtCursor('- [ ] ')} class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="체크리스트">
<ListChecks size={15} />
</button>
<button onclick={() => editInsertAtCursor('**텍스트**')} class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="굵게">
<Bold size={15} />
</button>
</div>
<div class="flex items-center gap-1.5">
<span class="text-[11px] text-dim">Ctrl+Enter / Esc</span>
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>취소</Button>
<Button variant="primary" size="sm" icon={Check} loading={saving} onclick={() => saveEdit(memo.id)}>저장</Button>
</div>
</div>
{:else}
<!-- 읽기 모드 -->
<!-- ═══ 읽기 모드 ═══ -->
{#if memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)}
<p class="text-xs font-semibold text-dim mb-1">{memo.title}</p>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -397,75 +510,40 @@
<!-- 태그 + 하단 -->
{#if editingId !== memo.id}
<!-- 태그 -->
{#if memo.user_tags?.length || memo.ai_tags?.length}
<div class="flex flex-wrap gap-1 mt-2">
{#each memo.user_tags || [] as tag}
<button
onclick={() => filterByTag(tag)}
class="px-1.5 py-0.5 rounded text-[11px] bg-accent/10 text-accent hover:bg-accent/20 transition-colors"
>#{tag}</button>
<button onclick={() => filterByTag(tag)} class="px-1.5 py-0.5 rounded text-[11px] bg-accent/10 text-accent hover:bg-accent/20 transition-colors">#{tag}</button>
{/each}
{#each memo.ai_tags || [] as tag}
<button
onclick={() => filterByTag(tag)}
class="px-1.5 py-0.5 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover transition-colors"
>{tag}</button>
<button onclick={() => filterByTag(tag)} class="px-1.5 py-0.5 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover transition-colors">{tag}</button>
{/each}
</div>
{/if}
<!-- 하단: 시간 + 액션 -->
<div class="flex items-center justify-between mt-2 pt-1.5 border-t border-default/50">
<div class="flex items-center gap-2">
<span class="text-[11px] text-dim">{formatTime(memo.created_at)}</span>
{#if !memo.ask_includable}
<span class="text-[10px] text-dim/50 flex items-center gap-0.5">
<EyeOff size={10} /> AI 제외
</span>
<span class="text-[10px] text-dim/50 flex items-center gap-0.5"><EyeOff size={10} /> AI 제외</span>
{/if}
</div>
<div class="flex items-center gap-0.5 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
{#if !showArchived}
<button
onclick={() => togglePin(memo.id)}
class="p-1 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
title={memo.pinned ? '핀 해제' : '핀 고정'}
>
<button onclick={() => togglePin(memo.id)} class="p-1 rounded text-dim hover:text-accent hover:bg-surface transition-colors" title={memo.pinned ? '핀 해제' : '핀 고정'}>
{#if memo.pinned}<PinOff size={13} />{:else}<Pin size={13} />{/if}
</button>
{/if}
<button
onclick={() => toggleAskIncludable(memo.id)}
class="p-1 rounded text-dim hover:text-text hover:bg-surface transition-colors"
title={memo.ask_includable ? 'AI 답변에서 제외' : 'AI 답변에 포함'}
>
<button onclick={() => toggleAskIncludable(memo.id)} class="p-1 rounded text-dim hover:text-text hover:bg-surface transition-colors" title={memo.ask_includable ? 'AI 답변에서 제외' : 'AI 답변에 포함'}>
{#if memo.ask_includable}<Eye size={13} />{:else}<EyeOff size={13} />{/if}
</button>
{#if !showArchived}
<button
onclick={() => startEdit(memo)}
class="p-1 rounded text-dim hover:text-text hover:bg-surface transition-colors"
title="편집"
><Pencil size={13} /></button>
<button
onclick={() => setArchive(memo.id, true)}
class="p-1 rounded text-dim hover:text-text hover:bg-surface transition-colors"
title="아카이브"
><Archive size={13} /></button>
<button onclick={() => startEdit(memo)} class="p-1 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="편집"><Pencil size={13} /></button>
<button onclick={() => setArchive(memo.id, true)} class="p-1 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="아카이브"><Archive size={13} /></button>
{:else}
<button
onclick={() => setArchive(memo.id, false)}
class="p-1 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
title="복원"
><ArchiveRestore size={13} /></button>
<button onclick={() => setArchive(memo.id, false)} class="p-1 rounded text-dim hover:text-accent hover:bg-surface transition-colors" title="복원"><ArchiveRestore size={13} /></button>
{/if}
<button
onclick={() => deleteMemo(memo.id)}
class="p-1 rounded text-dim hover:text-error hover:bg-error/10 transition-colors"
title="삭제"
><Trash2 size={13} /></button>
<button onclick={() => deleteMemo(memo.id)} class="p-1 rounded text-dim hover:text-error hover:bg-error/10 transition-colors" title="삭제"><Trash2 size={13} /></button>
</div>
</div>
{/if}
@@ -474,7 +552,6 @@
{/each}
</div>
<!-- 페이지네이션 -->
{#if total > 20}
<div class="flex justify-center gap-2 mt-6">
<Button variant="ghost" size="sm" disabled={page <= 1} onclick={() => { page -= 1; loadMemos(); }}>이전</Button>