Files
hyungi_document_server/frontend/src/lib/components/ReadCounter.svelte
T
Hyungi Ahn fb4897e256 feat(library): 자료실 회독 추적 frontend (PR-B)
PR-A backend 위에 사용자 UI:

[ReadCounter]
- frontend/src/lib/components/ReadCounter.svelte 신규
- "1회독 완료" 명시 클릭 → POST /read → 토스트
- 현재 N회독 / 마지막 읽음 (방금/N분 전/날짜) 표시
- ↩ 버튼 → DELETE /read/last → 마지막 1건 취소 (confirm)
- 자동 +1 

[자료 detail]
- routes/documents/[id]/+page.svelte 우측 editor stack 상단에
  ReadCounter 마운트 — category='library' 일 때만
- doc 응답의 read_count / last_read_at 으로 초기값 (추가 fetch 불필요)

[자료실 카드 회독 배지]
- DocumentCard.svelte 우측 메타에 텍스트 배지
  안 봄 / 1회독 / 2회독 / N회독 — 색은 매우 약하게 (오해 방지)
- doc.category === 'library' 만

[안 본 자료만 필터]
- backend: /api/documents/library 에 unread bool 파라미터
  Document.id NOT IN (현재 사용자 회독 doc_id) — scalar_subquery
- frontend: /library 페이지에 토글 버튼 (정렬 옆)
  URL ?unread=true 동기화, activeUnread reactive
2026-04-27 12:19:11 +09:00

114 lines
3.6 KiB
Svelte

<script>
/**
* ReadCounter — 자료 회독 카운트 + "1회독 완료" 명시 클릭 + 마지막 1건 취소.
*
* 동작 규칙 (사용자 명시 — 변경 금지):
* - detail 페이지 진입만으로 자동 +1 ❌
* - "1회독 완료" 버튼 클릭 시에만 +1
* - 같은 날 여러 번 가능
* - ↩ 클릭 = 마지막 1건 취소 (DELETE /read/last)
*/
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { Check, Undo2 } from 'lucide-svelte';
let { documentId, initialCount = 0, initialLastReadAt = null } = $props();
let count = $state(initialCount);
let lastReadAt = $state(initialLastReadAt);
let busy = $state(false);
function fmtDate(s) {
if (!s) return '';
const d = new Date(s);
const now = new Date();
const diff = now - d;
if (diff < 60_000) return '방금';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}분 전`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}시간 전`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}일 전`;
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
async function loadStats() {
try {
const r = await api(`/documents/${documentId}/read-stats`);
count = r.read_count;
lastReadAt = r.last_read_at;
} catch {
// 조용히 실패 — 초기값 유지
}
}
onMount(() => {
if (initialCount === 0 && !initialLastReadAt) loadStats();
});
async function addRead() {
if (busy) return;
busy = true;
try {
const r = await api(`/documents/${documentId}/read`, { method: 'POST' });
count = r.read_count;
lastReadAt = r.last_read_at;
addToast('success', `${count}회독 완료`);
} catch (err) {
addToast('error', err?.detail || '회독 기록 실패');
} finally {
busy = false;
}
}
async function undoLast() {
if (busy || count === 0) return;
if (!confirm('마지막 회독 1건을 취소합니다.')) return;
busy = true;
try {
const r = await api(`/documents/${documentId}/read/last`, { method: 'DELETE' });
count = r.read_count;
lastReadAt = r.last_read_at;
addToast('info', '마지막 회독 취소됨');
} catch (err) {
addToast('error', err?.detail || '취소 실패');
} finally {
busy = false;
}
}
</script>
<div class="flex items-center gap-2 px-3 py-2 rounded-lg border border-default bg-surface">
<button
type="button"
onclick={addRead}
disabled={busy}
style="touch-action: manipulation; user-select: none; -webkit-tap-highlight-color: transparent;"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-50 transition-colors"
>
<Check size={14} /> 1회독 완료
</button>
<div class="flex-1 min-w-0 text-xs">
<div class="text-text font-medium">{count}회독</div>
{#if lastReadAt}
<div class="text-dim text-[10px]">마지막 {fmtDate(lastReadAt)}</div>
{:else}
<div class="text-dim text-[10px]">아직 안 봄</div>
{/if}
</div>
{#if count > 0}
<button
type="button"
onclick={undoLast}
disabled={busy}
style="touch-action: manipulation; user-select: none; -webkit-tap-highlight-color: transparent;"
class="p-1.5 rounded-md text-dim hover:text-text hover:bg-bg disabled:opacity-50 transition-colors"
aria-label="마지막 회독 취소"
title="마지막 회독 취소"
>
<Undo2 size={14} />
</button>
{/if}
</div>