| 1 | import { |
| 2 | CheckIcon, |
| 3 | CopyIcon, |
| 4 | MoonIcon, |
| 5 | PaintbrushIcon, |
| 6 | PenLineIcon, |
| 7 | PlusIcon, |
| 8 | SunIcon, |
| 9 | Trash2Icon, |
| 10 | UploadIcon, |
| 11 | } from "lucide-react"; |
| 12 | import { useCallback, useEffect, useState, type ReactElement } from "react"; |
| 13 | import { useEnvironmentThemeDefinitions } from "../../hooks/useEnvironmentTheme"; |
| 14 | import { readThemeHalvesRaw } from "../../hooks/useTheme"; |
| 15 | import { cn } from "../../lib/utils"; |
| 16 | import { |
| 17 | getThemeDefinition, |
| 18 | singleAppearanceOf, |
| 19 | getThemeModes, |
| 20 | removeCustomThemes, |
| 21 | serializeThemeFile, |
| 22 | type ThemeAppearance, |
| 23 | type ThemeDefinition, |
| 24 | type ThemeHalves, |
| 25 | T3_CHAT_THEME, |
| 26 | EMBER_THEME, |
| 27 | GROVE_THEME, |
| 28 | IRIS_THEME, |
| 29 | OCEAN_THEME, |
| 30 | } from "../../themePalette"; |
| 31 | import { |
| 32 | AlertDialog, |
| 33 | AlertDialogClose, |
| 34 | AlertDialogDescription, |
| 35 | AlertDialogFooter, |
| 36 | AlertDialogHeader, |
| 37 | AlertDialogPopup, |
| 38 | AlertDialogTitle, |
| 39 | } from "../ui/alert-dialog"; |
| 40 | import { Button } from "../ui/button"; |
| 41 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 42 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; |
| 43 | import { ThemeImportDialog } from "./ThemeImportDialog"; |
| 44 | import { searchableSetting } from "./settingsSearch"; |
| 45 | import { useThemeEditorStore } from "./themeEditorStore"; |
| 46 | import { |
| 47 | STANDARD_THEME_CARDS, |
| 48 | getThemeCardDefinition, |
| 49 | previewColorsOf, |
| 50 | ThemePreviewCircles, |
| 51 | ThemePreviewCircle, |
| 52 | type ThemeCardDefinition, |
| 53 | type ThemeMode, |
| 54 | } from "./ThemePreviewCircles"; |
| 55 | import { ThemeWireframe } from "./ThemeWireframe"; |
| 56 | |
| 57 | const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [ |
| 58 | T3_CHAT_THEME, |
| 59 | GROVE_THEME, |
| 60 | OCEAN_THEME, |
| 61 | EMBER_THEME, |
| 62 | IRIS_THEME, |
| 63 | ]; |
| 64 | |
| 65 | function collectionVariantLabels(themes: ReadonlyArray<ThemeDefinition>): ReadonlyArray<string> { |
| 66 | if (themes.length === 0) return []; |
| 67 | const words = themes.map((theme) => theme.label.trim().split(/\s+/)); |
| 68 | const firstWords = words[0]!; |
| 69 | const sharedWordCount = firstWords.findIndex((word, index) => |
| 70 | words.some((labelWords) => labelWords[index]?.toLocaleLowerCase() !== word.toLocaleLowerCase()), |
| 71 | ); |
| 72 | const prefixLength = sharedWordCount === -1 ? firstWords.length - 1 : sharedWordCount; |
| 73 | |
| 74 | return themes.map((theme, index) => { |
| 75 | const shortLabel = words[index]?.slice(Math.max(0, prefixLength)).join(" ").trim(); |
| 76 | return shortLabel || theme.label; |
| 77 | }); |
| 78 | } |
| 79 | |
| 80 | function downloadThemeFile(filename: string, contents: string): void { |
| 81 | const url = URL.createObjectURL(new Blob([contents], { type: "application/json" })); |
| 82 | const anchor = document.createElement("a"); |
| 83 | anchor.href = url; |
| 84 | anchor.download = filename; |
| 85 | anchor.click(); |
| 86 | // Revoking synchronously can abort the download in some browsers; give the |
| 87 | // browser time to open the stream first. |
| 88 | setTimeout(() => URL.revokeObjectURL(url), 30_000); |
| 89 | } |
| 90 | |
| 91 | function ThemeVariantTooltip({ label, children }: { label: string; children: ReactElement }) { |
| 92 | return ( |
| 93 | <Tooltip> |
| 94 | <TooltipTrigger render={children} /> |
| 95 | <TooltipPopup>{label}</TooltipPopup> |
| 96 | </Tooltip> |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | function ThemeLibraryCard({ |
| 101 | theme, |
| 102 | isActive, |
| 103 | onUse, |
| 104 | onUseMode, |
| 105 | activeModes, |
| 106 | onEdit, |
| 107 | onDuplicate, |
| 108 | onDownload, |
| 109 | onRemove, |
| 110 | variantNavigation, |
| 111 | }: { |
| 112 | theme: ThemeCardDefinition; |
| 113 | isActive: boolean; |
| 114 | onUse: () => void; |
| 115 | onUseMode: (mode: ThemeMode) => void; |
| 116 | activeModes: ReadonlyArray<ThemeMode>; |
| 117 | onEdit?: () => void; |
| 118 | onDuplicate?: () => void; |
| 119 | onDownload?: () => void; |
| 120 | onRemove?: () => void; |
| 121 | variantNavigation?: { |
| 122 | collectionLabel: string; |
| 123 | options: ReadonlyArray<{ |
| 124 | themeIndex: number; |
| 125 | label: string; |
| 126 | activeModes: ReadonlyArray<ThemeMode>; |
| 127 | preview: ThemeCardDefinition["previews"][number]; |
| 128 | }>; |
| 129 | onSelectAndUse: (themeIndex: number, mode: ThemeAppearance) => void; |
| 130 | }; |
| 131 | }) { |
| 132 | // A one-appearance theme can only take its own side of the mix, so the card |
| 133 | // tooltip promises exactly what clicking it does. |
| 134 | const cardModes = theme.previews.map((preview) => preview.mode); |
| 135 | const [radialModeOpen, setRadialModeOpen] = useState<ThemeAppearance | null>(null); |
| 136 | const radialModeGroups = (["light", "dark"] as const).map((mode) => { |
| 137 | const options = |
| 138 | variantNavigation?.options.flatMap((option) => { |
| 139 | const preview = option.preview; |
| 140 | return preview.mode === mode ? [{ option, preview }] : []; |
| 141 | }) ?? []; |
| 142 | return { |
| 143 | mode, |
| 144 | options, |
| 145 | selected: options.find(({ option }) => option.activeModes.includes(mode)) ?? options[0], |
| 146 | }; |
| 147 | }); |
| 148 | return ( |
| 149 | // The card surface stays a plain div (buttons cannot nest inside a button |
| 150 | // role); the title button and mode circles carry the accessible actions, |
| 151 | // while the card click is a pointer-only convenience. |
| 152 | <Tooltip> |
| 153 | <TooltipTrigger |
| 154 | render={ |
| 155 | <div |
| 156 | className={cn( |
| 157 | "cursor-pointer overflow-hidden rounded-xl border border-border/70 bg-card/60 transition-colors hover:bg-accent/10", |
| 158 | isActive && "bg-accent/30", |
| 159 | )} |
| 160 | data-theme-library-card={theme.id} |
| 161 | onClick={onUse} |
| 162 | style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined} |
| 163 | > |
| 164 | <div className="relative"> |
| 165 | {variantNavigation ? ( |
| 166 | <div |
| 167 | aria-label="Light and dark theme variants" |
| 168 | className="relative h-20" |
| 169 | role="group" |
| 170 | onBlurCapture={(event) => { |
| 171 | const nextTarget = event.relatedTarget; |
| 172 | if ( |
| 173 | !(nextTarget instanceof Node) || |
| 174 | !event.currentTarget.contains(nextTarget) |
| 175 | ) { |
| 176 | setRadialModeOpen(null); |
| 177 | } |
| 178 | }} |
| 179 | onKeyDown={(event) => { |
| 180 | if (event.key === "Escape") setRadialModeOpen(null); |
| 181 | }} |
| 182 | onMouseLeave={() => setRadialModeOpen(null)} |
| 183 | > |
| 184 | {radialModeGroups.map(({ mode, options, selected }) => { |
| 185 | if (!selected) return null; |
| 186 | const rootOffsetX = mode === "light" ? -52 : 52; |
| 187 | const isOpen = radialModeOpen === mode; |
| 188 | const isActive = selected.option.activeModes.includes(mode); |
| 189 | const modeLabel = mode === "light" ? "Light" : "Dark"; |
| 190 | return ( |
| 191 | <div className="contents" key={mode}> |
| 192 | <ThemeVariantTooltip label={`${modeLabel}: ${selected.option.label}`}> |
| 193 | <button |
| 194 | aria-label={ |
| 195 | options.length > 1 |
| 196 | ? `Choose ${mode} variant, ${options.length} options, currently ${selected.option.label}` |
| 197 | : `Use ${mode} variant, currently ${selected.option.label}` |
| 198 | } |
| 199 | aria-pressed={isActive} |
| 200 | className="absolute left-1/2 top-2 z-20 flex size-14 items-center justify-center rounded-full outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring" |
| 201 | style={{ |
| 202 | transform: `translateX(calc(-50% + ${rootOffsetX}px))`, |
| 203 | }} |
| 204 | type="button" |
| 205 | onClick={(event) => { |
| 206 | event.stopPropagation(); |
| 207 | variantNavigation.onSelectAndUse(selected.option.themeIndex, mode); |
| 208 | }} |
| 209 | onFocus={() => setRadialModeOpen(mode)} |
| 210 | onMouseEnter={() => setRadialModeOpen(mode)} |
| 211 | > |
| 212 | <ThemePreviewCircle |
| 213 | colors={selected.preview.colors} |
| 214 | mode={selected.preview.mode} |
| 215 | /> |
| 216 | {isActive ? ( |
| 217 | <span |
| 218 | aria-hidden |
| 219 | className="pointer-events-none absolute inset-0 rounded-full ring-2 ring-inset ring-ring" |
| 220 | /> |
| 221 | ) : null} |
| 222 | {isActive ? ( |
| 223 | <span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full border border-border/70 bg-background text-foreground shadow-sm"> |
| 224 | {mode === "light" ? ( |
| 225 | <SunIcon className="size-2.5" /> |
| 226 | ) : ( |
| 227 | <MoonIcon className="size-2.5" /> |
| 228 | )} |
| 229 | </span> |
| 230 | ) : null} |
| 231 | </button> |
| 232 | </ThemeVariantTooltip> |
| 233 | <span |
| 234 | className="pointer-events-none absolute bottom-0 left-1/2 inline-flex max-w-24 -translate-x-1/2 items-center gap-1 text-2xs font-medium text-foreground" |
| 235 | style={{ marginLeft: rootOffsetX }} |
| 236 | > |
| 237 | <span className="truncate">{selected.option.label}</span> |
| 238 | {options.length > 1 ? ( |
| 239 | <span className="shrink-0 rounded-full bg-muted px-1 text-3xs text-muted-foreground"> |
| 240 | +{options.length - 1} |
| 241 | </span> |
| 242 | ) : null} |
| 243 | </span> |
| 244 | {options.length > 1 |
| 245 | ? options.map(({ option, preview }, optionIndex) => { |
| 246 | const progress = optionIndex / (options.length - 1) - 0.5; |
| 247 | const childOffsetX = rootOffsetX + progress * 68; |
| 248 | const childOffsetY = Math.abs(progress) * 10; |
| 249 | const optionIsActive = option.activeModes.includes(mode); |
| 250 | return ( |
| 251 | <ThemeVariantTooltip |
| 252 | key={option.label} |
| 253 | label={`Use ${option.label} for ${mode} mode`} |
| 254 | > |
| 255 | <button |
| 256 | aria-label={`Use ${option.label} for ${mode} mode${optionIsActive ? ", currently active" : ""}`} |
| 257 | aria-pressed={optionIsActive} |
| 258 | className={cn( |
| 259 | "absolute left-1/2 top-1 z-30 flex size-7 items-center justify-center rounded-full bg-background shadow-sm outline-none transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", |
| 260 | optionIsActive |
| 261 | ? "ring-2 ring-inset ring-ring" |
| 262 | : "ring-1 ring-border/70", |
| 263 | )} |
| 264 | style={{ |
| 265 | opacity: isOpen ? 1 : 0, |
| 266 | pointerEvents: isOpen ? "auto" : "none", |
| 267 | transform: `translate(calc(-50% + ${isOpen ? childOffsetX : rootOffsetX}px), ${isOpen ? childOffsetY : 28}px) scale(${isOpen ? 1 : 0.55})`, |
| 268 | transitionDelay: isOpen ? `${optionIndex * 35}ms` : "0ms", |
| 269 | }} |
| 270 | type="button" |
| 271 | onClick={(event) => { |
| 272 | event.stopPropagation(); |
| 273 | variantNavigation.onSelectAndUse(option.themeIndex, mode); |
| 274 | }} |
| 275 | onFocus={() => setRadialModeOpen(mode)} |
| 276 | onMouseEnter={() => setRadialModeOpen(mode)} |
| 277 | > |
| 278 | <span className="pointer-events-none scale-[0.43]"> |
| 279 | <ThemePreviewCircle |
| 280 | colors={preview.colors} |
| 281 | mode={preview.mode} |
| 282 | /> |
| 283 | </span> |
| 284 | </button> |
| 285 | </ThemeVariantTooltip> |
| 286 | ); |
| 287 | }) |
| 288 | : null} |
| 289 | </div> |
| 290 | ); |
| 291 | })} |
| 292 | </div> |
| 293 | ) : ( |
| 294 | <ThemePreviewCircles |
| 295 | label={theme.label} |
| 296 | activeModes={activeModes} |
| 297 | onSelectMode={onUseMode} |
| 298 | previews={theme.previews} |
| 299 | /> |
| 300 | )} |
| 301 | </div> |
| 302 | <div className="flex items-center gap-2 px-3 pb-3 pt-2"> |
| 303 | <div className="min-w-0 flex-1"> |
| 304 | <div className="flex items-center gap-1.5"> |
| 305 | <button |
| 306 | aria-label={`Use ${variantNavigation ? `${variantNavigation.collectionLabel}, ${theme.label} variant` : `${theme.label} theme`}${isActive ? ", currently active" : ""}`} |
| 307 | aria-pressed={isActive} |
| 308 | className="min-w-0 cursor-pointer truncate rounded-sm text-left text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card" |
| 309 | type="button" |
| 310 | onClick={(event) => { |
| 311 | event.stopPropagation(); |
| 312 | onUse(); |
| 313 | }} |
| 314 | > |
| 315 | {variantNavigation?.collectionLabel ?? theme.label} |
| 316 | </button> |
| 317 | </div> |
| 318 | </div> |
| 319 | {onEdit || onDuplicate || onDownload || onRemove ? ( |
| 320 | <div className="flex shrink-0 items-center gap-1"> |
| 321 | {onDuplicate ? ( |
| 322 | <Tooltip> |
| 323 | <TooltipTrigger |
| 324 | render={ |
| 325 | <Button |
| 326 | aria-label={`Duplicate ${theme.label}`} |
| 327 | size="icon-xs" |
| 328 | variant="ghost" |
| 329 | onClick={(event) => { |
| 330 | event.stopPropagation(); |
| 331 | onDuplicate(); |
| 332 | }} |
| 333 | > |
| 334 | <CopyIcon /> |
| 335 | </Button> |
| 336 | } |
| 337 | /> |
| 338 | <TooltipPopup>Duplicate theme</TooltipPopup> |
| 339 | </Tooltip> |
| 340 | ) : null} |
| 341 | {onEdit ? ( |
| 342 | <Tooltip> |
| 343 | <TooltipTrigger |
| 344 | render={ |
| 345 | <Button |
| 346 | aria-label={`Edit ${theme.label}`} |
| 347 | size="icon-xs" |
| 348 | variant="ghost" |
| 349 | onClick={(event) => { |
| 350 | event.stopPropagation(); |
| 351 | onEdit(); |
| 352 | }} |
| 353 | > |
| 354 | <PenLineIcon /> |
| 355 | </Button> |
| 356 | } |
| 357 | /> |
| 358 | <TooltipPopup>Edit theme</TooltipPopup> |
| 359 | </Tooltip> |
| 360 | ) : null} |
| 361 | {onDownload ? ( |
| 362 | <Tooltip> |
| 363 | <TooltipTrigger |
| 364 | render={ |
| 365 | <Button |
| 366 | aria-label={`Export ${theme.label}`} |
| 367 | size="icon-xs" |
| 368 | variant="ghost" |
| 369 | onClick={(event) => { |
| 370 | event.stopPropagation(); |
| 371 | onDownload(); |
| 372 | }} |
| 373 | > |
| 374 | <UploadIcon /> |
| 375 | </Button> |
| 376 | } |
| 377 | /> |
| 378 | <TooltipPopup>Export theme file</TooltipPopup> |
| 379 | </Tooltip> |
| 380 | ) : null} |
| 381 | {onRemove ? ( |
| 382 | <Tooltip> |
| 383 | <TooltipTrigger |
| 384 | render={ |
| 385 | <Button |
| 386 | aria-label={ |
| 387 | variantNavigation |
| 388 | ? `Remove themes from ${variantNavigation.collectionLabel}` |
| 389 | : `Remove ${theme.label}` |
| 390 | } |
| 391 | size="icon-xs" |
| 392 | variant="ghost-destructive" |
| 393 | onClick={(event) => { |
| 394 | event.stopPropagation(); |
| 395 | onRemove(); |
| 396 | }} |
| 397 | > |
| 398 | <Trash2Icon /> |
| 399 | </Button> |
| 400 | } |
| 401 | /> |
| 402 | <TooltipPopup> |
| 403 | {variantNavigation ? "Remove themes" : "Remove theme"} |
| 404 | </TooltipPopup> |
| 405 | </Tooltip> |
| 406 | ) : null} |
| 407 | </div> |
| 408 | ) : null} |
| 409 | </div> |
| 410 | </div> |
| 411 | } |
| 412 | /> |
| 413 | <TooltipPopup> |
| 414 | {variantNavigation |
| 415 | ? "Use the first variants for light and dark" |
| 416 | : cardModes.length > 1 |
| 417 | ? "Use for both light and dark" |
| 418 | : `Use for ${cardModes[0]} mode only`} |
| 419 | </TooltipPopup> |
| 420 | </Tooltip> |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | function CustomThemeCollectionCard({ |
| 425 | themes, |
| 426 | activeModesFor, |
| 427 | onUse, |
| 428 | onUseMode, |
| 429 | onDuplicate, |
| 430 | onEdit, |
| 431 | onDownload, |
| 432 | onRemove, |
| 433 | }: { |
| 434 | themes: ReadonlyArray<ThemeDefinition>; |
| 435 | activeModesFor: (themeId: string) => ReadonlyArray<ThemeMode>; |
| 436 | onUse: (theme: ThemeDefinition) => void; |
| 437 | onUseMode: (theme: ThemeDefinition, mode: ThemeMode) => void; |
| 438 | onDuplicate: (theme: ThemeDefinition) => void; |
| 439 | onEdit: (theme: ThemeDefinition) => void; |
| 440 | onDownload: (theme: ThemeDefinition) => void; |
| 441 | onRemove: (theme: ThemeDefinition) => void; |
| 442 | }) { |
| 443 | const [variantIndex, setVariantIndex] = useState(() => { |
| 444 | const activeIndex = themes.findIndex((theme) => activeModesFor(theme.id).length > 0); |
| 445 | return activeIndex < 0 ? 0 : activeIndex; |
| 446 | }); |
| 447 | const safeIndex = Math.min(variantIndex, themes.length - 1); |
| 448 | const theme = themes[safeIndex]; |
| 449 | |
| 450 | useEffect(() => { |
| 451 | if (variantIndex !== safeIndex) setVariantIndex(safeIndex); |
| 452 | }, [safeIndex, variantIndex]); |
| 453 | |
| 454 | if (!theme) return null; |
| 455 | const collectionLabel = theme.collection?.label ?? theme.label; |
| 456 | const variantLabels = collectionVariantLabels(themes); |
| 457 | const defaultLightTheme = themes.find((candidate) => getThemeModes(candidate).includes("light")); |
| 458 | const defaultDarkTheme = themes.find((candidate) => getThemeModes(candidate).includes("dark")); |
| 459 | const selectCollectionDefaults = () => { |
| 460 | if (themes.length === 1) { |
| 461 | onUse(theme); |
| 462 | return; |
| 463 | } |
| 464 | if (defaultLightTheme) onUseMode(defaultLightTheme, "light"); |
| 465 | if (defaultDarkTheme) onUseMode(defaultDarkTheme, "dark"); |
| 466 | setVariantIndex(0); |
| 467 | }; |
| 468 | |
| 469 | return ( |
| 470 | <ThemeLibraryCard |
| 471 | activeModes={activeModesFor(theme.id)} |
| 472 | isActive={false} |
| 473 | onDownload={() => onDownload(theme)} |
| 474 | onDuplicate={() => onDuplicate(theme)} |
| 475 | onEdit={() => onEdit(theme)} |
| 476 | onRemove={() => onRemove(theme)} |
| 477 | onUse={selectCollectionDefaults} |
| 478 | onUseMode={(mode) => onUseMode(theme, mode)} |
| 479 | theme={getThemeCardDefinition(theme)} |
| 480 | {...(themes.length > 1 |
| 481 | ? { |
| 482 | variantNavigation: { |
| 483 | collectionLabel, |
| 484 | options: themes.flatMap((variant, themeIndex) => |
| 485 | getThemeCardDefinition(variant).previews.map((preview) => ({ |
| 486 | themeIndex, |
| 487 | label: variantLabels[themeIndex] ?? variant.label, |
| 488 | activeModes: activeModesFor(variant.id), |
| 489 | preview, |
| 490 | })), |
| 491 | ), |
| 492 | onSelectAndUse: (themeIndex, mode) => { |
| 493 | const selectedTheme = themes[themeIndex]; |
| 494 | if (!selectedTheme) return; |
| 495 | setVariantIndex(themeIndex); |
| 496 | onUseMode(selectedTheme, mode); |
| 497 | }, |
| 498 | }, |
| 499 | } |
| 500 | : {})} |
| 501 | /> |
| 502 | ); |
| 503 | } |
| 504 | |
| 505 | export function ThemeLibrary({ |
| 506 | theme, |
| 507 | setTheme, |
| 508 | appearanceMode, |
| 509 | setAppearanceMode, |
| 510 | customThemes, |
| 511 | initialAppearance, |
| 512 | refreshTheme, |
| 513 | isImportOpen, |
| 514 | onImportOpenChange, |
| 515 | themeHalves, |
| 516 | setThemeHalf, |
| 517 | }: { |
| 518 | theme: string; |
| 519 | setTheme: (theme: string) => boolean; |
| 520 | appearanceMode: ThemeMode; |
| 521 | setAppearanceMode: (mode: ThemeMode) => boolean; |
| 522 | customThemes: ReadonlyArray<ThemeDefinition>; |
| 523 | initialAppearance: ThemeAppearance; |
| 524 | refreshTheme: () => void; |
| 525 | isImportOpen: boolean; |
| 526 | onImportOpenChange: (open: boolean) => void; |
| 527 | themeHalves: ThemeHalves | null; |
| 528 | setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean; |
| 529 | }) { |
| 530 | const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor); |
| 531 | const environmentThemes = useEnvironmentThemeDefinitions(); |
| 532 | const [themeRemovalTarget, setThemeRemovalTarget] = useState<{ |
| 533 | theme: ThemeDefinition; |
| 534 | collectionThemes: ReadonlyArray<ThemeDefinition>; |
| 535 | } | null>(null); |
| 536 | // Keep the target after closing so the dialog text remains populated during |
| 537 | // its exit animation. The next trash action replaces it before reopening. |
| 538 | const [isThemeRemovalOpen, setIsThemeRemovalOpen] = useState(false); |
| 539 | const [themeIdsToRemove, setThemeIdsToRemove] = useState<ReadonlyArray<string>>([]); |
| 540 | const themeIdsToRemoveSet = new Set(themeIdsToRemove); |
| 541 | const removeDialogTheme = themeRemovalTarget?.theme; |
| 542 | const removeDialogCollectionThemes = themeRemovalTarget?.collectionThemes ?? []; |
| 543 | const canRemoveCollection = removeDialogCollectionThemes.length > 1; |
| 544 | const removeDialogCollectionLabel = |
| 545 | removeDialogTheme?.collection?.label ?? removeDialogTheme?.label; |
| 546 | |
| 547 | const notifyThemeSaveFailure = useCallback(() => { |
| 548 | toastManager.add( |
| 549 | stackedThreadToast({ |
| 550 | type: "error", |
| 551 | title: "Couldn’t save theme selection", |
| 552 | description: "Try again.", |
| 553 | }), |
| 554 | ); |
| 555 | }, []); |
| 556 | |
| 557 | const notifyThemeRemovalFailure = useCallback(() => { |
| 558 | toastManager.add( |
| 559 | stackedThreadToast({ |
| 560 | type: "error", |
| 561 | title: "Couldn’t remove theme", |
| 562 | description: "Try again.", |
| 563 | }), |
| 564 | ); |
| 565 | }, []); |
| 566 | |
| 567 | const persistTheme = useCallback( |
| 568 | (nextTheme: string) => { |
| 569 | const didSave = setTheme(nextTheme); |
| 570 | if (!didSave) notifyThemeSaveFailure(); |
| 571 | return didSave; |
| 572 | }, |
| 573 | [notifyThemeSaveFailure, setTheme], |
| 574 | ); |
| 575 | |
| 576 | const handleRemoveTheme = useCallback( |
| 577 | (customTheme: ThemeDefinition, collectionThemes: ReadonlyArray<ThemeDefinition>) => { |
| 578 | setThemeRemovalTarget({ theme: customTheme, collectionThemes }); |
| 579 | setThemeIdsToRemove(collectionThemes.length > 1 ? [] : [customTheme.id]); |
| 580 | setIsThemeRemovalOpen(true); |
| 581 | }, |
| 582 | [], |
| 583 | ); |
| 584 | |
| 585 | const handleConfirmRemoveTheme = useCallback(() => { |
| 586 | if (!themeRemovalTarget) return; |
| 587 | const removedIds = new Set(themeIdsToRemove); |
| 588 | if (removedIds.size === 0) return; |
| 589 | const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? ""); |
| 590 | // Captured raw before persistTheme clears the mix: a half naming a |
| 591 | // published theme whose set has not streamed in yet is pruned from the |
| 592 | // `themeHalves` prop, and rebuilding from that would drop it. |
| 593 | const storedHalves = readThemeHalvesRaw(); |
| 594 | // Keep the themes installed if we cannot move the selection off one of |
| 595 | // them; the dialog stays open so the user can retry or cancel. |
| 596 | if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) { |
| 597 | return; |
| 598 | } |
| 599 | for (const appearance of ["light", "dark"] as const) { |
| 600 | const half = storedHalves[appearance]; |
| 601 | if (half === undefined) continue; |
| 602 | // Writing a base preference clears the whole mix, so halves that name |
| 603 | // a surviving theme are written back; removed halves fall back to base. |
| 604 | const next = half && removedIds.has(half) ? null : removesBase ? half : undefined; |
| 605 | if (next !== undefined && !setThemeHalf(appearance, next)) { |
| 606 | notifyThemeRemovalFailure(); |
| 607 | return; |
| 608 | } |
| 609 | } |
| 610 | try { |
| 611 | removeCustomThemes([...removedIds]); |
| 612 | } catch { |
| 613 | notifyThemeRemovalFailure(); |
| 614 | return; |
| 615 | } |
| 616 | setIsThemeRemovalOpen(false); |
| 617 | }, [ |
| 618 | appearanceMode, |
| 619 | notifyThemeRemovalFailure, |
| 620 | persistTheme, |
| 621 | setThemeHalf, |
| 622 | theme, |
| 623 | themeIdsToRemove, |
| 624 | themeRemovalTarget, |
| 625 | ]); |
| 626 | |
| 627 | // ----- Automatic-mode mixing ------------------------------------------- |
| 628 | // The pair model: one theme owns light, one owns dark, and the global |
| 629 | // appearance mode (light / dark / auto) decides which is showing. |
| 630 | const baseCardId = getThemeDefinition(theme)?.id ?? null; |
| 631 | const lightOwner = themeHalves?.light ?? baseCardId; |
| 632 | const darkOwner = themeHalves?.dark ?? baseCardId; |
| 633 | |
| 634 | const assignHalf = useCallback( |
| 635 | (appearance: ThemeAppearance, cardId: string | null) => { |
| 636 | const otherAppearance = appearance === "light" ? "dark" : "light"; |
| 637 | // Picking the default over a themed base cannot be stored as a half: |
| 638 | // the base would still own that appearance. Convert the base into an |
| 639 | // explicit half on the other side so this side falls back to default. |
| 640 | if (cardId === null && baseCardId !== null) { |
| 641 | // Read raw, before persistTheme clears the mix: the other half may |
| 642 | // name a published theme that has not streamed in yet, and falling |
| 643 | // back to the base would silently rewrite it. |
| 644 | const otherOwner = readThemeHalvesRaw()[otherAppearance] ?? baseCardId; |
| 645 | if (!persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) return; |
| 646 | if (!setThemeHalf(otherAppearance, otherOwner)) { |
| 647 | // Best-effort rollback: restore the whole-theme selection rather |
| 648 | // than leaving the user with no theme at all. |
| 649 | setTheme(theme); |
| 650 | notifyThemeSaveFailure(); |
| 651 | } |
| 652 | return; |
| 653 | } |
| 654 | if (!setThemeHalf(appearance, cardId)) { |
| 655 | notifyThemeSaveFailure(); |
| 656 | } |
| 657 | }, |
| 658 | [ |
| 659 | appearanceMode, |
| 660 | baseCardId, |
| 661 | notifyThemeSaveFailure, |
| 662 | persistTheme, |
| 663 | setTheme, |
| 664 | setThemeHalf, |
| 665 | theme, |
| 666 | ], |
| 667 | ); |
| 668 | |
| 669 | // "Create theme" starts from whatever is on screen for the appearance being |
| 670 | // edited, so tuning the theme you already use never means rebuilding it. |
| 671 | const activeThemeForAppearance = |
| 672 | getThemeDefinition((initialAppearance === "light" ? lightOwner : darkOwner) ?? "") ?? null; |
| 673 | |
| 674 | const cardDefById = (id: string | null): ThemeCardDefinition => { |
| 675 | if (id === null) return STANDARD_THEME_CARDS[0]!; |
| 676 | const definition = getThemeDefinition(id); |
| 677 | return definition ? getThemeCardDefinition(definition) : STANDARD_THEME_CARDS[0]!; |
| 678 | }; |
| 679 | |
| 680 | const pickColors = (id: string | null, appearance: ThemeAppearance) => { |
| 681 | const card = cardDefById(id); |
| 682 | return previewColorsOf(card, appearance) ?? card.previews[0]!.colors; |
| 683 | }; |
| 684 | |
| 685 | const setMode = (mode: ThemeMode) => { |
| 686 | if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); |
| 687 | }; |
| 688 | |
| 689 | // ----- Wireframe tiles on top, two-ball cards below -------------------- |
| 690 | const handlePairPick = (cardId: string | null) => (mode: ThemeMode) => { |
| 691 | if (mode === "system") return; |
| 692 | assignHalf(mode, cardId); |
| 693 | }; |
| 694 | |
| 695 | // Rings always show the effective owner of each appearance: an unpicked |
| 696 | // half belongs to the default card (a null owner), so a fresh install |
| 697 | // shows T3 Code selected instead of nothing. |
| 698 | const pickedModesFor = (cardId: string | null): ThemeMode[] => { |
| 699 | const rings: ThemeMode[] = []; |
| 700 | if (lightOwner === cardId) rings.push("light"); |
| 701 | if (darkOwner === cardId) rings.push("dark"); |
| 702 | return rings; |
| 703 | }; |
| 704 | |
| 705 | const wireframeColors = (appearance: ThemeAppearance) => |
| 706 | pickColors(appearance === "light" ? lightOwner : darkOwner, appearance); |
| 707 | |
| 708 | const renderWireframe = (mode: ThemeMode) => ( |
| 709 | <ThemeWireframe |
| 710 | className="h-[8.75rem]" |
| 711 | panes={ |
| 712 | mode === "system" |
| 713 | ? [ |
| 714 | { clip: "left", colors: wireframeColors("light") }, |
| 715 | { clip: "right", colors: wireframeColors("dark") }, |
| 716 | ] |
| 717 | : [{ colors: wireframeColors(mode === "dark" ? "dark" : "light") }] |
| 718 | } |
| 719 | /> |
| 720 | ); |
| 721 | |
| 722 | const renderModeTiles = () => ( |
| 723 | <div aria-label="Appearance mode" className="grid w-full grid-cols-3 gap-3" role="group"> |
| 724 | {(["system", "light", "dark"] as const).map((mode) => { |
| 725 | const isActive = appearanceMode === mode; |
| 726 | return ( |
| 727 | <button |
| 728 | aria-label={mode === "system" ? "Follow the system appearance" : `Use ${mode} mode`} |
| 729 | aria-pressed={isActive} |
| 730 | className={cn( |
| 731 | "flex cursor-pointer flex-col items-stretch gap-1.5 rounded-xl border p-2 outline-none transition-colors focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring", |
| 732 | isActive |
| 733 | ? "border-transparent bg-accent/30" |
| 734 | : "border-border/70 bg-card/60 hover:bg-accent/10", |
| 735 | )} |
| 736 | key={mode} |
| 737 | style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined} |
| 738 | onClick={() => setMode(mode)} |
| 739 | type="button" |
| 740 | > |
| 741 | {renderWireframe(mode)} |
| 742 | <span |
| 743 | className={cn( |
| 744 | "flex items-center justify-center text-xs font-medium", |
| 745 | isActive ? "text-foreground" : "text-muted-foreground", |
| 746 | )} |
| 747 | > |
| 748 | {mode === "system" ? "System" : mode === "light" ? "Light" : "Dark"} |
| 749 | </span> |
| 750 | </button> |
| 751 | ); |
| 752 | })} |
| 753 | </div> |
| 754 | ); |
| 755 | |
| 756 | const customThemeCollections = [ |
| 757 | ...customThemes |
| 758 | .reduce((groups, customTheme) => { |
| 759 | const groupId = customTheme.collection |
| 760 | ? `collection:${customTheme.collection.id}` |
| 761 | : `theme:${customTheme.id}`; |
| 762 | const group = groups.get(groupId); |
| 763 | if (group) group.push(customTheme); |
| 764 | else groups.set(groupId, [customTheme]); |
| 765 | return groups; |
| 766 | }, new Map<string, ThemeDefinition[]>()) |
| 767 | .entries(), |
| 768 | ]; |
| 769 | |
| 770 | const renderPairGrid = () => ( |
| 771 | // One shared provider so every tooltip in the grid hands off instantly to |
| 772 | // the next hovered trigger instead of stacking on top of it. The card |
| 773 | // tooltip briefly showing while crossing between a card's two circles is |
| 774 | // accepted — scoping the group tighter makes the handoffs feel sluggish. |
| 775 | <TooltipProvider> |
| 776 | <div |
| 777 | className="grid w-full gap-2" |
| 778 | style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }} |
| 779 | > |
| 780 | {STANDARD_THEME_CARDS.map((standardTheme) => ( |
| 781 | <ThemeLibraryCard |
| 782 | activeModes={pickedModesFor(null)} |
| 783 | isActive={false} |
| 784 | key={standardTheme.id} |
| 785 | onDuplicate={() => |
| 786 | openThemeEditor({ |
| 787 | editingThemeId: null, |
| 788 | seedThemeId: null, |
| 789 | seedName: `${standardTheme.label} copy`, |
| 790 | initialAppearance, |
| 791 | }) |
| 792 | } |
| 793 | onUse={() => persistTheme(appearanceMode === "system" ? "system" : appearanceMode)} |
| 794 | onUseMode={handlePairPick(null)} |
| 795 | theme={standardTheme} |
| 796 | /> |
| 797 | ))} |
| 798 | {MAINTAINER_THEMES.map((maintainerTheme) => { |
| 799 | const card = getThemeCardDefinition(maintainerTheme); |
| 800 | return ( |
| 801 | <ThemeLibraryCard |
| 802 | activeModes={pickedModesFor(maintainerTheme.id)} |
| 803 | isActive={false} |
| 804 | key={maintainerTheme.id} |
| 805 | onDuplicate={() => |
| 806 | openThemeEditor({ |
| 807 | editingThemeId: null, |
| 808 | seedThemeId: maintainerTheme.id, |
| 809 | seedName: `${maintainerTheme.label} copy`, |
| 810 | initialAppearance, |
| 811 | }) |
| 812 | } |
| 813 | onUse={() => persistTheme(maintainerTheme.id)} |
| 814 | onUseMode={handlePairPick(maintainerTheme.id)} |
| 815 | theme={card} |
| 816 | /> |
| 817 | ); |
| 818 | })} |
| 819 | {environmentThemes |
| 820 | .filter( |
| 821 | // A saved theme with the same id wins resolution, so its card is |
| 822 | // the one that must show; rendering both would also collide keys. |
| 823 | (environmentTheme) => !customThemes.some((theme) => theme.id === environmentTheme.id), |
| 824 | ) |
| 825 | .map((environmentTheme) => ( |
| 826 | // No edit or remove: the environment republishes these palettes on |
| 827 | // every change, so anything saved here would be overwritten. |
| 828 | // Duplicating is the way to keep a copy. |
| 829 | <ThemeLibraryCard |
| 830 | activeModes={pickedModesFor(environmentTheme.id)} |
| 831 | isActive={false} |
| 832 | key={environmentTheme.id} |
| 833 | onDuplicate={() => |
| 834 | openThemeEditor({ |
| 835 | editingThemeId: null, |
| 836 | seedThemeId: environmentTheme.id, |
| 837 | seedName: `${environmentTheme.label} copy`, |
| 838 | initialAppearance, |
| 839 | }) |
| 840 | } |
| 841 | onUse={() => { |
| 842 | const half = singleAppearanceOf(environmentTheme); |
| 843 | if (half === null) persistTheme(environmentTheme.id); |
| 844 | else assignHalf(half, environmentTheme.id); |
| 845 | }} |
| 846 | onUseMode={handlePairPick(environmentTheme.id)} |
| 847 | theme={getThemeCardDefinition(environmentTheme)} |
| 848 | /> |
| 849 | ))} |
| 850 | {customThemeCollections.map(([collectionId, themes]) => ( |
| 851 | <CustomThemeCollectionCard |
| 852 | activeModesFor={pickedModesFor} |
| 853 | key={collectionId} |
| 854 | onDownload={(customTheme) => |
| 855 | downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme)) |
| 856 | } |
| 857 | onDuplicate={(customTheme) => |
| 858 | openThemeEditor({ |
| 859 | editingThemeId: null, |
| 860 | seedThemeId: customTheme.id, |
| 861 | seedName: `${customTheme.label} copy`, |
| 862 | initialAppearance, |
| 863 | }) |
| 864 | } |
| 865 | onEdit={(customTheme) => |
| 866 | openThemeEditor({ |
| 867 | editingThemeId: customTheme.id, |
| 868 | seedThemeId: null, |
| 869 | seedName: null, |
| 870 | initialAppearance, |
| 871 | }) |
| 872 | } |
| 873 | onRemove={(customTheme) => handleRemoveTheme(customTheme, themes)} |
| 874 | onUse={(customTheme) => { |
| 875 | const modes = getThemeModes(customTheme); |
| 876 | if (modes.length === 1) assignHalf(modes[0]!, customTheme.id); |
| 877 | else persistTheme(customTheme.id); |
| 878 | }} |
| 879 | onUseMode={(customTheme, mode) => handlePairPick(customTheme.id)(mode)} |
| 880 | themes={themes} |
| 881 | /> |
| 882 | ))} |
| 883 | </div> |
| 884 | </TooltipProvider> |
| 885 | ); |
| 886 | |
| 887 | return ( |
| 888 | <div className="space-y-3"> |
| 889 | <h3 className="px-3 text-sm font-normal text-foreground/70 sm:px-4"> |
| 890 | {searchableSetting("color-scheme").title} |
| 891 | </h3> |
| 892 | {renderModeTiles()} |
| 893 | <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4"> |
| 894 | <h3 className="text-sm font-normal text-foreground/70"> |
| 895 | {searchableSetting("theme").title} |
| 896 | </h3> |
| 897 | <div className="flex flex-wrap items-center justify-end gap-2"> |
| 898 | <Button |
| 899 | size="xs" |
| 900 | variant="outline" |
| 901 | onClick={() => |
| 902 | openThemeEditor({ |
| 903 | editingThemeId: null, |
| 904 | seedThemeId: activeThemeForAppearance?.id ?? null, |
| 905 | seedName: null, |
| 906 | initialAppearance, |
| 907 | }) |
| 908 | } |
| 909 | > |
| 910 | <PaintbrushIcon /> |
| 911 | Create theme |
| 912 | </Button> |
| 913 | <Button size="xs" variant="outline" onClick={() => onImportOpenChange(true)}> |
| 914 | <PlusIcon /> |
| 915 | Add theme |
| 916 | </Button> |
| 917 | </div> |
| 918 | </div> |
| 919 | {renderPairGrid()} |
| 920 | <ThemeImportDialog |
| 921 | onImportedMany={(importedThemes, { updated }) => { |
| 922 | // Re-apply after collection updates. The update may remove the |
| 923 | // selected variant, in which case the theme hook falls back safely. |
| 924 | if (updated) refreshTheme(); |
| 925 | const verb = updated ? "updated" : "added"; |
| 926 | toastManager.add( |
| 927 | stackedThreadToast({ |
| 928 | type: "success", |
| 929 | title: |
| 930 | importedThemes.length === 1 |
| 931 | ? `${importedThemes[0]!.label} ${verb}` |
| 932 | : `${importedThemes.length} themes ${verb}`, |
| 933 | description: importedThemes.map((imported) => imported.label).join(", "), |
| 934 | }), |
| 935 | ); |
| 936 | }} |
| 937 | onImported={(importedTheme) => { |
| 938 | // Same rule as clicking the card: a one-appearance theme takes its |
| 939 | // side of the mix instead of becoming the base for both. |
| 940 | const modes = getThemeModes(importedTheme); |
| 941 | if (modes.length === 1) { |
| 942 | assignHalf(modes[0]!, importedTheme.id); |
| 943 | toastManager.add( |
| 944 | stackedThreadToast({ |
| 945 | type: "success", |
| 946 | title: `${importedTheme.label} added`, |
| 947 | description: `It’s now your ${modes[0]!} theme.`, |
| 948 | }), |
| 949 | ); |
| 950 | return true; |
| 951 | } |
| 952 | if (!persistTheme(importedTheme.id)) return false; |
| 953 | toastManager.add( |
| 954 | stackedThreadToast({ |
| 955 | type: "success", |
| 956 | title: `${importedTheme.label} added`, |
| 957 | description: "It’s now active.", |
| 958 | }), |
| 959 | ); |
| 960 | return true; |
| 961 | }} |
| 962 | onOpenChange={onImportOpenChange} |
| 963 | open={isImportOpen} |
| 964 | /> |
| 965 | <AlertDialog open={isThemeRemovalOpen} onOpenChange={setIsThemeRemovalOpen}> |
| 966 | <AlertDialogPopup> |
| 967 | <AlertDialogHeader> |
| 968 | <AlertDialogTitle> |
| 969 | {canRemoveCollection |
| 970 | ? `Remove themes from “${removeDialogCollectionLabel}”?` |
| 971 | : `Remove “${removeDialogTheme?.label}”?`} |
| 972 | </AlertDialogTitle> |
| 973 | <AlertDialogDescription> |
| 974 | {canRemoveCollection |
| 975 | ? "Select the variants you want to remove. You can restore them by importing the extension again." |
| 976 | : "You can bring it back anytime by importing its JSON file."} |
| 977 | </AlertDialogDescription> |
| 978 | </AlertDialogHeader> |
| 979 | {canRemoveCollection ? ( |
| 980 | <div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto px-6 pb-6 sm:grid-cols-2"> |
| 981 | {removeDialogCollectionThemes.map((customTheme) => { |
| 982 | const checked = themeIdsToRemoveSet.has(customTheme.id); |
| 983 | const card = getThemeCardDefinition(customTheme); |
| 984 | const checkboxId = `remove-theme-${customTheme.id}`; |
| 985 | return ( |
| 986 | <label |
| 987 | className="group relative flex min-h-28 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-border/70 bg-muted/25 p-3 has-checked:border-ring has-checked:bg-accent/20 hover:bg-muted/40" |
| 988 | htmlFor={checkboxId} |
| 989 | key={customTheme.id} |
| 990 | > |
| 991 | <span className="absolute right-2 top-2 inline-grid size-5 grid-cols-1 sm:size-4"> |
| 992 | <input |
| 993 | checked={checked} |
| 994 | className="col-start-1 row-start-1 size-full appearance-none rounded-sm border border-input bg-background outline-none checked:border-primary checked:bg-primary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring checked:focus-visible:inset-ring-3 checked:focus-visible:inset-ring-background dark:not-checked:bg-input/32 forced-colors:appearance-auto" |
| 995 | id={checkboxId} |
| 996 | name="themes-to-remove" |
| 997 | type="checkbox" |
| 998 | onChange={(event) => { |
| 999 | const shouldRemove = event.currentTarget.checked; |
| 1000 | setThemeIdsToRemove((current) => |
| 1001 | shouldRemove |
| 1002 | ? [...current, customTheme.id] |
| 1003 | : current.filter((themeId) => themeId !== customTheme.id), |
| 1004 | ); |
| 1005 | }} |
| 1006 | /> |
| 1007 | <CheckIcon className="pointer-events-none col-start-1 row-start-1 size-3.5 shrink-0 self-center justify-self-center stroke-primary-foreground opacity-0 group-has-checked:opacity-100 sm:size-3" /> |
| 1008 | </span> |
| 1009 | <span className="flex min-h-12 items-center justify-center gap-1"> |
| 1010 | {card.previews.map((preview) => ( |
| 1011 | <span |
| 1012 | className="flex size-11 shrink-0 items-center justify-center" |
| 1013 | key={preview.mode} |
| 1014 | > |
| 1015 | <span className="flex scale-75"> |
| 1016 | <ThemePreviewCircle colors={preview.colors} mode={preview.mode} /> |
| 1017 | </span> |
| 1018 | </span> |
| 1019 | ))} |
| 1020 | </span> |
| 1021 | <p className="max-w-full truncate text-center text-base font-medium text-foreground sm:text-sm"> |
| 1022 | {customTheme.label} |
| 1023 | </p> |
| 1024 | </label> |
| 1025 | ); |
| 1026 | })} |
| 1027 | </div> |
| 1028 | ) : null} |
| 1029 | <AlertDialogFooter> |
| 1030 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 1031 | <Button |
| 1032 | disabled={themeIdsToRemove.length === 0} |
| 1033 | variant="destructive" |
| 1034 | onClick={handleConfirmRemoveTheme} |
| 1035 | > |
| 1036 | {canRemoveCollection |
| 1037 | ? `Remove selected${themeIdsToRemove.length > 0 ? ` (${themeIdsToRemove.length})` : ""}` |
| 1038 | : "Remove theme"} |
| 1039 | </Button> |
| 1040 | </AlertDialogFooter> |
| 1041 | </AlertDialogPopup> |
| 1042 | </AlertDialog> |
| 1043 | </div> |
| 1044 | ); |
| 1045 | } |