返回 oh-my-ppt
styles.tsx
根目录 / src / renderer / src / pages / styles.tsx
1 import { useCallback, useEffect, useMemo, useState } from 'react'
2 import { useNavigate } from 'react-router-dom'
3 import { Button } from '../components/ui/Button'
4 import {
5 AlertDialog,
6 AlertDialogAction,
7 AlertDialogCancel,
8 AlertDialogContent,
9 AlertDialogDescription,
10 AlertDialogTitle
11 } from '../components/ui/AlertDialog'
12 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../components/ui/Tooltip'
13 import {
14 DropdownMenu,
15 DropdownMenuContent,
16 DropdownMenuItem,
17 DropdownMenuSeparator,
18 DropdownMenuTrigger
19 } from '../components/ui/DropdownMenu'
20 import { ipc, type HtmlThumbnailTask } from '@renderer/lib/ipc'
21 import { useStylePreviewStore, useToastStore } from '../store'
22 import {
23 ChevronDown,
24 Download,
25 FolderOpen,
26 Loader2,
27 Palette,
28 PencilLine,
29 Plus,
30 Search,
31 Sparkles,
32 Star,
33 Trash2,
34 Upload,
35 X
36 } from 'lucide-react'
37 import { useT } from '../i18n'
38 import { useThumbnailUpdates } from '../hooks/useThumbnailUpdates'
39 import { useVisibleItemIds } from '../hooks/useVisibleItemIds'
40 import { filterByStyleCase, filterByStyleKeyword, parseStyleCases } from '@renderer/lib/style-case'
41 import { StyleCaseFilter } from '../components/style/StyleCaseFilter'
42 import { cn } from '@renderer/lib/utils'
43 import { localAssetUrl } from '@shared/local-asset'
44
45 type StyleSummary = {
46 id: string
47 label: string
48 description: string
49 source?: 'builtin' | 'custom' | 'override'
50 editable?: boolean
51 category: string
52 styleCase?: string
53 previewPath?: string | null
54 thumbnailPath?: string | null
55 favoriteAt?: number | null
56 createdAt?: number
57 updatedAt?: number
58 }
59
60 const MAX_VISIBLE_IFRAMES = 8
61 const OFFICIAL_STYLE_SKILL_URL = 'https://github.com/arcsin1/style-generate-skill'
62
63 const stylePreviewUrl = (filePath: string): string =>
64 import.meta.env.MODE === 'test' ? 'about:blank' : localAssetUrl(filePath)
65 const compareStylesByUpdated = (a: StyleSummary, b: StyleSummary): number =>
66 (b.updatedAt || 0) - (a.updatedAt || 0) ||
67 (b.createdAt || 0) - (a.createdAt || 0) ||
68 a.id.localeCompare(b.id)
69 const compareStylesByFavorite = (a: StyleSummary, b: StyleSummary): number =>
70 (b.favoriteAt || 0) - (a.favoriteAt || 0) || compareStylesByUpdated(a, b)
71
72 export function StylesPage(): React.JSX.Element {
73 const navigate = useNavigate()
74 const [styles, setStyles] = useState<StyleSummary[]>([])
75 const [importingPackageType, setImportingPackageType] = useState<'zip' | 'directory' | ''>('')
76 const [exportingStyleId, setExportingStyleId] = useState('')
77 const [selectedStyleCase, setSelectedStyleCase] = useState('')
78 const [query, setQuery] = useState('')
79 const [favoriteOnly, setFavoriteOnly] = useState(false)
80 const [favoriteUpdatingStyleId, setFavoriteUpdatingStyleId] = useState('')
81 const [deleteTarget, setDeleteTarget] = useState<StyleSummary | null>(null)
82 const [deletingStyleId, setDeletingStyleId] = useState('')
83 const { error, info, success, warning } = useToastStore()
84 const generatingPreviewStyleId = useStylePreviewStore((state) => state.generatingStyleId)
85 const previewCompletionVersion = useStylePreviewStore((state) => state.completionVersion)
86 const generatePreview = useStylePreviewStore((state) => state.generatePreview)
87 const t = useT()
88
89 const favoriteCount = useMemo(() => styles.filter((style) => style.favoriteAt != null).length, [styles])
90 const styleCaseAvailableStyles = useMemo(() => {
91 const byKeyword = filterByStyleKeyword(styles, query)
92 return favoriteOnly ? byKeyword.filter((style) => style.favoriteAt != null) : byKeyword
93 }, [favoriteOnly, query, styles])
94 const filteredStyles = useMemo(() => {
95 const byCase = filterByStyleCase(styleCaseAvailableStyles, selectedStyleCase)
96 return favoriteOnly ? [...byCase].sort(compareStylesByFavorite) : byCase
97 }, [favoriteOnly, selectedStyleCase, styleCaseAvailableStyles])
98 const emptyStylesText =
99 favoriteOnly && favoriteCount === 0 ? t('styles.noFavoriteStyles') : t('styles.noMatchingStyles')
100 const fallbackStyleIds = useMemo(
101 () =>
102 new Set(
103 filteredStyles
104 .filter((style) => !style.thumbnailPath && style.previewPath)
105 .map((style) => style.id)
106 ),
107 [filteredStyles]
108 )
109 const { visibleIds: visibleFallbackIds, setItemRef } = useVisibleItemIds(
110 fallbackStyleIds,
111 MAX_VISIBLE_IFRAMES
112 )
113
114 const loadStyles = useCallback(async (): Promise<void> => {
115 try {
116 const { items } = await ipc.listStyles()
117 const sorted = [...items].sort(compareStylesByUpdated)
118 setStyles(sorted)
119 } catch (e) {
120 error(t('styles.loadFailed'), {
121 description: e instanceof Error ? e.message : t('common.retryLater'),
122 })
123 }
124 }, [error, t])
125
126 const applyThumbnail = useCallback((task: HtmlThumbnailTask): void => {
127 if (!task.thumbnailPath) return
128 setStyles((current) =>
129 current.map((style) =>
130 style.id === task.resourceId ? { ...style, thumbnailPath: task.thumbnailPath } : style
131 )
132 )
133 }, [])
134
135 useThumbnailUpdates('style', applyThumbnail)
136
137 useEffect(() => {
138 const timer = window.setTimeout(() => {
139 void loadStyles()
140 }, 0)
141 return () => window.clearTimeout(timer)
142 }, [loadStyles, previewCompletionVersion])
143
144 const handleDelete = useCallback(async (): Promise<void> => {
145 if (!deleteTarget || deletingStyleId) return
146 const style = deleteTarget
147 setDeletingStyleId(style.id)
148 try {
149 const result = await ipc.deleteStyle(style.id)
150 if (!result.deleted) {
151 warning(t('styles.deleteFailed'), { description: t('common.retryLater') })
152 return
153 }
154 info(t('styles.deleted'))
155 setDeleteTarget(null)
156 await loadStyles()
157 } catch (e) {
158 error(t('styles.deleteFailed'), {
159 description: e instanceof Error ? e.message : t('common.retryLater'),
160 })
161 } finally {
162 setDeletingStyleId('')
163 }
164 }, [deleteTarget, deletingStyleId, error, info, warning, t, loadStyles])
165
166 const handleImportPackage = useCallback(async (type: 'zip' | 'directory'): Promise<void> => {
167 if (importingPackageType) return
168 setImportingPackageType(type)
169 try {
170 const result =
171 type === 'zip'
172 ? await ipc.importStylePackageZip()
173 : await ipc.importStylePackageDirectory()
174 if (result.cancelled) return
175 success(t('styles.packageImported'), {
176 description:
177 result.source === 'override' ? t('styleEditor.savedOverride') : t('styleEditor.savedCustom')
178 })
179 await loadStyles()
180 } catch (e) {
181 error(t('styles.packageImportFailed'), {
182 description: e instanceof Error ? e.message : t('common.retryLater')
183 })
184 } finally {
185 setImportingPackageType('')
186 }
187 }, [error, importingPackageType, loadStyles, success, t])
188
189 const handleExportPackage = useCallback(async (style: StyleSummary): Promise<void> => {
190 if (exportingStyleId) return
191 setExportingStyleId(style.id)
192 try {
193 const result = await ipc.exportStylePackageZip({ styleId: style.id })
194 if (result.canceled) return
195 success(t('styles.packageExported'), {
196 description: result.filePath || style.label
197 })
198 } catch (e) {
199 error(t('styles.packageExportFailed'), {
200 description: e instanceof Error ? e.message : t('common.retryLater')
201 })
202 } finally {
203 setExportingStyleId('')
204 }
205 }, [error, exportingStyleId, success, t])
206
207 const handleGeneratePreview = useCallback(async (style: StyleSummary): Promise<void> => {
208 try {
209 const started = await generatePreview(style.id)
210 if (!started) return
211 success(t('styles.previewGenerated'), {
212 description: style.label
213 })
214 } catch (e) {
215 error(t('styles.previewGenerationFailed'), {
216 description: e instanceof Error ? e.message : t('common.retryLater')
217 })
218 }
219 }, [error, generatePreview, success, t])
220
221 const handleToggleFavorite = useCallback(async (style: StyleSummary): Promise<void> => {
222 if (favoriteUpdatingStyleId) return
223 const nextFavorite = style.favoriteAt == null
224 const previousFavoriteAt = style.favoriteAt ?? null
225 const optimisticFavoriteAt = nextFavorite ? Math.floor(Date.now() / 1000) : null
226 setFavoriteUpdatingStyleId(style.id)
227 setStyles((current) =>
228 current.map((item) =>
229 item.id === style.id ? { ...item, favoriteAt: optimisticFavoriteAt } : item
230 )
231 )
232 try {
233 const result = await ipc.setStyleFavorite({ styleId: style.id, favorite: nextFavorite })
234 if (!result.success) {
235 throw new Error(t('common.retryLater'))
236 }
237 setStyles((current) =>
238 current.map((item) =>
239 item.id === style.id ? { ...item, favoriteAt: result.favoriteAt } : item
240 )
241 )
242 } catch (e) {
243 setStyles((current) =>
244 current.map((item) =>
245 item.id === style.id ? { ...item, favoriteAt: previousFavoriteAt } : item
246 )
247 )
248 error(t('styles.favoriteFailed'), {
249 description: e instanceof Error ? e.message : t('common.retryLater')
250 })
251 } finally {
252 setFavoriteUpdatingStyleId('')
253 }
254 }, [error, favoriteUpdatingStyleId, t])
255
256 return (
257 <TooltipProvider delayDuration={180}>
258 <div className="mx-auto w-full max-w-6xl p-6">
259 <div className="mb-6">
260 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{t('styles.eyebrow')}</p>
261 <div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
262 <div className="min-w-0">
263 <h1 className="organic-serif text-[32px] font-semibold leading-none text-[#3e4a32]">{t('styles.title')}</h1>
264 </div>
265 <div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
266 <DropdownMenu>
267 <DropdownMenuTrigger asChild>
268 <Button
269 size="sm"
270 variant="secondary"
271 className="min-w-[112px]"
272 disabled={Boolean(importingPackageType)}
273 title={t('styles.importMenuTooltip')}
274 >
275 <Upload className="mr-2 h-4 w-4" />
276 {importingPackageType ? t('styles.importingPackage') : t('styles.importMenu')}
277 <ChevronDown className="ml-1 h-3.5 w-3.5 opacity-75" />
278 </Button>
279 </DropdownMenuTrigger>
280 <DropdownMenuContent align="end" className="w-[280px]">
281 <DropdownMenuItem
282 disabled={Boolean(importingPackageType)}
283 onSelect={() => void handleImportPackage('zip')}
284 >
285 <Upload className="h-4 w-4" />
286 <div className="flex flex-col">
287 <span className="text-sm font-medium">{t('styles.importPackage')}</span>
288 <span className="text-[11px] text-muted-foreground">
289 {t('styles.importPackageTooltip')}
290 </span>
291 </div>
292 </DropdownMenuItem>
293 <DropdownMenuItem
294 disabled={Boolean(importingPackageType)}
295 onSelect={() => void handleImportPackage('directory')}
296 >
297 <FolderOpen className="h-4 w-4" />
298 <div className="flex flex-col">
299 <span className="text-sm font-medium">
300 {t('styles.importPackageDirectory')}
301 </span>
302 <span className="text-[11px] text-muted-foreground">
303 {t('styles.importPackageDirectoryTooltip')}
304 </span>
305 </div>
306 </DropdownMenuItem>
307 <DropdownMenuSeparator />
308 <div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
309 {t('styles.importMenuTooltip')}{' '}
310 <a
311 href={OFFICIAL_STYLE_SKILL_URL}
312 target="_blank"
313 rel="noopener noreferrer"
314 className="font-medium text-[#5a7a4e] underline underline-offset-2 hover:text-[#3e5a34]"
315 >
316 {t('styles.officialSkillLabel')}
317 </a>
318 </div>
319 </DropdownMenuContent>
320 </DropdownMenu>
321 <Tooltip>
322 <TooltipTrigger asChild>
323 <Button size="sm" className="min-w-[112px]" onClick={() => navigate('/styles/new')}>
324 <Plus className="mr-2 h-4 w-4" />
325 {t('styles.newStyle')}
326 </Button>
327 </TooltipTrigger>
328 <TooltipContent side="bottom" align="end">
329 {t('styles.newStyleTooltip')}
330 </TooltipContent>
331 </Tooltip>
332 </div>
333 </div>
334 <p className="mt-2 text-[12px] text-muted-foreground">{t('styles.description')}</p>
335 </div>
336
337 <div className="mb-5 rounded-lg border border-[#d8ccb5]/75 bg-[#fff9ef]/76 p-3">
338 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
339 <div className="flex min-h-9 flex-1 items-center gap-2 rounded-md border border-[#d8ccb5]/80 bg-white/80 px-2.5">
340 <Search className="h-4 w-4 shrink-0 text-[#7c6a4c]/60" />
341 <input
342 type="text"
343 value={query}
344 onChange={(event) => setQuery(event.target.value)}
345 placeholder={t('styles.searchPlaceholder')}
346 className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
347 />
348 {query ? (
349 <button
350 type="button"
351 onClick={() => setQuery('')}
352 className="shrink-0 text-[#7c6a4c]/60 transition-colors hover:text-[#7c6a4c]"
353 aria-label={t('styles.clearSearch')}
354 title={t('styles.clearSearch')}
355 >
356 <X className="h-4 w-4" />
357 </button>
358 ) : null}
359 </div>
360 <button
361 type="button"
362 onClick={() => setFavoriteOnly((current) => !current)}
363 className={cn(
364 'inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors',
365 favoriteOnly
366 ? 'border-[#97aa7c] bg-[#dbe7ca] text-[#2f3b28]'
367 : 'border-[#d6c08d]/80 bg-white/70 text-[#7c6a4c] hover:bg-[#fff3d8]'
368 )}
369 aria-pressed={favoriteOnly}
370 >
371 <Star className={cn('h-3.5 w-3.5', favoriteOnly && 'fill-[#d6a942] text-[#d6a942]')} />
372 {`${t('styles.favoriteStyles')} · ${favoriteCount}`}
373 </button>
374 </div>
375 <StyleCaseFilter
376 className="mt-3"
377 items={styles}
378 availableItems={styleCaseAvailableStyles}
379 selected={selectedStyleCase}
380 onSelect={setSelectedStyleCase}
381 allLabel={t('styles.allStyleCases')}
382 title={t('styles.styleCaseFilter')}
383 />
384 </div>
385
386 <div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
387 {filteredStyles.map((style) => (
388 <div
389 key={style.id}
390 ref={!style.thumbnailPath && style.previewPath ? setItemRef(style.id) : undefined}
391 data-style-card-id={style.id}
392 className="group overflow-hidden rounded-2xl border border-[#d8cfbc]/75 bg-white/70 text-left 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)]"
393 >
394 <div className="relative aspect-video overflow-hidden bg-[#f5f1e8]">
395 {style.thumbnailPath ? (
396 <img
397 src={stylePreviewUrl(style.thumbnailPath)}
398 loading="lazy"
399 alt=""
400 aria-hidden="true"
401 className="absolute inset-0 h-full w-full object-cover"
402 />
403 ) : style.previewPath && visibleFallbackIds.has(style.id) ? (
404 <iframe
405 data-testid="style-preview-iframe"
406 src={stylePreviewUrl(style.previewPath)}
407 sandbox=""
408 tabIndex={-1}
409 className="pointer-events-none absolute left-0 top-0 h-[900px] w-[1600px] origin-top-left border-0 bg-white"
410 style={{ transform: 'scale(0.2)' }}
411 title={`${style.label} preview`}
412 />
413 ) : (
414 <div className="flex h-full items-center justify-center text-[#8a9a7b]">
415 {generatingPreviewStyleId === style.id ? (
416 <Loader2 className="h-8 w-8 animate-spin" />
417 ) : (
418 <Palette className="h-8 w-8" />
419 )}
420 </div>
421 )}
422 <Tooltip>
423 <TooltipTrigger asChild>
424 <Button
425 size="sm"
426 variant="outline"
427 className={cn(
428 'absolute left-3 top-3 z-20 h-8 w-8 rounded-md bg-white/95 p-0 text-[#8a7048] shadow-[0_3px_10px_rgba(40,48,34,0.16)]',
429 style.favoriteAt != null
430 ? 'text-[#d6a942] opacity-100'
431 : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
432 )}
433 disabled={favoriteUpdatingStyleId === style.id}
434 onClick={() => void handleToggleFavorite(style)}
435 aria-label={
436 style.favoriteAt != null
437 ? t('styles.unfavoriteStyle')
438 : t('styles.favoriteStyle')
439 }
440 title={
441 style.favoriteAt != null
442 ? t('styles.unfavoriteStyleTooltip')
443 : t('styles.favoriteStyleTooltip')
444 }
445 >
446 {favoriteUpdatingStyleId === style.id ? (
447 <Loader2 className="h-3.5 w-3.5 animate-spin" />
448 ) : (
449 <Star
450 className={cn(
451 'h-3.5 w-3.5',
452 style.favoriteAt != null && 'fill-[#d6a942] text-[#d6a942]'
453 )}
454 />
455 )}
456 </Button>
457 </TooltipTrigger>
458 <TooltipContent side="bottom" align="start">
459 {style.favoriteAt != null
460 ? t('styles.unfavoriteStyleTooltip')
461 : t('styles.favoriteStyleTooltip')}
462 </TooltipContent>
463 </Tooltip>
464 <div className="absolute inset-x-0 top-0 flex items-start justify-end gap-1.5 bg-gradient-to-b from-black/30 to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
465 {!style.previewPath && (
466 <Tooltip>
467 <TooltipTrigger asChild>
468 <Button
469 size="sm"
470 variant="outline"
471 className="h-8 w-8 rounded-md bg-white/95 p-0 text-[#3e4a32] shadow-[0_3px_10px_rgba(40,48,34,0.16)]"
472 disabled={Boolean(generatingPreviewStyleId)}
473 onClick={() => void handleGeneratePreview(style)}
474 aria-label={
475 generatingPreviewStyleId === style.id
476 ? t('styles.generatingPreview')
477 : t('styles.generatePreview')
478 }
479 title={
480 generatingPreviewStyleId === style.id
481 ? t('styles.generatingPreview')
482 : t('styles.generatePreviewTooltip')
483 }
484 >
485 {generatingPreviewStyleId === style.id ? (
486 <Loader2 className="h-3.5 w-3.5 animate-spin" />
487 ) : (
488 <Sparkles className="h-3.5 w-3.5" />
489 )}
490 </Button>
491 </TooltipTrigger>
492 <TooltipContent side="bottom" align="end">
493 {generatingPreviewStyleId === style.id
494 ? t('styles.generatingPreview')
495 : t('styles.generatePreviewTooltip')}
496 </TooltipContent>
497 </Tooltip>
498 )}
499 <Tooltip>
500 <TooltipTrigger asChild>
501 <Button
502 size="sm"
503 variant="outline"
504 className="h-8 w-8 rounded-md bg-white/95 p-0 text-[#3e4a32] shadow-[0_3px_10px_rgba(40,48,34,0.16)]"
505 onClick={() => navigate(`/styles/${style.id}`)}
506 aria-label={t('common.edit')}
507 title={t('styles.editTooltip')}
508 >
509 <PencilLine className="h-3.5 w-3.5" />
510 </Button>
511 </TooltipTrigger>
512 <TooltipContent side="bottom" align="end">
513 {t('styles.editTooltip')}
514 </TooltipContent>
515 </Tooltip>
516 <Tooltip>
517 <TooltipTrigger asChild>
518 <Button
519 size="sm"
520 variant="outline"
521 className="h-8 w-8 rounded-md bg-white/95 p-0 text-[#3e4a32] shadow-[0_3px_10px_rgba(40,48,34,0.16)]"
522 disabled={exportingStyleId === style.id}
523 onClick={() => void handleExportPackage(style)}
524 aria-label={t('styles.exportPackage')}
525 title={t('styles.exportPackageTooltip')}
526 >
527 {exportingStyleId === style.id ? (
528 <Loader2 className="h-3.5 w-3.5 animate-spin" />
529 ) : (
530 <Download className="h-3.5 w-3.5" />
531 )}
532 </Button>
533 </TooltipTrigger>
534 <TooltipContent side="bottom" align="end">
535 {t('styles.exportPackageTooltip')}
536 </TooltipContent>
537 </Tooltip>
538 <Tooltip>
539 <TooltipTrigger asChild>
540 <Button
541 size="sm"
542 variant="outline"
543 className="h-8 w-8 rounded-md bg-white/95 p-0 text-[#8f3f31] shadow-[0_3px_10px_rgba(40,48,34,0.16)] hover:text-[#743126]"
544 onClick={() => setDeleteTarget(style)}
545 aria-label={t('common.delete')}
546 title={t('styles.deleteTooltip')}
547 >
548 <Trash2 className="h-3.5 w-3.5" />
549 </Button>
550 </TooltipTrigger>
551 <TooltipContent side="bottom" align="end">
552 {t('styles.deleteTooltip')}
553 </TooltipContent>
554 </Tooltip>
555 </div>
556 </div>
557 <div className="p-3">
558 <div className="flex items-start justify-between gap-3">
559 <div className="min-w-0">
560 <p className="truncate text-sm font-semibold text-[#3e4a32]">{style.label}</p>
561 <p className="mt-0.5 text-[10px] font-medium text-[#718064]">
562 {style.category} · {style.source || t('styles.sourceBuiltin')}
563 </p>
564 </div>
565 </div>
566 <p className="mt-2 line-clamp-2 text-xs leading-5 text-[#6f6658]">
567 {style.description || style.id}
568 </p>
569 {style.styleCase && (
570 <div className="mt-2 flex flex-wrap gap-1">
571 {parseStyleCases(style.styleCase).map((styleCase) => (
572 <span
573 key={styleCase}
574 className="rounded-md border border-[#d6c08d]/80 bg-[#fff7e8] px-1.5 py-0.5 text-[11px] font-medium leading-4 text-[#8a7048]"
575 >
576 {styleCase}
577 </span>
578 ))}
579 </div>
580 )}
581 </div>
582 </div>
583 ))}
584 </div>
585 {filteredStyles.length === 0 && (
586 <div className="rounded-lg border border-dashed border-[#d8ccb5] py-12 text-center text-sm text-muted-foreground">
587 {emptyStylesText}
588 </div>
589 )}
590 <AlertDialog
591 open={Boolean(deleteTarget)}
592 onOpenChange={(open) => {
593 if (!open && !deletingStyleId) setDeleteTarget(null)
594 }}
595 >
596 <AlertDialogContent>
597 <AlertDialogTitle>{t('styles.deleteConfirmTitle')}</AlertDialogTitle>
598 <AlertDialogDescription>
599 {t('styles.deleteConfirmDescription', { name: deleteTarget?.label || '' })}
600 </AlertDialogDescription>
601 <div className="flex justify-end gap-2">
602 <AlertDialogCancel disabled={Boolean(deletingStyleId)}>
603 {t('common.cancel')}
604 </AlertDialogCancel>
605 <AlertDialogAction
606 disabled={Boolean(deletingStyleId)}
607 onClick={(event) => {
608 event.preventDefault()
609 void handleDelete()
610 }}
611 className="bg-[#8f3f31] text-white hover:bg-[#743126] disabled:cursor-not-allowed disabled:opacity-65"
612 >
613 {deletingStyleId ? (
614 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
615 ) : (
616 <Trash2 className="mr-2 h-4 w-4" />
617 )}
618 {t('common.delete')}
619 </AlertDialogAction>
620 </div>
621 </AlertDialogContent>
622 </AlertDialog>
623 </div>
624 </TooltipProvider>
625 )
626 }
627
627 lines Plain Text