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