feat(expiry): ExpiryBanner component + App.tsx mount (#5 v0.2.3)

This commit is contained in:
altair823
2026-05-02 00:22:38 +09:00
parent b7205597db
commit 7cbbd4dc97
2 changed files with 149 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';
import { TagUndoToast } from './components/TagUndoToast.js';
import { ExpiryBanner } from './components/ExpiryBanner.js';
export function App(): React.ReactElement {
const {
@@ -77,6 +78,7 @@ export function App(): React.ReactElement {
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<ExpiryBanner />
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react';
import { useInbox } from '../store.js';
export function ExpiryBanner(): React.ReactElement | null {
const candidates = useInbox((s) => s.expiredCandidates);
const snoozeUntilMs = useInbox((s) => s.expiredSnoozeUntilMs);
const trashExpiredBatch = useInbox((s) => s.trashExpiredBatch);
const snoozeExpired = useInbox((s) => s.snoozeExpired);
// Q5=A: 0건 / snooze 활성 시 collapse
if (candidates.length === 0) return null;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
return <ExpiryBannerInner
candidates={candidates}
onTrash={(ids) => void trashExpiredBatch(ids)}
onSnooze={() => snoozeExpired()}
/>;
}
interface InnerProps {
candidates: Array<{
id: string;
aiTitle: string | null;
rawText: string;
dueDate: string | null;
tags: Array<{ name: string }>
}>;
onTrash: (ids: string[]) => void;
onSnooze: () => void;
}
function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React.ReactElement {
const [expanded, setExpanded] = useState<boolean>(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
// candidates 가 변하면 selected 의 stale id 정리
useEffect(() => {
const valid = new Set(candidates.map((c) => c.id));
setSelected((prev) => {
const next = new Set<string>();
for (const id of prev) if (valid.has(id)) next.add(id);
return next;
});
}, [candidates]);
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
const someSelected = selected.size > 0 && !allSelected;
function toggleAll() {
if (allSelected) setSelected(new Set());
else setSelected(new Set(candidates.map((c) => c.id)));
}
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div style={{
background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> <b> {candidates.length}</b></span>
<button
onClick={() => setExpanded((e) => !e)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
aria-expanded={expanded}
>
{expanded ? '▲ 접기' : '▼ 펼치기'}
</button>
<button
onClick={onSnooze}
style={{
marginLeft: 'auto',
background: 'transparent', color: '#946100',
border: '1px solid #d99500', borderRadius: 4,
padding: '2px 8px', fontSize: 12, cursor: 'pointer'
}}
>
</button>
</div>
{expanded && (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '8px 0 4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={allSelected}
ref={(el) => { if (el) el.indeterminate = someSelected; }}
onChange={toggleAll}
/>
<span style={{ color: '#666' }}> ({selected.size}/{candidates.length})</span>
</label>
<div>
{candidates.map((n) => (
<label
key={n.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 0', cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.aiTitle ?? n.rawText.slice(0, 60)}
</span>
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
{n.tags[0] && (
<span style={{
background: '#fce8b2', color: '#946100', padding: '0 6px',
borderRadius: 10, fontSize: 11
}}>
#{n.tags[0].name}
</span>
)}
</label>
))}
</div>
<button
onClick={() => onTrash(Array.from(selected))}
disabled={selected.size === 0}
style={{
marginTop: 8,
background: selected.size === 0 ? '#999' : '#a33', color: '#fff',
border: 'none', borderRadius: 4,
padding: '4px 12px', fontSize: 12,
cursor: selected.size === 0 ? 'not-allowed' : 'pointer'
}}
>
({selected.size})
</button>
</>
)}
</div>
);
}