返回 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.deckEditJobs[sessionId] ||
190 state.styleSwitchJobs[sessionId]
191 )
192 )
193 const toastError = useToastStore((state) => state.error)
194 const toastSuccess = useToastStore((state) => state.success)
195 const [library, setLibrary] = useState<SessionLayoutLibrary>(buildDefaultSessionLayoutLibrary())
196 const [selectedIntent, setSelectedIntent] = useState<LayoutIntent>('cover')
197 const [loading, setLoading] = useState(false)
198 const [saving, setSaving] = useState(false)
199 const [error, setError] = useState('')
200 const requestIdRef = useRef(0)
201 const templates = getLayoutMasterTemplates()
202 const slideWidth = Math.max(1, currentSession?.slideWidth || 1600)
203 const slideHeight = Math.max(1, currentSession?.slideHeight || 900)
204 const previewAspectRatio = `${slideWidth} / ${slideHeight}`
205 const previewFitStyle =
206 slideWidth >= slideHeight
207 ? { width: '100%', aspectRatio: previewAspectRatio }
208 : { height: '100%', aspectRatio: previewAspectRatio }
209 const busy = loading || saving || isSavingEdits || mutationBusy
210
211 useEffect(() => {
212 const requestId = ++requestIdRef.current
213 if (!isOpen || !sessionId) return
214 setLoading(true)
215 setError('')
216 void ipc
217 .getSessionLayoutLibrary({ sessionId })
218 .then((status) => {
219 if (requestId !== requestIdRef.current) return
220 setLibrary(status.library)
221 })
222 .catch((loadError) => {
223 if (requestId !== requestIdRef.current) return
224 const message =
225 loadError instanceof Error ? loadError.message : t('sessionDetail.layoutMasterLoadFailed')
226 setError(message)
227 toastError(message)
228 })
229 .finally(() => {
230 if (requestId === requestIdRef.current) setLoading(false)
231 })
232 return () => {
233 if (requestId === requestIdRef.current) requestIdRef.current += 1
234 }
235 }, [isOpen, sessionId])
236
237 if (!sessionId) return null
238
239 const selectedTemplates = templates.filter((template) => template.intent === selectedIntent)
240 const selectedLayoutId = library.mappings[selectedIntent]
241 const selectedTemplate = selectedTemplates.find((template) => template.id === selectedLayoutId)
242 const templateName = (template: LayoutMasterTemplate): string =>
243 lang === 'en' ? template.name : template.nameZh
244 const templateDescription = (template: LayoutMasterTemplate): string =>
245 lang === 'en' ? template.description : template.descriptionZh
246
247 const chooseTemplate = (template: LayoutMasterTemplate): void => {
248 if (busy) return
249 setLibrary((current) => ({
250 ...current,
251 mappings: { ...current.mappings, [selectedIntent]: template.id }
252 }))
253 }
254
255 const close = (): void => {
256 if (saving) return
257 setError('')
258 setOpen(false)
259 }
260
261 const save = async (): Promise<void> => {
262 if (busy) return
263 setSaving(true)
264 setError('')
265 try {
266 const status = await ipc.saveSessionLayoutLibrary({ sessionId, library })
267 setLibrary(status.library)
268 toastSuccess(t('sessionDetail.layoutMasterSaved'))
269 setOpen(false)
270 } catch (saveError) {
271 const message =
272 saveError instanceof Error ? saveError.message : t('sessionDetail.layoutMasterSaveFailed')
273 setError(message)
274 toastError(message)
275 } finally {
276 setSaving(false)
277 }
278 }
279
280 return (
281 <Dialog
282 open={isOpen}
283 onOpenChange={(nextOpen) => {
284 if (!nextOpen) close()
285 }}
286 >
287 <DialogContent showClose={!saving} className="!max-w-[960px] gap-0 overflow-hidden p-0">
288 <DialogHeader className="border-b border-[#e3dacb] px-6 py-5">
289 <DialogTitle>{t('sessionDetail.layoutMasterTitle')}</DialogTitle>
290 <DialogDescription>{t('sessionDetail.layoutMasterDescription')}</DialogDescription>
291 </DialogHeader>
292
293 <div className="grid h-[min(500px,calc(100vh-14rem))] min-h-[340px] grid-cols-[154px_minmax(0,1fr)]">
294 <aside className="border-r border-[#e3dacb] bg-[#f5f0e6] px-2 py-3">
295 <div className="space-y-0.5">
296 {LAYOUT_INTENTS.map((intent) => (
297 <button
298 key={intent}
299 type="button"
300 className={cn(
301 'flex h-8 w-full items-center rounded-md px-3 text-left text-xs transition-colors',
302 selectedIntent === intent
303 ? 'bg-[#d9e4c9] font-semibold text-[#33422a]'
304 : 'text-[#667257] hover:bg-[#ebe4d6] hover:text-[#3f5035]'
305 )}
306 onClick={() => setSelectedIntent(intent)}
307 disabled={busy}
308 >
309 {t(intentLabelKeys[intent])}
310 </button>
311 ))}
312 </div>
313 </aside>
314
315 <ScrollArea className="h-full" viewportClassName="p-5">
316 {loading ? (
317 <div className="flex h-full items-center justify-center text-[#667257]">
318 <Loader2 className="h-5 w-5 animate-spin" />
319 </div>
320 ) : (
321 <div className="space-y-4">
322 <div className="flex items-center justify-between gap-4">
323 <p className="text-sm font-medium text-[#46553a]">
324 {t(intentLabelKeys[selectedIntent])}
325 </p>
326 {selectedTemplate && (
327 <span className="text-xs text-[#748067]">{templateName(selectedTemplate)}</span>
328 )}
329 </div>
330 <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
331 {selectedTemplates.map((template) => {
332 const selected = template.id === selectedLayoutId
333 return (
334 <button
335 key={template.id}
336 type="button"
337 className={cn(
338 'group overflow-hidden rounded-md border bg-[#fffdf8] text-left shadow-[0_1px_2px_rgba(78,62,43,0.06)] transition-all',
339 'w-full',
340 selected
341 ? 'border-[#71885b] ring-2 ring-[#a9bd91]/60'
342 : 'border-[#e1d8ca] hover:border-[#aebd9a] hover:shadow-[0_6px_16px_rgba(78,62,43,0.1)]'
343 )}
344 onClick={() => chooseTemplate(template)}
345 disabled={busy}
346 >
347 <div className="flex h-[220px] items-center justify-center overflow-hidden border-b border-[#ded5c6] bg-[#f5f0e6] p-3">
348 <div
349 className="relative max-h-full max-w-full overflow-hidden"
350 style={previewFitStyle}
351 >
352 <LayoutPreview preview={template.preview} />
353 </div>
354 </div>
355 <span className="flex min-h-[62px] items-start gap-2 px-3 py-3">
356 <span className="min-w-0 flex-1">
357 <span className="block text-sm font-semibold text-[#3d4c32]">
358 {templateName(template)}
359 </span>
360 <span className="mt-1 block text-xs leading-4 text-[#78816e]">
361 {templateDescription(template)}
362 </span>
363 </span>
364 {selected && (
365 <span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#5f7650] text-white">
366 <Check className="h-3.5 w-3.5" />
367 </span>
368 )}
369 </span>
370 </button>
371 )
372 })}
373 </div>
374 {error && <p className="text-xs leading-4 text-[#a14f4a]">{error}</p>}
375 </div>
376 )}
377 </ScrollArea>
378 </div>
379
380 <DialogFooter className="border-t border-[#e3dacb] bg-[#fffaf0] px-6 py-4">
381 <Button type="button" variant="ghost" size="sm" disabled={saving} onClick={close}>
382 {t('common.cancel')}
383 </Button>
384 <Button
385 type="button"
386 variant="outline"
387 size="sm"
388 disabled={busy}
389 onClick={() => setLibrary(buildDefaultSessionLayoutLibrary())}
390 >
391 {t('sessionDetail.layoutMasterReset')}
392 </Button>
393 <Button type="button" size="sm" disabled={busy} onClick={() => void save()}>
394 <LayoutTemplate className="h-3.5 w-3.5" />
395 {saving ? t('common.saving') : t('sessionDetail.layoutMasterSave')}
396 </Button>
397 </DialogFooter>
398 </DialogContent>
399 </Dialog>
400 )
401 }
402
402 lines Plain Text