Merge remote-tracking branch 'origin/feat/study-memo-card-p1' into feat/email-pkm-folder

This commit is contained in:
hyungi
2026-06-07 05:37:29 +00:00
4 changed files with 221 additions and 9 deletions
+21 -2
View File
@@ -3,14 +3,19 @@
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat } from 'lucide-svelte';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag } from 'lucide-svelte';
let cardReviewCount = $state(0);
let questionFlagCount = $state(0);
onMount(async () => {
try {
const r = await api('/study-cards/needs-review/count');
cardReviewCount = r?.count ?? 0;
} catch {}
try {
const r2 = await api('/study-questions/needs-review/count');
questionFlagCount = r2?.count ?? 0;
} catch {}
});
</script>
@@ -78,7 +83,21 @@
<Repeat size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">암기카드 학습</h2>
</div>
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 휙휙 훑어봅니다.</p>
<p class="text-xs text-dim">검수한 암기카드를 모바일에서 학습. <b>복습(간격반복 1·3·7·14일)</b>으로 자기평가하거나, <b>그냥 공부</b>로 덜 본 카드를 가볍게 훑어봅니다.</p>
</a>
<a
href="/study/questions-review"
class="block p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
>
<div class="flex items-center gap-2 mb-2">
<Flag size={18} class="text-accent" />
<h2 class="text-base font-semibold text-text">문제 신고함</h2>
{#if questionFlagCount > 0}
<span class="ml-auto rounded-full bg-warning px-2 py-0.5 text-xs font-bold text-white">{questionFlagCount}</span>
{/if}
</div>
<p class="text-xs text-dim">퀴즈·문제 화면에서 <b>이상하다고 신고한 문제</b>를 모아 확인하고 수정·검토 완료·폐기합니다.</p>
</a>
</div>
@@ -0,0 +1,125 @@
<script>
/**
* /study/questions-review — 문제 신고함 (이상 태깅된 study_questions 검수).
*
* 퀴즈/문제 화면의 '이 문제 이상해요'(PATCH needs_review=true)로 신고된 문제를 전 토픽 횡단으로 모아
* 확인 → 수정 / 검토 완료(신고 해제) / 폐기. 암기카드 검수(/study/cards-review)의 문제 버전.
*
* backend: GET /study-questions/needs-review (목록) · /needs-review/count (배지)
* PATCH /study-questions/{id} {needs_review:false} (검토 완료) · DELETE /study-questions/{id} (폐기, soft)
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Flag, Check, Pencil, Trash2, Eye } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
let loading = $state(true);
let items = $state([]); // [{id, study_topic_id, question_text, flagged_at, flagged_by}]
// flagged_by 사유 → 라벨/색 (서버 상수: user / source_changed / source_deleted)
const REASON = {
user: { label: '직접 신고', cls: 'border-warning/40 text-warning bg-warning/10' },
source_changed: { label: '문제 수정됨', cls: 'border-accent-ring text-accent bg-accent/10' },
source_deleted: { label: '문제 삭제됨', cls: 'border-error/40 text-error bg-error/10' },
};
function reasonOf(by) {
return REASON[by] ?? { label: '신고', cls: 'border-default text-dim bg-surface' };
}
async function load() {
loading = true;
try {
items = (await api('/study-questions/needs-review')) ?? [];
} catch (err) {
addToast('error', err?.detail || '신고 목록 조회 실패');
items = [];
} finally {
loading = false;
}
}
function removeLocal(id) {
items = items.filter((it) => it.id !== id);
}
async function resolve(it) {
try {
await api(`/study-questions/${it.id}`, {
method: 'PATCH',
body: JSON.stringify({ needs_review: false }),
});
removeLocal(it.id);
addToast('success', '검토 완료 — 신고를 해제했어요');
} catch (err) {
addToast('error', err?.detail || '처리 실패');
}
}
async function discard(it) {
if (!confirm('이 문제를 폐기할까요? (되돌릴 수 없음)')) return;
try {
await api(`/study-questions/${it.id}`, { method: 'DELETE' });
removeLocal(it.id);
addToast('success', '문제를 폐기했어요');
} catch (err) {
addToast('error', err?.detail || '삭제 실패');
}
}
function fmtDate(s) {
if (!s) return '';
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
}
onMount(load);
</script>
<svelte:head><title>문제 신고함</title></svelte:head>
<div class="mx-auto max-w-3xl px-4 py-6">
<div class="mb-5 flex items-center gap-3">
<Button variant="ghost" size="sm" icon={ArrowLeft} onclick={() => goto('/study')}>공부</Button>
<h1 class="text-xl font-bold text-text">문제 신고함</h1>
{#if items.length > 0}
<span class="rounded-full bg-warning px-2.5 py-0.5 text-xs font-bold text-white">{items.length}</span>
{/if}
</div>
<p class="mb-4 text-sm text-dim">
퀴즈나 문제 화면에서 <b class="text-text">이상하다고 신고한 문제</b>가 여기에 모입니다.
내용을 확인해 <b class="text-text">수정하거나, 검토 완료(신고 해제)하거나, 폐기</b>하세요.
</p>
{#if loading}
<div class="space-y-3">{#each Array(4).fill(0) as _, i (i)}<Skeleton class="h-24 w-full" />{/each}</div>
{:else if items.length === 0}
<EmptyState
title="신고된 문제가 없습니다"
description="문제나 정답이 이상할 때 퀴즈·문제 화면의 '이 문제 이상해요'로 신고하면 여기에 쌓입니다."
icon={Flag}
/>
{:else}
<div class="space-y-2.5">
{#each items as it (it.id)}
{@const r = reasonOf(it.flagged_by)}
<div class="rounded-card border border-default bg-surface p-3.5">
<div class="mb-2 flex items-center gap-2">
<span class="rounded-full border px-2 py-0.5 text-[11px] font-semibold {r.cls}">{r.label}</span>
{#if it.flagged_at}<span class="text-[11px] text-faint">{fmtDate(it.flagged_at)}</span>{/if}
</div>
<div class="text-sm leading-relaxed text-text">{it.question_text}</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button href={`/study/topics/${it.study_topic_id}/questions/${it.id}`} variant="secondary" size="sm" icon={Eye}>문제 보기</Button>
<Button href={`/study/topics/${it.study_topic_id}/questions/${it.id}/edit`} variant="ghost" size="sm" icon={Pencil}>수정</Button>
<Button variant="ghost" size="sm" icon={Check} onclick={() => resolve(it)}>검토 완료</Button>
<Button variant="danger" size="sm" icon={Trash2} onclick={() => discard(it)}>폐기</Button>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -22,7 +22,7 @@
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, ArrowRight, Edit, Sparkles, AlertCircle, CheckCircle2, XCircle, ListChecks,
ArrowLeft, ArrowRight, Edit, Sparkles, AlertCircle, CheckCircle2, XCircle, ListChecks, Flag,
} from 'lucide-svelte';
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
import Button from '$lib/components/ui/Button.svelte';
@@ -237,6 +237,26 @@
if (!s) return '';
return new Date(s).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
}
// 이 문제 이상해요 신고 토글 (PATCH needs_review). 신고함(/study/questions-review)에 모임.
let flagBusy = $state(false);
async function toggleFlag() {
if (flagBusy || !q) return;
flagBusy = true;
const next = !q.needs_review;
try {
const res = await api(`/study-questions/${qid}`, {
method: 'PATCH',
body: JSON.stringify({ needs_review: next }),
});
q = { ...q, needs_review: res.needs_review, flagged_by: res.flagged_by, flagged_at: res.flagged_at };
addToast(next ? 'success' : 'info', next ? '이상 문제로 신고했어요 — 검수함에 모았습니다' : '신고를 해제했어요');
} catch (err) {
addToast('error', err?.detail || '신고 처리 실패');
} finally {
flagBusy = false;
}
}
</script>
<svelte:head><title>문제 상세 — {topicName || '주제'}</title></svelte:head>
@@ -279,7 +299,22 @@
{#if !q.is_active}<span class="text-warning">· 비활성</span>{/if}
</div>
</div>
<Button href={`/study/topics/${topicId}/questions/${qid}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
<div class="flex items-center gap-1.5 shrink-0">
<button
type="button"
onclick={toggleFlag}
disabled={flagBusy}
class="flex items-center gap-1.5 text-xs h-7 px-2.5 rounded-md border transition-colors disabled:opacity-50
{q.needs_review
? 'border-warning bg-warning/10 text-warning'
: 'border-default text-dim hover:text-warning hover:border-warning/40'}"
title="문제나 정답이 이상하면 신고해 검수함에 모아둡니다"
>
<Flag size={13} />
<span>{q.needs_review ? '신고됨' : '이 문제 이상해요'}</span>
</button>
<Button href={`/study/topics/${topicId}/questions/${qid}/edit`} size="sm" variant="ghost" icon={Edit}>편집</Button>
</div>
</div>
<!-- 본문 -->
@@ -14,7 +14,7 @@
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, CheckCircle2, XCircle, HelpCircle, Sparkles, BookOpen, AlertCircle, Square, CheckSquare,
ArrowLeft, CheckCircle2, XCircle, HelpCircle, Sparkles, BookOpen, AlertCircle, Square, CheckSquare, Flag,
} from 'lucide-svelte';
import { renderMathMarkdown, renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
import Button from '$lib/components/ui/Button.svelte';
@@ -36,6 +36,24 @@
// PR-12-A: 카드별 round_count 배지 (틀린/모르겠음 헤더에 표시).
let relatedCounts = $state({}); // { [qid]: { repeat_round_count, similar_round_count, ... } }
// 문제 신고(이상 태깅): qid -> true. 검수함(/study/questions-review)에 모임.
let flagged = $state({});
let flagBusy = $state({});
async function flagQuestion(qid) {
if (flagBusy[qid]) return;
const next = !flagged[qid];
flagBusy = { ...flagBusy, [qid]: true };
try {
const res = await api(`/study-questions/${qid}`, { method: 'PATCH', body: JSON.stringify({ needs_review: next }) });
flagged = { ...flagged, [qid]: res?.needs_review ?? next };
addToast(next ? 'success' : 'info', next ? '이상 문제로 신고했어요 — 검수함에 모았습니다' : '신고를 해제했어요');
} catch (err) {
addToast('error', err?.detail || '신고 처리 실패');
} finally {
flagBusy = { ...flagBusy, [qid]: false };
}
}
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
@@ -51,6 +69,8 @@
if ((detail.summary.wrong_count ?? 0) > 0) activeTab = 'wrong';
else if ((detail.summary.unsure_count ?? 0) > 0) activeTab = 'unsure';
else activeTab = 'correct';
// 신고 상태의 영속 source 는 신고함 큐(/study/questions-review) — 세션 결과 payload 엔
// needs_review 가 없으므로 여기선 세션 내 optimistic 표시만. 새로고침 시 초기화됨.
// PR-12-A: 카드별 반복 출제/유사 유형 배지 — 1회 bulk 호출.
void loadRelatedCounts();
} catch (err) {
@@ -562,8 +582,21 @@
{@render subjectNoteBlock(it, cardState)}
{/if}
{#if kind !== 'correct'}
<div class="flex justify-end pt-2 border-t border-default">
<div class="flex items-center justify-between gap-2 pt-2 border-t border-default">
<button
type="button"
onclick={() => flagQuestion(it.q.id)}
disabled={flagBusy[it.q.id]}
class="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors disabled:opacity-50
{flagged[it.q.id]
? 'border-warning bg-warning/10 text-warning'
: 'border-default text-dim hover:text-warning hover:border-warning/40'}"
title="문제나 정답이 이상하면 신고해 검수함에 모아둡니다"
>
<Flag size={13} />
<span>{flagged[it.q.id] ? '신고됨' : '이 문제 이상해요'}</span>
</button>
{#if kind !== 'correct'}
<button
type="button"
onclick={() => toggleReviewed(it.attempt)}
@@ -575,8 +608,8 @@
{#if reviewed}<CheckSquare size={14} />{:else}<Square size={14} />{/if}
<span>{reviewed ? '학습완료' : '학습완료로 표시'}</span>
</button>
</div>
{/if}
{/if}
</div>
</div>
{/if}
</li>