feat(memos): UX 개선 — 편집 수정, 제목 제거, 체크박스, 아카이브

Phase A: 편집 버그 수정 (content만 PATCH, Ctrl+Enter/Esc),
제목 UI 제거 (자동생성 80자, 내부용), 카드 경량화.

Phase B: GFM task list 지원, taskIndex 기반 인터랙티브 토글,
DOMPurify checkbox 최소 허용, optimistic update + 롤백.

Phase C: archived 컬럼 (메모 UX 전용, 문서 미노출),
멱등 세팅 API (토글 아님), 활성/아카이브 뷰 분리 쿼리,
핀은 활성 메모용 (archived 시 무시).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-14 08:36:16 +09:00
parent 70729fd8a3
commit e435332ea1
4 changed files with 320 additions and 209 deletions
+49 -27
View File
@@ -39,10 +39,16 @@ def _content_hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _auto_title(content: str) -> str:
"""첫 줄에서 제목 자동 생성 (80자 절단, 마크다운 헤딩 제거)"""
first_line = content.split("\n", 1)[0].strip()
title = re.sub(r"^#+\s*", "", first_line)[:80] or "메모"
return title
async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
stages = ["classify", "embed", "chunk"]
# 기존 pending 건 삭제 (processing은 건드리지 않음 — worker 충돌 방지)
await session.execute(
delete(ProcessingQueue).where(
ProcessingQueue.document_id == document_id,
@@ -63,13 +69,15 @@ 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 | None = None
title: str | None = None
content: str
class ArchiveSet(BaseModel):
archived: bool
class MemoResponse(BaseModel):
@@ -83,6 +91,7 @@ class MemoResponse(BaseModel):
ai_sub_group: str | None
ai_summary: str | None
pinned: bool
archived: bool
ask_includable: bool
created_at: datetime
updated_at: datetime
@@ -110,6 +119,7 @@ def _to_memo_response(doc: Document) -> MemoResponse:
ai_sub_group=doc.ai_sub_group,
ai_summary=doc.ai_summary,
pinned=doc.pinned,
archived=doc.archived,
ask_includable=doc.ask_includable,
created_at=doc.created_at,
updated_at=doc.updated_at,
@@ -130,31 +140,23 @@ async def create_memo(
if not content:
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
# 제목: 명시 지정 또는 첫 줄에서 추출
title = body.title
if not title:
first_line = content.split("\n", 1)[0].strip()
# 마크다운 헤딩 제거
title = re.sub(r"^#+\s*", "", first_line)[:100] or "메모"
user_tags = _parse_hashtags(content)
doc = Document(
file_path=None,
file_hash=_content_hash(content),
file_format="md",
file_size=len(content.encode("utf-8")),
file_type="note",
title=title,
title=_auto_title(content),
extracted_text=content,
review_status="approved",
source_channel="memo",
user_tags=user_tags,
user_tags=_parse_hashtags(content),
pinned=False,
archived=False,
ask_includable=body.ask_includable,
)
session.add(doc)
await session.flush() # ID 확보
await session.flush()
await _enqueue_ai_stages(session, doc.id)
await session.commit()
@@ -170,15 +172,16 @@ async def list_memos(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"),
archived: bool = Query(False, description="true면 아카이브 목록"),
):
"""메모 목록 — 핀 우선 + 최신순"""
"""메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)"""
base = select(Document).where(
Document.file_type == "note",
Document.deleted_at == None, # noqa: E711
Document.archived == archived,
)
if tag:
# user_tags 또는 ai_tags에 포함된 태그 필터
base = base.where(
Document.user_tags.op("@>")(f'["{tag}"]')
| Document.ai_tags.op("@>")(f'["{tag}"]')
@@ -187,11 +190,13 @@ async def list_memos(
count_query = select(func.count()).select_from(base.subquery())
total = (await session.execute(count_query)).scalar() or 0
query = base.order_by(
Document.pinned.desc(),
Document.created_at.desc(),
).offset((page - 1) * page_size).limit(page_size)
# 활성: pinned DESC + created_at DESC / 아카이브: created_at DESC (핀 무시)
if archived:
query = base.order_by(Document.created_at.desc())
else:
query = base.order_by(Document.pinned.desc(), Document.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await session.execute(query)
items = result.scalars().all()
@@ -228,10 +233,6 @@ async def update_memo(
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
if body.title is not None:
doc.title = body.title
if body.content is not None:
content = body.content.strip()
if not content:
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
@@ -239,6 +240,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.user_tags = _parse_hashtags(content)
# stale AI 데이터 즉시 초기화
@@ -257,7 +259,7 @@ async def update_memo(
delete(DocumentChunk).where(DocumentChunk.document_id == memo_id)
)
# 재처리 큐 등록 (pending만 정리, processing은 건드리지 않음)
# 재처리 큐 등록
await _enqueue_ai_stages(session, memo_id)
doc.updated_at = datetime.now(timezone.utc)
@@ -301,6 +303,26 @@ async def toggle_pin(
return _to_memo_response(doc)
@router.patch("/{memo_id}/archive", response_model=MemoResponse)
async def set_archive(
memo_id: int,
body: ArchiveSet,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 아카이브 설정 (멱등, 토글 아님)"""
doc = await session.get(Document, memo_id)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
doc.archived = body.archived
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.patch("/{memo_id}/ask-includable", response_model=MemoResponse)
async def toggle_ask_includable(
memo_id: int,
+3
View File
@@ -61,6 +61,9 @@ class Document(Base):
# /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외)
ask_includable: Mapped[bool] = mapped_column(Boolean, default=True)
# 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음)
archived: Mapped[bool] = mapped_column(Boolean, default=False)
# ODF 변환
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
original_format: Mapped[str | None] = mapped_column(String(20))
+200 -116
View File
@@ -4,32 +4,69 @@
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check } from 'lucide-svelte';
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore } 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';
marked.use({ mangle: false, headerIds: false });
// GFM 활성화 (task list 지원)
marked.use({ gfm: true });
// 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>'
);
return DOMPurify.sanitize(marked(withTags), {
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,
(match) => {
const idx = taskIdx++;
const checked = match.includes('checked');
return `<input type="checkbox" data-task-index="${idx}" ${checked ? 'checked' : ''} class="memo-checkbox" />`;
}
);
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
ADD_TAGS: ['input'],
ADD_ATTR: ['type', 'checked', 'data-task-index'],
});
}
// task toggle — n번째 task item 줄만 토글
function toggleTask(content, taskIndex) {
const lines = content.split('\n');
let ti = 0;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(TASK_RE);
if (m) {
if (ti === taskIndex) {
lines[i] = m[1] + (m[2] === ' ' ? 'x' : ' ') + m[3];
break;
}
ti++;
}
}
return lines.join('\n');
}
let memos = $state([]);
let total = $state(0);
let page = $state(1);
let loading = $state(true);
let creating = $state(false);
// 빠른 입력
let newContent = $state('');
@@ -38,11 +75,11 @@
// 인라인 편집
let editingId = $state(null);
let editContent = $state('');
let editTitle = $state('');
let saving = $state(false);
// 태그 필터
// 필터
let activeTag = $state(null);
let showArchived = $state(false);
onMount(() => {
const params = new URLSearchParams(window.location.search);
@@ -54,7 +91,7 @@
loading = true;
try {
const tagParam = activeTag ? `&tag=${encodeURIComponent(activeTag)}` : '';
const res = await api(`/memos?page=${page}&page_size=20${tagParam}`);
const res = await api(`/memos?page=${page}&page_size=20&archived=${showArchived}${tagParam}`);
memos = res.items;
total = res.total;
} catch (err) {
@@ -87,13 +124,11 @@
function startEdit(memo) {
editingId = memo.id;
editContent = memo.content || '';
editTitle = memo.title || '';
}
function cancelEdit() {
editingId = null;
editContent = '';
editTitle = '';
}
async function saveEdit(memoId) {
@@ -101,13 +136,14 @@
try {
const updated = await api(`/memos/${memoId}`, {
method: 'PATCH',
body: JSON.stringify({ content: editContent, title: editTitle }),
body: JSON.stringify({ content: editContent }),
});
memos = memos.map((m) => (m.id === memoId ? updated : m));
editingId = null;
addToast('success', '메모 수정됨');
} catch (err) {
addToast('error', '메모 수정 실패');
// 실패 시 편집 상태 유지 (내용 손실 방지)
} finally {
saving = false;
}
@@ -129,16 +165,32 @@
try {
const updated = await api(`/memos/${memoId}/pin`, { method: 'PATCH' });
memos = memos.map((m) => (m.id === memoId ? updated : m));
// 핀 변경 시 정렬 재적용
if (!showArchived) {
memos = [...memos].sort((a, b) => {
if (a.pinned !== b.pinned) return b.pinned ? 1 : -1;
return new Date(b.created_at) - new Date(a.created_at);
});
}
} catch (err) {
addToast('error', '핀 변경 실패');
}
}
async function setArchive(memoId, archived) {
try {
await api(`/memos/${memoId}/archive`, {
method: 'PATCH',
body: JSON.stringify({ archived }),
});
// 아카이브/복원 시 현재 뷰에서 제거
memos = memos.filter((m) => m.id !== memoId);
total -= 1;
addToast('success', archived ? '아카이브됨' : '복원됨');
} catch (err) {
addToast('error', archived ? '아카이브 실패' : '복원 실패');
}
}
async function toggleAskIncludable(memoId) {
try {
const updated = await api(`/memos/${memoId}/ask-includable`, { method: 'PATCH' });
@@ -148,6 +200,36 @@
}
}
// 체크박스 클릭 핸들러 (이벤트 위임)
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];
try {
const updated = await api(`/memos/${memo.id}`, {
method: 'PATCH',
body: JSON.stringify({ content: newCont }),
});
memos = memos.map((m) => (m.id === memo.id ? updated : m));
} catch (err) {
// 롤백
memo.content = oldContent;
memos = [...memos];
addToast('error', '체크박스 변경 실패');
}
}
function clearTag() {
activeTag = null;
history.replaceState(null, '', '/memos');
@@ -161,6 +243,12 @@
loadMemos();
}
function toggleArchiveView() {
showArchived = !showArchived;
page = 1;
loadMemos();
}
function handleKeydown(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
@@ -168,6 +256,16 @@
}
}
function handleEditKeydown(e, memoId) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
saveEdit(memoId);
}
if (e.key === 'Escape') {
cancelEdit();
}
}
function formatTime(dateStr) {
const d = new Date(dateStr);
const now = new Date();
@@ -186,16 +284,26 @@
<div class="max-w-3xl mx-auto px-4 py-6">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold text-text">메모</h1>
<div class="flex items-center gap-3">
<button
onclick={toggleArchiveView}
class="text-xs flex items-center gap-1 px-2 py-1 rounded transition-colors
{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>
{/if}
</div>
</div>
<!-- 태그 필터 표시 -->
{#if activeTag}
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center gap-2 mb-3">
<span class="text-sm text-dim">필터:</span>
<button
onclick={clearTag}
@@ -207,16 +315,16 @@
</div>
{/if}
<!-- 빠른 입력 -->
<Card class="mb-6">
<!-- 빠른 입력 (아카이브 뷰에서는 숨김) -->
{#if !showArchived}
<Card class="mb-5">
<textarea
bind:value={newContent}
onkeydown={handleKeydown}
placeholder="메모 입력... (Ctrl+Enter 저장, #태그 지원)"
class="w-full h-24 bg-transparent text-text text-sm resize-none outline-none placeholder:text-dim"
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-between items-center mt-2">
<span class="text-xs text-dim">마크다운 지원</span>
<div class="flex justify-end mt-1.5">
<Button
variant="primary"
size="sm"
@@ -228,17 +336,20 @@
</Button>
</div>
</Card>
{/if}
<!-- 메모 피드 -->
{#if loading}
{#each Array(3) as _}
<div class="mb-4">
<Skeleton class="h-32 w-full rounded-card" />
<div class="mb-3">
<Skeleton class="h-24 w-full rounded-card" />
</div>
{/each}
{:else if memos.length === 0}
<EmptyState>
{#if activeTag}
{#if showArchived}
<p>아카이브된 메모가 없습니다</p>
{:else if activeTag}
<p>#{activeTag} 태그의 메모가 없습니다</p>
{:else}
<p>아직 메모가 없습니다</p>
@@ -246,71 +357,68 @@
{/if}
</EmptyState>
{:else}
<div class="space-y-3">
<div class="space-y-2.5">
{#each memos as memo (memo.id)}
<Card padded={false} class="group relative">
<Card padded={false} class="group relative {showArchived ? 'opacity-70' : ''}">
<!-- 핀 뱃지 -->
{#if memo.pinned}
{#if memo.pinned && !showArchived}
<div class="absolute top-2 right-2">
<span class="text-xs text-accent"><Pin size={14} /></span>
<span class="text-xs text-accent"><Pin size={12} /></span>
</div>
{/if}
<div class="p-4">
<div class="px-4 py-3">
{#if editingId === memo.id}
<!-- 인라인 편집 모드 -->
<textarea
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 gap-2 mt-2">
<Button variant="primary" size="sm" icon={Check} loading={saving} onclick={() => saveEdit(memo.id)}>
저장
</Button>
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>
취소
</Button>
<div class="flex items-center justify-between mt-2">
<span class="text-[11px] text-dim">Ctrl+Enter 저장 / Esc 취소</span>
<div class="flex gap-2">
<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}
<h3 class="text-sm font-semibold text-text mb-1">{memo.title}</h3>
{/if}
<div class="prose prose-sm text-text max-w-none memo-content">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="prose prose-sm text-text max-w-none memo-content"
onclick={(e) => handleCheckboxClick(e, memo)}
>
{@html renderMd(memo.content || '')}
</div>
{/if}
<!-- 태그 + 하단 -->
{#if editingId !== memo.id}
<!-- 태그 -->
{#if (memo.user_tags?.length || memo.ai_tags?.length) && editingId !== memo.id}
<div class="flex flex-wrap gap-1.5 mt-3">
{#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>
>#{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>
>{tag}</button>
{/each}
</div>
{/if}
<!-- 하단: 시간 + 액션 -->
{#if editingId !== memo.id}
<div class="flex items-center justify-between mt-3 pt-2 border-t border-default">
<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-xs text-dim">{formatTime(memo.created_at)}</span>
{#if memo.ai_domain}
<span class="text-xs text-dim/70">| {memo.ai_domain}</span>
{/if}
<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 제외
@@ -319,42 +427,45 @@
</div>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{#if !showArchived}
<button
onclick={() => togglePin(memo.id)}
class="p-1.5 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
class="p-1 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
title={memo.pinned ? '핀 해제' : '핀 고정'}
>
{#if memo.pinned}
<PinOff size={14} />
{:else}
<Pin size={14} />
{/if}
{#if memo.pinned}<PinOff size={13} />{:else}<Pin size={13} />{/if}
</button>
{/if}
<button
onclick={() => toggleAskIncludable(memo.id)}
class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors"
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={14} />
{:else}
<EyeOff size={14} />
{/if}
{#if memo.ask_includable}<Eye size={13} />{:else}<EyeOff size={13} />{/if}
</button>
{#if !showArchived}
<button
onclick={() => startEdit(memo)}
class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors"
class="p-1 rounded text-dim hover:text-text hover:bg-surface transition-colors"
title="편집"
>
<Pencil size={14} />
</button>
><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>
{/if}
<button
onclick={() => deleteMemo(memo.id)}
class="p-1.5 rounded text-dim hover:text-error hover:bg-error/10 transition-colors"
class="p-1 rounded text-dim hover:text-error hover:bg-error/10 transition-colors"
title="삭제"
>
<Trash2 size={14} />
</button>
><Trash2 size={13} /></button>
</div>
</div>
{/if}
@@ -366,56 +477,29 @@
<!-- 페이지네이션 -->
{#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>
<Button variant="ghost" size="sm" disabled={page <= 1} onclick={() => { page -= 1; loadMemos(); }}>이전</Button>
<span class="text-sm text-dim self-center">{page} / {Math.ceil(total / 20)}</span>
<Button
variant="ghost"
size="sm"
disabled={page * 20 >= total}
onclick={() => { page += 1; loadMemos(); }}
>
다음
</Button>
<Button variant="ghost" size="sm" disabled={page * 20 >= total} onclick={() => { page += 1; loadMemos(); }}>다음</Button>
</div>
{/if}
{/if}
</div>
<style>
.memo-content :global(p) {
margin: 0.25em 0;
}
.memo-content :global(ul), .memo-content :global(ol) {
margin: 0.25em 0;
padding-left: 1.5em;
}
.memo-content :global(code) {
background: var(--bg);
padding: 0.1em 0.3em;
border-radius: 3px;
font-size: 0.85em;
}
.memo-content :global(pre) {
background: var(--bg);
padding: 0.75em;
border-radius: 6px;
overflow-x: auto;
margin: 0.5em 0;
}
.memo-content :global(a) {
color: var(--accent);
}
.memo-content :global(blockquote) {
border-left: 3px solid var(--border-default);
padding-left: 0.75em;
color: var(--text-dim);
margin: 0.5em 0;
.memo-content :global(p) { margin: 0.2em 0; }
.memo-content :global(ul), .memo-content :global(ol) { margin: 0.2em 0; padding-left: 1.5em; }
.memo-content :global(li) { margin: 0.1em 0; }
.memo-content :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.memo-content :global(pre) { background: var(--bg); padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.5em 0; }
.memo-content :global(a) { color: var(--accent); }
.memo-content :global(blockquote) { border-left: 3px solid var(--border-default); padding-left: 0.75em; color: var(--text-dim); margin: 0.5em 0; }
.memo-content :global(.memo-checkbox) {
cursor: pointer;
width: 15px;
height: 15px;
accent-color: var(--accent);
vertical-align: middle;
margin-right: 4px;
}
.memo-content :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
</style>
+2
View File
@@ -0,0 +1,2 @@
-- 113: 메모 아카이브. 현재 메모(file_type='note') UX 전용. 문서 쪽에는 노출하지 않음.
ALTER TABLE documents ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT false