返回 oh-my-ppt
MasterLayoutLibraryDialog.tsx
根目录 / src / renderer / src / components / master-layouts / MasterLayoutLibraryDialog.tsx
1 import { Check, LayoutTemplate, Loader2 } from 'lucide-react'
2 import { useEffect, useRef, useState } from 'react'
3 import { useLang, useT } from '@renderer/i18n'
4 import { ipc } from '@renderer/lib/ipc'
5 import {
6 useEditSessionStore,
7 useGenerateStore,
8 useLayoutMasterStore,
9 useSessionStore,
10 useToastStore
11 } from '@renderer/store'
12 import {
13 buildDefaultSessionLayoutLibrary,
14 getLayoutMasterTemplates,
15 type LayoutMasterTemplate,
16 type SessionLayoutLibrary
17 } from '@shared/layout-master'
18 import { LAYOUT_INTENTS, type LayoutIntent } from '@shared/layout-intent'
19 import { cn } from '@renderer/lib/utils'
20 import { Button } from '../ui/Button'
21 import {
22 Dialog,
23 DialogContent,
24 DialogDescription,
25 DialogFooter,
26 DialogHeader,
27 DialogTitle
28 } from '../ui/Dialog'
29 import { ScrollArea } from '../ui/ScrollArea'
30
31 const intentLabelKeys: Record<
32 LayoutIntent,
33 | 'sessionDetail.layoutMasterIntentCover'
34 | 'sessionDetail.layoutMasterIntentData'
35 | 'sessionDetail.layoutMasterIntentComparison'
36 | 'sessionDetail.layoutMasterIntentTimeline'
37 | 'sessionDetail.layoutMasterIntentConcept'
38 | 'sessionDetail.layoutMasterIntentProcess'
39 | 'sessionDetail.layoutMasterIntentSummary'
40 | 'sessionDetail.layoutMasterIntentQuote'
41 | 'sessionDetail.layoutMasterIntentImage'
42 > = {
43 cover: 'sessionDetail.layoutMasterIntentCover',
44 'data-focus': 'sessionDetail.layoutMasterIntentData',
45 comparison: 'sessionDetail.layoutMasterIntentComparison',
46 timeline: 'sessionDetail.layoutMasterIntentTimeline',
47 concept: 'sessionDetail.layoutMasterIntentConcept',
48 process: 'sessionDetail.layoutMasterIntentProcess',
49 summary: 'sessionDetail.layoutMasterIntentSummary',
50 quote: 'sessionDetail.layoutMasterIntentQuote',
51 'image-focus': 'sessionDetail.layoutMasterIntentImage'
52 }
53
54 function LayoutPreview({ preview }: { preview: LayoutMasterTemplate['preview'] }): React.JSX.Element {
55 const title = (
56 <span className="absolute left-[11%] top-[13%] h-[8%] w-[36%] rounded-sm bg-[#3e4a32]" />
57 )
58 const muted = 'bg-[#a8b892]'
59 const light = 'bg-[#dfe7d4]'
60 const accent = 'bg-[#c77a62]'
61
62 return (
63 <div
64 className="relative h-full w-full overflow-hidden rounded-sm border border-[#ded5c6] bg-[#f9f5ed] shadow-[0_1px_2px_rgba(78,62,43,0.08)]"
65 >
66 {title}
67 {preview === 'title-center' && (
68 <>
69 <span className="absolute left-[30%] top-[37%] h-[13%] w-[40%] rounded-sm bg-[#42533a]" />
70 <span className={`absolute left-[38%] top-[57%] h-[4%] w-[24%] rounded-sm ${muted}`} />
71 <span className={`absolute left-[46%] top-[72%] h-[7%] w-[8%] rounded-full ${accent}`} />
72 </>
73 )}
74 {preview === 'title-split' && (
75 <>
76 <span className={`absolute right-0 top-0 h-full w-[43%] ${accent}`} />
77 <span className={`absolute left-[11%] top-[39%] h-[12%] w-[34%] rounded-sm ${muted}`} />
78 <span className={`absolute left-[11%] top-[58%] h-[4%] w-[23%] rounded-sm ${light}`} />
79 </>
80 )}
81 {preview === 'editorial' && (
82 <>
83 <span className={`absolute left-[11%] top-[35%] h-[42%] w-[46%] rounded-sm ${light}`} />
84 <span className={`absolute right-[11%] top-[35%] h-[18%] w-[22%] rounded-sm ${muted}`} />
85 <span className={`absolute right-[11%] top-[59%] h-[5%] w-[19%] rounded-sm ${accent}`} />
86 <span className={`absolute right-[11%] top-[70%] h-[5%] w-[16%] rounded-sm ${light}`} />
87 </>
88 )}
89 {preview === 'two-column' && (
90 <>
91 <span className={`absolute left-[11%] top-[35%] h-[45%] w-[34%] rounded-sm ${muted}`} />
92 <span className={`absolute right-[11%] top-[35%] h-[19%] w-[34%] rounded-sm ${light}`} />
93 <span className={`absolute right-[11%] top-[60%] h-[19%] w-[34%] rounded-sm ${accent}`} />
94 </>
95 )}
96 {preview === 'metric-grid' && (
97 <>
98 <span className={`absolute left-[11%] top-[35%] h-[43%] w-[31%] rounded-sm ${accent}`} />
99 <span className={`absolute left-[47%] top-[35%] h-[19%] w-[18%] rounded-sm ${light}`} />
100 <span className={`absolute right-[11%] top-[35%] h-[19%] w-[18%] rounded-sm ${muted}`} />
101 <span className={`absolute left-[47%] top-[59%] h-[19%] w-[42%] rounded-sm ${light}`} />
102 </>
103 )}
104 {preview === 'chart-side' && (
105 <>
106 <span className={`absolute left-[11%] top-[35%] h-[45%] w-[53%] rounded-sm ${light}`} />
107 <span className={`absolute right-[11%] top-[35%] h-[45%] w-[19%] rounded-sm ${accent}`} />
108 <span className="absolute bottom-[21%] left-[18%] h-[3%] w-[8%] rotate-[-22deg] bg-[#5f7650]" />
109 <span className="absolute bottom-[31%] left-[31%] h-[3%] w-[13%] rotate-[15deg] bg-[#5f7650]" />
110 </>
111 )}
112 {preview === 'versus' && (
113 <>
114 <span className={`absolute left-[11%] top-[35%] h-[45%] w-[34%] rounded-sm ${muted}`} />
115 <span className={`absolute right-[11%] top-[35%] h-[45%] w-[34%] rounded-sm ${accent}`} />
116 <span className="absolute left-[47%] top-[53%] h-[12%] w-[6%] rounded-full bg-[#fffaf0] text-center text-[7px] leading-[24px] text-[#42533a]">
117 VS
118 </span>
119 </>
120 )}
121 {preview === 'timeline' && (
122 <>
123 <span className="absolute left-[14%] top-[59%] h-[2px] w-[72%] bg-[#92a47d]" />
124 {[17, 36, 55, 74].map((left, index) => (
125 <span
126 key={left}
127 className={cn(
128 'absolute top-[53%] h-[12%] w-[7%] rounded-full',
129 index === 2 ? accent : muted
130 )}
131 style={{ left: `${left}%` }}
132 />
133 ))}
134 </>
135 )}
136 {preview === 'process' && (
137 <>
138 {[11, 34, 57, 80].map((left, index) => (
139 <span
140 key={left}
141 className={cn(
142 'absolute top-[46%] h-[23%] w-[14%] rounded-sm',
143 index === 1 ? accent : index === 2 ? muted : light
144 )}
145 style={{ left: `${left}%` }}
146 />
147 ))}
148 </>
149 )}
150 {preview === 'quote' && (
151 <>
152 <span className="absolute left-[13%] top-[36%] text-[33px] font-serif leading-none text-[#c77a62]">
153
154 </span>
155 <span className="absolute left-[24%] top-[42%] h-[9%] w-[49%] rounded-sm bg-[#42533a]" />
156 <span className={`absolute left-[24%] top-[59%] h-[4%] w-[31%] rounded-sm ${muted}`} />
157 </>
158 )}
159 {preview === 'image-focus' && (
160 <>
161 <span className={`absolute right-0 top-0 h-full w-[62%] ${muted}`} />
162 <span className={`absolute left-[11%] top-[38%] h-[14%] w-[34%] rounded-sm ${accent}`} />
163 <span className={`absolute left-[11%] top-[60%] h-[4%] w-[26%] rounded-sm ${light}`} />
164 </>
165 )}
166 {preview === 'closing' && (
167 <>
168 <span className={`absolute left-[11%] top-[37%] h-[13%] w-[53%] rounded-sm ${accent}`} />
169 <span className={`absolute left-[11%] top-[61%] h-[5%] w-[35%] rounded-sm ${muted}`} />
170 <span className={`absolute left-[11%] top-[72%] h-[5%] w-[23%] rounded-sm ${light}`} />
171 </>
172 )}
173 </div>
174 )
175 }
176
177 export function MasterLayoutLibraryDialog(): React.JSX.Element | null {
178 const t = useT()
179 const { lang } = useLang()
180 const isOpen = useLayoutMasterStore((state) => state.isOpen)
181 const setOpen = useLayoutMasterStore((state) => state.setOpen)
182 const currentSession = useSessionStore((state) => state.currentSession)
183 const sessionId = currentSession?.id || ''
184 const isSavingEdits = useEditSessionStore((state) => state.isSavingEdits)
185 const mutationBusy = useGenerateStore((state) =>
186 Boolean(
187 state.isGenerating ||
188 state.pageEditJobs[sessionId] ||
189 state.pageBeautifyJobs[sessionId] ||
190 state.deckEditJobs[sessionId] ||
191 state.styleSwitchJobs[sessionId]
192 )
193 )
194 const toastError = useToastStore((state) => state.error)
195 const toastSuccess = useToastStore((state) => state.success)
196 const [library, setLibrary] = useState<SessionLayoutLibrary>(buildDefaultSessionLayoutLibrary())
197 const [selectedIntent, setSelectedIntent] = useState<LayoutIntent>('cover')
198 const [loading, setLoading] = useState(false)
199 const [saving, setSaving] = useState(false)
200 const [error, setError] = useState('')
201 const requestIdRef = useRef(0)
202 const templates = getLayoutMasterTemplates()
203 const slideWidth = Math.max(1, currentSession?.slideWidth || 1600)
204 const slideHeight = Math.max(1, currentSession?.slideHeight || 900)
205 const previewAspectRatio = `${slideWidth} / ${slideHeight}`
206 const previewFitStyle =
207 slideWidth >= slideHeight
208 ? { width: '100%', aspectRatio: previewAspectRatio }
209 : { height: '100%', aspectRatio: previewAspectRatio }
210 const busy = loading || saving || isSavingEdits || mutationBusy
211
212 useEffect(() => {
213 const requestId = ++requestIdRef.current
214 if (!isOpen || !sessionId) return
215 setLoading(true)
216 setError('')
217 void ipc
218 .getSessionLayoutLibrary({ sessionId })
219 .then((status) => {
220 if (requestId !== requestIdRef.current) return
221 setLibrary(status.library)
222 })
223 .catch((loadError) => {
224 if (requestId !== requestIdRef.current) return
225 const message =
226 loadError instanceof Error ? loadError.message : t('sessionDetail.layoutMasterLoadFailed')
227 setError(message)
228 toastError(message)
229 })
230 .finally(() => {
231 if (requestId === requestIdRef.current) setLoading(false)
232 })
233 return () => {
234 if (requestId === requestIdRef.current) requestIdRef.current += 1
235 }
236 }, [isOpen, sessionId])
237
238 if (!sessionId) return null
239
240 const selectedTemplates = templates.filter((template) => template.intent === selectedIntent)
241 const selectedLayoutId = library.mappings[selectedIntent]
242 const selectedTemplate = selectedTemplates.find((template) => template.id === selectedLayoutId)
243 const templateName = (template: LayoutMasterTemplate): string =>
244 lang === 'en' ? template.name : template.nameZh
245 const templateDescription = (template: LayoutMasterTemplate): string =>
246 lang === 'en' ? template.description : template.descriptionZh
247
248 const chooseTemplate = (template: LayoutMasterTemplate): void => {
249 if (busy) return
250 setLibrary((current) => ({
251 ...current,
252 mappings: { ...current.mappings, [selectedIntent]: template.id }
253 }))
254 }
255
256 const close = (): void => {
257 if (saving) return
258 setError('')
259 setOpen(false)
260 }
261
262 const save = async (): Promise<void> => {
263 if (busy) return
264 setSaving(true)
265 setError('')
266 try {
267 const status = await ipc.saveSessionLayoutLibrary({ sessionId, library })
268 setLibrary(status.library)
269 toastSuccess(t('sessionDetail.layoutMasterSaved'))
270 setOpen(false)
271 } catch (saveError) {
272 const message =
273 saveError instanceof Error ? saveError.message : t('sessionDetail.layoutMasterSaveFailed')
274 setError(message)
275 toastError(message)
276 } finally {
277 setSaving(false)
278 }
279 }
280
281 return (
282 <Dialog
283 open={isOpen}
284 onOpenChange={(nextOpen) => {
285 if (!nextOpen) close()
286 }}
287 >
288 <DialogContent showClose={!saving} className="!max-w-[960px] gap-0 overflow-hidden p-0">
289 <DialogHeader className="border-b border-[#e3dacb] px-6 py-5">
290 <DialogTitle>{t('sessionDetail.layoutMasterTitle')}</DialogTitle>
291 <DialogDescription>{t('sessionDetail.layoutMasterDescription')}</DialogDescription>
292 </DialogHeader>
293
294 <div className="grid h-[min(500px,calc(100vh-14rem))] min-h-[340px] grid-cols-[154px_minmax(0,1fr)]">
295 <aside className="border-r border-[#e3dacb] bg-[#f5f0e6] px-2 py-3">
296 <div className="space-y-0.5">
297 {LAYOUT_INTENTS.map((intent) => (
298 <button
299 key={intent}
300 type="button"
301 className={cn(
302 'flex h-8 w-full items-center rounded-md px-3 text-left text-xs transition-colors',
303 selectedIntent === intent
304 ? 'bg-[#d9e4c9] font-semibold text-[#33422a]'
305 : 'text-[#667257] hover:bg-[#ebe4d6] hover:text-[#3f5035]'
306 )}
307 onClick={() => setSelectedIntent(intent)}
308 disabled={busy}
309 >
310 {t(intentLabelKeys[intent])}
311 </button>
312 ))}
313 </div>
314 </aside>
315
316 <ScrollArea className="h-full" viewportClassName="p-5">
317 {loading ? (
318 <div className="flex h-full items-center justify-center text-[#667257]">
319 <Loader2 className="h-5 w-5 animate-spin" />
320 </div>
321 ) : (
322 <div className="space-y-4">
323 <div className="flex items-center justify-between gap-4">
324 <p className="text-sm font-medium text-[#46553a]">
325 {t(intentLabelKeys[selectedIntent])}
326 </p>
327 {selectedTemplate && (
328 <span className="text-xs text-[#748067]">{templateName(selectedTemplate)}</span>
329 )}
330 </div>
331 <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
332 {selectedTemplates.map((template) => {
333 const selected = template.id === selectedLayoutId
334 return (
335 <button
336 key={template.id}
337 type="button"
338 className={cn(
339 'group overflow-hidden rounded-md border bg-[#fffdf8] text-left shadow-[0_1px_2px_rgba(78,62,43,0.06)] transition-all',
340 'w-full',
341 selected
342 ? 'border-[#71885b] ring-2 ring-[#a9bd91]/60'
343 : 'border-[#e1d8ca] hover:border-[#aebd9a] hover:shadow-[0_6px_16px_rgba(78,62,43,0.1)]'
344 )}
345 onClick={() => chooseTemplate(template)}
346 disabled={busy}
347 >
348 <div className="flex h-[220px] items-center justify-center overflow-hidden border-b border-[#ded5c6] bg-[#f5f0e6] p-3">
349 <div
350 className="relative max-h-full max-w-full overflow-hidden"
351 style={previewFitStyle}
352 >
353 <LayoutPreview preview={template.preview} />
354 </div>
355 </div>
356 <span className="flex min-h-[62px] items-start gap-2 px-3 py-3">
357 <span className="min-w-0 flex-1">
358 <span className="block text-sm font-semibold text-[#3d4c32]">
359 {templateName(template)}
360 </span>
361 <span className="mt-1 block text-xs leading-4 text-[#78816e]">
362 {templateDescription(template)}
363 </span>
364 </span>
365 {selected && (
366 <span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#5f7650] text-white">
367 <Check className="h-3.5 w-3.5" />
368 </span>
369 )}
370 </span>
371 </button>
372 )
373 })}
374 </div>
375 {error && <p className="text-xs leading-4 text-[#a14f4a]">{error}</p>}
376 </div>
377 )}
378 </ScrollArea>
379 </div>
380
381 <DialogFooter className="border-t border-[#e3dacb] bg-[#fffaf0] px-6 py-4">
382 <Button type="button" variant="ghost" size="sm" disabled={saving} onClick={close}>
383 {t('common.cancel')}
384 </Button>
385 <Button
386 type="button"
387 variant="outline"
388 size="sm"
389 disabled={busy}
390 onClick={() => setLibrary(buildDefaultSessionLayoutLibrary())}
391 >
392 {t('sessionDetail.layoutMasterReset')}
393 </Button>
394 <Button type="button" size="sm" disabled={busy} onClick={() => void save()}>
395 <LayoutTemplate className="h-3.5 w-3.5" />
396 {saving ? t('common.saving') : t('sessionDetail.layoutMasterSave')}
397 </Button>
398 </DialogFooter>
399 </DialogContent>
400 </Dialog>
401 )
402 }
403
403 lines Plain Text