feat(recall): RecallBanner + App.tsx mount + NoteCard id (#6 v0.2.3)
- RecallBanner: 노트 제목 + N일 전 + 3 버튼 (열어보기/다음에/더 이상)
- 첫 렌더 시 emitRecallShown (recallShownIds Set 으로 per-session 1회 제약)
- snoozeUntilMs 만료 setTimeout (ExpiryBanner 패턴)
- 위치: ExpiryBanner 다음 (banner stack 끝)
- NoteCard 외곽 div 에 id="note-${note.id}" — "열어보기" scrollIntoView target
- 컬러 테마: 파랑 (#e8f0fe / #4a7ec0) — 다른 banner (적/황/적) 와 구별
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { RecoveryToast } from './components/RecoveryToast.js';
|
||||
import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
@@ -86,6 +87,7 @@ export function App(): React.ReactElement {
|
||||
<PendingBanner />
|
||||
<FailedBanner />
|
||||
<ExpiryBanner />
|
||||
<RecallBanner />
|
||||
{tagFilter !== null && (
|
||||
<div style={{
|
||||
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
||||
|
||||
@@ -184,7 +184,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
||||
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
|
||||
<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 && (
|
||||
|
||||
95
src/renderer/inbox/components/RecallBanner.tsx
Normal file
95
src/renderer/inbox/components/RecallBanner.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
export function RecallBanner(): React.ReactElement | null {
|
||||
const candidate = useInbox((s) => s.recallCandidate);
|
||||
const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
|
||||
const shownIds = useInbox((s) => s.recallShownIds);
|
||||
const openRecall = useInbox((s) => s.openRecall);
|
||||
const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
|
||||
const snoozeRecall = useInbox((s) => s.snoozeRecall);
|
||||
|
||||
// ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (snoozeUntilMs === null) return;
|
||||
const remaining = snoozeUntilMs - Date.now();
|
||||
if (remaining <= 0) return;
|
||||
const t = setTimeout(() => setTick((n) => n + 1), remaining);
|
||||
return () => clearTimeout(t);
|
||||
}, [snoozeUntilMs]);
|
||||
|
||||
// first-render emit recall_shown (per-session 1회 per note)
|
||||
useEffect(() => {
|
||||
if (!candidate) return;
|
||||
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
|
||||
if (shownIds.has(candidate.id)) return;
|
||||
void inboxApi.emitRecallShown(candidate.id);
|
||||
useInbox.setState({ recallShownIds: new Set([...shownIds, candidate.id]) });
|
||||
}, [candidate, snoozeUntilMs, shownIds]);
|
||||
|
||||
if (candidate === null) return null;
|
||||
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
|
||||
|
||||
const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
|
||||
const title = candidate.aiTitle ?? candidate.rawText.slice(0, 60);
|
||||
|
||||
function onOpen() {
|
||||
void openRecall(candidate!.id);
|
||||
const el = document.getElementById(`note-${candidate!.id}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>💭 <b>오늘 회상해볼 노트</b></span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
|
||||
{title}
|
||||
</span>
|
||||
<span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays}일 전</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
background: '#4a7ec0', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
열어보기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void snoozeRecall()}
|
||||
style={{
|
||||
background: 'transparent', color: '#4a7ec0',
|
||||
border: '1px solid #4a7ec0', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
다음에
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void dismissRecallNote(candidate.id)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'transparent', color: '#888',
|
||||
border: 'none', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
더 이상
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeAgeDays(refIso: string): number {
|
||||
const refMs = new Date(refIso).getTime();
|
||||
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
|
||||
}
|
||||
Reference in New Issue
Block a user