fb4897e256
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
114 lines
3.6 KiB
Svelte
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>
|