feat(inbox): NoteCard with AI proposal labels, intent badge, IntentBanner slot
Task 25 of the slice plan. Single-card view of a note with local optimistic state plus the IPC writes. Per-status branches: - pending: 'Inkling이 정리하는 중…' headline, raw text auto-open. - failed: '정리 보류 — 원문은 안전합니다' with hover tooltip. - done: editable title + summary (each with the gray 'AI' badge hidden as soon as *_edited_by_user flips), tag chips with AI subscript, optional 💡 user_intent row, IntentBanner surfaced exactly when intent_prompted_at is null. Tag click removes (per spec), title/summary edits route through inboxApi.updateAiFields (which sets the edited flag server-side), intent edits use inboxApi.setIntent. Delete confirms with '이 기억을 버릴까요? 되돌릴 수 없습니다.' and routes through CaptureService.deleteNote so media gets cleaned up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,163 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
export function NoteCard({ note }: { note: Note; onDeleted: () => void; onUpdated: (n: Note) => void }) {
|
||||
return <div style={{ background: 'white', padding: 12, marginBottom: 10, borderRadius: 8 }}>{note.rawText}</div>;
|
||||
import { inboxApi } from '../api.js';
|
||||
import { EditableField } from './EditableField.js';
|
||||
import { IntentBanner } from './IntentBanner.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
onDeleted: () => void;
|
||||
onUpdated: (n: Note) => void;
|
||||
}
|
||||
|
||||
const aiBadgeStyle: React.CSSProperties = {
|
||||
display: 'inline-block', marginLeft: 6, padding: '1px 5px',
|
||||
background: '#eee', color: '#666', fontSize: 10, borderRadius: 3, verticalAlign: 'middle'
|
||||
};
|
||||
|
||||
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
|
||||
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 removeTag(tagName: string) {
|
||||
const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
|
||||
await inboxApi.updateAiFields(note.id, { tags: next });
|
||||
const updated = { ...local, tags: local.tags.filter((t) => t.name !== tagName) };
|
||||
setLocal(updated); onUpdated(updated);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
|
||||
|
||||
{showIntentBanner && (
|
||||
<IntentBanner
|
||||
noteId={note.id}
|
||||
onResolved={(intentText) => {
|
||||
const now = new Date().toISOString();
|
||||
const updated = { ...local, userIntent: intentText ?? null, intentPromptedAt: now };
|
||||
setLocal(updated); onUpdated(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{local.aiStatus === 'pending' && (
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#666', marginTop: 4 }}>
|
||||
Inkling이 정리하는 중…
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'failed' && (
|
||||
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55', marginTop: 4 }}>
|
||||
정리 보류 — 원문은 안전합니다
|
||||
</div>
|
||||
)}
|
||||
{local.aiStatus === 'done' && (
|
||||
<>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<EditableField
|
||||
value={local.aiTitle ?? ''}
|
||||
onSave={saveTitle}
|
||||
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
|
||||
singleLine
|
||||
/>
|
||||
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<EditableField
|
||||
value={local.aiSummary ?? ''}
|
||||
onSave={saveSummary}
|
||||
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
|
||||
singleLine={false}
|
||||
/>
|
||||
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
|
||||
</div>
|
||||
{local.tags.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{local.tags.map((t) => (
|
||||
<span
|
||||
key={t.name}
|
||||
onClick={() => void removeTag(t.name)}
|
||||
style={{
|
||||
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
|
||||
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
title={t.source === 'ai' ? 'AI 제안 — 클릭으로 제거' : '내가 추가 — 클릭으로 제거'}
|
||||
>
|
||||
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{local.userIntent !== null && (
|
||||
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
|
||||
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
|
||||
<EditableField
|
||||
value={local.userIntent}
|
||||
onSave={saveIntent}
|
||||
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
|
||||
singleLine
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{local.media.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
|
||||
{local.media.map((m) => (
|
||||
<div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button onClick={() => setRawOpen((o) => !o)} style={{ background: 'none', border: 'none', color: '#555', fontSize: 12, cursor: 'pointer', padding: 0 }}>
|
||||
{rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
|
||||
</button>
|
||||
{rawOpen && (
|
||||
<pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
|
||||
{local.rawText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
||||
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
|
||||
🗑 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user