| 1 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 2 | import { useNavigate } from 'react-router-dom' |
| 3 | import { |
| 4 | FileUp, |
| 5 | LayoutTemplate, |
| 6 | Loader2 |
| 7 | } from 'lucide-react' |
| 8 | import { Button } from '../components/ui/Button' |
| 9 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/Dialog' |
| 10 | import { SaveTemplateDialog } from '../components/templates/SaveTemplateDialog' |
| 11 | import { TemplateCard, TemplateEmptyState } from '../components/templates/TemplateCard' |
| 12 | import { TemplateUseDialog } from '../components/templates/TemplateUseDialog' |
| 13 | import { ModelSplitButton } from '../components/model/ModelActionButton' |
| 14 | import { useTemplateStore, useToastStore } from '../store' |
| 15 | import { ipc, type TemplateListItem } from '../lib/ipc' |
| 16 | import { useT } from '../i18n' |
| 17 | import { useModelAction } from '@renderer/hooks/useModelAction' |
| 18 | import { useThumbnailUpdates } from '@renderer/hooks/useThumbnailUpdates' |
| 19 | import { localAssetUrl } from '@shared/local-asset' |
| 20 | import { resolveSlideSize } from '@shared/slide-size' |
| 21 | |
| 22 | const MAX_PPTX_SIZE_MB = 80 |
| 23 | const MAX_PPTX_SIZE_BYTES = MAX_PPTX_SIZE_MB * 1024 * 1024 |
| 24 | const DIRECT_CREATE_DONE_DELAY_MS = 500 |
| 25 | |
| 26 | const templateThumbnailUrl = (filePath: string): string => { |
| 27 | return `${localAssetUrl(filePath)}?print=1&thumbnail=1&fit=off&pptPlayback=0` |
| 28 | } |
| 29 | |
| 30 | const wait = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms)) |
| 31 | |
| 32 | export function TemplatesPage(): React.JSX.Element { |
| 33 | const navigate = useNavigate() |
| 34 | const t = useT() |
| 35 | const { |
| 36 | templates, |
| 37 | loading, |
| 38 | fetchTemplates, |
| 39 | applyTemplateThumbnail, |
| 40 | createEditableSessionFromTemplate, |
| 41 | importPptxAsTemplate, |
| 42 | updateTemplateMetadata, |
| 43 | deleteTemplate |
| 44 | } = useTemplateStore() |
| 45 | const { success, error, warning } = useToastStore() |
| 46 | const modelAction = useModelAction() |
| 47 | const { ensureModelActive } = modelAction |
| 48 | const [useTarget, setUseTarget] = useState<TemplateListItem | null>(null) |
| 49 | const [previewTarget, setPreviewTarget] = useState<TemplateListItem | null>(null) |
| 50 | const [editTarget, setEditTarget] = useState<TemplateListItem | null>(null) |
| 51 | const [deleteTarget, setDeleteTarget] = useState<TemplateListItem | null>(null) |
| 52 | const [directCreatingTemplate, setDirectCreatingTemplate] = useState<TemplateListItem | null>(null) |
| 53 | const [editing, setEditing] = useState(false) |
| 54 | const [deleting, setDeleting] = useState(false) |
| 55 | const pptxInputRef = useRef<HTMLInputElement | null>(null) |
| 56 | const pptxImportModelConfigIdRef = useRef<string>('') |
| 57 | const [importingPptxTemplate, setImportingPptxTemplate] = useState(false) |
| 58 | const [pptxTemplateProgress, setPptxTemplateProgress] = useState<string | null>(null) |
| 59 | const previewSlideSize = previewTarget |
| 60 | ? resolveSlideSize({ |
| 61 | id: previewTarget.slideSizeId, |
| 62 | width: previewTarget.slideWidth, |
| 63 | height: previewTarget.slideHeight |
| 64 | }) |
| 65 | : null |
| 66 | |
| 67 | useThumbnailUpdates('template', (task) => { |
| 68 | if (task.variant !== 'cover' || !task.thumbnailPath) return |
| 69 | applyTemplateThumbnail(task.resourceId, task.thumbnailPath) |
| 70 | }) |
| 71 | |
| 72 | const load = useCallback(async (): Promise<void> => { |
| 73 | try { |
| 74 | await fetchTemplates() |
| 75 | } catch (err) { |
| 76 | error(t('templates.loadFailed'), { |
| 77 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 78 | }) |
| 79 | } |
| 80 | }, [error, fetchTemplates, t]) |
| 81 | |
| 82 | useEffect(() => { |
| 83 | void load() |
| 84 | }, [load]) |
| 85 | |
| 86 | useEffect(() => { |
| 87 | return ipc.onTemplatePptxImportProgress((payload) => { |
| 88 | setPptxTemplateProgress(`${payload.label}${payload.progress ? ` · ${payload.progress}%` : ''}`) |
| 89 | }) |
| 90 | }, []) |
| 91 | |
| 92 | const openUseDialog = async (template: TemplateListItem): Promise<void> => { |
| 93 | const ready = await ensureModelActive() |
| 94 | if (!ready) return |
| 95 | setUseTarget(template) |
| 96 | } |
| 97 | |
| 98 | const ensureUploadPrerequisites = async (): Promise<boolean> => { |
| 99 | const validation = await ipc.validateUploadPrerequisites() |
| 100 | if (validation.ready) return true |
| 101 | warning(t('templates.settingsRequiredTitle'), { |
| 102 | description: validation.message || t('templates.settingsRequiredDescription'), |
| 103 | action: { |
| 104 | label: t('templates.goToSettings'), |
| 105 | onClick: () => navigate('/settings') |
| 106 | } |
| 107 | }) |
| 108 | return false |
| 109 | } |
| 110 | |
| 111 | const handleImportPptxTemplateClick = async (modelConfigId: string): Promise<void> => { |
| 112 | if (importingPptxTemplate) return |
| 113 | if (!(await ensureUploadPrerequisites())) return |
| 114 | const resolvedModelConfigId = await ensureModelActive(modelConfigId) |
| 115 | if (!resolvedModelConfigId) return |
| 116 | pptxImportModelConfigIdRef.current = resolvedModelConfigId |
| 117 | pptxInputRef.current?.click() |
| 118 | } |
| 119 | |
| 120 | const handlePptxTemplateFilesSelected = async (files: FileList | null): Promise<void> => { |
| 121 | const selectedFiles = Array.from(files || []) |
| 122 | if (pptxInputRef.current) { |
| 123 | pptxInputRef.current.value = '' |
| 124 | } |
| 125 | if (selectedFiles.length === 0) return |
| 126 | if (selectedFiles.length > 1) { |
| 127 | error(t('templates.pptxSingleOnlyTitle'), { description: t('templates.pptxSingleOnly') }) |
| 128 | return |
| 129 | } |
| 130 | const selectedFile = selectedFiles[0] |
| 131 | if (!/\.pptx$/i.test(selectedFile.name)) { |
| 132 | error(t('templates.unsupportedPptxTitle'), { description: t('templates.unsupportedPptx') }) |
| 133 | return |
| 134 | } |
| 135 | if (selectedFile.size > MAX_PPTX_SIZE_BYTES) { |
| 136 | error(t('templates.pptxTooLargeTitle'), { |
| 137 | description: t('templates.pptxTooLarge', { maxSize: MAX_PPTX_SIZE_MB }) |
| 138 | }) |
| 139 | return |
| 140 | } |
| 141 | const filePath = window.electron?.getPathForFile?.(selectedFile) || '' |
| 142 | if (!filePath) { |
| 143 | error(t('templates.pptxPathFailedTitle'), { description: t('templates.pptxPathFailed') }) |
| 144 | return |
| 145 | } |
| 146 | |
| 147 | setImportingPptxTemplate(true) |
| 148 | setPptxTemplateProgress(t('templates.pptxTemplatePreparing')) |
| 149 | try { |
| 150 | const modelConfigId = await ensureModelActive(pptxImportModelConfigIdRef.current) |
| 151 | if (!modelConfigId) return |
| 152 | const result = await importPptxAsTemplate({ |
| 153 | filePath, |
| 154 | name: selectedFile.name.replace(/\.pptx$/i, ''), |
| 155 | modelConfigId |
| 156 | }) |
| 157 | await wait(DIRECT_CREATE_DONE_DELAY_MS) |
| 158 | success(t('templates.pptxTemplateImported'), { |
| 159 | description: |
| 160 | result.warnings.length > 0 |
| 161 | ? t('templates.pptxTemplateImportedWithWarnings', { |
| 162 | pageCount: result.pageCount, |
| 163 | warningCount: result.warnings.length |
| 164 | }) |
| 165 | : t('templates.pptxTemplateImportedDescription', { pageCount: result.pageCount }) |
| 166 | }) |
| 167 | } catch (err) { |
| 168 | error(t('templates.pptxTemplateImportFailed'), { |
| 169 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 170 | }) |
| 171 | } finally { |
| 172 | pptxImportModelConfigIdRef.current = '' |
| 173 | setImportingPptxTemplate(false) |
| 174 | setPptxTemplateProgress(null) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | const handleCreateEditable = async (template: TemplateListItem): Promise<void> => { |
| 179 | if (directCreatingTemplate) return |
| 180 | const deckTitle = template.name |
| 181 | setDirectCreatingTemplate(template) |
| 182 | try { |
| 183 | const sessionId = await createEditableSessionFromTemplate({ |
| 184 | templateId: template.id, |
| 185 | title: deckTitle |
| 186 | }) |
| 187 | await wait(DIRECT_CREATE_DONE_DELAY_MS) |
| 188 | success(t('templates.sessionCreated'), { description: t('templates.directEditCreatedDescription') }) |
| 189 | setUseTarget(null) |
| 190 | navigate(`/sessions/${sessionId}`) |
| 191 | } catch (err) { |
| 192 | error(t('templates.createFailed'), { |
| 193 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 194 | }) |
| 195 | } finally { |
| 196 | setDirectCreatingTemplate(null) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | const handleDelete = async (): Promise<void> => { |
| 201 | if (!deleteTarget || deleting) return |
| 202 | setDeleting(true) |
| 203 | try { |
| 204 | await deleteTemplate(deleteTarget.id) |
| 205 | success(t('templates.deleted')) |
| 206 | setDeleteTarget(null) |
| 207 | } catch (err) { |
| 208 | error(t('templates.deleteFailed'), { |
| 209 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 210 | }) |
| 211 | } finally { |
| 212 | setDeleting(false) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | const handleUpdateMetadata = async (payload: { |
| 217 | name: string |
| 218 | description: string |
| 219 | tags: string[] |
| 220 | }): Promise<void> => { |
| 221 | if (!editTarget || editing) return |
| 222 | setEditing(true) |
| 223 | try { |
| 224 | await updateTemplateMetadata({ |
| 225 | templateId: editTarget.id, |
| 226 | ...payload |
| 227 | }) |
| 228 | success(t('templates.updated')) |
| 229 | setEditTarget(null) |
| 230 | } catch (err) { |
| 231 | error(t('templates.updateFailed'), { |
| 232 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 233 | }) |
| 234 | } finally { |
| 235 | setEditing(false) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | return ( |
| 240 | <div className="mx-auto w-full max-w-7xl p-6"> |
| 241 | <div className="mb-6"> |
| 242 | <p className="text-xs uppercase tracking-[0.22em] text-[#8a7e6c]">{t('templates.eyebrow')}</p> |
| 243 | <div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 244 | <div className="min-w-0"> |
| 245 | <h1 className="organic-serif text-[32px] font-semibold leading-none text-[#3e4a32]">{t('templates.title')}</h1> |
| 246 | <p className="mt-2 text-[12px] text-muted-foreground"> |
| 247 | {t('templates.description')} |
| 248 | </p> |
| 249 | </div> |
| 250 | <div className="flex shrink-0 items-center gap-2"> |
| 251 | <span className="rounded-md border border-[#d6c08d]/70 bg-[#fff7e8] px-2.5 py-1.5 text-xs font-medium text-[#7c6a4c]"> |
| 252 | {t('templates.count', { count: templates.length })} |
| 253 | </span> |
| 254 | <ModelSplitButton |
| 255 | modelAction={modelAction} |
| 256 | label={t('templates.importPptx')} |
| 257 | loadingLabel={t('templates.importingPptxTemplate')} |
| 258 | loading={importingPptxTemplate} |
| 259 | disabled={loading || importingPptxTemplate} |
| 260 | icon={FileUp} |
| 261 | tone="subtle" |
| 262 | onRun={(modelConfigId) => handleImportPptxTemplateClick(modelConfigId)} |
| 263 | /> |
| 264 | </div> |
| 265 | </div> |
| 266 | </div> |
| 267 | |
| 268 | <input |
| 269 | ref={pptxInputRef} |
| 270 | type="file" |
| 271 | accept=".pptx" |
| 272 | className="hidden" |
| 273 | onChange={(event) => void handlePptxTemplateFilesSelected(event.target.files)} |
| 274 | /> |
| 275 | |
| 276 | {templates.length === 0 ? ( |
| 277 | <TemplateEmptyState /> |
| 278 | ) : ( |
| 279 | <div className="grid grid-cols-1 gap-3 lg:grid-cols-3"> |
| 280 | {templates.map((template) => ( |
| 281 | <TemplateCard |
| 282 | key={template.id} |
| 283 | template={template} |
| 284 | directCreating={directCreatingTemplate?.id === template.id} |
| 285 | onUseDirect={(item) => void handleCreateEditable(item)} |
| 286 | onUseGenerate={(item) => void openUseDialog(item)} |
| 287 | onPreview={setPreviewTarget} |
| 288 | onEdit={setEditTarget} |
| 289 | onDelete={setDeleteTarget} |
| 290 | /> |
| 291 | ))} |
| 292 | </div> |
| 293 | )} |
| 294 | |
| 295 | {directCreatingTemplate ? ( |
| 296 | <div className="fixed inset-0 z-50 flex items-center justify-center bg-[#2d261f]/28 backdrop-blur-[2px]"> |
| 297 | <div className="w-[min(360px,calc(100vw-32px))] rounded-xl border border-[#ded2bd]/80 bg-[#fffdf8] px-5 py-4 shadow-[0_18px_45px_rgba(57,47,36,0.22)]"> |
| 298 | <div className="flex items-center gap-3"> |
| 299 | <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#eef3e7] text-[#5f6b50]"> |
| 300 | <Loader2 className="h-4 w-4 animate-spin" /> |
| 301 | </div> |
| 302 | <div className="min-w-0"> |
| 303 | <p className="text-sm font-medium text-[#34402c]">{t('templates.creatingEditable')}</p> |
| 304 | <p className="mt-1 truncate text-xs text-muted-foreground">{directCreatingTemplate.name}</p> |
| 305 | </div> |
| 306 | </div> |
| 307 | </div> |
| 308 | </div> |
| 309 | ) : null} |
| 310 | |
| 311 | {importingPptxTemplate ? ( |
| 312 | <div className="fixed inset-0 z-50 flex items-center justify-center bg-[#2d261f]/28 backdrop-blur-[2px]"> |
| 313 | <div className="w-[min(380px,calc(100vw-32px))] rounded-xl border border-[#ded2bd]/80 bg-[#fffdf8] px-5 py-4 shadow-[0_18px_45px_rgba(57,47,36,0.22)]"> |
| 314 | <div className="flex items-center gap-3"> |
| 315 | <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#eef3e7] text-[#5f6b50]"> |
| 316 | <Loader2 className="h-4 w-4 animate-spin" /> |
| 317 | </div> |
| 318 | <div className="min-w-0"> |
| 319 | <p className="text-sm font-medium text-[#34402c]">{t('templates.importingPptxTemplate')}</p> |
| 320 | <p className="mt-1 truncate text-xs text-muted-foreground"> |
| 321 | {pptxTemplateProgress || t('templates.pptxTemplatePreparing')} |
| 322 | </p> |
| 323 | </div> |
| 324 | </div> |
| 325 | </div> |
| 326 | </div> |
| 327 | ) : null} |
| 328 | |
| 329 | <Dialog open={Boolean(previewTarget)} onOpenChange={(open) => !open && setPreviewTarget(null)}> |
| 330 | <DialogContent className="w-auto max-w-none gap-3 rounded-lg bg-[#f6efe2] p-4"> |
| 331 | <DialogHeader className="pr-10"> |
| 332 | <DialogTitle className="flex items-center gap-2"> |
| 333 | <LayoutTemplate className="h-4 w-4" /> |
| 334 | {previewTarget?.name || t('templates.previewTitle')} |
| 335 | </DialogTitle> |
| 336 | </DialogHeader> |
| 337 | {previewTarget && previewSlideSize ? ( |
| 338 | <div className="max-h-[min(72vh,720px)] w-[min(84vw,920px)] overflow-y-auto pr-1"> |
| 339 | <div className="grid justify-center gap-3 [grid-template-columns:repeat(auto-fill,260px)]"> |
| 340 | {(previewTarget.previewPages.length > 0 |
| 341 | ? previewTarget.previewPages |
| 342 | : previewTarget.previewHtmlPath |
| 343 | ? [ |
| 344 | { |
| 345 | pageNumber: 1, |
| 346 | pageId: 'preview', |
| 347 | title: previewTarget.name, |
| 348 | htmlPath: previewTarget.previewHtmlPath |
| 349 | } |
| 350 | ] |
| 351 | : [] |
| 352 | ).map((page) => ( |
| 353 | <div |
| 354 | key={`${previewTarget.id}-${page.pageId}-${page.pageNumber}`} |
| 355 | className="overflow-hidden rounded-lg border border-[#ded2bd]/80 bg-[#fffdf8] shadow-[0_8px_18px_rgba(74,59,42,0.09)]" |
| 356 | > |
| 357 | <div |
| 358 | className="relative overflow-hidden bg-white" |
| 359 | style={{ |
| 360 | aspectRatio: `${previewSlideSize.width}/${previewSlideSize.height}` |
| 361 | }} |
| 362 | > |
| 363 | <iframe |
| 364 | src={templateThumbnailUrl(page.htmlPath)} |
| 365 | className="absolute left-0 top-0 origin-top-left border-0 bg-white" |
| 366 | style={{ |
| 367 | width: previewSlideSize.width, |
| 368 | height: previewSlideSize.height, |
| 369 | transform: `scale(${260 / previewSlideSize.width})` |
| 370 | }} |
| 371 | title={`${previewTarget.name} page ${page.pageNumber}`} |
| 372 | /> |
| 373 | </div> |
| 374 | <div className="flex min-w-0 items-center gap-2 border-t border-[#eee4d2]/80 px-2.5 py-2"> |
| 375 | <span className="shrink-0 rounded-md bg-[#e8f0df] px-1.5 py-0.5 text-[11px] font-semibold text-[#4f6340]"> |
| 376 | P{page.pageNumber} |
| 377 | </span> |
| 378 | <span className="min-w-0 flex-1 truncate text-xs text-[#5f6b50]"> |
| 379 | {page.title || t('templates.pageFallback', { pageNumber: page.pageNumber })} |
| 380 | </span> |
| 381 | </div> |
| 382 | </div> |
| 383 | ))} |
| 384 | </div> |
| 385 | </div> |
| 386 | ) : null} |
| 387 | </DialogContent> |
| 388 | </Dialog> |
| 389 | |
| 390 | <TemplateUseDialog template={useTarget} onOpenChange={(open) => !open && setUseTarget(null)} /> |
| 391 | |
| 392 | <Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !deleting && !open && setDeleteTarget(null)}> |
| 393 | <DialogContent> |
| 394 | <DialogHeader> |
| 395 | <DialogTitle>{t('templates.deleteTitle')}</DialogTitle> |
| 396 | <DialogDescription> |
| 397 | {t('templates.deleteDescription', { name: deleteTarget?.name || '' })} |
| 398 | </DialogDescription> |
| 399 | </DialogHeader> |
| 400 | <DialogFooter> |
| 401 | <Button type="button" variant="outline" size="sm" onClick={() => setDeleteTarget(null)} disabled={deleting}> |
| 402 | {t('common.cancel')} |
| 403 | </Button> |
| 404 | <Button type="button" size="sm" onClick={() => void handleDelete()} disabled={deleting}> |
| 405 | {deleting ? t('templates.deleting') : t('common.delete')} |
| 406 | </Button> |
| 407 | </DialogFooter> |
| 408 | </DialogContent> |
| 409 | </Dialog> |
| 410 | |
| 411 | <SaveTemplateDialog |
| 412 | open={Boolean(editTarget)} |
| 413 | mode="edit" |
| 414 | defaultName={editTarget?.name || ''} |
| 415 | defaultDescription={editTarget?.description || ''} |
| 416 | defaultTags={editTarget?.tags || []} |
| 417 | saving={editing} |
| 418 | onOpenChange={(open) => !open && setEditTarget(null)} |
| 419 | onSubmit={(payload) => void handleUpdateMetadata(payload)} |
| 420 | /> |
| 421 | </div> |
| 422 | ) |
| 423 | } |
| 424 |