feat(memo): auto-hide completed tasks after 10s with toggle
체크박스 체크 후 10초 경과 항목을 대시보드 핀 메모 / /memos 에서
자동 숨김, 메모 푸터 "완료 N개 보기" 버튼으로 토글.
- migration 161: documents.memo_task_state JSONB — {"<idx>":{"checked_at":"ISO"}}
- PATCH /memos/{id}/tasks/{task_index} 전용 엔드포인트:
· SELECT FOR UPDATE 로 동시 토글 race 차단
· task_index drift 시 stale state 자동 정리 (400 대신 200)
· AI 재처리/큐 enqueue 의도적 스킵 + memo_task_toggle_skip_ai 로그
- renderMemoHtml(taskStates, now) → 경과 항목에 memo-task-hidden 클래스
- Svelte 5 $effect cleanup 으로 setInterval 누수 방지
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""메모 CRUD API — 파일 없는 문서(file_type='note')"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
@@ -16,8 +17,13 @@ from models.document import Document
|
||||
from models.queue import ProcessingQueue, enqueue_stage
|
||||
from models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# markdown task line: "- [ ] ..." 또는 "- [x] ..."
|
||||
TASK_LINE_RE = re.compile(r"^(\s*- \[)([ xX])(\].*)$")
|
||||
|
||||
# #태그 파싱 패턴: 한글/영문/숫자/밑줄, 2자 이상
|
||||
TAG_PATTERN = re.compile(r"(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})")
|
||||
|
||||
@@ -46,6 +52,28 @@ def _auto_title(content: str) -> str:
|
||||
return title
|
||||
|
||||
|
||||
def _toggle_task_line(content: str, target_index: int, checked: bool) -> tuple[str, bool]:
|
||||
"""N번째 markdown task line을 찾아 checked/unchecked 상태로 설정.
|
||||
|
||||
(new_content, found) 반환. found=False면 target_index에 해당하는 task line이 없음
|
||||
(본문 편집으로 drift된 경우).
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
ti = 0
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
m = TASK_LINE_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
if ti == target_index:
|
||||
mark = "x" if checked else " "
|
||||
lines[i] = m.group(1) + mark + m.group(3)
|
||||
found = True
|
||||
break
|
||||
ti += 1
|
||||
return "\n".join(lines), found
|
||||
|
||||
|
||||
async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
|
||||
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
|
||||
stages = ["classify", "embed", "chunk"]
|
||||
@@ -78,6 +106,10 @@ class ArchiveSet(BaseModel):
|
||||
archived: bool
|
||||
|
||||
|
||||
class TaskToggle(BaseModel):
|
||||
checked: bool
|
||||
|
||||
|
||||
class MemoResponse(BaseModel):
|
||||
id: int
|
||||
title: str | None
|
||||
@@ -91,6 +123,7 @@ class MemoResponse(BaseModel):
|
||||
pinned: bool
|
||||
archived: bool
|
||||
ask_includable: bool
|
||||
memo_task_state: dict # {"<task_index>": {"checked_at": "<ISO8601>"}}
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -119,6 +152,7 @@ def _to_memo_response(doc: Document) -> MemoResponse:
|
||||
pinned=doc.pinned,
|
||||
archived=doc.archived,
|
||||
ask_includable=doc.ask_includable,
|
||||
memo_task_state=dict(doc.memo_task_state or {}),
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
)
|
||||
@@ -279,6 +313,68 @@ async def update_memo(
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
@router.patch("/{memo_id}/tasks/{task_index}", response_model=MemoResponse)
|
||||
async def toggle_memo_task(
|
||||
memo_id: int,
|
||||
task_index: int,
|
||||
body: TaskToggle,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""메모 체크박스 토글 전용 엔드포인트.
|
||||
|
||||
N번째 markdown task line의 체크 상태를 설정하고 memo_task_state에 시각 기록.
|
||||
AI 재처리(classify/embed/chunk)는 **의도적으로 스킵** — 체크박스 한 번에 재분석을 트리거하는 건 과하다.
|
||||
같은 row를 동시에 토글하는 race 방지를 위해 SELECT ... FOR UPDATE 사용.
|
||||
"""
|
||||
# ❶ FOR UPDATE: 같은 row 동시 토글 race 차단 (JSONB 전체 replace라 필수)
|
||||
doc = await session.get(Document, memo_id, with_for_update=True)
|
||||
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
state = dict(doc.memo_task_state or {})
|
||||
key = str(task_index)
|
||||
|
||||
# ❷ content의 N번째 task line 토글
|
||||
new_content, found = _toggle_task_line(doc.extracted_text or "", task_index, body.checked)
|
||||
if not found:
|
||||
# drift: 사용자가 본문 편집으로 task_index 매칭이 깨짐 → stale state만 정리하고 200 OK
|
||||
stale_removed = key in state
|
||||
if stale_removed:
|
||||
state.pop(key, None)
|
||||
doc.memo_task_state = state
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
logger.info(
|
||||
"memo_task_toggle_drift memo_id=%s task_index=%s stale_removed=%s",
|
||||
memo_id, task_index, stale_removed,
|
||||
)
|
||||
return _to_memo_response(doc)
|
||||
|
||||
doc.extracted_text = new_content
|
||||
doc.file_hash = _content_hash(new_content)
|
||||
doc.file_size = len(new_content.encode("utf-8"))
|
||||
|
||||
# ❸ task_state 갱신 (JSONB 전체 replace — FOR UPDATE lock 아래라 race safe)
|
||||
if body.checked:
|
||||
state[key] = {"checked_at": datetime.now(timezone.utc).isoformat()}
|
||||
else:
|
||||
state.pop(key, None)
|
||||
doc.memo_task_state = state
|
||||
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
# AI 재처리 / user_tags 재파싱 / chunks 삭제 / queue enqueue — 모두 의도적 스킵.
|
||||
# 왜 스킵하는지 나중에 디버깅하지 않아도 되도록 명시 로그.
|
||||
logger.info(
|
||||
"memo_task_toggle_skip_ai memo_id=%s task_index=%s checked=%s",
|
||||
memo_id, task_index, body.checked,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(doc)
|
||||
return _to_memo_response(doc)
|
||||
|
||||
|
||||
@router.delete("/{memo_id}", status_code=204)
|
||||
async def delete_memo(
|
||||
memo_id: int,
|
||||
|
||||
@@ -67,6 +67,10 @@ class Document(Base):
|
||||
# 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음)
|
||||
archived: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 메모 체크박스별 메타 — {"<task_index>": {"checked_at": "<ISO8601 UTC>"}}
|
||||
# UI에서 체크 후 10초 경과 항목 숨김 판정에 사용. file_type='note'에서만 의미 있음.
|
||||
memo_task_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
# ODF 변환
|
||||
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
|
||||
original_format: Mapped[str | None] = mapped_column(String(20))
|
||||
|
||||
@@ -30,8 +30,16 @@ const CHECKBOX_INPUT_RE = /<input\b(?=[^>]*type="checkbox")(?=[^>]*disabled)[^>]
|
||||
export interface RenderOptions {
|
||||
compact?: boolean;
|
||||
interactive?: boolean;
|
||||
/** 서버에서 내려온 memo_task_state — {"<task_index>": {"checked_at": "<ISO>"}} */
|
||||
taskStates?: Record<string, { checked_at?: string }>;
|
||||
/** 현재 시각 tick. 지정 시 checked_at 경과 항목에 task-hidden 클래스 부여. */
|
||||
now?: Date;
|
||||
/** 체크 후 자동 숨김까지 대기 시간 (ms). 기본 10초. */
|
||||
hideAfterMs?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_HIDE_AFTER_MS = 10_000;
|
||||
|
||||
/**
|
||||
* 메모 마크다운 → HTML 변환.
|
||||
* - GFM task list checkbox를 인터랙티브하게 교체
|
||||
@@ -39,7 +47,12 @@ export interface RenderOptions {
|
||||
* - #태그를 클릭 가능 링크로 변환
|
||||
*/
|
||||
export function renderMemoHtml(markdown: string, options: RenderOptions = {}): string {
|
||||
const { interactive = true } = options;
|
||||
const {
|
||||
interactive = true,
|
||||
taskStates = {},
|
||||
now,
|
||||
hideAfterMs = DEFAULT_HIDE_AFTER_MS,
|
||||
} = options;
|
||||
|
||||
// 1. #태그 → 링크 (marked 전에 처리, 마크다운 파싱 방해 않는 위치)
|
||||
let text = markdown.replace(
|
||||
@@ -50,21 +63,33 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s
|
||||
// 2. marked → HTML
|
||||
let html = marked(text) as string;
|
||||
|
||||
// 3. checkbox input 교체 (인터랙티브 + data-task-index)
|
||||
// 3. checkbox input 교체 (인터랙티브 + data-task-index + data-checked-at)
|
||||
let taskIdx = 0;
|
||||
html = html.replace(CHECKBOX_INPUT_RE, (match) => {
|
||||
const idx = taskIdx++;
|
||||
const isChecked = /checked/.test(match);
|
||||
const checkedAt = taskStates[String(idx)]?.checked_at ?? '';
|
||||
const dataCheckedAt = checkedAt ? ` data-checked-at="${checkedAt}"` : '';
|
||||
if (interactive) {
|
||||
return `<input type="checkbox" data-task-index="${idx}" ${isChecked ? 'checked' : ''} class="memo-checkbox" />`;
|
||||
return `<input type="checkbox" data-task-index="${idx}"${dataCheckedAt} ${isChecked ? 'checked' : ''} class="memo-checkbox" />`;
|
||||
}
|
||||
return `<input type="checkbox" ${isChecked ? 'checked' : ''} disabled class="memo-checkbox" />`;
|
||||
return `<input type="checkbox"${dataCheckedAt} ${isChecked ? 'checked' : ''} disabled class="memo-checkbox" />`;
|
||||
});
|
||||
|
||||
// 4. checkbox line의 due date badge 변환
|
||||
// <li> 태그 내부에서만 @YYYY-MM-DD를 badge로 교체
|
||||
// 4. checkbox line의 due date badge 변환 + 자동 숨김 클래스
|
||||
const nowMs = now ? now.getTime() : Date.now();
|
||||
html = html.replace(/<li>(<input[^>]*class="memo-checkbox"[^>]*>)(.*?)<\/li>/gi, (fullMatch, inputTag, rest) => {
|
||||
const isChecked = /checked/.test(inputTag);
|
||||
// checked_at 추출 → 10초 경과 판정
|
||||
const checkedAtMatch = /data-checked-at="([^"]+)"/.exec(inputTag);
|
||||
let hidden = false;
|
||||
if (isChecked && checkedAtMatch) {
|
||||
const checkedAtMs = Date.parse(checkedAtMatch[1]);
|
||||
if (!Number.isNaN(checkedAtMs) && nowMs - checkedAtMs >= hideAfterMs) {
|
||||
hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 @YYYY-MM-DD만 badge로 변환
|
||||
let lastDue: ParsedDueDate | null = null;
|
||||
let lastMatch: { index: number; length: number } | null = null;
|
||||
@@ -78,18 +103,22 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s
|
||||
}
|
||||
}
|
||||
|
||||
const classes = [
|
||||
isChecked ? 'memo-task-done' : '',
|
||||
hidden ? 'memo-task-hidden' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const classAttr = classes ? ` class="${classes}"` : '';
|
||||
|
||||
if (lastDue && lastMatch) {
|
||||
const status = isChecked ? 'normal' : getDueStatus(lastDue.date);
|
||||
const badgeClass = getDueBadgeClass(status, isChecked);
|
||||
const label = formatDueLabel(lastDue.date, status);
|
||||
const badge = `<span class="${badgeClass}">${label}</span>`;
|
||||
const newRest = rest.slice(0, lastMatch.index) + badge + rest.slice(lastMatch.index + lastMatch.length);
|
||||
const liClass = isChecked ? 'memo-task-done' : '';
|
||||
return `<li class="${liClass}">${inputTag}${newRest}</li>`;
|
||||
return `<li${classAttr}>${inputTag}${newRest}</li>`;
|
||||
}
|
||||
|
||||
const liClass = isChecked ? 'memo-task-done' : '';
|
||||
return `<li class="${liClass}">${inputTag}${rest}</li>`;
|
||||
return `<li${classAttr}>${inputTag}${rest}</li>`;
|
||||
});
|
||||
|
||||
// 5. DOMPurify sanitize
|
||||
@@ -99,10 +128,56 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
ADD_TAGS: ['input'],
|
||||
ADD_ATTR: ['type', 'checked', 'data-task-index', 'class'],
|
||||
ADD_ATTR: ['type', 'checked', 'data-task-index', 'data-checked-at', 'class'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 후 hideAfterMs 이내에 다시 가려져야 할 항목의 남은 시간(ms) 중 최소값을 반환.
|
||||
* 다음 tick 타이머를 정확히 예측하는 데 사용. 가려질 항목이 없으면 null.
|
||||
*/
|
||||
export function nextHideInMs(
|
||||
taskStates: Record<string, { checked_at?: string }> | undefined | null,
|
||||
now: Date,
|
||||
hideAfterMs = DEFAULT_HIDE_AFTER_MS,
|
||||
): number | null {
|
||||
if (!taskStates) return null;
|
||||
const nowMs = now.getTime();
|
||||
let soonest: number | null = null;
|
||||
for (const key of Object.keys(taskStates)) {
|
||||
const at = taskStates[key]?.checked_at;
|
||||
if (!at) continue;
|
||||
const atMs = Date.parse(at);
|
||||
if (Number.isNaN(atMs)) continue;
|
||||
const remain = atMs + hideAfterMs - nowMs;
|
||||
if (remain > 0 && (soonest === null || remain < soonest)) {
|
||||
soonest = remain;
|
||||
}
|
||||
}
|
||||
return soonest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모에 표시할 "숨긴 체크 항목 개수". 토글 버튼 라벨용.
|
||||
*/
|
||||
export function countHiddenTasks(
|
||||
taskStates: Record<string, { checked_at?: string }> | undefined | null,
|
||||
now: Date,
|
||||
hideAfterMs = DEFAULT_HIDE_AFTER_MS,
|
||||
): number {
|
||||
if (!taskStates) return 0;
|
||||
const nowMs = now.getTime();
|
||||
let n = 0;
|
||||
for (const key of Object.keys(taskStates)) {
|
||||
const at = taskStates[key]?.checked_at;
|
||||
if (!at) continue;
|
||||
const atMs = Date.parse(at);
|
||||
if (Number.isNaN(atMs)) continue;
|
||||
if (nowMs - atMs >= hideAfterMs) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* n번째 task item의 checkbox 토글. 일반 줄은 카운트하지 않음.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil,
|
||||
Library, Mic, Video, Sparkles,
|
||||
} from 'lucide-svelte';
|
||||
import { renderMemoHtml, toggleTaskLine } from '$lib/utils/memoRenderer';
|
||||
import { renderMemoHtml, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
|
||||
let summary = $derived<DashboardSummary | null>($dashboardSummary);
|
||||
@@ -26,6 +26,16 @@
|
||||
|
||||
// ─── 핀 고정 메모 ───
|
||||
let pinnedMemos = $state<any[]>([]);
|
||||
// 메모별 "완료 항목 펼침" 토글 — key: memo.id, value: true 면 숨겨진 체크 항목 노출
|
||||
let showHiddenByMemo = $state<Record<number, boolean>>({});
|
||||
// 자동 숨김 tick. 1초 해상도로 충분 (hideAfter 10초라 오차 수용).
|
||||
let nowTick = $state(new Date());
|
||||
|
||||
$effect(() => {
|
||||
const id = setInterval(() => { nowTick = new Date(); }, 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await api<any>('/memos/?pinned=true&page_size=3&archived=false');
|
||||
@@ -39,26 +49,27 @@
|
||||
if (target.tagName !== 'INPUT' || (target as HTMLInputElement).type !== 'checkbox') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // details 토글 충돌 방지
|
||||
const taskIndex = parseInt((target as HTMLElement).dataset.taskIndex || '', 10);
|
||||
const input = target as HTMLInputElement;
|
||||
const taskIndex = parseInt(input.dataset.taskIndex || '', 10);
|
||||
if (isNaN(taskIndex)) return;
|
||||
|
||||
const oldContent = memo.content;
|
||||
const newContent = toggleTaskLine(oldContent, taskIndex);
|
||||
memo.content = newContent;
|
||||
pinnedMemos = [...pinnedMemos];
|
||||
|
||||
const checked = input.checked;
|
||||
try {
|
||||
await api(`/memos/${memo.id}`, {
|
||||
const updated = await api<any>(`/memos/${memo.id}/tasks/${taskIndex}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ content: newContent }),
|
||||
body: JSON.stringify({ checked }),
|
||||
});
|
||||
pinnedMemos = pinnedMemos.map((m) => (m.id === memo.id ? updated : m));
|
||||
} catch {
|
||||
memo.content = oldContent;
|
||||
pinnedMemos = [...pinnedMemos];
|
||||
input.checked = !checked; // 롤백
|
||||
addToast('error', '체크박스 변경 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowHidden(memoId: number) {
|
||||
showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] };
|
||||
}
|
||||
|
||||
// ─── 파이프라인 ───
|
||||
const STAGE_ORDER = ['extract', 'stt', 'classify', 'embed', 'preview', 'thumbnail'] as const;
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
@@ -204,10 +215,31 @@
|
||||
class="mt-1 px-3 py-2.5 bg-surface/50 border border-default/30 rounded-lg text-sm text-text"
|
||||
onclick={(e) => handlePinCheckbox(e, memo)}
|
||||
>
|
||||
<div class="prose prose-sm max-w-none memo-content-pin">
|
||||
{@html renderMemoHtml(memo.content || '', { compact: true, interactive: true })}
|
||||
<div
|
||||
class="prose prose-sm max-w-none memo-content-pin"
|
||||
class:show-hidden={showHiddenByMemo[memo.id]}
|
||||
>
|
||||
{@html renderMemoHtml(memo.content || '', {
|
||||
compact: true,
|
||||
interactive: true,
|
||||
taskStates: memo.memo_task_state ?? {},
|
||||
now: nowTick,
|
||||
})}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
{#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"
|
||||
onclick={(e) => { e.stopPropagation(); toggleShowHidden(memo.id); }}
|
||||
>
|
||||
{#if showHiddenByMemo[memo.id]}
|
||||
완료 항목 숨기기
|
||||
{:else}
|
||||
완료 {countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS)}개 보기
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<a href="/memos" class="text-[11px] text-accent hover:underline">메모함에서 보기 →</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,6 +540,9 @@
|
||||
.memo-content-pin :global(.memo-checkbox) { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent); vertical-align: middle; margin-right: 3px; }
|
||||
.memo-content-pin :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
|
||||
.memo-content-pin :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; }
|
||||
/* 체크 후 10초 경과 항목 자동 숨김 (`show-hidden` 클래스로 토글 해제) */
|
||||
.memo-content-pin :global(.memo-task-hidden) { display: none; }
|
||||
.memo-content-pin.show-hidden :global(.memo-task-hidden) { display: list-item; }
|
||||
.memo-content-pin :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 8px; margin-left: 3px; }
|
||||
.memo-content-pin :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); }
|
||||
.memo-content-pin :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { renderMemoHtml, toggleTaskLine, todayIso } from '$lib/utils/memoRenderer';
|
||||
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 } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
@@ -31,6 +31,19 @@
|
||||
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');
|
||||
@@ -243,20 +256,15 @@
|
||||
const taskIndex = parseInt(target.dataset.taskIndex, 10);
|
||||
if (isNaN(taskIndex)) return;
|
||||
|
||||
const oldContent = memo.content;
|
||||
const newCont = toggleTaskLine(oldContent, taskIndex);
|
||||
memo.content = newCont;
|
||||
memos = [...memos];
|
||||
|
||||
const checked = target.checked;
|
||||
try {
|
||||
const updated = await api(`/memos/${memo.id}`, {
|
||||
const updated = await api(`/memos/${memo.id}/tasks/${taskIndex}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ content: newCont }),
|
||||
body: JSON.stringify({ checked }),
|
||||
});
|
||||
memos = memos.map((m) => (m.id === memo.id ? updated : m));
|
||||
} catch (err) {
|
||||
memo.content = oldContent;
|
||||
memos = [...memos];
|
||||
target.checked = !checked; // 롤백
|
||||
addToast('error', '체크박스 변경 실패');
|
||||
}
|
||||
}
|
||||
@@ -472,10 +480,27 @@
|
||||
<!-- 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)}
|
||||
>
|
||||
{@html renderMemoHtml(memo.content || '')}
|
||||
{@html renderMemoHtml(memo.content || '', {
|
||||
taskStates: memo.memo_task_state ?? {},
|
||||
now: nowTick,
|
||||
})}
|
||||
</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}
|
||||
|
||||
<!-- 태그 + 하단 -->
|
||||
@@ -550,6 +575,9 @@
|
||||
}
|
||||
.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); }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 156: 메모 체크박스 개별 항목의 체크 시각 저장 (자동 숨김용)
|
||||
-- 구조: {"<task_index>": {"checked_at": "<ISO8601 UTC>"}}
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS memo_task_state JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
Reference in New Issue
Block a user