Files
inkling/src/renderer/inbox/components/NoteCard.tsx
altair823 f6bea623bf feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)
- 회색 placeholder div → <img src=inkling-media://...> 로 교체
- onClick 으로 inboxApi.openMedia(relPath) 호출 (현재는 InboxApi 인터페이스에 부재 → unknown cast 사용; Task 3 에서 정식 시그니처 추가 후 cast 제거 예정)
- alt='' 로 decorative 처리 (role=presentation), title 에 relPath 유지
- flex-wrap 추가 — 다수 이미지 시 줄바꿈

Tests: tests/unit/NoteCard.test.tsx 신규 2건 (img src 검증, click → openMedia 호출)
회귀: 468 → 470 pass
2026-05-09 14:06:21 +09:00

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
}): 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 (
<span
onClick={() => setEditing(true)}
style={{ fontSize: 11, color: '#bbb', cursor: 'pointer' }}
title="마감일 추가"
>
📅
</span>
);
}
const past = isPastDue(value, today);
return (
<span style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
onClick={() => setEditing(true)}
style={{
color: past ? '#999' : '#666',
textDecoration: past ? 'line-through' : 'none',
cursor: 'pointer'
}}
title={past ? '지난 마감일 — 클릭으로 편집' : '클릭으로 편집'}
>
📅 {value}
</span>
{!isEdited && (
<span
style={{
fontSize: 9, padding: '0 4px', background: '#eee',
color: '#888', borderRadius: 2
}}
title="AI 추출"
>
AI
</span>
)}
</span>
);
}
return (
<input
type="date"
value={draft}
onChange={(e) => 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)
<div id={`note-${note.id}`} 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>
{!isTrash && 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' && (
<>
{isTrash ? (
<>
<div style={{ marginTop: 4 }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{local.aiTitle ?? '(제목 없음)'}</h3>
</div>
<div style={{ marginTop: 6, fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}>
{local.aiSummary ?? '(요약 없음)'}
</div>
{local.dueDate !== null && (
<div style={{ marginTop: 6 }}>
<span style={{ fontSize: 11, color: '#666' }}>📅 {local.dueDate}</span>
</div>
)}
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 8px',
borderRadius: 12,
fontSize: 12
}}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
))}
</div>
)}
</>
) : (
<>
<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>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (
<span
key={t.name}
style={{
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
padding: '2px 4px 2px 8px',
borderRadius: 12,
fontSize: 12,
display: 'inline-flex',
alignItems: 'center',
gap: 4
}}
>
<span
onClick={() => filterByTag(t.name)}
style={{ cursor: 'pointer' }}
title={`#${t.name} 노트만 보기`}
>
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
</span>
<button
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit',
fontSize: 14,
padding: '0 2px',
lineHeight: 1,
opacity: 0.6
}}
title="태그 제거"
aria-label={`${t.name} 태그 제거`}
>
×
</button>
</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', flexWrap: 'wrap', gap: 6 }}>
{local.media.map((m) => (
<img
key={m.id}
src={`inkling-media://${m.relPath}`}
alt=""
title={m.relPath}
onClick={() => { void (inboxApi as unknown as { openMedia: (rel: string) => Promise<unknown> }).openMedia(m.relPath); }}
style={{
width: 48,
height: 48,
objectFit: 'cover',
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #e0e0e0'
}}
/>
))}
</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' }}>
{isTrash ? (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onRestore}
style={{
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🔄
</button>
<button
onClick={onPermanentDelete}
style={{
background: 'none', border: '1px solid #c93030', color: '#c93030',
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
}}
>
🗑
</button>
</div>
) : (
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
🗑
</button>
)}
</div>
</div>
);
}