- 회색 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
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|