diff --git a/config.json b/config.json index f0c3c8b61..02ed78536 100644 --- a/config.json +++ b/config.json @@ -15,6 +15,9 @@ "settingsLinkBaseUrl": "https://app.sable.moe", + "themeCatalogBaseUrl": "https://raw.githubusercontent.com/SableClient/themes/main/", + "themeCatalogApprovedHostPrefixes": ["https://raw.githubusercontent.com/SableClient/themes/"], + "slidingSync": { "enabled": true }, diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index cc07d5082..c38e67504 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -29,7 +29,15 @@ import { UnsupportedContent, VideoContent, } from './message'; -import { UrlPreviewCard, UrlPreviewHolder, ClientPreview, youtubeUrl } from './url-preview'; +import { + UrlPreviewCard, + UrlPreviewHolder, + ClientPreview, + ThemePreviewUrlCard, + TweakPreviewUrlCard, + youtubeUrl, +} from './url-preview'; +import { isHttpsFullSableCssUrl } from '../theme/previewUrls'; import { Image, MediaControl, PersistedVolumeVideo } from './media'; import { ImageViewer } from './image-viewer'; import { PdfViewer } from './Pdf-viewer'; @@ -82,6 +90,7 @@ function RenderMessageContentInternal({ const [autoplayGifs] = useSetting(settingsAtom, 'autoplayGifs'); const [captionPosition] = useSetting(settingsAtom, 'captionPosition'); + const [themeChatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl'); const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const captionPositionMap = { [CaptionPosition.Above]: 'column-reverse', @@ -110,6 +119,15 @@ function RenderMessageContentInternal({ ); if (filteredUrls.length === 0) return undefined; + const themePreviewUrls = themeChatAny + ? filteredUrls.filter((u) => /\.preview\.sable\.css(\?|#|$)/i.test(u)) + : []; + const themeToRender = themePreviewUrls.filter((u) => /^https:\/\//i.test(u)); + + const tweakCandidateUrls = themeChatAny + ? filteredUrls.filter((u) => isHttpsFullSableCssUrl(u)) + : []; + const analyzed = filteredUrls.map((url) => ({ url, type: getMediaType(url), @@ -120,7 +138,15 @@ function RenderMessageContentInternal({ return ( + {themeToRender.map((url) => ( + + ))} + {tweakCandidateUrls.map((url) => ( + + ))} {toRender.map(({ url, type }) => { + if (themeToRender.includes(url)) return null; + if (tweakCandidateUrls.includes(url)) return null; if (type) { return ; } @@ -135,9 +161,9 @@ function RenderMessageContentInternal({ ); }, - [ts, clientUrlPreview, settingsLinkBaseUrl, urlPreview] + [ts, clientUrlPreview, settingsLinkBaseUrl, urlPreview, themeChatAny] ); - const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined; + const messageUrlsPreview = urlPreview || themeChatAny ? renderUrlsPreview : undefined; const renderCaption = () => { const hasCaption = content.body && content.body.trim().length > 0; diff --git a/src/app/components/theme/ThemeMigrationBanner.tsx b/src/app/components/theme/ThemeMigrationBanner.tsx new file mode 100644 index 000000000..793e84755 --- /dev/null +++ b/src/app/components/theme/ThemeMigrationBanner.tsx @@ -0,0 +1,163 @@ +import { useCallback, useMemo, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, +} from 'folds'; +import { useStore } from 'jotai/react'; + +import { useOptionalClientConfig } from '$hooks/useClientConfig'; +import { useSetting } from '$state/hooks/settings'; +import { trimTrailingSlash } from '$utils/common'; +import { defaultSettings, settingsAtom } from '$state/settings'; +import { stopPropagation } from '$utils/keyboard'; + +import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch'; +import { DEFAULT_THEME_CATALOG_BASE } from '../../theme/catalogDefaults'; +import { needsLegacyThemeMigration } from '../../theme/legacyToCatalogMap'; +import { runLegacyThemeMigration } from '../../theme/migrateLegacyThemes'; + +export function ThemeMigrationBanner() { + const store = useStore(); + const [themeMigrationDismissed] = useSetting(settingsAtom, 'themeMigrationDismissed'); + const [themeId] = useSetting(settingsAtom, 'themeId'); + const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); + const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); + const patchSettings = usePatchSettings(); + const clientConfig = useOptionalClientConfig(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const visible = useMemo( + () => + needsLegacyThemeMigration({ + ...defaultSettings, + themeMigrationDismissed: themeMigrationDismissed ?? false, + themeId, + lightThemeId, + darkThemeId, + }), + [themeMigrationDismissed, themeId, lightThemeId, darkThemeId] + ); + + const catalogBase = trimTrailingSlash( + clientConfig?.themeCatalogBaseUrl?.trim() || DEFAULT_THEME_CATALOG_BASE + ); + + const dismiss = useCallback(() => { + patchSettings({ themeMigrationDismissed: true }); + }, [patchSettings]); + + const dismissSafe = useCallback(() => { + if (busy) return; + dismiss(); + }, [busy, dismiss]); + + const migrate = useCallback(async () => { + setError(null); + setBusy(true); + try { + const current = store.get(settingsAtom); + const result = await runLegacyThemeMigration(current, catalogBase); + if (!result.ok) { + setError(result.error); + return; + } + patchSettings(result.partial); + } finally { + setBusy(false); + } + }, [catalogBase, patchSettings, store]); + + if (!visible) return null; + + return ( + }> + + + +
+ + + Update your theme selection + + + + + +
+ + + Older bundled color themes are no longer included in the app. Migrate to the same + looks from the official catalog (downloaded and cached on this device), or dismiss + this reminder. + + {error && ( + + {error} + + )} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/components/theme/ThemePreviewCard.tsx b/src/app/components/theme/ThemePreviewCard.tsx new file mode 100644 index 000000000..7002b9292 --- /dev/null +++ b/src/app/components/theme/ThemePreviewCard.tsx @@ -0,0 +1,283 @@ +import { type ReactNode, useCallback, useMemo } from 'react'; +import { + Box, + Button, + Icon, + IconButton, + Icons, + Text, + Tooltip, + TooltipProvider, + toRem, + config, +} from 'folds'; + +import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { copyToClipboard } from '$utils/dom'; +import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../../theme/previewCss'; + +export type ThemePreviewCardProps = { + title: string; + subtitle?: ReactNode; + beforePreview?: ReactNode; + previewCssText: string; + scopeSlug: string; + copyText?: string; + isFavorited?: boolean; + onToggleFavorite?: () => void | Promise; + + systemTheme: boolean; + onApplyLight?: () => void | Promise; + onApplyDark?: () => void | Promise; + onApplyManual?: () => void | Promise; + isAppliedLight?: boolean; + isAppliedDark?: boolean; + isAppliedManual?: boolean; + + onRevert?: () => void; + canRevert?: boolean; + thirdParty?: boolean; +}; + +function safeSlug(input: string): string { + return (input || 'theme').replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; +} + +export function ThemePreviewCard({ + title, + subtitle, + beforePreview, + previewCssText, + scopeSlug, + copyText, + isFavorited, + onToggleFavorite, + systemTheme, + onApplyLight, + onApplyDark, + onApplyManual, + isAppliedLight, + isAppliedDark, + isAppliedManual, + onRevert, + canRevert, + thirdParty, +}: ThemePreviewCardProps) { + const [copied, setCopied] = useTimeoutToggle(); + + const scopeClass = useMemo(() => `sable-theme-preview--${safeSlug(scopeSlug)}`, [scopeSlug]); + + const styleBlock = useMemo(() => { + const vars = extractSafePreviewCustomProperties(previewCssText); + return buildPreviewStyleBlock(vars, scopeClass); + }, [previewCssText, scopeClass]); + + const handleCopy = useCallback(async () => { + if (!copyText) return; + if (await copyToClipboard(copyText)) setCopied(); + }, [copyText, setCopied]); + + return ( + + + + + {title} + {thirdParty && ( + + Third-party theme. Only use themes you trust. + + } + > + {(triggerRef) => ( + + )} + + )} + + {subtitle && ( + + {subtitle} + + )} + + + + {copyText && ( + { + handleCopy().catch(() => undefined); + }} + > + + + )} + + {typeof isFavorited === 'boolean' && onToggleFavorite && ( + { + Promise.resolve(onToggleFavorite()).catch(() => undefined); + }} + > + + + )} + + + + {beforePreview} + + {styleBlock ? ( + <> + + + + Sample text + + + + Primary + + + Surface + + + + + ) : ( + + No preview tokens + + )} + + + {systemTheme ? ( + <> + {onApplyLight && ( + + )} + {onApplyDark && ( + + )} + + ) : ( + onApplyManual && ( + + ) + )} + + {onRevert && ( + + )} + + + ); +} diff --git a/src/app/components/url-preview/ThemePreviewUrlCard.tsx b/src/app/components/url-preview/ThemePreviewUrlCard.tsx new file mode 100644 index 000000000..3f707deca --- /dev/null +++ b/src/app/components/url-preview/ThemePreviewUrlCard.tsx @@ -0,0 +1,448 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Box, Text } from 'folds'; +import { useStore } from 'jotai/react'; + +import { useClientConfig } from '$hooks/useClientConfig'; +import { ThemeKind } from '$hooks/useTheme'; +import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom, type Settings, type ThemeRemoteFavorite } from '$state/settings'; +import { + extractFullThemeUrlFromPreview, + parseSableThemeMetadata, + type SableThemeContrast, +} from '../../theme/metadata'; +import { putCachedThemeCss } from '../../theme/cache'; +import { fullUrlFromPreviewUrl } from '../../theme/previewUrls'; +import { ThemePreviewCard } from '../theme/ThemePreviewCard'; +import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner'; + +function isHttps(url: string): boolean { + return /^https:\/\//i.test(url); +} + +function isPreviewThemeUrl(url: string): boolean { + return /\.preview\.sable\.css(\?|#|$)/i.test(url); +} + +function isApprovedByPrefix(url: string, prefixes: string[] | undefined): boolean { + if (!prefixes || prefixes.length === 0) return true; + return prefixes.some((p) => url.startsWith(p)); +} + +function basenameFromUrl(url: string): string { + const tail = url.split('/').pop() ?? url; + return tail.replace(/\.preview\.sable\.css(\?|#|$)/i, '').replace(/\.sable\.css(\?|#|$)/i, ''); +} + +function baseLabel(url: string): string { + try { + const u = new URL(url); + return u.hostname; + } catch { + return 'Unofficial theme'; + } +} + +export function ThemePreviewUrlCard({ url }: { url: string }) { + const clientConfig = useClientConfig(); + const store = useStore(); + const patchSettings = usePatchSettings(); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const [chatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl'); + const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites'); + const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); + const [manualRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl'); + const [lightRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl'); + const [darkRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl'); + + const allowed = useMemo(() => { + if (!chatAny) return false; + return isHttps(url) && isPreviewThemeUrl(url); + }, [chatAny, url]); + + const isOfficial = useMemo( + () => isApprovedByPrefix(url, clientConfig.themeCatalogApprovedHostPrefixes), + [clientConfig.themeCatalogApprovedHostPrefixes, url] + ); + + const showThirdPartyBanner = useMemo(() => { + const p = clientConfig.themeCatalogApprovedHostPrefixes; + return Boolean(p && p.length > 0 && !isApprovedByPrefix(url, p)); + }, [clientConfig.themeCatalogApprovedHostPrefixes, url]); + + const previewQuery = useQuery({ + queryKey: ['theme-preview-embed', url], + enabled: allowed, + staleTime: 10 * 60_000, + queryFn: async () => { + const res = await fetch(url, { mode: 'cors' }); + if (!res.ok) throw new Error(`theme preview fetch failed: ${res.status}`); + const previewText = await res.text(); + const meta = parseSableThemeMetadata(previewText); + const fullFromMeta = extractFullThemeUrlFromPreview(previewText); + const fullUrl = fullUrlFromPreviewUrl(url, fullFromMeta); + const kind = meta.kind ?? ThemeKind.Light; + const displayName = meta.name?.trim() || basenameFromUrl(url); + const author = meta.author?.trim() || undefined; + const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low'; + const tags = meta.tags ?? []; + return { previewText, fullUrl, kind, displayName, author, contrast, tags }; + }, + }); + + const revertRef = useRef | null>(null); + const lastAutoSavedUrlRef = useRef(null); + const [canRevert, setCanRevert] = useState(false); + const [favoriteTouched, setFavoriteTouched] = useState(false); + + const appliedHere = useMemo(() => { + const u = previewQuery.data?.fullUrl; + if (!u) return false; + if (systemTheme) { + return lightRemoteFullUrl === u || darkRemoteFullUrl === u; + } + return manualRemoteFullUrl === u; + }, [ + previewQuery.data?.fullUrl, + systemTheme, + lightRemoteFullUrl, + darkRemoteFullUrl, + manualRemoteFullUrl, + ]); + + const canRevertUi = useMemo(() => canRevert || appliedHere, [canRevert, appliedHere]); + + const isFav = useMemo( + () => favorites.some((f) => f.fullUrl === previewQuery.data?.fullUrl), + [favorites, previewQuery.data?.fullUrl] + ); + + const pruneFavorites = useCallback( + (nextFavorites: ThemeRemoteFavorite[], nextActive: string[]) => { + const active = new Set(nextActive); + return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl)); + }, + [] + ); + + const toggleFavorite = useCallback(async () => { + const fullUrl = previewQuery.data?.fullUrl; + if (!fullUrl) return; + + const existing = favorites.find((f) => f.fullUrl === fullUrl); + if (existing) { + setFavoriteTouched(false); + const cleared: Partial = {}; + if (lightRemoteFullUrl === fullUrl) { + cleared.themeRemoteLightFullUrl = undefined; + cleared.themeRemoteLightKind = undefined; + } + if (darkRemoteFullUrl === fullUrl) { + cleared.themeRemoteDarkFullUrl = undefined; + cleared.themeRemoteDarkKind = undefined; + } + if (manualRemoteFullUrl === fullUrl) { + cleared.themeRemoteManualFullUrl = undefined; + cleared.themeRemoteManualKind = undefined; + } + + const nextFavs = favorites.filter((f) => f.fullUrl !== fullUrl); + const nextActive = [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl] + .filter((u): u is string => Boolean(u && u.trim().length > 0)) + .filter((u) => u !== fullUrl); + patchSettings({ ...cleared, themeRemoteFavorites: pruneFavorites(nextFavs, nextActive) }); + return; + } + + setFavoriteTouched(true); + const res = await fetch(fullUrl, { mode: 'cors' }); + if (!mountedRef.current) return; + if (!res.ok) return; + const cssText = await res.text(); + if (!mountedRef.current) return; + await putCachedThemeCss(fullUrl, cssText); + if (!mountedRef.current) return; + + const next: ThemeRemoteFavorite = { + fullUrl, + displayName: previewQuery.data?.displayName ?? basenameFromUrl(fullUrl), + basename: basenameFromUrl(fullUrl), + kind: previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light', + pinned: true, + }; + + patchSettings({ themeRemoteFavorites: [...favorites, next] }); + }, [ + darkRemoteFullUrl, + favorites, + lightRemoteFullUrl, + manualRemoteFullUrl, + patchSettings, + previewQuery.data, + pruneFavorites, + ]); + + const applyManual = useCallback(() => { + const fullUrl = previewQuery.data?.fullUrl; + if (!fullUrl) return; + const kind = previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light'; + patchSettings({ themeRemoteManualFullUrl: fullUrl, themeRemoteManualKind: kind }); + }, [patchSettings, previewQuery.data]); + + const applyLightSlot = useCallback(() => { + const fullUrl = previewQuery.data?.fullUrl; + if (!fullUrl) return; + const kind = previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light'; + patchSettings({ themeRemoteLightFullUrl: fullUrl, themeRemoteLightKind: kind }); + }, [patchSettings, previewQuery.data]); + + const applyDarkSlot = useCallback(() => { + const fullUrl = previewQuery.data?.fullUrl; + if (!fullUrl) return; + const kind = previewQuery.data?.kind === ThemeKind.Dark ? 'dark' : 'light'; + patchSettings({ themeRemoteDarkFullUrl: fullUrl, themeRemoteDarkKind: kind }); + }, [patchSettings, previewQuery.data]); + + const ensureFavorited = useCallback(async (): Promise => { + const fullUrl = previewQuery.data?.fullUrl; + if (!fullUrl) return false; + if (favorites.some((f) => f.fullUrl === fullUrl)) return false; + + const res = await fetch(fullUrl, { mode: 'cors' }); + if (!mountedRef.current) return false; + if (!res.ok) return false; + const cssText = await res.text(); + if (!mountedRef.current) return false; + await putCachedThemeCss(fullUrl, cssText); + if (!mountedRef.current) return false; + + const next: ThemeRemoteFavorite = { + fullUrl, + displayName: previewQuery.data!.displayName, + basename: basenameFromUrl(fullUrl), + kind: previewQuery.data!.kind === ThemeKind.Dark ? 'dark' : 'light', + pinned: false, + }; + + patchSettings({ themeRemoteFavorites: [...favorites, next] }); + return true; + }, [favorites, patchSettings, previewQuery.data]); + + const snapshotAnd = useCallback( + async (fn: () => void, nextApplied: Partial) => { + const current = store.get(settingsAtom); + if ( + nextApplied.themeRemoteManualFullUrl && + current.themeRemoteManualFullUrl === nextApplied.themeRemoteManualFullUrl + ) { + return; + } + if ( + nextApplied.themeRemoteLightFullUrl && + current.themeRemoteLightFullUrl === nextApplied.themeRemoteLightFullUrl + ) { + return; + } + if ( + nextApplied.themeRemoteDarkFullUrl && + current.themeRemoteDarkFullUrl === nextApplied.themeRemoteDarkFullUrl + ) { + return; + } + revertRef.current = { + themeId: current.themeId, + themeRemoteManualFullUrl: current.themeRemoteManualFullUrl, + themeRemoteManualKind: current.themeRemoteManualKind, + themeRemoteLightFullUrl: current.themeRemoteLightFullUrl, + themeRemoteLightKind: current.themeRemoteLightKind, + themeRemoteDarkFullUrl: current.themeRemoteDarkFullUrl, + themeRemoteDarkKind: current.themeRemoteDarkKind, + } satisfies Partial; + setCanRevert(true); + + if (!favoriteTouched) { + const added = await ensureFavorited(); + if (!mountedRef.current) return; + lastAutoSavedUrlRef.current = added ? (previewQuery.data?.fullUrl ?? null) : null; + } else { + lastAutoSavedUrlRef.current = null; + } + + fn(); + if (!mountedRef.current) return; + + const nextActive = [ + nextApplied.themeRemoteManualFullUrl ?? manualRemoteFullUrl, + nextApplied.themeRemoteLightFullUrl ?? lightRemoteFullUrl, + nextApplied.themeRemoteDarkFullUrl ?? darkRemoteFullUrl, + ].filter((u): u is string => Boolean(u && u.trim().length > 0)); + + patchSettings({ + themeRemoteFavorites: pruneFavorites( + store.get(settingsAtom).themeRemoteFavorites, + nextActive + ), + }); + }, + [ + ensureFavorited, + favoriteTouched, + lightRemoteFullUrl, + darkRemoteFullUrl, + manualRemoteFullUrl, + patchSettings, + previewQuery.data?.fullUrl, + pruneFavorites, + store, + ] + ); + + const revert = useCallback(() => { + const syncFavoritesAfterRevert = (removeAutoSavedFullUrl: string | null) => { + const after = store.get(settingsAtom); + let favs = after.themeRemoteFavorites; + if (removeAutoSavedFullUrl) { + favs = favs.filter((f) => f.fullUrl !== removeAutoSavedFullUrl); + } + const nextActive = [ + after.themeRemoteManualFullUrl, + after.themeRemoteLightFullUrl, + after.themeRemoteDarkFullUrl, + ].filter((u): u is string => Boolean(u && u.trim().length > 0)); + patchSettings({ + themeRemoteFavorites: pruneFavorites(favs, nextActive), + }); + }; + + const snap = revertRef.current; + if (snap) { + patchSettings(snap); + syncFavoritesAfterRevert(lastAutoSavedUrlRef.current); + + revertRef.current = null; + lastAutoSavedUrlRef.current = null; + setCanRevert(false); + return; + } + + const u = previewQuery.data?.fullUrl; + if (!u) return; + const partial: Partial = {}; + if (lightRemoteFullUrl === u) { + partial.themeRemoteLightFullUrl = undefined; + partial.themeRemoteLightKind = undefined; + } + if (darkRemoteFullUrl === u) { + partial.themeRemoteDarkFullUrl = undefined; + partial.themeRemoteDarkKind = undefined; + } + if (manualRemoteFullUrl === u) { + partial.themeRemoteManualFullUrl = undefined; + partial.themeRemoteManualKind = undefined; + partial.themeId = previewQuery.data?.kind === ThemeKind.Dark ? 'dark-theme' : 'light-theme'; + } + if (Object.keys(partial).length === 0) return; + patchSettings(partial); + syncFavoritesAfterRevert(null); + }, [ + darkRemoteFullUrl, + lightRemoteFullUrl, + manualRemoteFullUrl, + patchSettings, + previewQuery.data?.fullUrl, + previewQuery.data?.kind, + pruneFavorites, + store, + ]); + + if (!allowed) return null; + + const title = previewQuery.data?.displayName ?? 'Theme preview'; + let kindLabel = 'Theme'; + if (previewQuery.data?.kind === ThemeKind.Dark) kindLabel = 'Dark'; + else if (previewQuery.data?.kind === ThemeKind.Light) kindLabel = 'Light'; + const contrastLabel = previewQuery.data?.contrast ? `${previewQuery.data.contrast} contrast` : ''; + const authorLabel = previewQuery.data?.author ? `by ${previewQuery.data.author}` : ''; + const tagsLabel = + previewQuery.data?.tags && previewQuery.data.tags.length > 0 + ? previewQuery.data.tags.join(', ') + : ''; + const sourceLabel = isOfficial ? 'Official theme' : baseLabel(url); + const subtitleLine1 = [kindLabel, contrastLabel].filter(Boolean).join(' · '); + const subtitleLine2 = [authorLabel, tagsLabel].filter(Boolean).join(' · '); + const subtitleLine3 = sourceLabel; + const subtitle = ( + <> + {subtitleLine1} + {subtitleLine2 ? ( + <> +
+ {subtitleLine2} + + ) : null} +
+ {subtitleLine3} + + ); + const fullUrl = previewQuery.data?.fullUrl; + + return ( + + : undefined + } + previewCssText={previewQuery.data?.previewText ?? ''} + scopeSlug={`chat-${basenameFromUrl(url)}`} + copyText={url} + isFavorited={fullUrl ? isFav : false} + onToggleFavorite={fullUrl ? () => toggleFavorite() : undefined} + systemTheme={systemTheme} + onApplyLight={ + systemTheme && fullUrl + ? () => snapshotAnd(() => applyLightSlot(), { themeRemoteLightFullUrl: fullUrl }) + : undefined + } + onApplyDark={ + systemTheme && fullUrl + ? () => snapshotAnd(() => applyDarkSlot(), { themeRemoteDarkFullUrl: fullUrl }) + : undefined + } + onApplyManual={ + !systemTheme && fullUrl + ? () => snapshotAnd(() => applyManual(), { themeRemoteManualFullUrl: fullUrl }) + : undefined + } + isAppliedLight={fullUrl ? lightRemoteFullUrl === fullUrl : false} + isAppliedDark={fullUrl ? darkRemoteFullUrl === fullUrl : false} + isAppliedManual={fullUrl ? manualRemoteFullUrl === fullUrl : false} + canRevert={canRevertUi} + onRevert={revert} + /> + {previewQuery.isPending && ( + + Loading preview… + + )} + + ); +} diff --git a/src/app/components/url-preview/ThemeThirdPartyBanner.tsx b/src/app/components/url-preview/ThemeThirdPartyBanner.tsx new file mode 100644 index 000000000..6fedcc548 --- /dev/null +++ b/src/app/components/url-preview/ThemeThirdPartyBanner.tsx @@ -0,0 +1,34 @@ +import { Box, Icon, Icons, Text, toRem, config } from 'folds'; + +type ThemeThirdPartyBannerProps = { + hostLabel: string; +}; + +export function ThemeThirdPartyBanner({ hostLabel }: ThemeThirdPartyBannerProps) { + return ( + + + + + + Third-party theme + + + This preview is hosted on {hostLabel}, outside the Sable catalog allowlist. Saving or + applying installs full theme CSS from that host—only use themes you trust. + + + + + ); +} diff --git a/src/app/components/url-preview/TweakPreviewUrlCard.tsx b/src/app/components/url-preview/TweakPreviewUrlCard.tsx new file mode 100644 index 000000000..9362214a2 --- /dev/null +++ b/src/app/components/url-preview/TweakPreviewUrlCard.tsx @@ -0,0 +1,385 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Icon, + IconButton, + Icons, + Spinner, + Switch, + Text, + Tooltip, + TooltipProvider, + config, + toRem, +} from 'folds'; + +import { useClientConfig } from '$hooks/useClientConfig'; +import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom, type ThemeRemoteTweakFavorite } from '$state/settings'; +import { copyToClipboard } from '$utils/dom'; +import { putCachedThemeCss } from '../../theme/cache'; +import { getSableCssPackageKind, parseSableTweakMetadata } from '../../theme/metadata'; +import { isHttpsFullSableCssUrl } from '../../theme/previewUrls'; +import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../../theme/previewCss'; +import { isThirdPartyThemeUrl } from '../../theme/themeApproval'; +import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner'; + +function baseLabel(url: string): string { + try { + return new URL(url).hostname; + } catch { + return 'Unofficial tweak'; + } +} + +function isApprovedByPrefix(url: string, prefixes: string[] | undefined): boolean { + if (!prefixes || prefixes.length === 0) return true; + return prefixes.some((p) => url.startsWith(p)); +} + +function basenameFromFullSableUrl(url: string): string { + const tail = url.split('/').pop() ?? url; + return tail.replace(/\.sable\.css(\?.*)?$/i, '') || 'tweak'; +} + +function pruneTweakFavorites( + nextFavorites: ThemeRemoteTweakFavorite[], + nextEnabledUrls: string[] +): ThemeRemoteTweakFavorite[] { + const enabled = new Set(nextEnabledUrls); + return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl)); +} + +function safeSlug(input: string): string { + return (input || 'tweak').replace(/[^a-zA-Z0-9_-]/g, '-') || 'tweak'; +} + +type TweakPreviewData = { + cssText: string; + displayName: string; + description?: string; + author?: string; + tags: string[]; + basename: string; +}; + +export function TweakPreviewUrlCard({ url }: { url: string }) { + const clientConfig = useClientConfig(); + const patchSettings = usePatchSettings(); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const [chatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl'); + const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites'); + const [enabledTweakFullUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls'); + + const [copied, setCopied] = useTimeoutToggle(); + + const allowed = useMemo(() => chatAny && isHttpsFullSableCssUrl(url), [chatAny, url]); + + const isOfficial = useMemo( + () => isApprovedByPrefix(url, clientConfig.themeCatalogApprovedHostPrefixes), + [clientConfig.themeCatalogApprovedHostPrefixes, url] + ); + + const showThirdPartyBanner = useMemo( + () => + Boolean( + clientConfig.themeCatalogApprovedHostPrefixes && + clientConfig.themeCatalogApprovedHostPrefixes.length > 0 && + !isApprovedByPrefix(url, clientConfig.themeCatalogApprovedHostPrefixes) + ), + [clientConfig.themeCatalogApprovedHostPrefixes, url] + ); + + const tweakPreviewQuery = useQuery({ + queryKey: ['tweak-preview-embed', url], + enabled: allowed, + staleTime: 10 * 60_000, + queryFn: async (): Promise => { + const res = await fetch(url, { mode: 'cors' }); + if (!res.ok) throw new Error(`tweak fetch failed: ${res.status}`); + const cssText = await res.text(); + if (getSableCssPackageKind(cssText) !== 'tweak') return null; + const meta = parseSableTweakMetadata(cssText); + const basename = meta.id?.trim() || basenameFromFullSableUrl(url); + return { + cssText, + displayName: meta.name?.trim() || basenameFromFullSableUrl(url), + description: meta.description?.trim() || undefined, + author: meta.author?.trim() || undefined, + tags: meta.tags ?? [], + basename, + }; + }, + }); + + const { data } = tweakPreviewQuery; + + const scopeClass = useMemo(() => `sable-tweak-preview--${safeSlug(url)}`, [url]); + + const styleBlock = useMemo(() => { + if (!data?.cssText) return ''; + const vars = extractSafePreviewCustomProperties(data.cssText); + return buildPreviewStyleBlock(vars, scopeClass); + }, [data?.cssText, scopeClass]); + + const handleCopy = useCallback(async () => { + if (await copyToClipboard(url)) setCopied(); + }, [setCopied, url]); + + const toggleFavorite = useCallback(async () => { + if (!data) return; + const existing = tweakFavorites.find((f) => f.fullUrl === url); + if (existing) { + const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== url); + const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url); + patchSettings({ + themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteEnabledTweakFullUrls: nextEnabled, + }); + return; + } + await putCachedThemeCss(url, data.cssText); + if (!mountedRef.current) return; + const next: ThemeRemoteTweakFavorite = { + fullUrl: url, + displayName: data.displayName, + basename: data.basename, + pinned: true, + }; + patchSettings({ + themeRemoteTweakFavorites: pruneTweakFavorites( + [...tweakFavorites, next], + enabledTweakFullUrls + ), + }); + }, [data, enabledTweakFullUrls, patchSettings, tweakFavorites, url]); + + const setTweakEnabled = useCallback( + async (apply: boolean) => { + if (!data) return; + if (apply) { + await putCachedThemeCss(url, data.cssText); + if (!mountedRef.current) return; + const nextEnabled = enabledTweakFullUrls.includes(url) + ? [...enabledTweakFullUrls] + : [...enabledTweakFullUrls, url]; + const nextFavs = [...tweakFavorites]; + if (!nextFavs.some((f) => f.fullUrl === url)) { + nextFavs.push({ + fullUrl: url, + displayName: data.displayName, + basename: data.basename, + pinned: false, + }); + } + patchSettings({ + themeRemoteEnabledTweakFullUrls: nextEnabled, + themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + }); + } else { + const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url); + patchSettings({ + themeRemoteEnabledTweakFullUrls: nextEnabled, + themeRemoteTweakFavorites: pruneTweakFavorites(tweakFavorites, nextEnabled), + }); + } + }, + [data, enabledTweakFullUrls, patchSettings, tweakFavorites, url] + ); + + if (!allowed) return null; + + if (tweakPreviewQuery.isPending) { + return ( + + + + Loading tweak… + + + ); + } + + if (tweakPreviewQuery.isError || data === null || data === undefined) { + return null; + } + + const isFav = tweakFavorites.some((f) => f.fullUrl === url); + const isOn = enabledTweakFullUrls.includes(url); + const thirdPartyIcon = isThirdPartyThemeUrl(url, clientConfig.themeCatalogApprovedHostPrefixes); + + const descParts = [ + data.description, + data.author ? `by ${data.author}` : '', + data.tags.length > 0 ? data.tags.join(', ') : '', + ].filter(Boolean); + const descLine = descParts.join(' · '); + const sourceLabel = isOfficial ? 'Official catalog' : baseLabel(url); + + return ( + + + + + {data.displayName} + {thirdPartyIcon && ( + + Third-party tweak. Only enable CSS you trust. + + } + > + {(triggerRef) => ( + + )} + + )} + + {descLine ? ( + + {descLine} + + ) : null} + + {sourceLabel} + + + + + { + handleCopy().catch(() => undefined); + }} + > + + + { + toggleFavorite().catch(() => undefined); + }} + > + + + { + setTweakEnabled(v).catch(() => undefined); + }} + /> + + + + {showThirdPartyBanner ? : undefined} + + {styleBlock ? ( + <> + + + + Sample text + + + + Primary + + + Surface + + + + + ) : ( + + No preview tokens in this tweak + + )} + + ); +} diff --git a/src/app/components/url-preview/index.ts b/src/app/components/url-preview/index.ts index ccf60be87..fce263b0c 100644 --- a/src/app/components/url-preview/index.ts +++ b/src/app/components/url-preview/index.ts @@ -1,3 +1,6 @@ export * from './UrlPreview'; export * from './UrlPreviewCard'; export * from './ClientPreview'; +export * from './ThemePreviewUrlCard'; +export * from './TweakPreviewUrlCard'; +export * from './ThemeThirdPartyBanner'; diff --git a/src/app/features/settings/cosmetics/Cosmetics.tsx b/src/app/features/settings/cosmetics/Cosmetics.tsx index 3d50d886e..fcbbbb926 100644 --- a/src/app/features/settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/settings/cosmetics/Cosmetics.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, useState } from 'react'; +import { MouseEventHandler, useEffect, useRef, useState } from 'react'; import { Box, Button, @@ -258,17 +258,45 @@ type CosmeticsProps = { }; export function Cosmetics({ requestBack, requestClose }: CosmeticsProps) { + const [themeBrowserOpen, setThemeBrowserOpen] = useState(false); + const appearanceScrollRef = useRef(null); + + useEffect(() => { + let timeoutId: number | undefined; + const el = appearanceScrollRef.current; + + if (themeBrowserOpen && el) { + const scrollToTop = () => { + el.scrollTop = 0; + }; + + scrollToTop(); + requestAnimationFrame(scrollToTop); + timeoutId = window.setTimeout(scrollToTop, 0); + } + + return () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + }; + }, [themeBrowserOpen]); + return ( - + - - - - - + + {!themeBrowserOpen && ( + <> + + + + + + )} diff --git a/src/app/features/settings/cosmetics/ThemeAppearanceSection.tsx b/src/app/features/settings/cosmetics/ThemeAppearanceSection.tsx new file mode 100644 index 000000000..d3a4e2f74 --- /dev/null +++ b/src/app/features/settings/cosmetics/ThemeAppearanceSection.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect } from 'react'; +import { Box, Button, Switch, Text } from 'folds'; + +import { SettingMenuSelector } from '$components/setting-menu-selector'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { DarkTheme, LightTheme, Theme, ThemeKind, useThemeNames, useThemes } from '$hooks/useTheme'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { useStore } from 'jotai/react'; + +import { useThemeCatalogOnboardingGate } from './ThemeCatalogOnboarding'; +import { ThemeCatalogSettings, usePatchSettings } from './ThemeCatalogSettings'; + +function makeThemeOptions(themes: Theme[], themeNames: Record) { + return themes.map((theme) => ({ + value: theme.id, + label: themeNames[theme.id] ?? theme.id, + })); +} + +function SelectTheme({ disabled }: Readonly<{ disabled?: boolean }>) { + const themes = useThemes(); + const themeNames = useThemeNames(); + const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId'); + + const themeOptions = makeThemeOptions(themes, themeNames); + const selectedThemeId = + themeOptions.find((theme) => theme.value === themeId)?.value ?? LightTheme.id; + + return ( + + ); +} + +function SystemThemePreferences() { + const themeNames = useThemeNames(); + const themes = useThemes(); + const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId'); + const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId'); + + const lightThemes = themes.filter((theme) => theme.kind === ThemeKind.Light); + const darkThemes = themes.filter((theme) => theme.kind === ThemeKind.Dark); + const lightThemeOptions = makeThemeOptions(lightThemes, themeNames); + const darkThemeOptions = makeThemeOptions(darkThemes, themeNames); + + const selectedLightThemeId = + lightThemeOptions.find((theme) => theme.value === lightThemeId)?.value ?? LightTheme.id; + const selectedDarkThemeId = + darkThemeOptions.find((theme) => theme.value === darkThemeId)?.value ?? DarkTheme.id; + + return ( + + + } + /> + + } + /> + + ); +} + +function ClassicThemeSection({ onBrowseCatalog }: { onBrowseCatalog: () => void }) { + const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme'); + + return ( + + + } + /> + {systemTheme && } + + + + } + /> + + + + + Browse catalog… + + } + /> + + + ); +} + +function RemoteCatalogThemeSection({ + onBrowseOpenChange, +}: { + onBrowseOpenChange?: (open: boolean) => void; +}) { + return ; +} + +export function ThemeAppearanceSection({ + onBrowseOpenChange, +}: { + onBrowseOpenChange?: (open: boolean) => void; +} = {}) { + const store = useStore(); + const [onboardingDone] = useSetting(settingsAtom, 'themeCatalogOnboardingDone'); + const [catalogEnabled] = useSetting(settingsAtom, 'themeRemoteCatalogEnabled'); + + useEffect(() => { + if (!catalogEnabled) onBrowseOpenChange?.(false); + }, [catalogEnabled, onBrowseOpenChange]); + + const completeOnboarding = useCallback( + (enabled: boolean) => { + const next = { ...store.get(settingsAtom), themeCatalogOnboardingDone: true }; + next.themeRemoteCatalogEnabled = enabled; + store.set(settingsAtom, next); + }, + [store] + ); + + const { dialog } = useThemeCatalogOnboardingGate(onboardingDone, completeOnboarding); + + const patchSettings = usePatchSettings(); + const enableCatalog = useCallback(() => { + patchSettings({ themeRemoteCatalogEnabled: true, themeCatalogOnboardingDone: true }); + }, [patchSettings]); + + return ( + + Theme + {dialog} + {catalogEnabled ? ( + + ) : ( + + )} + + ); +} diff --git a/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx b/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx new file mode 100644 index 000000000..954c87799 --- /dev/null +++ b/src/app/features/settings/cosmetics/ThemeCatalogOnboarding.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, +} from 'folds'; + +import { stopPropagation } from '$utils/keyboard'; + +type ThemeCatalogOnboardingProps = { + open: boolean; + onEnable: () => void; + onDecline: () => void; +}; + +export function ThemeCatalogOnboarding({ open, onEnable, onDecline }: ThemeCatalogOnboardingProps) { + return ( + }> + + + +
+ + Remote themes + + + + +
+ + + Load themes from the official Sable theme catalog on GitHub? You can browse + previews, save favorites locally, and sync them with light and dark mode. If you + choose not to, you can keep using the built-in Light and Dark themes only. + + + + + + +
+
+
+
+ ); +} + +export function useThemeCatalogOnboardingGate( + onboardingDone: boolean, + onComplete: (enabled: boolean) => void +) { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!onboardingDone) { + setOpen(true); + } + }, [onboardingDone]); + + const handleEnable = useCallback(() => { + setOpen(false); + onComplete(true); + }, [onComplete]); + + const handleDecline = useCallback(() => { + setOpen(false); + onComplete(false); + }, [onComplete]); + + return { + open, + dialog: ( + + ), + }; +} diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx new file mode 100644 index 000000000..16fc817fb --- /dev/null +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx @@ -0,0 +1,1497 @@ +import { type ChangeEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { copyToClipboard } from '$utils/dom'; +import { useQuery } from '@tanstack/react-query'; +import { + Box, + Button, + Chip, + Icon, + IconButton, + Icons, + Input, + Scroll, + Spinner, + Switch, + Text, + config, + toRem, +} from 'folds'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { ThemeKind } from '$hooks/useTheme'; +import { useSetting } from '$state/hooks/settings'; +import { + settingsAtom, + type Settings, + type ThemeRemoteFavorite, + type ThemeRemoteTweakFavorite, +} from '$state/settings'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { ThemePreviewCard } from '$components/theme/ThemePreviewCard'; +import { usePatchSettings } from './themeSettingsPatch'; +import { ThemeImportModal } from './ThemeImportModal'; +import { getCachedThemeCss, putCachedThemeCss } from '../../../theme/cache'; +import { + fetchThemeCatalogBundle, + type ThemePair, + type TweakCatalogEntry, +} from '../../../theme/catalog'; +import { isLocalImportBundledUrl, isLocalImportThemeUrl } from '../../../theme/localImportUrls'; +import { isThirdPartyThemeUrl } from '../../../theme/themeApproval'; +import { themeCatalogListingBaseUrl } from '../../../theme/catalogDefaults'; +import { + extractFullThemeUrlFromPreview, + parseSableThemeMetadata, + parseSableTweakMetadata, + type SableThemeContrast, +} from '../../../theme/metadata'; +import { previewUrlFromFullThemeUrl } from '../../../theme/previewUrls'; + +export type CatalogPreviewRow = ThemePair & { + previewText: string; + displayName: string; + author?: string; + kind: ThemeKind; + contrast: SableThemeContrast; + tags: string[]; + fullInstallUrl: string; +}; + +export type LocalPreviewRow = ThemeRemoteFavorite & { + previewUrl: string; + previewText: string; + displayName: string; + author?: string; + contrast: SableThemeContrast; + tags: string[]; + importedLocal?: boolean; +}; + +export type CatalogTweakRow = TweakCatalogEntry & { + fullCssText: string; + displayName: string; + description?: string; + author?: string; + tags: string[]; +}; + +export type LocalTweakRow = ThemeRemoteTweakFavorite & { + fullCssText: string; + description?: string; + author?: string; + tags: string[]; +}; + +export type ThemeCatalogSettingsMode = 'full' | 'local' | 'chat' | 'remote' | 'appearance'; + +export { usePatchSettings } from './themeSettingsPatch'; + +type ThemeCatalogSettingsProps = { + mode?: ThemeCatalogSettingsMode; + onBrowseOpenChange?: (open: boolean) => void; +}; + +type CatalogTweakCardProps = { + displayName: string; + description: string; + copyUrl?: string; + thirdPartyChip: boolean; + isFavorited: boolean; + onToggleFavorite: () => void | Promise; + isOn: boolean; + onSetApplied: (v: boolean) => void; +}; + +function CatalogTweakCard({ + displayName, + description, + copyUrl, + thirdPartyChip, + isFavorited, + onToggleFavorite, + isOn, + onSetApplied, +}: CatalogTweakCardProps) { + const [copied, setCopied] = useTimeoutToggle(); + const handleCopy = useCallback(async () => { + if (!copyUrl) return; + if (await copyToClipboard(copyUrl)) setCopied(); + }, [copyUrl, setCopied]); + + return ( + + + + {displayName} + + {description} + + + + {thirdPartyChip && ( + + Third-party URL + + )} + {copyUrl && ( + { + handleCopy().catch(() => undefined); + }} + > + + + )} + { + Promise.resolve(onToggleFavorite()).catch(() => undefined); + }} + > + + + + + + + ); +} + +type SavedTweakRowProps = { + displayName: string; + description: string; + copyUrl?: string; + thirdPartyChip: boolean; + isOn: boolean; + onRemove: () => void; + onSetApplied: (v: boolean) => void; +}; + +function SavedTweakRow({ + displayName, + description, + copyUrl, + thirdPartyChip, + isOn, + onRemove, + onSetApplied, +}: SavedTweakRowProps) { + const [copied, setCopied] = useTimeoutToggle(); + const handleCopy = useCallback(async () => { + if (!copyUrl) return; + if (await copyToClipboard(copyUrl)) setCopied(); + }, [copyUrl, setCopied]); + + return ( + + + + {displayName} + + {description} + + + + {thirdPartyChip && ( + + Third-party URL + + )} + {copyUrl && ( + { + handleCopy().catch(() => undefined); + }} + > + + + )} + + + + + + ); +} + +export function ThemeCatalogSettings({ + mode = 'full', + onBrowseOpenChange, +}: ThemeCatalogSettingsProps) { + const clientConfig = useClientConfig(); + const patchSettings = usePatchSettings(); + const configBase = clientConfig.themeCatalogBaseUrl?.trim(); + const catalogBase = themeCatalogListingBaseUrl(configBase); + const catalogManifestUrl = clientConfig.themeCatalogManifestUrl?.trim() || undefined; + + const isAppearanceMode = mode === 'appearance'; + const [browseOpen, setBrowseOpen] = useState(false); + const [importModalOpen, setImportModalOpen] = useState(false); + + useEffect(() => { + if (isAppearanceMode) { + onBrowseOpenChange?.(browseOpen); + } + }, [browseOpen, isAppearanceMode, onBrowseOpenChange]); + + const isRemoteMode = mode === 'remote' || mode === 'full' || (isAppearanceMode && browseOpen); + const isChatMode = mode === 'chat' || mode === 'full' || (isAppearanceMode && !browseOpen); + const showAssignmentChrome = + mode === 'full' || mode === 'local' || (isAppearanceMode && !browseOpen); + const showSavedLibrary = + (mode === 'full' || mode === 'local' || isAppearanceMode) && !(isAppearanceMode && browseOpen); + + const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites'); + const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites'); + const [enabledTweakFullUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls'); + const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme'); + const [manualRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl'); + const [lightRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl'); + const [darkRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl'); + const [chatAny, setChatAny] = useSetting(settingsAtom, 'themeChatPreviewAnyUrl'); + + const [themeSearch, setThemeSearch] = useState(''); + const [tweakSearch, setTweakSearch] = useState(''); + const [kindFilter, setKindFilter] = useState<'all' | 'light' | 'dark'>('all'); + const [contrastFilter, setContrastFilter] = useState<'all' | SableThemeContrast>('all'); + const [tweakApplyFilter, setTweakApplyFilter] = useState<'all' | 'enabled' | 'disabled'>('all'); + + const onThemeSearchChange: ChangeEventHandler = (e) => + setThemeSearch(e.target.value); + const onTweakSearchChange: ChangeEventHandler = (e) => + setTweakSearch(e.target.value); + + const activeUrls = useMemo( + () => + [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl].filter((u): u is string => + Boolean(u && u.trim().length > 0) + ), + [darkRemoteFullUrl, lightRemoteFullUrl, manualRemoteFullUrl] + ); + + const pruneFavorites = useCallback( + (nextFavorites: ThemeRemoteFavorite[], nextActiveUrls: string[]) => { + const active = new Set(nextActiveUrls); + return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl)); + }, + [] + ); + + const pruneTweakFavorites = useCallback( + (nextFavorites: ThemeRemoteTweakFavorite[], nextEnabledUrls: string[]) => { + const enabled = new Set(nextEnabledUrls); + return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl)); + }, + [] + ); + + const clearAssignmentsIfMatch = useCallback( + (fullUrl: string) => { + const partial: Partial = {}; + if (lightRemoteFullUrl === fullUrl) { + partial.themeRemoteLightFullUrl = undefined; + partial.themeRemoteLightKind = undefined; + } + if (darkRemoteFullUrl === fullUrl) { + partial.themeRemoteDarkFullUrl = undefined; + partial.themeRemoteDarkKind = undefined; + } + if (manualRemoteFullUrl === fullUrl) { + partial.themeRemoteManualFullUrl = undefined; + partial.themeRemoteManualKind = undefined; + } + return partial; + }, + [darkRemoteFullUrl, lightRemoteFullUrl, manualRemoteFullUrl] + ); + + const catalogQuery = useQuery({ + queryKey: ['theme-catalog-bundle', catalogBase, catalogManifestUrl ?? ''], + queryFn: () => fetchThemeCatalogBundle(catalogBase, { manifestUrl: catalogManifestUrl }), + enabled: isRemoteMode, + staleTime: 5 * 60_000, + }); + + const previewsQuery = useQuery({ + queryKey: [ + 'theme-catalog-previews', + catalogBase, + catalogQuery.data?.themes?.map((p) => p.previewUrl).join('|') ?? '', + ], + queryFn: async (): Promise => { + const pairs = catalogQuery.data?.themes ?? []; + const rows = await Promise.all( + pairs.map(async (pair) => { + const res = await fetch(pair.previewUrl, { mode: 'cors' }); + const previewText = res.ok ? await res.text() : ''; + const meta = parseSableThemeMetadata(previewText); + const fullFromMeta = extractFullThemeUrlFromPreview(previewText); + const fullInstallUrl = + fullFromMeta && /^https:\/\//i.test(fullFromMeta) ? fullFromMeta : pair.fullUrl; + const kind = meta.kind ?? ThemeKind.Light; + const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low'; + return { + ...pair, + previewText, + displayName: meta.name?.trim() || pair.basename, + author: meta.author?.trim() || undefined, + kind, + contrast, + tags: meta.tags ?? [], + fullInstallUrl, + }; + }) + ); + return rows; + }, + enabled: isRemoteMode && Boolean(catalogQuery.data?.themes?.length), + staleTime: 10 * 60_000, + }); + + const tweakDetailsQuery = useQuery({ + queryKey: [ + 'theme-catalog-tweak-details', + catalogBase, + catalogQuery.data?.tweaks?.map((t) => t.fullUrl).join('|') ?? '', + ], + queryFn: async (): Promise => { + const tweaks = catalogQuery.data?.tweaks ?? []; + const rows = await Promise.all( + tweaks.map(async (entry) => { + try { + let text: string; + if (isLocalImportBundledUrl(entry.fullUrl)) { + text = (await getCachedThemeCss(entry.fullUrl)) ?? ''; + } else { + const res = await fetch(entry.fullUrl, { mode: 'cors' }); + text = res.ok ? await res.text() : ''; + } + const meta = parseSableTweakMetadata(text); + return { + ...entry, + fullCssText: text, + displayName: meta.name?.trim() || entry.basename, + description: meta.description?.trim() || undefined, + author: meta.author?.trim() || undefined, + tags: meta.tags ?? [], + }; + } catch { + return { + ...entry, + fullCssText: '', + displayName: entry.basename, + tags: [], + }; + } + }) + ); + return rows; + }, + enabled: isRemoteMode && Boolean(catalogQuery.data?.tweaks?.length), + staleTime: 10 * 60_000, + }); + + const filteredTweakRows = useMemo(() => { + const rows = tweakDetailsQuery.data ?? []; + const q = tweakSearch.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((row) => { + const hay = + `${row.displayName} ${row.basename} ${row.description ?? ''} ${row.tags.join(' ')}`.toLowerCase(); + return hay.includes(q); + }); + }, [tweakDetailsQuery.data, tweakSearch]); + + const catalogTweaksAfterApplyFilter = useMemo(() => { + if (tweakApplyFilter === 'all') return filteredTweakRows; + if (tweakApplyFilter === 'enabled') { + return filteredTweakRows.filter((r) => enabledTweakFullUrls.includes(r.fullUrl)); + } + return filteredTweakRows.filter((r) => !enabledTweakFullUrls.includes(r.fullUrl)); + }, [filteredTweakRows, enabledTweakFullUrls, tweakApplyFilter]); + + const filteredRows = useMemo(() => { + const rows = previewsQuery.data ?? []; + const q = themeSearch.trim().toLowerCase(); + return rows.filter((row) => { + if (kindFilter !== 'all') { + const want = kindFilter === 'dark' ? ThemeKind.Dark : ThemeKind.Light; + if (row.kind !== want) return false; + } + if (contrastFilter !== 'all' && row.contrast !== contrastFilter) return false; + if (q) { + const hay = `${row.displayName} ${row.basename} ${row.tags.join(' ')}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + }, [previewsQuery.data, themeSearch, kindFilter, contrastFilter]); + + const localPreviewsQuery = useQuery({ + queryKey: ['theme-local-previews', favorites.map((f) => f.fullUrl).join('|')], + enabled: showSavedLibrary && favorites.length > 0, + staleTime: 10 * 60_000, + queryFn: async (): Promise => { + const rows = await Promise.all( + favorites.map(async (fav) => { + const previewUrl = previewUrlFromFullThemeUrl(fav.fullUrl); + if (!previewUrl) return undefined; + + try { + let previewText: string; + if (isLocalImportThemeUrl(previewUrl)) { + previewText = (await getCachedThemeCss(previewUrl)) ?? ''; + } else { + const res = await fetch(previewUrl, { mode: 'cors' }); + if (!res.ok) return undefined; + previewText = await res.text(); + } + const meta = parseSableThemeMetadata(previewText); + const displayName = meta.name?.trim() || fav.displayName || fav.basename; + const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low'; + const authorTrim = meta.author?.trim(); + const row: LocalPreviewRow = { + ...fav, + previewUrl, + previewText, + displayName, + contrast, + tags: meta.tags ?? [], + importedLocal: fav.importedLocal, + ...(authorTrim ? { author: authorTrim } : {}), + }; + return row; + } catch { + return undefined; + } + }) + ); + + return rows.filter((r): r is LocalPreviewRow => Boolean(r)); + }, + }); + + const localTweaksQuery = useQuery({ + queryKey: ['theme-local-tweaks', tweakFavorites.map((f) => f.fullUrl).join('|')], + enabled: showSavedLibrary && tweakFavorites.length > 0, + staleTime: 10 * 60_000, + queryFn: async (): Promise => { + const rows = await Promise.all( + tweakFavorites.map(async (fav) => { + try { + let text: string; + if (isLocalImportBundledUrl(fav.fullUrl)) { + text = (await getCachedThemeCss(fav.fullUrl)) ?? ''; + } else { + const res = await fetch(fav.fullUrl, { mode: 'cors' }); + if (!res.ok) return undefined; + text = await res.text(); + } + const meta = parseSableTweakMetadata(text); + const authorTrim = meta.author?.trim(); + const row: LocalTweakRow = { + ...fav, + fullCssText: text, + displayName: meta.name?.trim() || fav.displayName || fav.basename, + description: meta.description?.trim() || undefined, + tags: meta.tags ?? [], + importedLocal: fav.importedLocal, + ...(authorTrim ? { author: authorTrim } : {}), + }; + return row; + } catch { + return undefined; + } + }) + ); + return rows.filter((r): r is LocalTweakRow => Boolean(r)); + }, + }); + + const removeFavorite = useCallback( + (fullUrl: string) => { + const nextFavorites = favorites.filter((f) => f.fullUrl !== fullUrl); + const cleared = clearAssignmentsIfMatch(fullUrl); + const nextActive = [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl] + .filter((u): u is string => Boolean(u && u.trim().length > 0)) + .filter((u) => u !== fullUrl); + patchSettings({ + ...cleared, + themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + }); + }, + [ + clearAssignmentsIfMatch, + darkRemoteFullUrl, + favorites, + lightRemoteFullUrl, + manualRemoteFullUrl, + patchSettings, + pruneFavorites, + ] + ); + + const applyFavoriteToLight = useCallback( + (row: LocalPreviewRow) => { + patchSettings({ + themeRemoteLightFullUrl: row.fullUrl, + themeRemoteLightKind: row.kind, + }); + }, + [patchSettings] + ); + + const applyFavoriteToDark = useCallback( + (row: LocalPreviewRow) => { + patchSettings({ + themeRemoteDarkFullUrl: row.fullUrl, + themeRemoteDarkKind: row.kind, + }); + }, + [patchSettings] + ); + + const applyFavoriteToManual = useCallback( + (row: LocalPreviewRow) => { + patchSettings({ + themeRemoteManualFullUrl: row.fullUrl, + themeRemoteManualKind: row.kind, + }); + }, + [patchSettings] + ); + + const useBuiltinForLightSlot = useCallback( + () => + patchSettings({ + themeRemoteLightFullUrl: undefined, + themeRemoteLightKind: undefined, + }), + [patchSettings] + ); + + const useBuiltinForDarkSlot = useCallback( + () => + patchSettings({ + themeRemoteDarkFullUrl: undefined, + themeRemoteDarkKind: undefined, + }), + [patchSettings] + ); + + const useBuiltinForManualLight = useCallback( + () => + patchSettings({ + themeRemoteManualFullUrl: undefined, + themeRemoteManualKind: undefined, + themeId: 'light-theme', + }), + [patchSettings] + ); + + const useBuiltinForManualDark = useCallback( + () => + patchSettings({ + themeRemoteManualFullUrl: undefined, + themeRemoteManualKind: undefined, + themeId: 'dark-theme', + }), + [patchSettings] + ); + + const prefetchFull = useCallback(async (url: string): Promise => { + try { + if (isLocalImportBundledUrl(url)) { + const cached = await getCachedThemeCss(url); + return Boolean(cached); + } + const res = await fetch(url, { mode: 'cors' }); + if (!res.ok) return false; + const text = await res.text(); + await putCachedThemeCss(url, text); + return true; + } catch { + return false; + } + }, []); + + const toggleFavorite = useCallback( + async (row: CatalogPreviewRow) => { + const existing = favorites.find((f: ThemeRemoteFavorite) => f.fullUrl === row.fullInstallUrl); + if (existing) { + const nextFavorites = favorites.filter((f) => f.fullUrl !== row.fullInstallUrl); + const cleared = clearAssignmentsIfMatch(row.fullInstallUrl); + const nextActive = activeUrls.filter((u) => u !== row.fullInstallUrl); + patchSettings({ + ...cleared, + themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + }); + return; + } + const ok = await prefetchFull(row.fullInstallUrl); + if (!ok) return; + const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light'; + const next: ThemeRemoteFavorite = { + fullUrl: row.fullInstallUrl, + displayName: row.displayName, + basename: row.basename, + kind, + pinned: true, + }; + patchSettings({ + themeRemoteFavorites: [...favorites, next], + }); + }, + [activeUrls, clearAssignmentsIfMatch, favorites, patchSettings, prefetchFull, pruneFavorites] + ); + + const installFromCatalogLight = useCallback( + async (row: CatalogPreviewRow) => { + const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light'; + const nextActive = Array.from( + new Set( + [manualRemoteFullUrl, darkRemoteFullUrl, row.fullInstallUrl].filter(Boolean) as string[] + ) + ); + + let nextFavorites = favorites; + const existing = favorites.find((f) => f.fullUrl === row.fullInstallUrl); + if (!existing) { + const ok = await prefetchFull(row.fullInstallUrl); + if (!ok) return; + nextFavorites = [ + ...favorites, + { + fullUrl: row.fullInstallUrl, + displayName: row.displayName, + basename: row.basename, + kind, + pinned: false, + }, + ]; + } + + patchSettings({ + themeRemoteLightFullUrl: row.fullInstallUrl, + themeRemoteLightKind: kind, + themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + }); + }, + [darkRemoteFullUrl, favorites, manualRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites] + ); + + const installFromCatalogDark = useCallback( + async (row: CatalogPreviewRow) => { + const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light'; + const nextActive = Array.from( + new Set( + [manualRemoteFullUrl, lightRemoteFullUrl, row.fullInstallUrl].filter(Boolean) as string[] + ) + ); + + let nextFavorites = favorites; + const existing = favorites.find((f) => f.fullUrl === row.fullInstallUrl); + if (!existing) { + const ok = await prefetchFull(row.fullInstallUrl); + if (!ok) return; + nextFavorites = [ + ...favorites, + { + fullUrl: row.fullInstallUrl, + displayName: row.displayName, + basename: row.basename, + kind, + pinned: false, + }, + ]; + } + + patchSettings({ + themeRemoteDarkFullUrl: row.fullInstallUrl, + themeRemoteDarkKind: kind, + themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + }); + }, + [ + favorites, + lightRemoteFullUrl, + manualRemoteFullUrl, + patchSettings, + prefetchFull, + pruneFavorites, + ] + ); + + const installFromCatalogManual = useCallback( + async (row: CatalogPreviewRow) => { + const kind: 'light' | 'dark' = row.kind === ThemeKind.Dark ? 'dark' : 'light'; + const nextActive = Array.from( + new Set( + [lightRemoteFullUrl, darkRemoteFullUrl, row.fullInstallUrl].filter(Boolean) as string[] + ) + ); + + let nextFavorites = favorites; + const existing = favorites.find((f) => f.fullUrl === row.fullInstallUrl); + if (!existing) { + const ok = await prefetchFull(row.fullInstallUrl); + if (!ok) return; + nextFavorites = [ + ...favorites, + { + fullUrl: row.fullInstallUrl, + displayName: row.displayName, + basename: row.basename, + kind, + pinned: false, + }, + ]; + } + + patchSettings({ + themeRemoteManualFullUrl: row.fullInstallUrl, + themeRemoteManualKind: kind, + themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + }); + }, + [darkRemoteFullUrl, favorites, lightRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites] + ); + + const clearRemote = useCallback(() => { + patchSettings({ + themeRemoteManualFullUrl: undefined, + themeRemoteManualKind: undefined, + themeRemoteLightFullUrl: undefined, + themeRemoteLightKind: undefined, + themeRemoteDarkFullUrl: undefined, + themeRemoteDarkKind: undefined, + }); + }, [patchSettings]); + + const setTweakApplied = useCallback( + async (fullUrl: string, apply: boolean, hint?: { displayName?: string; basename?: string }) => { + const trimmed = fullUrl.trim(); + if (!trimmed) return; + + if (apply) { + const ok = await prefetchFull(trimmed); + if (!ok) return; + const nextEnabled = enabledTweakFullUrls.includes(trimmed) + ? [...enabledTweakFullUrls] + : [...enabledTweakFullUrls, trimmed]; + const nextFavs = [...tweakFavorites]; + if (!nextFavs.some((f) => f.fullUrl === trimmed)) { + const cached = (await getCachedThemeCss(trimmed)) ?? ''; + const meta = parseSableTweakMetadata(cached); + const base = + trimmed + .replace(/\.sable\.css(\?.*)?$/i, '') + .split('/') + .pop() ?? 'tweak'; + nextFavs.push({ + fullUrl: trimmed, + displayName: hint?.displayName ?? meta.name?.trim() ?? base, + basename: hint?.basename ?? meta.id?.trim() ?? base, + pinned: false, + }); + } + patchSettings({ + themeRemoteEnabledTweakFullUrls: nextEnabled, + themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + }); + } else { + const nextEnabled = enabledTweakFullUrls.filter((u) => u !== trimmed); + patchSettings({ + themeRemoteEnabledTweakFullUrls: nextEnabled, + themeRemoteTweakFavorites: pruneTweakFavorites(tweakFavorites, nextEnabled), + }); + } + }, + [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites] + ); + + const toggleCatalogTweakFavorite = useCallback( + async (row: CatalogTweakRow) => { + const existing = tweakFavorites.find((f) => f.fullUrl === row.fullUrl); + if (existing) { + const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== row.fullUrl); + const nextEnabled = enabledTweakFullUrls.filter((u) => u !== row.fullUrl); + patchSettings({ + themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteEnabledTweakFullUrls: nextEnabled, + }); + return; + } + const ok = await prefetchFull(row.fullUrl); + if (!ok) return; + const next: ThemeRemoteTweakFavorite = { + fullUrl: row.fullUrl, + displayName: row.displayName, + basename: row.basename, + pinned: true, + }; + patchSettings({ + themeRemoteTweakFavorites: pruneTweakFavorites( + [...tweakFavorites, next], + enabledTweakFullUrls + ), + }); + }, + [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites] + ); + + const removeTweakFavorite = useCallback( + (fullUrl: string) => { + const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== fullUrl); + const nextEnabled = enabledTweakFullUrls.filter((u) => u !== fullUrl); + patchSettings({ + themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteEnabledTweakFullUrls: nextEnabled, + }); + }, + [enabledTweakFullUrls, patchSettings, pruneTweakFavorites, tweakFavorites] + ); + + const catalogBundle = catalogQuery.data; + const catalogThemeCount = catalogBundle?.themes.length ?? 0; + const catalogTweakCount = catalogBundle?.tweaks.length ?? 0; + const catalogHasEntries = catalogThemeCount + catalogTweakCount > 0; + + return ( + + {showAssignmentChrome && ( + <> + + } + /> + + {systemTheme ? ( + + + + + ) : ( + + + + + )} + + + + + Clear + + } + /> + + + )} + + {showSavedLibrary && ( + <> + + Saved themes + {localPreviewsQuery.isPending && favorites.length > 0 && ( + + + Loading local previews… + + )} + + {favorites.length === 0 && ( + + No saved themes yet. Star themes in the catalog to download them locally. + + )} + + {localPreviewsQuery.isSuccess && favorites.length > 0 && ( + <> + {localPreviewsQuery.data.length === 0 ? ( + + Could not load local previews. If this happens, the theme preview file may be + missing or not paired as `*.preview.sable.css`. + + ) : ( + + {localPreviewsQuery.data.map((row) => { + const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; + const kindLabel = row.kind === 'dark' ? 'Dark' : 'Light'; + const line1 = `${kindLabel} · ${row.contrast} contrast`; + const line2 = `${row.author ? `by ${row.author}` : ''}${ + row.tags.length > 0 + ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}` + : '' + }`.trim(); + const subtitle = ( + <> + {line1} + {line2 ? ( + <> +
+ {line2} + + ) : null} + + ); + return ( + removeFavorite(row.fullUrl)} + systemTheme={systemTheme} + onApplyLight={systemTheme ? () => applyFavoriteToLight(row) : undefined} + onApplyDark={systemTheme ? () => applyFavoriteToDark(row) : undefined} + onApplyManual={ + !systemTheme ? () => applyFavoriteToManual(row) : undefined + } + isAppliedLight={lightRemoteFullUrl === row.fullUrl} + isAppliedDark={darkRemoteFullUrl === row.fullUrl} + isAppliedManual={manualRemoteFullUrl === row.fullUrl} + /> + ); + })} +
+ )} + + )} +
+ + + Saved tweaks + {localTweaksQuery.isPending && tweakFavorites.length > 0 && ( + + + Loading tweaks… + + )} + {tweakFavorites.length === 0 && ( + + No saved tweaks. Star tweaks in the catalog to keep them here, or enable a tweak to + cache it automatically. + + )} + {localTweaksQuery.isSuccess && tweakFavorites.length > 0 && ( + + {localTweaksQuery.data.length === 0 ? ( + + Could not load tweak CSS. Check the URL or your connection. + + ) : ( + localTweaksQuery.data.map((row) => { + const isOn = enabledTweakFullUrls.includes(row.fullUrl); + const descParts = [ + row.description, + row.author ? `by ${row.author}` : '', + row.tags.length > 0 ? row.tags.join(', ') : '', + ].filter(Boolean); + const desc = + descParts.join(' · ') || + 'Applies on top of your current theme after it loads.'; + return ( + removeTweakFavorite(row.fullUrl)} + onSetApplied={(v) => + setTweakApplied(row.fullUrl, v, { + displayName: row.displayName, + basename: row.basename, + }) + } + /> + ); + }) + )} + + )} + + + )} + + {isAppearanceMode && !browseOpen && ( + <> + + setBrowseOpen(true)} + > + Browse catalog… + + } + /> + + + + setImportModalOpen(true)} + > + Import… + + } + /> + + + setImportModalOpen(false)} /> + + )} + + {isRemoteMode && ( + <> + {!isAppearanceMode && Browse catalog} + + {(catalogQuery.isPending || + catalogQuery.isError || + (catalogQuery.isSuccess && catalogHasEntries)) && ( + + {isAppearanceMode && browseOpen && ( + setBrowseOpen(false)} + > + Back + + } + /> + )} + + {(catalogQuery.isPending || catalogQuery.isError) && ( + + {catalogQuery.isPending && ( + + + Loading catalog… + + )} + {catalogQuery.isError && ( + + {catalogQuery.error?.message ?? 'Failed to load catalog'} + + )} + + )} + + {catalogQuery.isSuccess && catalogHasEntries && ( + + {catalogThemeCount > 0 && previewsQuery.isPending && ( + + + Loading previews… + + )} + + {catalogTweakCount > 0 && tweakDetailsQuery.isPending && ( + + + Loading tweaks… + + )} + + {catalogThemeCount > 0 && previewsQuery.isSuccess && ( + + Themes + + + Kind: + {(['all', 'light', 'dark'] as const).map((k) => ( + setKindFilter(k)} + > + {k === 'all' ? 'All' : k} + + ))} + Contrast: + {(['all', 'low', 'high'] as const).map((c) => ( + setContrastFilter(c)} + > + {c === 'all' ? 'All' : c} + + ))} + + + + + {filteredRows.map((row) => { + const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; + const kindLabel = row.kind === ThemeKind.Dark ? 'Dark' : 'Light'; + const isFav = favorites.some((f) => f.fullUrl === row.fullInstallUrl); + const line1 = `${kindLabel} · ${row.contrast} contrast`; + const line2 = `${row.author ? `by ${row.author}` : ''}${ + row.tags.length > 0 + ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}` + : '' + }`.trim(); + const subtitle = ( + <> + {line1} + {line2 ? ( + <> +
+ {line2} + + ) : null} + + ); + return ( + toggleFavorite(row)} + systemTheme={systemTheme} + onApplyLight={ + systemTheme ? () => installFromCatalogLight(row) : undefined + } + onApplyDark={ + systemTheme ? () => installFromCatalogDark(row) : undefined + } + onApplyManual={ + !systemTheme ? () => installFromCatalogManual(row) : undefined + } + isAppliedLight={lightRemoteFullUrl === row.fullInstallUrl} + isAppliedDark={darkRemoteFullUrl === row.fullInstallUrl} + isAppliedManual={manualRemoteFullUrl === row.fullInstallUrl} + /> + ); + })} +
+ + {filteredRows.length === 0 && ( + + No themes match filters. + + )} +
+
+
+ )} + + {catalogTweakCount > 0 && tweakDetailsQuery.isSuccess && ( + + Tweaks + + + Status: + {(['all', 'enabled', 'disabled'] as const).map((f) => ( + setTweakApplyFilter(f)} + > + + { + { + all: 'All', + enabled: 'Enabled', + disabled: 'Disabled', + }[f] + } + + + ))} + + + + {catalogTweaksAfterApplyFilter.map((row) => { + const isFav = tweakFavorites.some((f) => f.fullUrl === row.fullUrl); + const isOn = enabledTweakFullUrls.includes(row.fullUrl); + const descParts = [ + row.description, + row.author ? `by ${row.author}` : '', + row.tags.length > 0 ? row.tags.join(', ') : '', + ].filter(Boolean); + const desc = + descParts.join(' · ') || + 'Applies on top of your current theme after it loads.'; + return ( + toggleCatalogTweakFavorite(row)} + isOn={isOn} + onSetApplied={(v) => + setTweakApplied(row.fullUrl, v, { + displayName: row.displayName, + basename: row.basename, + }) + } + /> + ); + })} + {filteredTweakRows.length === 0 && ( + + No tweaks match your search. + + )} + {filteredTweakRows.length > 0 && + catalogTweaksAfterApplyFilter.length === 0 && ( + + No tweaks match this status filter. + + )} + + + + )} +
+ )} +
+ )} + + )} + + {isChatMode && ( + + } + /> + + )} +
+ ); +} diff --git a/src/app/features/settings/cosmetics/ThemeImportModal.tsx b/src/app/features/settings/cosmetics/ThemeImportModal.tsx new file mode 100644 index 000000000..d6abbb1c3 --- /dev/null +++ b/src/app/features/settings/cosmetics/ThemeImportModal.tsx @@ -0,0 +1,364 @@ +import { type ChangeEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, + toRem, +} from 'folds'; + +import { useSetting } from '$state/hooks/settings'; +import { + settingsAtom, + type ThemeRemoteFavorite, + type ThemeRemoteTweakFavorite, +} from '$state/settings'; +import { stopPropagation } from '$utils/keyboard'; + +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { SequenceCard } from '$components/sequence-card'; +import { + processImportedHttpsUrl, + processPastedOrUploadedCss, + type ProcessedThemeImport, +} from '../../../theme/processThemeImport'; + +import { usePatchSettings } from './themeSettingsPatch'; + +type ThemeImportModalProps = { + open: boolean; + onClose: () => void; +}; + +export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) { + const patchSettings = usePatchSettings(); + const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites'); + const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites'); + const [enabledTweakFullUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls'); + const [manualRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl'); + const [lightRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl'); + const [darkRemoteFullUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl'); + + const [importUrl, setImportUrl] = useState(''); + const [importPaste, setImportPaste] = useState(''); + const [uploadedFileCss, setUploadedFileCss] = useState(null); + const [importError, setImportError] = useState(null); + const [importBusy, setImportBusy] = useState(false); + const [importFileName, setImportFileName] = useState(undefined); + const importFileRef = useRef(null); + + const activeUrls = useMemo( + () => + [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl].filter((u): u is string => + Boolean(u && u.trim().length > 0) + ), + [darkRemoteFullUrl, lightRemoteFullUrl, manualRemoteFullUrl] + ); + + const pruneFavorites = useCallback( + (nextFavorites: ThemeRemoteFavorite[], nextActiveUrls: string[]) => { + const active = new Set(nextActiveUrls); + return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl)); + }, + [] + ); + + const pruneTweakFavorites = useCallback( + (nextFavorites: ThemeRemoteTweakFavorite[], nextEnabledUrls: string[]) => { + const enabled = new Set(nextEnabledUrls); + return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl)); + }, + [] + ); + + useEffect(() => { + if (!open) { + setImportUrl(''); + setImportPaste(''); + setUploadedFileCss(null); + setImportError(null); + setImportFileName(undefined); + if (importFileRef.current) importFileRef.current.value = ''; + } + }, [open]); + + const onImportUrlChange: ChangeEventHandler = (e) => + setImportUrl(e.target.value); + + const onImportPasteChange: ChangeEventHandler = (e) => + setImportPaste(e.target.value); + + const addImportedResult = useCallback( + (r: Extract) => { + if (r.role === 'tweak') { + const existing = tweakFavorites.find((f) => f.fullUrl === r.fullUrl); + if (existing) { + setImportError('That tweak is already saved.'); + return; + } + const next: ThemeRemoteTweakFavorite = { + fullUrl: r.fullUrl, + displayName: r.displayName, + basename: r.basename, + pinned: true, + importedLocal: r.importedLocal, + }; + const nextEnabled = enabledTweakFullUrls.includes(r.fullUrl) + ? [...enabledTweakFullUrls] + : [...enabledTweakFullUrls, r.fullUrl]; + patchSettings({ + themeRemoteTweakFavorites: pruneTweakFavorites([...tweakFavorites, next], nextEnabled), + themeRemoteEnabledTweakFullUrls: nextEnabled, + }); + onClose(); + return; + } + + const existing = favorites.find((f) => f.fullUrl === r.fullUrl); + if (existing) { + setImportError('That theme is already saved.'); + return; + } + const next: ThemeRemoteFavorite = { + fullUrl: r.fullUrl, + displayName: r.displayName, + basename: r.basename, + kind: r.kind, + pinned: true, + importedLocal: r.importedLocal, + }; + const nextActive = Array.from( + new Set( + [...activeUrls, r.fullUrl].filter((u): u is string => Boolean(u && u.trim().length > 0)) + ) + ); + patchSettings({ + themeRemoteFavorites: pruneFavorites([...favorites, next], nextActive), + }); + onClose(); + }, + [ + activeUrls, + enabledTweakFullUrls, + favorites, + onClose, + patchSettings, + pruneFavorites, + pruneTweakFavorites, + tweakFavorites, + ] + ); + + const onImportFileChange: ChangeEventHandler = (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setImportFileName(file.name); + setImportPaste(''); + const reader = new FileReader(); + reader.onload = () => { + setUploadedFileCss(typeof reader.result === 'string' ? reader.result : ''); + }; + reader.readAsText(file); + }; + + const handleImportTheme = useCallback(async () => { + setImportError(null); + const urlTrim = importUrl.trim(); + if (/^https:\/\//i.test(urlTrim)) { + setImportBusy(true); + try { + const result = await processImportedHttpsUrl(urlTrim); + if (!result.ok) { + setImportError(result.error); + return; + } + addImportedResult(result); + } finally { + setImportBusy(false); + } + return; + } + const pasted = importPaste.trim(); + const fromFile = uploadedFileCss?.trim(); + const cssToImport = fromFile || pasted; + if (!cssToImport) { + setImportError('Enter an URL, paste CSS, or choose a file.'); + return; + } + setImportBusy(true); + try { + const result = await processPastedOrUploadedCss( + cssToImport, + fromFile ? importFileName : undefined + ); + if (!result.ok) { + setImportError(result.error); + return; + } + addImportedResult(result); + } finally { + setImportBusy(false); + } + }, [addImportedResult, importFileName, importPaste, importUrl, uploadedFileCss]); + + const dismissSafe = useCallback(() => { + if (importBusy) return; + onClose(); + }, [importBusy, onClose]); + + if (!open) return null; + + return ( + }> + + + +
+ + + Import a theme or tweak + + + + + +
+ + + Paste a link to a .sable.css file, or paste CSS / upload a file. + Files whose first metadata block uses @sable-tweak are saved as + tweaks (applied on top of your current theme and turned on immediately). Themes use{' '} + @sable-theme. If theme CSS includes fullThemeUrl{' '} + and that URL loads, it is used; otherwise the theme is stored only on this device. + + + + {importFileName && uploadedFileCss !== null && ( + + + Loaded file: {importFileName}. + + + + )} +