245 lines
8.2 KiB
TypeScript
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>
|
|
)
|
|
}
|