返回 oh-my-ppt
templates.tsx
根目录 / src / renderer / src / pages / templates.tsx
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
424 lines Plain Text