feat(expiry): ExpiryBanner component + App.tsx mount (#5 v0.2.3)
This commit is contained in:
@@ -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',
|
||||
|
||||
147
src/renderer/inbox/components/ExpiryBanner.tsx
Normal file
147
src/renderer/inbox/components/ExpiryBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user