返回 oh-my-ppt
edit-html-list.tsx
根目录 / src / renderer / src / pages / edit-html-list.tsx
1 import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
2 import { useNavigate } from 'react-router-dom'
3 import {
4 AlertDialog,
5 AlertDialogAction,
6 AlertDialogCancel,
7 AlertDialogContent,
8 AlertDialogDescription,
9 AlertDialogTitle
10 } from '../components/ui/AlertDialog'
11 import { Button } from '../components/ui/Button'
12 import { Card, CardContent } from '../components/ui/Card'
13 import { Input } from '../components/ui/Input'
14 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../components/ui/Tooltip'
15 import { FileCode2, FileUp, Loader2, Pencil, Search, Trash2, X } from 'lucide-react'
16 import { useHtmlEditorStore } from '../store/htmlEditorStore'
17 import { useHtmlEditStore } from '../store/htmlEditStore'
18 import { useHtmlEditHistoryStore } from '../store/htmlEditHistoryStore'
19 import { useHtmlEditorUiStore } from '../store/htmlEditorUiStore'
20 import { useToastStore } from '../store/toastStore'
21 import { useT } from '../i18n'
22 import { useThumbnailUpdates } from '../hooks/useThumbnailUpdates'
23 import dayjs from 'dayjs'
24 import { localAssetUrl } from '@shared/local-asset'
25
26 const getFileName = (filePath: string | null): string => filePath?.split(/[\\/]/).pop() || ''
27 const thumbnailUrl = (filePath: string): string =>
28 import.meta.env.MODE === 'test' ? 'about:blank' : localAssetUrl(filePath)
29
30 /** HTML 编辑器文档库页(/edit-html,带侧栏内容区)。 */
31 export function EditHtmlListPage(): ReactElement {
32 const navigate = useNavigate()
33 const t = useT()
34 const documents = useHtmlEditorStore((s) => s.documents)
35 const importing = useHtmlEditorStore((s) => s.importing)
36 const removeDocument = useHtmlEditorStore((s) => s.removeDocument)
37 const [searchQuery, setSearchQuery] = useState('')
38 const [searchOpen, setSearchOpen] = useState(false)
39 const [deleteTarget, setDeleteTarget] = useState<(typeof documents)[number] | null>(null)
40 const [deletingDocumentId, setDeletingDocumentId] = useState('')
41 const searchInputRef = useRef<HTMLInputElement | null>(null)
42
43 useThumbnailUpdates('html-editor', (task) => {
44 if (!task.thumbnailPath) return
45 useHtmlEditorStore.getState().setDocumentThumbnail(task.resourceId, task.thumbnailPath)
46 })
47
48 useEffect(() => {
49 void useHtmlEditorStore.getState().loadDocuments()
50 }, [])
51
52 useEffect(() => {
53 if (searchOpen) searchInputRef.current?.focus()
54 }, [searchOpen])
55
56 const filteredDocuments = useMemo(() => {
57 const query = searchQuery.trim().toLocaleLowerCase()
58 if (!query) return documents
59 return documents.filter((document) => {
60 const sourceName = getFileName(document.sourcePath || document.htmlPath)
61 return [document.title, sourceName, document.sourcePath]
62 .filter((value): value is string => Boolean(value))
63 .some((value) => value.toLocaleLowerCase().includes(query))
64 })
65 }, [documents, searchQuery])
66
67 const enterDoc = (docId: string): void => {
68 useHtmlEditStore.getState().reset()
69 useHtmlEditHistoryStore.getState().clear()
70 useHtmlEditorUiStore.getState().setInteractionMode('edit')
71 navigate(`/edit-html/${docId}`)
72 }
73
74 const handleImport = async (): Promise<void> => {
75 const outcome = await useHtmlEditorStore.getState().importFile()
76 if (!outcome.ok) {
77 if (outcome.reason === 'storage-not-configured') {
78 useToastStore.getState().warning(t('home.settingsRequiredTitle'), {
79 description: t('home.settingsRequired')
80 })
81 } else if (outcome.reason === 'error') {
82 useToastStore.getState().error(outcome.message || t('common.retryLater'))
83 }
84 return
85 }
86 await useHtmlEditorStore.getState().loadDocuments()
87 }
88
89 const handleDelete = async (): Promise<void> => {
90 if (!deleteTarget || deletingDocumentId) return
91 setDeletingDocumentId(deleteTarget.id)
92 try {
93 const removed = await removeDocument(deleteTarget.id)
94 if (!removed) {
95 useToastStore.getState().error(t('htmlEditor.removeFromLibraryFailed'))
96 return
97 }
98 useToastStore.getState().success(t('htmlEditor.removedFromLibrary'))
99 setDeleteTarget(null)
100 } finally {
101 setDeletingDocumentId('')
102 }
103 }
104
105 return (
106 <TooltipProvider delayDuration={180}>
107 <div className="mx-auto w-full max-w-6xl p-6">
108 <div className="mb-6">
109 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
110 {t('htmlEditor.eyebrow')}
111 </p>
112 <div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
113 <div className="min-w-0">
114 <h1 className="organic-serif text-[32px] font-semibold leading-none text-[#3e4a32]">
115 {t('htmlEditor.listTitle')}
116 </h1>
117 </div>
118 <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
119 {documents.length > 0 ? (
120 searchOpen || searchQuery ? (
121 <div className="relative w-full sm:w-64">
122 <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#829071]" />
123 <Input
124 ref={searchInputRef}
125 type="search"
126 value={searchQuery}
127 placeholder={t('htmlEditor.searchPlaceholder')}
128 className="h-9 bg-[#fffaf1] pl-9 pr-10"
129 onChange={(event) => setSearchQuery(event.target.value)}
130 onBlur={() => {
131 if (!searchQuery.trim()) setSearchOpen(false)
132 }}
133 />
134 <Button
135 type="button"
136 variant="ghost"
137 size="sm"
138 aria-label={t('htmlEditor.clearSearch')}
139 className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0 text-[#829071] hover:text-[#3e4a32]"
140 onMouseDown={(event) => event.preventDefault()}
141 onClick={() => {
142 setSearchQuery('')
143 setSearchOpen(false)
144 }}
145 >
146 <X className="h-4 w-4" />
147 </Button>
148 </div>
149 ) : (
150 <Tooltip>
151 <TooltipTrigger asChild>
152 <Button
153 size="sm"
154 variant="outline"
155 aria-label={t('htmlEditor.searchButton')}
156 onClick={() => setSearchOpen(true)}
157 >
158 <Search className="h-4 w-4" />
159 </Button>
160 </TooltipTrigger>
161 <TooltipContent side="bottom" align="end">
162 {t('htmlEditor.searchButton')}
163 </TooltipContent>
164 </Tooltip>
165 )
166 ) : null}
167 {documents.length > 0 ? (
168 <Tooltip>
169 <TooltipTrigger asChild>
170 <Button
171 size="sm"
172 variant="outline"
173 className="min-w-[132px]"
174 onClick={() => void handleImport()}
175 disabled={importing}
176 >
177 <FileUp className="mr-2 h-4 w-4" />
178 {importing ? t('common.loading') : t('htmlEditor.import')}
179 </Button>
180 </TooltipTrigger>
181 <TooltipContent side="bottom" align="end">
182 {t('htmlEditor.importTooltip')}
183 </TooltipContent>
184 </Tooltip>
185 ) : null}
186 </div>
187 </div>
188 </div>
189
190 {documents.length === 0 ? (
191 <section className="flex min-h-[calc(100vh-220px)] items-center justify-center px-4 py-12">
192 <div className="flex w-full max-w-[460px] flex-col items-center text-center">
193 <div className="mb-6 flex h-16 w-16 items-center justify-center rounded-lg border border-[#d7cab1] bg-[#fff9ef] text-[#617052] shadow-[0_6px_16px_rgba(78,88,62,0.08)]">
194 <FileCode2 className="h-8 w-8" />
195 </div>
196 <h3 className="text-xl font-semibold text-[#3e4a32]">{t('htmlEditor.emptyTitle')}</h3>
197 <p className="mt-2 text-sm leading-6 text-[#7b705f]">{t('htmlEditor.emptyHint')}</p>
198 <Tooltip>
199 <TooltipTrigger asChild>
200 <Button
201 type="button"
202 className="mt-6 min-w-[148px] bg-[#5d6b4d] text-white hover:bg-[#4b593d]"
203 onClick={() => void handleImport()}
204 disabled={importing}
205 >
206 <FileUp className="mr-2 h-4 w-4" />
207 {importing ? t('common.loading') : t('htmlEditor.import')}
208 </Button>
209 </TooltipTrigger>
210 <TooltipContent side="bottom">{t('htmlEditor.importTooltip')}</TooltipContent>
211 </Tooltip>
212 </div>
213 </section>
214 ) : filteredDocuments.length === 0 ? (
215 <Card>
216 <CardContent className="flex flex-col items-center justify-center py-12 text-center">
217 <Search className="mb-4 h-10 w-10 text-muted-foreground" />
218 <h3 className="mb-2 text-lg font-medium">{t('htmlEditor.noSearchResultsTitle')}</h3>
219 <p className="text-muted-foreground">{t('htmlEditor.noSearchResultsDescription')}</p>
220 </CardContent>
221 </Card>
222 ) : (
223 <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
224 {filteredDocuments.map((document) => {
225 const sourcePath = document.sourcePath || document.htmlPath
226 const sourceName = getFileName(sourcePath)
227 return (
228 <div
229 key={document.id}
230 data-html-document-card-id={document.id}
231 className="group overflow-hidden rounded-lg border border-[#d8cfbc]/75 bg-white/70 shadow-[0_4px_16px_rgba(93,107,77,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_10px_26px_rgba(93,107,77,0.15)]"
232 >
233 <button
234 type="button"
235 className="block w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8ca77e]"
236 onClick={() => enterDoc(document.id)}
237 >
238 <div
239 className="relative aspect-video overflow-hidden bg-[#f5f1e8]"
240 data-html-document-thumbnail-frame
241 >
242 {document.thumbnailPath ? (
243 <img
244 src={thumbnailUrl(document.thumbnailPath)}
245 loading="lazy"
246 alt=""
247 aria-hidden="true"
248 className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.015]"
249 />
250 ) : (
251 <div className="flex h-full flex-col items-center justify-center gap-2 text-[#7f8d70]">
252 <Loader2 className="h-5 w-5 animate-spin" />
253 <span className="text-xs font-medium">
254 {t('htmlEditor.thumbnailGenerating')}
255 </span>
256 </div>
257 )}
258 <div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 via-black/10 to-transparent" />
259 <span className="absolute bottom-3 left-3 inline-flex items-center gap-1.5 rounded-md bg-[#fffaf0]/92 px-2.5 py-1 text-xs font-semibold text-[#3e4a32] shadow-[0_4px_12px_rgba(31,38,29,0.16)] backdrop-blur-sm">
260 <Pencil className="h-3 w-3" />
261 {t('htmlEditor.edit')}
262 </span>
263 </div>
264 <div className="min-w-0 p-4">
265 <div className="line-clamp-2 min-h-10 text-base font-semibold leading-5 text-[#3e4a32]">
266 {document.title || t('htmlEditor.untitled')}
267 </div>
268 <div className="mt-1.5 flex min-w-0 items-center gap-2 text-xs text-[#847866]">
269 <span className="shrink-0 rounded border border-[#d8ccb5]/70 bg-[#fffaf0] px-1.5 py-0.5 text-[10px] font-semibold text-[#6c795e]">
270 HTML
271 </span>
272 <span className="truncate" title={sourcePath}>
273 {sourceName || sourcePath}
274 </span>
275 </div>
276 </div>
277 </button>
278 <div className="flex items-center justify-between border-t border-[#e5dccd]/58 px-4 py-2.5">
279 <time
280 className="text-xs text-[#847866]"
281 dateTime={dayjs(document.updatedAt).toISOString()}
282 >
283 {dayjs(document.updatedAt).format('YYYY/MM/DD HH:mm')}
284 </time>
285 <Tooltip>
286 <TooltipTrigger asChild>
287 <Button
288 type="button"
289 size="sm"
290 variant="ghost"
291 className="h-7 w-7 rounded-[6px] p-0 text-[#8a514b] hover:text-[#7a332d]"
292 aria-label={t('htmlEditor.delete')}
293 disabled={Boolean(deletingDocumentId)}
294 onClick={() => setDeleteTarget(document)}
295 >
296 <Trash2 className="h-3.5 w-3.5" />
297 </Button>
298 </TooltipTrigger>
299 <TooltipContent side="bottom">{t('htmlEditor.delete')}</TooltipContent>
300 </Tooltip>
301 </div>
302 </div>
303 )
304 })}
305 </div>
306 )}
307 <AlertDialog
308 open={Boolean(deleteTarget)}
309 onOpenChange={(open) => {
310 if (!open && !deletingDocumentId) setDeleteTarget(null)
311 }}
312 >
313 <AlertDialogContent>
314 <AlertDialogTitle>{t('htmlEditor.removeFromLibraryTitle')}</AlertDialogTitle>
315 <AlertDialogDescription>
316 {t('htmlEditor.removeFromLibraryDescription', {
317 name: deleteTarget?.title || t('htmlEditor.untitled')
318 })}
319 </AlertDialogDescription>
320 <div className="flex justify-end gap-2">
321 <AlertDialogCancel disabled={Boolean(deletingDocumentId)}>
322 {t('common.cancel')}
323 </AlertDialogCancel>
324 <AlertDialogAction
325 disabled={Boolean(deletingDocumentId)}
326 onClick={(event) => {
327 event.preventDefault()
328 void handleDelete()
329 }}
330 className="bg-[#8f3f31] text-white hover:bg-[#743126] disabled:cursor-not-allowed disabled:opacity-65"
331 >
332 {deletingDocumentId ? (
333 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
334 ) : (
335 <Trash2 className="mr-2 h-4 w-4" />
336 )}
337 {t('common.delete')}
338 </AlertDialogAction>
339 </div>
340 </AlertDialogContent>
341 </AlertDialog>
342 </div>
343 </TooltipProvider>
344 )
345 }
346
346 lines Plain Text