Files
hyungi_document_server/frontend/src/routes/memos/+page.svelte
T
hyungi 9ffbdc0c23 fix(ui): 모바일 가로 오버플로 제거 (min-w-0/minmax/flex-wrap/break)
flex/grid 자식이 truncate·긴 텍스트를 품으면서 min-w-0 부재 → 좁은 화면서 줄지 못해
페이지 좌우 스크롤·글자 화면 벗어남(대시보드 최근활동 타임라인이 대표 사례).
- dashboard: 타임라인 grid 1fr→minmax(0,1fr)+셀 min-w-0 / 도메인라벨·고정항목 flex-1 min-w-0(+break-words)
- inbox: 리스트 제목 min-w-0
- ask: 검색바 flex-wrap + 입력 min-w-0 + select min-w-0 max-w
- library: 트리노드·브레드크럼 min-w-0/truncate/flex-wrap
- events: 메타행 min-w-0 + project_tag break-all
- memos: 본문/code/링크 overflow-wrap:anywhere + table 가로스크롤 가드
감사 11p→수정 6p, 페이지별 적대 재스캔으로 잔존 antipattern까지 제거. 데스크탑 무회귀·토큰/이모지 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:41:57 +09:00

687 lines
28 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { renderMemoHtml, todayIso, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen } from 'lucide-svelte';
import { getAccessToken } from '$lib/api';
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';
let memos = $state([]);
let total = $state(0);
let page = $state(1);
let loading = $state(true);
// 빠른 입력
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);
// 필터
let activeTag = $state(null);
let showArchived = $state(false);
// 자동 숨김 tick (1초 해상도) + 메모별 "숨겨진 항목 펼침" 상태
let nowTick = $state(new Date());
let showHiddenByMemo = $state({});
$effect(() => {
const id = setInterval(() => { nowTick = new Date(); }, 1000);
return () => clearInterval(id);
});
function toggleShowHidden(memoId) {
showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] };
}
onMount(() => {
const params = new URLSearchParams(window.location.search);
activeTag = params.get('tag');
loadMemos();
});
async function loadMemos() {
loading = true;
try {
const tagParam = activeTag ? `&tag=${encodeURIComponent(activeTag)}` : '';
const res = await api(`/memos/?page=${page}&page_size=20&archived=${showArchived}${tagParam}`);
memos = res.items;
total = res.total;
} catch (err) {
addToast('error', '메모 로딩 실패');
} finally {
loading = false;
}
}
async function createMemo() {
const content = newContent.trim();
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(body),
});
memos = [memo, ...memos];
total += 1;
newContent = '';
newTitle = '';
showTitle = false;
addToast('success', '메모 생성됨');
} catch (err) {
addToast('error', '메모 생성 실패');
} finally {
submitting = false;
}
}
// ─── 툴바: 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 addDueDate() {
const ta = document.querySelector('[data-memo-input]');
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const ins = ` @${todayIso()}`;
newContent = newContent.slice(0, start) + ins + newContent.slice(end);
requestAnimationFrame(() => {
const pos = start + ins.length;
ta.focus();
ta.setSelectionRange(pos, pos);
});
}
// 편집 모드 툴바
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(body),
});
memos = memos.map((m) => (m.id === memoId ? updated : m));
editingId = null;
addToast('success', '메모 수정됨');
} catch (err) {
addToast('error', '메모 수정 실패');
} finally {
saving = false;
}
}
async function deleteMemo(memoId) {
if (!confirm('이 메모를 삭제하시겠습니까?')) return;
try {
await api(`/memos/${memoId}`, { method: 'DELETE' });
memos = memos.filter((m) => m.id !== memoId);
total -= 1;
addToast('success', '메모 삭제됨');
} catch (err) {
addToast('error', '메모 삭제 실패');
}
}
async function togglePin(memoId) {
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' });
memos = memos.map((m) => (m.id === memoId ? updated : m));
} catch (err) {
addToast('error', 'AI 포함 설정 변경 실패');
}
}
// ─── PR-2B: 메모 → events 1-click promote ───
async function promoteMemo(memoId, kind) {
const labels = { task: '할 일', calendar_event: '일정', activity_log: '활동 기록' };
try {
const res = await api(`/memos/${memoId}/promote-to-event`, {
method: 'POST',
body: JSON.stringify({ kind }),
});
addToast('success', `${labels[kind]} 로 승급 (events #${res.event_id})`);
// 로컬 상태 갱신 — promoted 표시를 위해 메모에 임시 마킹 (서버 미반영, UX 힌트만)
memos = memos.map((m) => m.id === memoId ? { ...m, _last_promoted: { event_id: res.event_id, kind } } : m);
} catch (err) {
addToast('error', err?.detail || '승급 실패');
}
}
async function dismissEventSuggestion(memoId) {
try {
const updated = await api(`/memos/${memoId}/dismiss-event-suggestion`, { method: 'POST' });
memos = memos.map((m) => (m.id === memoId ? updated : m));
} catch (err) {
addToast('error', '처리 실패');
}
}
// voice 메모 audio URL — /api/documents/{id}/file?token= 패턴 재사용
function voiceAudioUrl(memoId) {
const token = getAccessToken();
return `/api/documents/${memoId}/file?token=${encodeURIComponent(token ?? '')}`;
}
// ai_event_kind 별 라벨 / 색상
const KIND_LABELS = {
note: '메모',
task: '할 일',
calendar_event: '일정',
activity_log: '활동',
reference: '참조',
};
const KIND_BADGE_CLASS = {
note: 'bg-surface text-dim',
task: 'bg-accent/15 text-accent-hover',
calendar_event: 'bg-domain-engineering/15 text-domain-engineering',
activity_log: 'bg-success/15 text-success',
reference: 'bg-domain-reference/15 text-domain-reference',
};
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 checked = target.checked;
try {
const updated = await api(`/memos/${memo.id}/tasks/${taskIndex}`, {
method: 'PATCH',
body: JSON.stringify({ checked }),
});
memos = memos.map((m) => (m.id === memo.id ? updated : m));
} catch (err) {
target.checked = !checked; // 롤백
addToast('error', '체크박스 변경 실패');
}
}
function clearTag() {
activeTag = null;
history.replaceState(null, '', '/memos');
loadMemos();
}
function filterByTag(tag) {
activeTag = tag;
page = 1;
history.replaceState(null, '', `/memos?tag=${encodeURIComponent(tag)}`);
loadMemos();
}
function toggleArchiveView() {
showArchived = !showArchived;
page = 1;
loadMemos();
}
function handleKeydown(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
createMemo();
}
}
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();
const diff = now - d;
if (diff < 60000) return '방금';
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}시간 전`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}일 전`;
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
</script>
<svelte:head>
<title>메모 — Document Server</title>
</svelte:head>
<div class="max-w-3xl mx-auto px-4 py-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} />
아카이브
</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-3">
<span class="text-sm text-dim">필터:</span>
<button
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} />
</button>
</div>
{/if}
<!-- ═══ 빠른 입력 (상단 고정) ═══ -->
{#if !showArchived}
<Card class="mb-5 sticky top-0 z-10 shadow-sm">
<!-- 선택적 제목 -->
{#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 저장)"
class="w-full h-20 bg-transparent text-text text-sm resize-none outline-none placeholder:text-dim"
></textarea>
<!-- 툴바 + 저장 -->
<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>
<button onclick={addDueDate} class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="마감일">
<CalendarDays 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"
loading={submitting}
disabled={!newContent.trim()}
onclick={createMemo}
>
저장
</Button>
</div>
</Card>
{/if}
<!-- ═══ 메모 피드 ═══ -->
{#if loading}
{#each Array(3) as _}
<div class="mb-3"><Skeleton class="h-24 w-full rounded-card" /></div>
{/each}
{:else if memos.length === 0}
<EmptyState>
{#if showArchived}
<p>아카이브된 메모가 없습니다</p>
{:else if activeTag}
<p>#{activeTag} 태그의 메모가 없습니다</p>
{:else}
<p>아직 메모가 없습니다</p>
<p class="text-dim text-sm mt-1">위 입력창에서 첫 메모를 작성해보세요</p>
{/if}
</EmptyState>
{:else}
<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>
</div>
{/if}
<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">
<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>
<button onclick={() => editInsertAtCursor(` @${todayIso()}`)} class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors" title="마감일">
<CalendarDays 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}
<!-- ═══ 읽기 모드 ═══ -->
<!-- PR-2B/2C: 분류 배지 + voice icon + 마지막 promote 결과 -->
{#if memo.source_channel === 'voice' || memo.ai_event_kind || memo._last_promoted}
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
{#if memo.source_channel === 'voice'}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-domain-philosophy/15 text-domain-philosophy" title="음성 메모">
<Mic size={10} /> 음성
</span>
{/if}
{#if memo.ai_event_kind && memo.ai_event_kind !== 'note'}
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] {KIND_BADGE_CLASS[memo.ai_event_kind] || 'bg-surface text-dim'}">
AI 추천: {KIND_LABELS[memo.ai_event_kind] || memo.ai_event_kind}{memo.ai_event_confidence != null ? ` · ${Math.round(memo.ai_event_confidence * 100)}%` : ''}
</span>
{/if}
{#if memo._last_promoted}
<a href={`/events/${memo._last_promoted.event_id}`} class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-success/15 text-success hover:bg-success/25">
<ArrowRight size={10} /> events #{memo._last_promoted.event_id}
</a>
{/if}
</div>
{/if}
{#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}
<!-- voice 메모 audio player -->
{#if memo.source_channel === 'voice' && memo.file_path}
<audio controls preload="metadata" src={voiceAudioUrl(memo.id)} class="w-full mb-2 h-9"></audio>
{/if}
<!-- 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"
class:show-hidden={showHiddenByMemo[memo.id]}
onclick={(e) => handleCheckboxClick(e, memo)}
>
{#if memo.content}
{@html renderMemoHtml(memo.content || '', {
taskStates: memo.memo_task_state ?? {},
now: nowTick,
})}
{:else if memo.source_channel === 'voice'}
<p class="text-xs text-dim italic">음성 → 텍스트 변환 대기 중…</p>
{/if}
</div>
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
<button
type="button"
class="text-[11px] text-dim hover:text-text underline-offset-2 hover:underline mt-1"
onclick={() => toggleShowHidden(memo.id)}
>
{#if showHiddenByMemo[memo.id]}
완료 항목 숨기기
{:else}
완료 {countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS)}개 보기
{/if}
</button>
{/if}
{/if}
<!-- PR-2B: AI triage 결과 → 1-click promote 버튼 (분류 결과 있고 dismissed 아닌 메모) -->
{#if editingId !== memo.id && memo.ai_event_kind && memo.ai_event_kind !== 'note' && !memo._last_promoted && !showArchived}
<div class="flex flex-wrap gap-1 mt-2 pt-2 border-t border-default/30">
<button onclick={() => promoteMemo(memo.id, 'task')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'task' ? 'bg-accent text-white hover:bg-accent-hover' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
<FileText size={11} /> 할 일로
</button>
<button onclick={() => promoteMemo(memo.id, 'calendar_event')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'calendar_event' ? 'bg-domain-engineering text-white hover:opacity-90' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
<Calendar size={11} /> 일정으로
</button>
<button onclick={() => promoteMemo(memo.id, 'activity_log')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'activity_log' ? 'bg-success text-white hover:opacity-90' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
<Activity size={11} /> 활동으로
</button>
<button onclick={() => dismissEventSuggestion(memo.id)} class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover hover:text-text transition-colors">
<X size={11} /> 그냥 메모
</button>
</div>
{/if}
<!-- 태그 + 하단 -->
{#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>
{/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>
{/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>
{/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 ? '핀 해제' : '핀 고정'}>
{#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 답변에 포함'}>
{#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>
{: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 rounded text-dim hover:text-error hover:bg-error/10 transition-colors" title="삭제"><Trash2 size={13} /></button>
</div>
</div>
{/if}
</div>
</Card>
{/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>
<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>
</div>
{/if}
{/if}
</div>
<style>
.memo-content { overflow-wrap: anywhere; word-break: break-word; }
.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; overflow-wrap: anywhere; word-break: break-word; }
.memo-content :global(pre) { background: var(--bg); padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.5em 0; }
.memo-content :global(table) { display: block; overflow-x: auto; max-width: 100%; }
.memo-content :global(a) { color: var(--accent); overflow-wrap: anywhere; word-break: break-word; }
.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; }
.memo-content :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; }
/* 체크 후 10초 경과 항목 자동 숨김 (.show-hidden 클래스로 해제) */
.memo-content :global(.memo-task-hidden) { display: none; }
.memo-content.show-hidden :global(.memo-task-hidden) { display: list-item; }
.memo-content :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 8px; margin-left: 4px; vertical-align: middle; }
.memo-content :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); }
.memo-content :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
.memo-content :global(.due-normal) { background: var(--surface); color: var(--text-dim); }
.memo-content :global(.due-done) { background: var(--surface); color: var(--text-dim); opacity: 0.6; }
</style>