import React, { useState } from 'react'; import type { Note } from '@shared/types'; import { inboxApi } from '../api.js'; import { useInbox } from '../store.js'; import { EditableField } from './EditableField.js'; import { IntentBanner } from './IntentBanner.js'; import { pushTagUndo } from './TagUndoToast.js'; interface Props { note: Note; onDeleted?: () => void; // inbox mode 전용 (trash mode 에서 미사용) onUpdated: (n: Note) => void; mode?: 'inbox' | 'trash'; // default 'inbox' onRestore?: () => void; onPermanentDelete?: () => void; } const aiBadgeStyle: React.CSSProperties = { display: 'inline-block', marginLeft: 6, padding: '1px 5px', background: '#eee', color: '#666', fontSize: 10, borderRadius: 3, verticalAlign: 'middle' }; function isPastDue(iso: string, today: string): boolean { return iso < today; // string comparison works for ISO YYYY-MM-DD } function todayKstIso(): string { const KST_OFFSET_MS = 9 * 60 * 60 * 1000; const k = new Date(Date.now() + KST_OFFSET_MS); return k.toISOString().slice(0, 10); } function DueDateBadge({ value, isEdited, today, onSave }: { value: string | null; isEdited: boolean; today: string; onSave: (next: string) => Promise; }): React.ReactElement { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value ?? ''); React.useEffect(() => { if (!editing) setDraft(value ?? ''); }, [value, editing]); if (!editing) { if (value === null) { return ( setEditing(true)} style={{ fontSize: 11, color: '#bbb', cursor: 'pointer' }} title="마감일 추가" > 📅 마감일 추가 ); } const past = isPastDue(value, today); return ( setEditing(true)} style={{ color: past ? '#999' : '#666', textDecoration: past ? 'line-through' : 'none', cursor: 'pointer' }} title={past ? '지난 마감일 — 클릭으로 편집' : '클릭으로 편집'} > 📅 {value} {!isEdited && ( AI )} ); } return ( setDraft(e.target.value)} onBlur={async () => { try { await onSave(draft); } catch { /* keep editing if invalid */ return; } setEditing(false); }} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); if (e.key === 'Escape') { setDraft(value ?? ''); setEditing(false); } }} autoFocus style={{ fontSize: 11, padding: 1 }} /> ); } export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement { const isTrash = mode === 'trash'; const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done'); const [local, setLocal] = useState(note); React.useEffect(() => { setLocal(note); }, [note]); const formatted = new Date(note.createdAt).toLocaleString('ko-KR'); async function handleDelete() { if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return; await inboxApi.deleteNote(note.id); onDeleted?.(); } async function saveTitle(next: string) { await inboxApi.updateAiFields(note.id, { title: next }); const updated = { ...local, aiTitle: next, titleEditedByUser: true }; setLocal(updated); onUpdated(updated); } async function saveSummary(next: string) { await inboxApi.updateAiFields(note.id, { summary: next }); const updated = { ...local, aiSummary: next, summaryEditedByUser: true }; setLocal(updated); onUpdated(updated); } async function saveDueDate(next: string) { const value = next.trim() === '' ? null : next.trim(); // Light validation: empty or YYYY-MM-DD if (value !== null && !/^\d{4}-\d{2}-\d{2}$/.test(value)) { throw new Error('Invalid date'); } await inboxApi.setDueDate(note.id, value); const updated = { ...local, dueDate: value, dueDateEditedByUser: true }; setLocal(updated); onUpdated(updated); } async function removeTag(tagName: string) { const removed = local.tags.find((t) => t.name === tagName); const nextTagNames = local.tags.filter((t) => t.name !== tagName).map((t) => t.name); await inboxApi.updateAiFields(note.id, { tags: nextTagNames }); const updated = { ...local, tags: local.tags.filter((t) => t.name !== tagName) }; setLocal(updated); onUpdated(updated); if (removed !== undefined) { pushTagUndo({ noteId: note.id, tag: removed, onUndo: async () => { // Restore by re-adding the removed tag. // Note: source flag is reset to 'user' on the backend by `updateAiFields` (it // only takes names), which mirrors current behaviour for any user-driven // tag mutation. Visual restoration matches latest local state. const restoredNames = [...updated.tags.map((t) => t.name), removed.name]; await inboxApi.updateAiFields(note.id, { tags: restoredNames }); const u2 = { ...updated, tags: [...updated.tags, removed] }; setLocal(u2); onUpdated(u2); } }); } } function filterByTag(tagName: string): void { useInbox.getState().setTagFilter(tagName); } async function saveIntent(next: string) { await inboxApi.setIntent(note.id, next); const now = new Date().toISOString(); const updated = { ...local, userIntent: next, intentPromptedAt: local.intentPromptedAt ?? now }; setLocal(updated); onUpdated(updated); } const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null; return ( // id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
{formatted}
{!isTrash && showIntentBanner && ( { const now = new Date().toISOString(); const updated = { ...local, userIntent: intentText ?? null, intentPromptedAt: now }; setLocal(updated); onUpdated(updated); }} /> )} {local.aiStatus === 'pending' && (
Inkling이 정리하는 중…
)} {local.aiStatus === 'failed' && (
정리 보류 — 원문은 안전합니다
)} {local.aiStatus === 'done' && ( <> {isTrash ? ( <>

{local.aiTitle ?? '(제목 없음)'}

{local.aiSummary ?? '(요약 없음)'}
{local.dueDate !== null && (
📅 {local.dueDate}
)} {local.tags.length > 0 && (
{local.tags.map((t) => ( {t.name}{t.source === 'ai' && AI} ))}
)} ) : ( <>
{!local.titleEditedByUser && AI}
{!local.summaryEditedByUser && AI}
{local.tags.length > 0 && (
{local.tags.map((t) => ( filterByTag(t.name)} style={{ cursor: 'pointer' }} title={`#${t.name} 노트만 보기`} > {t.name}{t.source === 'ai' && AI} ))}
)} {local.userIntent !== null && (
💡
)} )} )} {local.media.length > 0 && (
{local.media.map((m) => ( { void inboxApi.openMedia(m.relPath); }} style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4, cursor: 'pointer', border: '1px solid #e0e0e0' }} /> ))}
)}
{rawOpen && (
            {local.rawText}
          
)}
{isTrash ? (
) : ( )}
); }