feat(trash): Inbox 탭 toggle + 휴지통 view + NoteCard mode prop (#4 v0.2.3)
This commit is contained in:
@@ -12,15 +12,10 @@ import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
notes,
|
||||
loading,
|
||||
loadInitial,
|
||||
refreshMeta,
|
||||
upsertNote,
|
||||
removeNote,
|
||||
continuity,
|
||||
tagFilter,
|
||||
setTagFilter
|
||||
notes, trashNotes, trashCount, showTrash,
|
||||
loading, loadInitial, refreshMeta, upsertNote, removeNote,
|
||||
continuity, tagFilter, setTagFilter,
|
||||
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
|
||||
} = useInbox();
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
|
||||
@@ -38,64 +33,114 @@ export function App(): React.ReactElement {
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
const filtered = selectFilteredNotes({ notes, tagFilter });
|
||||
|
||||
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
background: active ? '#0a4b80' : 'transparent',
|
||||
color: active ? '#fff' : '#0a4b80',
|
||||
border: '1px solid #0a4b80',
|
||||
borderRadius: 4,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="header">
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
<button
|
||||
onClick={() => { if (showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={!showTrash}
|
||||
style={tabBtnStyle(!showTrash)}
|
||||
>
|
||||
Inbox({notes.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={showTrash}
|
||||
style={tabBtnStyle(showTrash)}
|
||||
>
|
||||
휴지통({trashCount})
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
</div>
|
||||
</div>
|
||||
<main className="main">
|
||||
<OllamaBanner />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
{tagFilter !== null && (
|
||||
<div
|
||||
style={{
|
||||
background: '#eaf3ff',
|
||||
color: '#0a4b80',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
margin: '8px 0',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
<span>🔎 필터: <strong>#{tagFilter}</strong></span>
|
||||
<span style={{ color: '#666' }}>({filtered.length}개)</span>
|
||||
<button
|
||||
onClick={() => setTagFilter(null)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#0a4b80',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12
|
||||
}}
|
||||
title="필터 해제"
|
||||
>
|
||||
✕ 해제
|
||||
</button>
|
||||
</div>
|
||||
{!showTrash && (
|
||||
<>
|
||||
<OllamaBanner />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
{tagFilter !== null && (
|
||||
<div style={{
|
||||
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
||||
borderRadius: 6, margin: '8px 0', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 8
|
||||
}}>
|
||||
<span>🔎 필터: <strong>#{tagFilter}</strong></span>
|
||||
<span style={{ color: '#666' }}>({filtered.length}개)</span>
|
||||
<button
|
||||
onClick={() => setTagFilter(null)}
|
||||
style={{ marginLeft: 'auto', background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12 }}
|
||||
title="필터 해제"
|
||||
>
|
||||
✕ 해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="inbox"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
onUpdated={(u) => upsertNote(u)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
|
||||
))
|
||||
{showTrash && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0' }}>
|
||||
<div style={{ fontSize: 13, color: '#666' }}>
|
||||
{trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void emptyTrash()}
|
||||
disabled={trashCount === 0}
|
||||
style={{
|
||||
background: trashCount === 0 ? '#666' : '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4, padding: '4px 10px',
|
||||
fontSize: 12, cursor: trashCount === 0 ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
휴지통 비우기 ({trashCount}개)
|
||||
</button>
|
||||
</div>
|
||||
{trashNotes.length === 0 ? null : (
|
||||
trashNotes.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="trash"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
onUpdated={(u) => upsertNote(u)}
|
||||
onRestore={() => void restoreNote(n.id)}
|
||||
onPermanentDelete={() => void permanentDeleteNote(n.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<TagUndoToast />
|
||||
|
||||
@@ -10,6 +10,9 @@ interface Props {
|
||||
note: Note;
|
||||
onDeleted: () => void;
|
||||
onUpdated: (n: Note) => void;
|
||||
mode?: 'inbox' | 'trash'; // default 'inbox'
|
||||
onRestore?: () => void;
|
||||
onPermanentDelete?: () => void;
|
||||
}
|
||||
|
||||
const aiBadgeStyle: React.CSSProperties = {
|
||||
@@ -104,7 +107,8 @@ function DueDateBadge({
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
|
||||
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);
|
||||
|
||||
@@ -183,7 +187,7 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
<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 && (
|
||||
{!isTrash && showIntentBanner && (
|
||||
<IntentBanner
|
||||
noteId={note.id}
|
||||
onResolved={(intentText) => {
|
||||
@@ -206,86 +210,122 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -310,9 +350,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
||||
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
|
||||
🗑 삭제
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user