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:
+71
-49
@@ -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,37 +233,34 @@ 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
|
||||
content = body.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
|
||||
|
||||
if body.content is not None:
|
||||
content = body.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
|
||||
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)
|
||||
|
||||
doc.extracted_text = content
|
||||
doc.file_hash = _content_hash(content)
|
||||
doc.file_size = len(content.encode("utf-8"))
|
||||
doc.user_tags = _parse_hashtags(content)
|
||||
# stale AI 데이터 즉시 초기화
|
||||
doc.ai_summary = None
|
||||
doc.ai_domain = None
|
||||
doc.ai_sub_group = None
|
||||
doc.ai_tags = None
|
||||
doc.ai_confidence = None
|
||||
doc.ai_processed_at = None
|
||||
doc.embedding = None
|
||||
doc.embedded_at = None
|
||||
|
||||
# stale AI 데이터 즉시 초기화
|
||||
doc.ai_summary = None
|
||||
doc.ai_domain = None
|
||||
doc.ai_sub_group = None
|
||||
doc.ai_tags = None
|
||||
doc.ai_confidence = None
|
||||
doc.ai_processed_at = None
|
||||
doc.embedding = None
|
||||
doc.embedded_at = None
|
||||
# 기존 chunks 삭제
|
||||
from models.chunk import DocumentChunk
|
||||
await session.execute(
|
||||
delete(DocumentChunk).where(DocumentChunk.document_id == memo_id)
|
||||
)
|
||||
|
||||
# 기존 chunks 삭제
|
||||
from models.chunk import DocumentChunk
|
||||
await session.execute(
|
||||
delete(DocumentChunk).where(DocumentChunk.document_id == memo_id)
|
||||
)
|
||||
|
||||
# 재처리 큐 등록 (pending만 정리, processing은 건드리지 않음)
|
||||
await _enqueue_ai_stages(session, memo_id)
|
||||
# 재처리 큐 등록
|
||||
await _enqueue_ai_stages(session, memo_id)
|
||||
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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));
|
||||
// 핀 변경 시 정렬 재적용
|
||||
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);
|
||||
});
|
||||
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>
|
||||
{#if total > 0}
|
||||
<span class="text-sm text-dim">{total}개</span>
|
||||
{/if}
|
||||
<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,38 +315,41 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 빠른 입력 -->
|
||||
<Card class="mb-6">
|
||||
<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"
|
||||
></textarea>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span class="text-xs text-dim">마크다운 지원</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={submitting}
|
||||
disabled={!newContent.trim()}
|
||||
onclick={createMemo}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- 빠른 입력 (아카이브 뷰에서는 숨김) -->
|
||||
{#if !showArchived}
|
||||
<Card class="mb-5">
|
||||
<textarea
|
||||
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 justify-end mt-1.5">
|
||||
<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-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 (memo.user_tags?.length || memo.ai_tags?.length) && editingId !== memo.id}
|
||||
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||
{#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}
|
||||
|
||||
<!-- 하단: 시간 + 액션 -->
|
||||
<!-- 태그 + 하단 -->
|
||||
{#if editingId !== memo.id}
|
||||
<div class="flex items-center justify-between mt-3 pt-2 border-t border-default">
|
||||
<!-- 태그 -->
|
||||
{#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-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">
|
||||
<button
|
||||
onclick={() => togglePin(memo.id)}
|
||||
class="p-1.5 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}
|
||||
</button>
|
||||
{#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.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}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => startEdit(memo)}
|
||||
class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors"
|
||||
title="편집"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{#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.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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 113: 메모 아카이브. 현재 메모(file_type='note') UX 전용. 문서 쪽에는 노출하지 않음.
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT false
|
||||
Reference in New Issue
Block a user