| 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 |