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()
|
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):
|
async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
|
||||||
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
|
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
|
||||||
stages = ["classify", "embed", "chunk"]
|
stages = ["classify", "embed", "chunk"]
|
||||||
# 기존 pending 건 삭제 (processing은 건드리지 않음 — worker 충돌 방지)
|
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(ProcessingQueue).where(
|
delete(ProcessingQueue).where(
|
||||||
ProcessingQueue.document_id == document_id,
|
ProcessingQueue.document_id == document_id,
|
||||||
@@ -63,13 +69,15 @@ async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
|
|||||||
|
|
||||||
class MemoCreate(BaseModel):
|
class MemoCreate(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
title: str | None = None
|
|
||||||
ask_includable: bool = True
|
ask_includable: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MemoUpdate(BaseModel):
|
class MemoUpdate(BaseModel):
|
||||||
content: str | None = None
|
content: str
|
||||||
title: str | None = None
|
|
||||||
|
|
||||||
|
class ArchiveSet(BaseModel):
|
||||||
|
archived: bool
|
||||||
|
|
||||||
|
|
||||||
class MemoResponse(BaseModel):
|
class MemoResponse(BaseModel):
|
||||||
@@ -83,6 +91,7 @@ class MemoResponse(BaseModel):
|
|||||||
ai_sub_group: str | None
|
ai_sub_group: str | None
|
||||||
ai_summary: str | None
|
ai_summary: str | None
|
||||||
pinned: bool
|
pinned: bool
|
||||||
|
archived: bool
|
||||||
ask_includable: bool
|
ask_includable: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -110,6 +119,7 @@ def _to_memo_response(doc: Document) -> MemoResponse:
|
|||||||
ai_sub_group=doc.ai_sub_group,
|
ai_sub_group=doc.ai_sub_group,
|
||||||
ai_summary=doc.ai_summary,
|
ai_summary=doc.ai_summary,
|
||||||
pinned=doc.pinned,
|
pinned=doc.pinned,
|
||||||
|
archived=doc.archived,
|
||||||
ask_includable=doc.ask_includable,
|
ask_includable=doc.ask_includable,
|
||||||
created_at=doc.created_at,
|
created_at=doc.created_at,
|
||||||
updated_at=doc.updated_at,
|
updated_at=doc.updated_at,
|
||||||
@@ -130,31 +140,23 @@ async def create_memo(
|
|||||||
if not content:
|
if not content:
|
||||||
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
|
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(
|
doc = Document(
|
||||||
file_path=None,
|
file_path=None,
|
||||||
file_hash=_content_hash(content),
|
file_hash=_content_hash(content),
|
||||||
file_format="md",
|
file_format="md",
|
||||||
file_size=len(content.encode("utf-8")),
|
file_size=len(content.encode("utf-8")),
|
||||||
file_type="note",
|
file_type="note",
|
||||||
title=title,
|
title=_auto_title(content),
|
||||||
extracted_text=content,
|
extracted_text=content,
|
||||||
review_status="approved",
|
review_status="approved",
|
||||||
source_channel="memo",
|
source_channel="memo",
|
||||||
user_tags=user_tags,
|
user_tags=_parse_hashtags(content),
|
||||||
pinned=False,
|
pinned=False,
|
||||||
|
archived=False,
|
||||||
ask_includable=body.ask_includable,
|
ask_includable=body.ask_includable,
|
||||||
)
|
)
|
||||||
session.add(doc)
|
session.add(doc)
|
||||||
await session.flush() # ID 확보
|
await session.flush()
|
||||||
|
|
||||||
await _enqueue_ai_stages(session, doc.id)
|
await _enqueue_ai_stages(session, doc.id)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -170,15 +172,16 @@ async def list_memos(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"),
|
tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"),
|
||||||
|
archived: bool = Query(False, description="true면 아카이브 목록"),
|
||||||
):
|
):
|
||||||
"""메모 목록 — 핀 우선 + 최신순"""
|
"""메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)"""
|
||||||
base = select(Document).where(
|
base = select(Document).where(
|
||||||
Document.file_type == "note",
|
Document.file_type == "note",
|
||||||
Document.deleted_at == None, # noqa: E711
|
Document.deleted_at == None, # noqa: E711
|
||||||
|
Document.archived == archived,
|
||||||
)
|
)
|
||||||
|
|
||||||
if tag:
|
if tag:
|
||||||
# user_tags 또는 ai_tags에 포함된 태그 필터
|
|
||||||
base = base.where(
|
base = base.where(
|
||||||
Document.user_tags.op("@>")(f'["{tag}"]')
|
Document.user_tags.op("@>")(f'["{tag}"]')
|
||||||
| Document.ai_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())
|
count_query = select(func.count()).select_from(base.subquery())
|
||||||
total = (await session.execute(count_query)).scalar() or 0
|
total = (await session.execute(count_query)).scalar() or 0
|
||||||
|
|
||||||
query = base.order_by(
|
# 활성: pinned DESC + created_at DESC / 아카이브: created_at DESC (핀 무시)
|
||||||
Document.pinned.desc(),
|
if archived:
|
||||||
Document.created_at.desc(),
|
query = base.order_by(Document.created_at.desc())
|
||||||
).offset((page - 1) * page_size).limit(page_size)
|
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)
|
result = await session.execute(query)
|
||||||
items = result.scalars().all()
|
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:
|
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
||||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||||
|
|
||||||
if body.title is not None:
|
content = body.content.strip()
|
||||||
doc.title = body.title
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
|
||||||
|
|
||||||
if body.content is not None:
|
doc.extracted_text = content
|
||||||
content = body.content.strip()
|
doc.file_hash = _content_hash(content)
|
||||||
if not content:
|
doc.file_size = len(content.encode("utf-8"))
|
||||||
raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다")
|
doc.title = _auto_title(content)
|
||||||
|
doc.user_tags = _parse_hashtags(content)
|
||||||
|
|
||||||
doc.extracted_text = content
|
# stale AI 데이터 즉시 초기화
|
||||||
doc.file_hash = _content_hash(content)
|
doc.ai_summary = None
|
||||||
doc.file_size = len(content.encode("utf-8"))
|
doc.ai_domain = None
|
||||||
doc.user_tags = _parse_hashtags(content)
|
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 데이터 즉시 초기화
|
# 기존 chunks 삭제
|
||||||
doc.ai_summary = None
|
from models.chunk import DocumentChunk
|
||||||
doc.ai_domain = None
|
await session.execute(
|
||||||
doc.ai_sub_group = None
|
delete(DocumentChunk).where(DocumentChunk.document_id == memo_id)
|
||||||
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 _enqueue_ai_stages(session, memo_id)
|
||||||
await session.execute(
|
|
||||||
delete(DocumentChunk).where(DocumentChunk.document_id == memo_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 재처리 큐 등록 (pending만 정리, processing은 건드리지 않음)
|
|
||||||
await _enqueue_ai_stages(session, memo_id)
|
|
||||||
|
|
||||||
doc.updated_at = datetime.now(timezone.utc)
|
doc.updated_at = datetime.now(timezone.utc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -301,6 +303,26 @@ async def toggle_pin(
|
|||||||
return _to_memo_response(doc)
|
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)
|
@router.patch("/{memo_id}/ask-includable", response_model=MemoResponse)
|
||||||
async def toggle_ask_includable(
|
async def toggle_ask_includable(
|
||||||
memo_id: int,
|
memo_id: int,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class Document(Base):
|
|||||||
# /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외)
|
# /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외)
|
||||||
ask_includable: Mapped[bool] = mapped_column(Boolean, default=True)
|
ask_includable: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음)
|
||||||
|
archived: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# ODF 변환
|
# ODF 변환
|
||||||
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
|
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
|
||||||
original_format: Mapped[str | None] = mapped_column(String(20))
|
original_format: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
|||||||
@@ -4,32 +4,69 @@
|
|||||||
import { addToast } from '$lib/stores/toast';
|
import { addToast } from '$lib/stores/toast';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
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 Button from '$lib/components/ui/Button.svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import Card from '$lib/components/ui/Card.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Skeleton from '$lib/components/ui/Skeleton.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) {
|
function renderMd(text) {
|
||||||
// #태그를 클릭 가능한 링크로 변환
|
// #태그를 클릭 가능한 링크로 변환
|
||||||
const withTags = text.replace(
|
const withTags = text.replace(
|
||||||
/(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})/g,
|
/(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})/g,
|
||||||
'<a href="/memos?tag=$1" class="text-accent hover:underline">#$1</a>'
|
'<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 },
|
USE_PROFILES: { html: true },
|
||||||
FORBID_TAGS: ['style', 'script'],
|
FORBID_TAGS: ['style', 'script'],
|
||||||
FORBID_ATTR: ['onerror', 'onclick'],
|
FORBID_ATTR: ['onerror', 'onclick'],
|
||||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
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 memos = $state([]);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let page = $state(1);
|
let page = $state(1);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let creating = $state(false);
|
|
||||||
|
|
||||||
// 빠른 입력
|
// 빠른 입력
|
||||||
let newContent = $state('');
|
let newContent = $state('');
|
||||||
@@ -38,11 +75,11 @@
|
|||||||
// 인라인 편집
|
// 인라인 편집
|
||||||
let editingId = $state(null);
|
let editingId = $state(null);
|
||||||
let editContent = $state('');
|
let editContent = $state('');
|
||||||
let editTitle = $state('');
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
// 태그 필터
|
// 필터
|
||||||
let activeTag = $state(null);
|
let activeTag = $state(null);
|
||||||
|
let showArchived = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -54,7 +91,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const tagParam = activeTag ? `&tag=${encodeURIComponent(activeTag)}` : '';
|
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;
|
memos = res.items;
|
||||||
total = res.total;
|
total = res.total;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -87,13 +124,11 @@
|
|||||||
function startEdit(memo) {
|
function startEdit(memo) {
|
||||||
editingId = memo.id;
|
editingId = memo.id;
|
||||||
editContent = memo.content || '';
|
editContent = memo.content || '';
|
||||||
editTitle = memo.title || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
editContent = '';
|
editContent = '';
|
||||||
editTitle = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit(memoId) {
|
async function saveEdit(memoId) {
|
||||||
@@ -101,13 +136,14 @@
|
|||||||
try {
|
try {
|
||||||
const updated = await api(`/memos/${memoId}`, {
|
const updated = await api(`/memos/${memoId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ content: editContent, title: editTitle }),
|
body: JSON.stringify({ content: editContent }),
|
||||||
});
|
});
|
||||||
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
||||||
editingId = null;
|
editingId = null;
|
||||||
addToast('success', '메모 수정됨');
|
addToast('success', '메모 수정됨');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast('error', '메모 수정 실패');
|
addToast('error', '메모 수정 실패');
|
||||||
|
// 실패 시 편집 상태 유지 (내용 손실 방지)
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -129,16 +165,32 @@
|
|||||||
try {
|
try {
|
||||||
const updated = await api(`/memos/${memoId}/pin`, { method: 'PATCH' });
|
const updated = await api(`/memos/${memoId}/pin`, { method: 'PATCH' });
|
||||||
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
memos = memos.map((m) => (m.id === memoId ? updated : m));
|
||||||
// 핀 변경 시 정렬 재적용
|
if (!showArchived) {
|
||||||
memos = [...memos].sort((a, b) => {
|
memos = [...memos].sort((a, b) => {
|
||||||
if (a.pinned !== b.pinned) return b.pinned ? 1 : -1;
|
if (a.pinned !== b.pinned) return b.pinned ? 1 : -1;
|
||||||
return new Date(b.created_at) - new Date(a.created_at);
|
return new Date(b.created_at) - new Date(a.created_at);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast('error', '핀 변경 실패');
|
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) {
|
async function toggleAskIncludable(memoId) {
|
||||||
try {
|
try {
|
||||||
const updated = await api(`/memos/${memoId}/ask-includable`, { method: 'PATCH' });
|
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() {
|
function clearTag() {
|
||||||
activeTag = null;
|
activeTag = null;
|
||||||
history.replaceState(null, '', '/memos');
|
history.replaceState(null, '', '/memos');
|
||||||
@@ -161,6 +243,12 @@
|
|||||||
loadMemos();
|
loadMemos();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleArchiveView() {
|
||||||
|
showArchived = !showArchived;
|
||||||
|
page = 1;
|
||||||
|
loadMemos();
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(e) {
|
function handleKeydown(e) {
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
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) {
|
function formatTime(dateStr) {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -186,16 +284,26 @@
|
|||||||
|
|
||||||
<div class="max-w-3xl mx-auto px-4 py-6">
|
<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>
|
<h1 class="text-xl font-bold text-text">메모</h1>
|
||||||
{#if total > 0}
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-dim">{total}개</span>
|
<button
|
||||||
{/if}
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- 태그 필터 표시 -->
|
<!-- 태그 필터 표시 -->
|
||||||
{#if activeTag}
|
{#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>
|
<span class="text-sm text-dim">필터:</span>
|
||||||
<button
|
<button
|
||||||
onclick={clearTag}
|
onclick={clearTag}
|
||||||
@@ -207,38 +315,41 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- 빠른 입력 -->
|
<!-- 빠른 입력 (아카이브 뷰에서는 숨김) -->
|
||||||
<Card class="mb-6">
|
{#if !showArchived}
|
||||||
<textarea
|
<Card class="mb-5">
|
||||||
bind:value={newContent}
|
<textarea
|
||||||
onkeydown={handleKeydown}
|
bind:value={newContent}
|
||||||
placeholder="메모 입력... (Ctrl+Enter로 저장, #태그 지원)"
|
onkeydown={handleKeydown}
|
||||||
class="w-full h-24 bg-transparent text-text text-sm resize-none outline-none placeholder:text-dim"
|
placeholder="메모 입력... (Ctrl+Enter 저장, #태그, - [ ] 체크리스트)"
|
||||||
></textarea>
|
class="w-full h-20 bg-transparent text-text text-sm resize-none outline-none placeholder:text-dim"
|
||||||
<div class="flex justify-between items-center mt-2">
|
></textarea>
|
||||||
<span class="text-xs text-dim">마크다운 지원</span>
|
<div class="flex justify-end mt-1.5">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={!newContent.trim()}
|
disabled={!newContent.trim()}
|
||||||
onclick={createMemo}
|
onclick={createMemo}
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- 메모 피드 -->
|
<!-- 메모 피드 -->
|
||||||
{#if loading}
|
{#if loading}
|
||||||
{#each Array(3) as _}
|
{#each Array(3) as _}
|
||||||
<div class="mb-4">
|
<div class="mb-3">
|
||||||
<Skeleton class="h-32 w-full rounded-card" />
|
<Skeleton class="h-24 w-full rounded-card" />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if memos.length === 0}
|
{:else if memos.length === 0}
|
||||||
<EmptyState>
|
<EmptyState>
|
||||||
{#if activeTag}
|
{#if showArchived}
|
||||||
|
<p>아카이브된 메모가 없습니다</p>
|
||||||
|
{:else if activeTag}
|
||||||
<p>#{activeTag} 태그의 메모가 없습니다</p>
|
<p>#{activeTag} 태그의 메모가 없습니다</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>아직 메모가 없습니다</p>
|
<p>아직 메모가 없습니다</p>
|
||||||
@@ -246,71 +357,68 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-2.5">
|
||||||
{#each memos as memo (memo.id)}
|
{#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">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="px-4 py-3">
|
||||||
{#if editingId === memo.id}
|
{#if editingId === memo.id}
|
||||||
<!-- 인라인 편집 모드 -->
|
<!-- 인라인 편집 모드 -->
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={editContent}
|
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"
|
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>
|
></textarea>
|
||||||
<div class="flex gap-2 mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
<Button variant="primary" size="sm" icon={Check} loading={saving} onclick={() => saveEdit(memo.id)}>
|
<span class="text-[11px] text-dim">Ctrl+Enter 저장 / Esc 취소</span>
|
||||||
저장
|
<div class="flex gap-2">
|
||||||
</Button>
|
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>취소</Button>
|
||||||
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>
|
<Button variant="primary" size="sm" icon={Check} loading={saving} onclick={() => saveEdit(memo.id)}>저장</Button>
|
||||||
취소
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- 읽기 모드 -->
|
<!-- 읽기 모드 -->
|
||||||
{#if memo.title}
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<h3 class="text-sm font-semibold text-text mb-1">{memo.title}</h3>
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
{/if}
|
<div
|
||||||
<div class="prose prose-sm text-text max-w-none memo-content">
|
class="prose prose-sm text-text max-w-none memo-content"
|
||||||
|
onclick={(e) => handleCheckboxClick(e, memo)}
|
||||||
|
>
|
||||||
{@html renderMd(memo.content || '')}
|
{@html renderMd(memo.content || '')}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#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">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs text-dim">{formatTime(memo.created_at)}</span>
|
<span class="text-[11px] text-dim">{formatTime(memo.created_at)}</span>
|
||||||
{#if memo.ai_domain}
|
|
||||||
<span class="text-xs text-dim/70">| {memo.ai_domain}</span>
|
|
||||||
{/if}
|
|
||||||
{#if !memo.ask_includable}
|
{#if !memo.ask_includable}
|
||||||
<span class="text-[10px] text-dim/50 flex items-center gap-0.5">
|
<span class="text-[10px] text-dim/50 flex items-center gap-0.5">
|
||||||
<EyeOff size={10} /> AI 제외
|
<EyeOff size={10} /> AI 제외
|
||||||
@@ -319,42 +427,45 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
{#if !showArchived}
|
||||||
onclick={() => togglePin(memo.id)}
|
<button
|
||||||
class="p-1.5 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
|
onclick={() => togglePin(memo.id)}
|
||||||
title={memo.pinned ? '핀 해제' : '핀 고정'}
|
class="p-1 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
|
||||||
>
|
title={memo.pinned ? '핀 해제' : '핀 고정'}
|
||||||
{#if memo.pinned}
|
>
|
||||||
<PinOff size={14} />
|
{#if memo.pinned}<PinOff size={13} />{:else}<Pin size={13} />{/if}
|
||||||
{:else}
|
</button>
|
||||||
<Pin size={14} />
|
{/if}
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleAskIncludable(memo.id)}
|
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 답변에 포함'}
|
title={memo.ask_includable ? 'AI 답변에서 제외' : 'AI 답변에 포함'}
|
||||||
>
|
>
|
||||||
{#if memo.ask_includable}
|
{#if memo.ask_includable}<Eye size={13} />{:else}<EyeOff size={13} />{/if}
|
||||||
<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} />
|
|
||||||
</button>
|
</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
|
<button
|
||||||
onclick={() => deleteMemo(memo.id)}
|
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="삭제"
|
title="삭제"
|
||||||
>
|
><Trash2 size={13} /></button>
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -366,56 +477,29 @@
|
|||||||
<!-- 페이지네이션 -->
|
<!-- 페이지네이션 -->
|
||||||
{#if total > 20}
|
{#if total > 20}
|
||||||
<div class="flex justify-center gap-2 mt-6">
|
<div class="flex justify-center gap-2 mt-6">
|
||||||
<Button
|
<Button variant="ghost" size="sm" disabled={page <= 1} onclick={() => { page -= 1; loadMemos(); }}>이전</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>
|
<span class="text-sm text-dim self-center">{page} / {Math.ceil(total / 20)}</span>
|
||||||
<Button
|
<Button variant="ghost" size="sm" disabled={page * 20 >= total} onclick={() => { page += 1; loadMemos(); }}>다음</Button>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={page * 20 >= total}
|
|
||||||
onclick={() => { page += 1; loadMemos(); }}
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.memo-content :global(p) {
|
.memo-content :global(p) { margin: 0.2em 0; }
|
||||||
margin: 0.25em 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(ul), .memo-content :global(ol) {
|
.memo-content :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||||
margin: 0.25em 0;
|
.memo-content :global(pre) { background: var(--bg); padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.5em 0; }
|
||||||
padding-left: 1.5em;
|
.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(code) {
|
.memo-content :global(.memo-checkbox) {
|
||||||
background: var(--bg);
|
cursor: pointer;
|
||||||
padding: 0.1em 0.3em;
|
width: 15px;
|
||||||
border-radius: 3px;
|
height: 15px;
|
||||||
font-size: 0.85em;
|
accent-color: var(--accent);
|
||||||
}
|
vertical-align: middle;
|
||||||
.memo-content :global(pre) {
|
margin-right: 4px;
|
||||||
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(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
|
||||||
</style>
|
</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