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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user