d3ro-voice/apps/desktop/src/renderer/pages/HistoryPage.tsx
2026-08-29 18:33:45 +09:00

245 lines
8.2 KiB
TypeScript

// src/renderer/pages/HistoryPage.tsx
// High-End Agency Dictation History & Memory Timeline
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box, IconButton, Tooltip } from '@mui/material'
import { Download, Trash2, Clock, Filter } from 'lucide-react'
import { PhosphorText, TactileBadge, PhysicalButton } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { getDateKey } from '../utils/formatters'
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'
import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@d3ro/core/types'
const PAGE_SIZE = 50
export function HistoryPage(): React.ReactElement {
const { t, formatRelativeDate } = useI18n()
const [data, setData] = useState<HistoryPageData | null>(null)
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [allTags, setAllTags] = useState<TagCount[]>([])
const [activeTag, setActiveTag] = useState<string | null>(null)
const loadTags = useCallback(async () => {
const result = await window.electronAPI.memo.getAllTags()
if (result.success) setAllTags(result.data)
}, [])
const loadData = useCallback(async () => {
setLoading(true)
let result: { success: boolean; data: HistoryPageData } | { success: false; error: unknown }
if (activeTag) {
result = await window.electronAPI.memo.searchByTag({ tag: activeTag, page: 0, pageSize: PAGE_SIZE })
} else if (search.trim()) {
result = await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE })
} else {
result = await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' })
}
if (result.success) setData(result.data)
setLoading(false)
}, [search, activeTag])
useEffect(() => {
loadData()
loadTags()
const unsub = window.electronAPI.app.onDataChanged(() => {
loadData()
loadTags()
})
return unsub
}, [loadData, loadTags])
const groupedEntries = useMemo(() => {
if (!data) return []
const groups: Record<string, { label: string; entries: HistoryEntry[] }> = {}
for (const entry of data.entries) {
const key = getDateKey(entry.createdAt)
if (!groups[key]) {
groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] }
}
groups[key].entries.push(entry)
}
return Object.values(groups)
}, [data, formatRelativeDate])
const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text)
}, [])
const isExportingRef = useRef(false)
const isDeletingAllRef = useRef(false)
const isDeletingSingleRef = useRef(false)
const handleDelete = useCallback(
async (id: string) => {
if (isDeletingSingleRef.current) return
isDeletingSingleRef.current = true
try {
await window.electronAPI.history.delete({ id })
loadData()
} finally {
isDeletingSingleRef.current = false
}
},
[loadData],
)
const handleTagClick = useCallback((tag: string) => {
setActiveTag((prev) => (prev === tag ? null : tag))
setSearch('')
}, [])
const handleExport = useCallback(async () => {
if (isExportingRef.current) return
isExportingRef.current = true
try {
await window.electronAPI.memo.export({
format: 'markdown' as const,
tag: activeTag ?? undefined,
})
} finally {
isExportingRef.current = false
}
}, [activeTag])
const handleDeleteAll = useCallback(async () => {
if (isDeletingAllRef.current) return
isDeletingAllRef.current = true
try {
await window.electronAPI.history.deleteAll()
loadData()
} finally {
isDeletingAllRef.current = false
}
}, [loadData])
return (
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
{/* Page Header */}
<PageHeader
title={t('history.title')}
count={t('history.entries', { count: data?.total ?? 0 })}
action={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<Tooltip title={t('memo.export')} arrow>
<PhysicalButton size="small" onClick={handleExport} sx={{ height: 34, px: 1.5 }}>
<Download size={14} style={{ marginRight: 4 }} />
{t('memo.export')}
</PhysicalButton>
</Tooltip>
<Tooltip title={t('common.clearAll') || '모두 지우기'} arrow>
<IconButton
size="small"
data-testid="clear-all-history-button"
onClick={handleDeleteAll}
sx={{
color: d3roPalette.text.inactive,
p: 0.75,
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairline}`,
'&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg },
}}
>
<Trash2 size={16} />
</IconButton>
</Tooltip>
</Box>
}
/>
{/* Search Input Bar */}
{!activeTag && (
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('history.search')}
/>
)}
{/* Tag Filters Bar */}
{allTags.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mr: 0.5, color: d3roPalette.text.dimLabel }}>
<Filter size={12} />
<PhosphorText variant="meta">{t('memo.tags') || 'TAGS'}:</PhosphorText>
</Box>
{allTags.map((tc) => {
const isSelected = activeTag === tc.tag
return (
<TactileBadge
key={tc.tag}
mono
tone={isSelected ? 'accent' : 'default'}
onClick={() => handleTagClick(tc.tag)}
sx={{ cursor: 'pointer' }}
>
#{tc.tag} <Box component="span" sx={{ opacity: 0.6 }}>({tc.count})</Box>
</TactileBadge>
)
})}
{activeTag && (
<TactileBadge
tone="error"
onClick={() => setActiveTag(null)}
sx={{ cursor: 'pointer' }}
>
{t('memo.clearFilter')}
</TactileBadge>
)}
</Box>
)}
{loading ? (
<Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">{t('common.loading')}</PhosphorText>
</Box>
) : !data || data.entries.length === 0 ? (
<EmptyStateCard
message={search ? t('history.noResults') : t('history.noHistory')}
icon={<Clock />}
/>
) : (
groupedEntries.map((group) => (
<Box key={group.label} sx={{ mb: 3.5 }}>
{/* Date Group Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.75 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('history.count', { count: group.entries.length })}
</PhosphorText>
</Box>
{/* Entries Grid */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
md: 'repeat(2, 1fr)',
},
gap: 1.75,
}}
>
{group.entries.map((entry: HistoryEntry) => (
<HistoryEntryCard
key={entry.id}
entry={entry}
onCopy={handleCopy}
onDelete={handleDelete}
showTags
onTagClick={handleTagClick}
/>
))}
</Box>
</Box>
))
)}
</Box>
)
}