feat(frontend): show memo triage and voice source UI

PR-2B/2C frontend (commit 4/4). plan v9 Memo Intake Upgrade.

PR-2B 분류 표시 + 1-click promote:
- 메모 카드 상단에 AI 분류 배지 (task/calendar/activity/reference + confidence%)
- ai_event_kind != 'note' 메모 하단에 4 버튼:
  · [할 일로] [일정으로] [활동으로] (AI 추천 kind 는 색깔 highlight)
  · [그냥 메모] (dismiss → ai_event_kind='note' 강제)
- promote 후 메모 카드에 "→ events #N" link 배지 (사용자 시각 확인)

PR-2C 음성 메모 표시:
- source_channel='voice' 메모는 🎙️ "음성" 배지
- audio player (<audio src=/api/documents/{id}/file?token=>) — 기존 file endpoint 재활용
- STT 대기 중인 voice 메모는 "음성 → 텍스트 변환 대기 중…" placeholder

API helpers:
- promoteMemo(memoId, kind) → POST /memos/{id}/promote-to-event
- dismissEventSuggestion(memoId) → POST /memos/{id}/dismiss-event-suggestion
- voiceAudioUrl(memoId) → /api/documents/{id}/file?token= (access token URL pattern)

Sidebar 영향 0 (events 진입점은 이미 PR-2 에서 추가됨).

원칙 (재명시): AI worker 는 events row 직접 생성 X — 본 UI 의 promote 버튼만이 events 진입.
This commit is contained in:
Hyungi Ahn
2026-05-11 12:08:34 +09:00
parent 6490050b04
commit e3adbb8961
+103 -5
View File
@@ -3,7 +3,8 @@
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
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 { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen } from 'lucide-svelte';
import { getAccessToken } from '$lib/api';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
@@ -249,6 +250,54 @@
}
}
// ─── PR-2B: 메모 → events 1-click promote ───
async function promoteMemo(memoId, kind) {
const labels = { task: '할 일', calendar_event: '일정', activity_log: '활동 기록' };
try {
const res = await api(`/memos/${memoId}/promote-to-event`, {
method: 'POST',
body: JSON.stringify({ kind }),
});
addToast('success', `${labels[kind]} 로 승급 (events #${res.event_id})`);
// 로컬 상태 갱신 — promoted 표시를 위해 메모에 임시 마킹 (서버 미반영, UX 힌트만)
memos = memos.map((m) => m.id === memoId ? { ...m, _last_promoted: { event_id: res.event_id, kind } } : m);
} catch (err) {
addToast('error', err?.detail || '승급 실패');
}
}
async function dismissEventSuggestion(memoId) {
try {
const updated = await api(`/memos/${memoId}/dismiss-event-suggestion`, { method: 'POST' });
memos = memos.map((m) => (m.id === memoId ? updated : m));
} catch (err) {
addToast('error', '처리 실패');
}
}
// voice 메모 audio URL — /api/documents/{id}/file?token= 패턴 재사용
function voiceAudioUrl(memoId) {
const token = getAccessToken();
return `/api/documents/${memoId}/file?token=${encodeURIComponent(token ?? '')}`;
}
// ai_event_kind 별 라벨 / 색상
const KIND_LABELS = {
note: '메모',
task: '할 일',
calendar_event: '일정',
activity_log: '활동',
reference: '참조',
};
const KIND_BADGE_CLASS = {
note: 'bg-surface text-dim',
task: 'bg-indigo-100 text-indigo-700',
calendar_event: 'bg-blue-100 text-blue-700',
activity_log: 'bg-emerald-100 text-emerald-700',
reference: 'bg-amber-100 text-amber-700',
};
async function handleCheckboxClick(e, memo) {
const target = e.target;
if (target.tagName !== 'INPUT' || target.type !== 'checkbox') return;
@@ -473,9 +522,36 @@
</div>
{:else}
<!-- ═══ 읽기 모드 ═══ -->
<!-- PR-2B/2C: 분류 배지 + voice icon + 마지막 promote 결과 -->
{#if memo.source_channel === 'voice' || memo.ai_event_kind || memo._last_promoted}
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
{#if memo.source_channel === 'voice'}
<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-rose-100 text-rose-700" title="음성 메모">
<Mic size={10} /> 음성
</span>
{/if}
{#if memo.ai_event_kind && memo.ai_event_kind !== 'note'}
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] {KIND_BADGE_CLASS[memo.ai_event_kind] || 'bg-surface text-dim'}">
AI 추천: {KIND_LABELS[memo.ai_event_kind] || memo.ai_event_kind}{memo.ai_event_confidence != null ? ` · ${Math.round(memo.ai_event_confidence * 100)}%` : ''}
</span>
{/if}
{#if memo._last_promoted}
<a href={`/events/${memo._last_promoted.event_id}`} class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] bg-emerald-100 text-emerald-700 hover:bg-emerald-200">
<ArrowRight size={10} /> events #{memo._last_promoted.event_id}
</a>
{/if}
</div>
{/if}
{#if memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)}
<p class="text-xs font-semibold text-dim mb-1">{memo.title}</p>
{/if}
<!-- voice 메모 audio player -->
{#if memo.source_channel === 'voice' && memo.file_path}
<audio controls preload="metadata" src={voiceAudioUrl(memo.id)} class="w-full mb-2 h-9"></audio>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -483,10 +559,14 @@
class:show-hidden={showHiddenByMemo[memo.id]}
onclick={(e) => handleCheckboxClick(e, memo)}
>
{@html renderMemoHtml(memo.content || '', {
taskStates: memo.memo_task_state ?? {},
now: nowTick,
})}
{#if memo.content}
{@html renderMemoHtml(memo.content || '', {
taskStates: memo.memo_task_state ?? {},
now: nowTick,
})}
{:else if memo.source_channel === 'voice'}
<p class="text-xs text-dim italic">음성 → 텍스트 변환 대기 중…</p>
{/if}
</div>
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
<button
@@ -503,6 +583,24 @@
{/if}
{/if}
<!-- PR-2B: AI triage 결과 → 1-click promote 버튼 (분류 결과 있고 dismissed 아닌 메모) -->
{#if editingId !== memo.id && memo.ai_event_kind && memo.ai_event_kind !== 'note' && !memo._last_promoted && !showArchived}
<div class="flex flex-wrap gap-1 mt-2 pt-2 border-t border-default/30">
<button onclick={() => promoteMemo(memo.id, 'task')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'task' ? 'bg-indigo-500 text-white hover:bg-indigo-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
<FileText size={11} /> 할 일로
</button>
<button onclick={() => promoteMemo(memo.id, 'calendar_event')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'calendar_event' ? 'bg-blue-500 text-white hover:bg-blue-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
<Calendar size={11} /> 일정으로
</button>
<button onclick={() => promoteMemo(memo.id, 'activity_log')} class={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] transition-colors ${memo.ai_event_kind === 'activity_log' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : 'bg-surface text-dim hover:bg-surface-hover hover:text-text'}`}>
<Activity size={11} /> 활동으로
</button>
<button onclick={() => dismissEventSuggestion(memo.id)} class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover hover:text-text transition-colors">
<X size={11} /> 그냥 메모
</button>
</div>
{/if}
<!-- 태그 + 하단 -->
{#if editingId !== memo.id}
{#if memo.user_tags?.length || memo.ai_tags?.length}