pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/

add custom theme

Pas cc029c8c 87851c11

+671 -9
+6
src/assets/locales/en.json
··· 1200 1200 "activeTheme": "Active", 1201 1201 "themes": { 1202 1202 "blue": "Blue", 1203 + "custom": "Custom", 1203 1204 "default": "Default", 1204 1205 "gray": "Gray", 1205 1206 "red": "Red", ··· 1222 1223 "cobalt": "Cobalt", 1223 1224 "frost": "Frost", 1224 1225 "christmas": "Christmas" 1226 + }, 1227 + "customParts": { 1228 + "primary": "Primary", 1229 + "secondary": "Secondary", 1230 + "tertiary": "Tertiary" 1225 1231 }, 1226 1232 "title": "Appearance", 1227 1233 "options": {
+8
src/backend/accounts/settings.ts
··· 4 4 import { AccountWithToken } from "@/stores/auth"; 5 5 import { KeyboardShortcuts } from "@/utils/keyboardShortcuts"; 6 6 7 + export interface CustomThemeSettings { 8 + primary: string; 9 + secondary: string; 10 + tertiary: string; 11 + } 12 + 7 13 export interface SettingsInput { 8 14 applicationLanguage?: string; 9 15 applicationTheme?: string | null; ··· 38 44 enableDoubleClickToSeek?: boolean; 39 45 enableAutoResumeOnPlaybackError?: boolean; 40 46 keyboardShortcuts?: KeyboardShortcuts; 47 + customTheme?: CustomThemeSettings; 41 48 } 42 49 43 50 export interface SettingsResponse { ··· 74 81 enableDoubleClickToSeek?: boolean; 75 82 enableAutoResumeOnPlaybackError?: boolean; 76 83 keyboardShortcuts?: KeyboardShortcuts; 84 + customTheme?: CustomThemeSettings; 77 85 } 78 86 79 87 export function updateSettings(
+24 -2
src/hooks/useSettingsState.ts
··· 8 8 } from "react"; 9 9 10 10 import { SubtitleStyling } from "@/stores/subtitles"; 11 - import { usePreviewThemeStore } from "@/stores/theme"; 11 + import { usePreviewThemeStore, useThemeStore } from "@/stores/theme"; 12 12 13 13 export function useDerived<T>( 14 14 initial: T, ··· 81 81 manualSourceSelection: boolean, 82 82 enableDoubleClickToSeek: boolean, 83 83 enableAutoResumeOnPlaybackError: boolean, 84 + customTheme: { 85 + primary: string; 86 + secondary: string; 87 + tertiary: string; 88 + }, 84 89 ) { 85 90 const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] = 86 91 useDerived(proxyUrls); ··· 272 277 resetEnableAutoResumeOnPlaybackError, 273 278 enableAutoResumeOnPlaybackErrorChanged, 274 279 ] = useDerived(enableAutoResumeOnPlaybackError); 280 + const [ 281 + customThemeState, 282 + setCustomThemeState, 283 + resetCustomTheme, 284 + customThemeChanged, 285 + ] = useDerived(customTheme); 286 + const setCustomThemeStore = useThemeStore((s) => s.setCustomTheme); 275 287 276 288 function reset() { 277 289 resetTheme(); ··· 311 323 resetManualSourceSelection(); 312 324 resetEnableDoubleClickToSeek(); 313 325 resetEnableAutoResumeOnPlaybackError(); 326 + resetCustomTheme(); 314 327 } 315 328 316 329 const changed = ··· 350 363 homeSectionOrderChanged || 351 364 manualSourceSelectionChanged || 352 365 enableDoubleClickToSeekChanged || 353 - enableAutoResumeOnPlaybackErrorChanged; 366 + enableAutoResumeOnPlaybackErrorChanged || 367 + customThemeChanged; 354 368 355 369 return { 356 370 reset, ··· 539 553 state: enableAutoResumeOnPlaybackErrorState, 540 554 set: setEnableAutoResumeOnPlaybackErrorState, 541 555 changed: enableAutoResumeOnPlaybackErrorChanged, 556 + }, 557 + customTheme: { 558 + state: customThemeState, 559 + set: (v: { primary: string; secondary: string; tertiary: string }) => { 560 + setCustomThemeState(v); 561 + setCustomThemeStore(v); 562 + }, 563 + changed: customThemeChanged, 542 564 }, 543 565 }; 544 566 }
+152 -4
src/pages/Settings.tsx
··· 290 290 const { t } = useTranslation(); 291 291 const activeTheme = useThemeStore((s) => s.theme); 292 292 const setTheme = useThemeStore((s) => s.setTheme); 293 + const customTheme = useThemeStore((s) => s.customTheme); 294 + const setCustomTheme = useThemeStore((s) => s.setCustomTheme); 293 295 const previewTheme = usePreviewThemeStore((s) => s.previewTheme); 294 296 const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme); 297 + 298 + // Baseline for custom theme so "changed" is detected when only colors change. 299 + // Only updated on load from backend or after save; prevents useDerived from 300 + // resetting when we update the store for preview. 301 + const [customThemeBaseline, setCustomThemeBaseline] = useState<{ 302 + primary: string; 303 + secondary: string; 304 + tertiary: string; 305 + } | null>(null); 306 + useEffect(() => { 307 + if (customThemeBaseline === null) { 308 + setCustomThemeBaseline(customTheme); 309 + } 310 + }, [customTheme, customThemeBaseline]); 295 311 296 312 // Simple text search with highlighting 297 313 const handleSearchChange = useCallback((value: string, _force: boolean) => { ··· 539 555 const loadSettings = async () => { 540 556 if (account && backendUrl) { 541 557 const settings = await getSettings(backendUrl, account); 542 - if (settings.febboxKey) { 558 + if (settings.applicationTheme !== undefined) { 559 + setTheme(settings.applicationTheme); 560 + } 561 + if (settings.applicationLanguage) { 562 + setAppLanguage(settings.applicationLanguage); 563 + } 564 + if (settings.proxyUrls !== undefined) { 565 + setProxySet(settings.proxyUrls?.filter((v) => v !== "") ?? null); 566 + } 567 + if (settings.febboxKey !== undefined) { 543 568 setFebboxKey(settings.febboxKey); 544 569 } 545 - if (settings.debridToken) { 570 + if (settings.debridToken !== undefined) { 546 571 setdebridToken(settings.debridToken); 547 572 } 573 + if (settings.debridService) { 574 + setdebridService(settings.debridService); 575 + } 576 + if (settings.enableThumbnails !== undefined) { 577 + setEnableThumbnails(settings.enableThumbnails); 578 + } 579 + if (settings.enableAutoplay !== undefined) { 580 + setEnableAutoplay(settings.enableAutoplay); 581 + } 582 + if (settings.enableSkipCredits !== undefined) { 583 + setEnableSkipCredits(settings.enableSkipCredits); 584 + } 585 + if (settings.enableAutoSkipSegments !== undefined) { 586 + setEnableAutoSkipSegments(settings.enableAutoSkipSegments); 587 + } 588 + if (settings.enableDiscover !== undefined) { 589 + setEnableDiscover(settings.enableDiscover); 590 + } 591 + if (settings.enableFeatured !== undefined) { 592 + setEnableFeatured(settings.enableFeatured); 593 + } 594 + if (settings.enableDetailsModal !== undefined) { 595 + setEnableDetailsModal(settings.enableDetailsModal); 596 + } 597 + if (settings.enableImageLogos !== undefined) { 598 + setEnableImageLogos(settings.enableImageLogos); 599 + } 600 + if ( 601 + settings.sourceOrder !== undefined && 602 + Array.isArray(settings.sourceOrder) 603 + ) { 604 + setSourceOrder(settings.sourceOrder); 605 + } 606 + if (settings.enableSourceOrder !== undefined) { 607 + setEnableSourceOrder(settings.enableSourceOrder); 608 + } 609 + if (settings.lastSuccessfulSource !== undefined) { 610 + setLastSuccessfulSource(settings.lastSuccessfulSource); 611 + } 612 + if (settings.enableLastSuccessfulSource !== undefined) { 613 + setEnableLastSuccessfulSource(settings.enableLastSuccessfulSource); 614 + } 615 + if (settings.proxyTmdb !== undefined) { 616 + setProxyTmdb(settings.proxyTmdb); 617 + } 618 + if (settings.enableCarouselView !== undefined) { 619 + setEnableCarouselView(settings.enableCarouselView); 620 + } 621 + if (settings.enableMinimalCards !== undefined) { 622 + setEnableMinimalCards(settings.enableMinimalCards); 623 + } 624 + if (settings.forceCompactEpisodeView !== undefined) { 625 + setForceCompactEpisodeView(settings.forceCompactEpisodeView); 626 + } 627 + if (settings.enableLowPerformanceMode !== undefined) { 628 + setEnableLowPerformanceMode(settings.enableLowPerformanceMode); 629 + } 630 + if (settings.enableHoldToBoost !== undefined) { 631 + setEnableHoldToBoost(settings.enableHoldToBoost); 632 + } 633 + if ( 634 + settings.homeSectionOrder !== undefined && 635 + Array.isArray(settings.homeSectionOrder) 636 + ) { 637 + setHomeSectionOrder(settings.homeSectionOrder); 638 + } 639 + if (settings.manualSourceSelection !== undefined) { 640 + setManualSourceSelection(settings.manualSourceSelection); 641 + } 642 + if (settings.enableDoubleClickToSeek !== undefined) { 643 + setEnableDoubleClickToSeek(settings.enableDoubleClickToSeek); 644 + } 645 + if (settings.enableAutoResumeOnPlaybackError !== undefined) { 646 + setEnableAutoResumeOnPlaybackError( 647 + settings.enableAutoResumeOnPlaybackError, 648 + ); 649 + } 650 + if (settings.customTheme) { 651 + setCustomTheme(settings.customTheme); 652 + setCustomThemeBaseline(settings.customTheme); 653 + } else { 654 + setCustomThemeBaseline(useThemeStore.getState().customTheme); 655 + } 548 656 } 549 657 }; 550 658 loadSettings(); 551 - }, [account, backendUrl, setFebboxKey, setdebridToken, setdebridService]); 659 + }, [ 660 + account, 661 + backendUrl, 662 + setTheme, 663 + setAppLanguage, 664 + setProxySet, 665 + setFebboxKey, 666 + setdebridToken, 667 + setdebridService, 668 + setEnableThumbnails, 669 + setEnableAutoplay, 670 + setEnableSkipCredits, 671 + setEnableAutoSkipSegments, 672 + setEnableDiscover, 673 + setEnableFeatured, 674 + setEnableDetailsModal, 675 + setEnableImageLogos, 676 + setSourceOrder, 677 + setEnableSourceOrder, 678 + setLastSuccessfulSource, 679 + setEnableLastSuccessfulSource, 680 + setProxyTmdb, 681 + setEnableCarouselView, 682 + setEnableMinimalCards, 683 + setForceCompactEpisodeView, 684 + setEnableLowPerformanceMode, 685 + setEnableHoldToBoost, 686 + setHomeSectionOrder, 687 + setManualSourceSelection, 688 + setEnableDoubleClickToSeek, 689 + setEnableAutoResumeOnPlaybackError, 690 + setCustomTheme, 691 + ]); 552 692 553 693 const state = useSettingsState( 554 694 activeTheme, ··· 588 728 manualSourceSelection, 589 729 enableDoubleClickToSeek, 590 730 enableAutoResumeOnPlaybackError, 731 + customThemeBaseline ?? customTheme, 591 732 ); 592 733 593 734 const availableSources = useMemo(() => { ··· 655 796 state.homeSectionOrder.changed || 656 797 state.manualSourceSelection.changed || 657 798 state.enableDoubleClickToSeek.changed || 658 - state.enableAutoResumeOnPlaybackError 799 + state.enableAutoResumeOnPlaybackError.changed || 800 + state.customTheme.changed 659 801 ) { 660 802 await updateSettings(backendUrl, account, { 661 803 applicationLanguage: state.appLanguage.state, ··· 687 829 enableDoubleClickToSeek: state.enableDoubleClickToSeek.state, 688 830 enableAutoResumeOnPlaybackError: 689 831 state.enableAutoResumeOnPlaybackError.state, 832 + customTheme: state.customTheme.state, 690 833 }); 691 834 } 692 835 if (state.deviceName.changed) { ··· 746 889 setEnableAutoResumeOnPlaybackError( 747 890 state.enableAutoResumeOnPlaybackError.state, 748 891 ); 892 + setCustomTheme(state.customTheme.state); 893 + setCustomThemeBaseline(state.customTheme.state); 749 894 750 895 if (state.profile.state) { 751 896 updateProfile(state.profile.state); ··· 806 951 setManualSourceSelection, 807 952 setEnableDoubleClickToSeek, 808 953 setEnableAutoResumeOnPlaybackError, 954 + setCustomTheme, 809 955 ]); 810 956 return ( 811 957 <SubPageLayout> ··· 921 1067 homeSectionOrder={state.homeSectionOrder.state} 922 1068 setHomeSectionOrder={state.homeSectionOrder.set} 923 1069 enableLowPerformanceMode={state.enableLowPerformanceMode.state} 1070 + customTheme={state.customTheme.state} 1071 + setCustomTheme={state.customTheme.set} 924 1072 /> 925 1073 </div> 926 1074 )}
+123
src/pages/parts/settings/AppearancePart.tsx
··· 13 13 import { useAuthStore } from "@/stores/auth"; 14 14 import { useBookmarkStore } from "@/stores/bookmarks"; 15 15 import { useGroupOrderStore } from "@/stores/groupOrder"; 16 + import { 17 + primaryOptions, 18 + secondaryOptions, 19 + tertiaryOptions, 20 + } from "@themes/custom"; 16 21 17 22 const availableThemes = [ 18 23 { ··· 130 135 selector: "theme-christmas", 131 136 key: "settings.appearance.themes.christmas", 132 137 }, 138 + { 139 + id: "custom", 140 + selector: "theme-custom", 141 + key: "settings.appearance.themes.custom", 142 + }, 133 143 ]; 134 144 135 145 function ThemePreview(props: { ··· 225 235 ); 226 236 } 227 237 238 + function ColorOption(props: { 239 + active: boolean; 240 + colors: Record<string, string>; 241 + onClick: () => void; 242 + title: string; 243 + }) { 244 + const c1 = 245 + props.colors["--colors-type-logo"] || 246 + props.colors["--colors-background-main"] || 247 + props.colors["--colors-type-text"]; 248 + const c2 = 249 + props.colors["--colors-lightBar-light"] || 250 + props.colors["--colors-modal-background"] || 251 + props.colors["--colors-utils-divider"]; 252 + 253 + return ( 254 + <div 255 + className={classNames( 256 + "cursor-pointer p-1 rounded-full border-2 transition-all", 257 + props.active 258 + ? "border-type-link scale-110" 259 + : "border-transparent hover:border-white/20 hover:scale-105", 260 + )} 261 + onClick={props.onClick} 262 + title={props.title} 263 + > 264 + <div className="w-8 h-8 rounded-full overflow-hidden flex transform rotate-45"> 265 + <div 266 + className="flex-1 h-full" 267 + style={{ backgroundColor: `rgb(${c1})` }} 268 + /> 269 + <div 270 + className="flex-1 h-full" 271 + style={{ backgroundColor: `rgb(${c2})` }} 272 + /> 273 + </div> 274 + </div> 275 + ); 276 + } 277 + 228 278 export function AppearancePart(props: { 229 279 active: string; 230 280 inUse: string; ··· 255 305 setHomeSectionOrder: (v: string[]) => void; 256 306 257 307 enableLowPerformanceMode: boolean; 308 + 309 + customTheme: { 310 + primary: string; 311 + secondary: string; 312 + tertiary: string; 313 + }; 314 + setCustomTheme: (v: { 315 + primary: string; 316 + secondary: string; 317 + tertiary: string; 318 + }) => void; 258 319 }) { 259 320 const { t } = useTranslation(); 321 + 322 + const customTheme = props.customTheme; 323 + const setCustomTheme = props.setCustomTheme; 260 324 261 325 const carouselRef = useRef<HTMLDivElement>(null); 262 326 const activeThemeRef = useRef<HTMLDivElement>(null); ··· 628 692 </div> 629 693 ))} 630 694 </div> 695 + 696 + {props.active === "custom" && ( 697 + <div className="animate-fade-in space-y-6 pt-4 border-t border-utils-divider"> 698 + <div> 699 + <p className="text-white font-bold mb-3"> 700 + {t("settings.appearance.customParts.primary")} 701 + </p> 702 + <div className="flex flex-wrap gap-3"> 703 + {primaryOptions.map((opt) => ( 704 + <ColorOption 705 + key={opt.id} 706 + active={customTheme.primary === opt.id} 707 + colors={opt.colors} 708 + onClick={() => 709 + setCustomTheme({ ...customTheme, primary: opt.id }) 710 + } 711 + title={t(`settings.appearance.themes.${opt.id}`)} 712 + /> 713 + ))} 714 + </div> 715 + </div> 716 + <div> 717 + <p className="text-white font-bold mb-3"> 718 + {t("settings.appearance.customParts.secondary")} 719 + </p> 720 + <div className="flex flex-wrap gap-3"> 721 + {secondaryOptions.map((opt) => ( 722 + <ColorOption 723 + key={opt.id} 724 + active={customTheme.secondary === opt.id} 725 + colors={opt.colors} 726 + onClick={() => 727 + setCustomTheme({ ...customTheme, secondary: opt.id }) 728 + } 729 + title={t(`settings.appearance.themes.${opt.id}`)} 730 + /> 731 + ))} 732 + </div> 733 + </div> 734 + <div> 735 + <p className="text-white font-bold mb-3"> 736 + {t("settings.appearance.customParts.tertiary")} 737 + </p> 738 + <div className="flex flex-wrap gap-3"> 739 + {tertiaryOptions.map((opt) => ( 740 + <ColorOption 741 + key={opt.id} 742 + active={customTheme.tertiary === opt.id} 743 + colors={opt.colors} 744 + onClick={() => 745 + setCustomTheme({ ...customTheme, tertiary: opt.id }) 746 + } 747 + title={t(`settings.appearance.themes.${opt.id}`)} 748 + /> 749 + ))} 750 + </div> 751 + </div> 752 + </div> 753 + )} 631 754 </div> 632 755 </div> 633 756
+50
src/stores/theme/index.tsx
··· 4 4 import { persist } from "zustand/middleware"; 5 5 import { immer } from "zustand/middleware/immer"; 6 6 7 + import { 8 + primaryOptions, 9 + secondaryOptions, 10 + tertiaryOptions, 11 + } from "@themes/custom"; 12 + 7 13 export interface ThemeStore { 8 14 theme: string | null; 15 + customTheme: { 16 + primary: string; 17 + secondary: string; 18 + tertiary: string; 19 + }; 9 20 setTheme(v: string | null): void; 21 + setCustomTheme(v: { 22 + primary: string; 23 + secondary: string; 24 + tertiary: string; 25 + }): void; 10 26 } 11 27 12 28 const currentDate = new Date(); ··· 19 35 persist( 20 36 immer<ThemeStore>((set) => ({ 21 37 theme: is420 ? "green" : isHalloween ? "autumn" : null, 38 + customTheme: { 39 + primary: "classic", 40 + secondary: "classic", 41 + tertiary: "classic", 42 + }, 22 43 setTheme(v) { 23 44 set((s) => { 24 45 s.theme = v; 25 46 }); 26 47 }, 48 + setCustomTheme(v) { 49 + set((s) => { 50 + s.customTheme = v; 51 + }); 52 + }, 27 53 })), 28 54 { 29 55 name: "__MW::theme", ··· 53 79 }) { 54 80 const previewTheme = usePreviewThemeStore((s) => s.previewTheme); 55 81 const theme = useThemeStore((s) => s.theme); 82 + const customTheme = useThemeStore((s) => s.customTheme); 56 83 57 84 const themeToDisplay = previewTheme ?? theme; 58 85 const themeSelector = themeToDisplay ? `theme-${themeToDisplay}` : undefined; 59 86 87 + let styleContent = ""; 88 + if (themeToDisplay === "custom" && customTheme) { 89 + const primary = 90 + primaryOptions.find((o) => o.id === customTheme.primary)?.colors || {}; 91 + const secondary = 92 + secondaryOptions.find((o) => o.id === customTheme.secondary)?.colors || 93 + {}; 94 + const tertiary = 95 + tertiaryOptions.find((o) => o.id === customTheme.tertiary)?.colors || {}; 96 + 97 + const vars = { ...primary, ...secondary, ...tertiary }; 98 + const cssVars = Object.entries(vars) 99 + .map(([k, v]) => `${k}: ${v};`) 100 + .join(" "); 101 + 102 + styleContent = `.theme-custom { ${cssVars} }`; 103 + } 104 + 60 105 return ( 61 106 <div className={themeSelector}> 107 + {styleContent ? ( 108 + <Helmet> 109 + <style>{styleContent}</style> 110 + </Helmet> 111 + ) : null} 62 112 {props.applyGlobal ? ( 63 113 <Helmet> 64 114 <body className={themeSelector} />
+67
src/utils/color.ts
··· 1 + export function hexToRgb(hex: string): string | null { 2 + // Remove hash 3 + hex = hex.replace(/^#/, ""); 4 + 5 + // Convert 3-char hex to 6-char 6 + if (hex.length === 3) { 7 + hex = hex 8 + .split("") 9 + .map((c) => c + c) 10 + .join(""); 11 + } 12 + 13 + // Parse hex 14 + const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec( 15 + hex, 16 + ); 17 + return result 18 + ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt( 19 + result[3], 20 + 16, 21 + )}` 22 + : null; 23 + } 24 + 25 + // Convert HSL/HSLA to RGB 26 + // hsla(240, 25%, 6%, 1) -> 15 15 19 (approx) 27 + function hslToRgb(h: number, s: number, l: number): string { 28 + s /= 100; 29 + l /= 100; 30 + 31 + const k = (n: number) => (n + h / 30) % 12; 32 + const a = s * Math.min(l, 1 - l); 33 + const f = (n: number) => 34 + l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); 35 + 36 + return `${Math.round(255 * f(0))} ${Math.round(255 * f(8))} ${Math.round( 37 + 255 * f(4), 38 + )}`; 39 + } 40 + 41 + function parseHsla(hsla: string): string | null { 42 + // matches hsla(H, S%, L%, A) or hsl(H, S%, L%) 43 + // simple regex, assuming comma separation and valid syntax 44 + const match = hsla.match(/hsla?\((\d+),\s*(\d+)%,\s*(\d+)%(?:,\s*[\d.]+)?\)/); 45 + if (match) { 46 + return hslToRgb( 47 + parseInt(match[1], 10), 48 + parseInt(match[2], 10), 49 + parseInt(match[3], 10), 50 + ); 51 + } 52 + return null; 53 + } 54 + 55 + export function colorToRgbString(color: string): string { 56 + if (color.startsWith("#")) { 57 + const rgb = hexToRgb(color); 58 + if (rgb) return rgb; 59 + } else if (color.startsWith("hsl")) { 60 + const rgb = parseHsla(color); 61 + if (rgb) return rgb; 62 + } 63 + // If parsing fails, assume it's already in RGB or named color 64 + // However, returning "red" for Tailwind opacity utility will likely fail (as it expects RGB components) 65 + // But returning original string allows basic non-opacity usage to potentially work or fail gracefully. 66 + return color; 67 + }
+232
themes/custom.ts
··· 1 + import merge from "lodash.merge"; 2 + import { createTheme } from "./types"; 3 + import { defaultTheme } from "./default"; 4 + import classic from "./list/classic"; 5 + import blue from "./list/blue"; 6 + import red from "./list/red"; 7 + import teal from "./list/teal"; 8 + import green from "./list/green"; 9 + import pink from "./list/pink"; 10 + import autumn from "./list/autumn"; 11 + import frost from "./list/frost"; 12 + import grape from "./list/grape"; 13 + import { colorToRgbString } from "../src/utils/color"; 14 + 15 + const availableThemes = [ 16 + { id: "classic", theme: classic }, 17 + { id: "blue", theme: blue }, 18 + { id: "red", theme: red }, 19 + { id: "teal", theme: teal }, 20 + { id: "green", theme: green }, 21 + { id: "pink", theme: pink }, 22 + { id: "autumn", theme: autumn }, 23 + { id: "frost", theme: frost }, 24 + { id: "grape", theme: grape }, 25 + ]; 26 + 27 + function cssVarName(path: string) { 28 + return `--colors-${path}`; 29 + } 30 + 31 + // Generate the custom theme structure with CSS variables 32 + function generateCustomThemeStructure(theme: any, prefix = ""): any { 33 + const result: any = {}; 34 + for (const key in theme) { 35 + if (typeof theme[key] === "object" && theme[key] !== null) { 36 + result[key] = generateCustomThemeStructure(theme[key], `${prefix}${key}-`); 37 + } else { 38 + result[key] = `rgb(var(${cssVarName(`${prefix}${key}`)}) / <alpha-value>)`; 39 + } 40 + } 41 + return result; 42 + } 43 + 44 + export const customTheme = createTheme({ 45 + name: "custom", 46 + extend: { 47 + colors: generateCustomThemeStructure(defaultTheme.extend.colors), 48 + }, 49 + }); 50 + 51 + // Define parts 52 + const parts = { 53 + primary: [ 54 + "lightBar.light", 55 + "type.logo", 56 + "buttons.primary", 57 + "buttons.primaryText", 58 + "buttons.primaryHover", 59 + "buttons.toggle", 60 + "buttons.toggleDisabled", 61 + "buttons.purple", 62 + "buttons.purpleHover", 63 + "global.accentA", 64 + "global.accentB", 65 + "pill.highlight", 66 + "progress.filled", 67 + "video.audio.set", 68 + "video.context.type.accent", 69 + "video.context.sliderFilled", 70 + "video.scraping.loading", 71 + "onboarding.good", 72 + "onboarding.best", 73 + "onboarding.link", 74 + "onboarding.barFilled", 75 + "settings.sidebar.type.iconActivated", 76 + "settings.sidebar.type.activated", 77 + "type.link", 78 + "type.linkHover", 79 + "largeCard.icon", 80 + "mediaCard.barFillColor", 81 + ], 82 + secondary: [ 83 + "type.text", 84 + "type.dimmed", 85 + "type.secondary", 86 + "type.emphasis", 87 + "type.divider", 88 + "type.danger", 89 + "type.success", 90 + "buttons.secondary", 91 + "buttons.secondaryText", 92 + "buttons.secondaryHover", 93 + "buttons.danger", 94 + "buttons.dangerHover", 95 + "buttons.cancel", 96 + "buttons.cancelHover", 97 + "utils.divider", 98 + "search.text", 99 + "search.placeholder", 100 + "search.icon", 101 + "dropdown.text", 102 + "dropdown.secondary", 103 + "dropdown.border", 104 + "authentication.border", 105 + "authentication.inputBg", 106 + "authentication.inputBgHover", 107 + "authentication.wordBackground", 108 + "authentication.copyText", 109 + "authentication.copyTextHover", 110 + "authentication.errorText", 111 + "settings.sidebar.activeLink", 112 + "settings.sidebar.badge", 113 + "settings.sidebar.type.secondary", 114 + "settings.sidebar.type.inactive", 115 + "settings.sidebar.type.icon", 116 + "settings.card.border", 117 + "onboarding.bar", 118 + "onboarding.divider", 119 + "onboarding.border", 120 + "errors.border", 121 + "errors.type.secondary", 122 + "about.circle", 123 + "about.circleText", 124 + "editBadge.bg", 125 + "editBadge.bgHover", 126 + "editBadge.text", 127 + "progress.background", 128 + "progress.preloaded", 129 + "pill.background", 130 + "pill.backgroundHover", 131 + "pill.activeBackground", 132 + "video.buttonBackground", 133 + "video.autoPlay.background", 134 + "video.autoPlay.hover", 135 + "video.scraping.error", 136 + "video.scraping.success", 137 + "video.scraping.noresult", 138 + "video.context.light", 139 + "video.context.border", 140 + "video.context.hoverColor", 141 + "video.context.buttonFocus", 142 + "video.context.inputBg", 143 + "video.context.buttonOverInputHover", 144 + "video.context.inputPlaceholder", 145 + "video.context.cardBorder", 146 + "video.context.slider", 147 + "video.context.error", 148 + "video.context.buttons.list", 149 + "video.context.buttons.active", 150 + "video.context.closeHover", 151 + "video.context.type.main", 152 + "video.context.type.secondary", 153 + "mediaCard.barColor", 154 + "mediaCard.badge", 155 + "mediaCard.badgeText", 156 + ], 157 + tertiary: [ 158 + "background.main", 159 + "background.secondary", 160 + "background.secondaryHover", 161 + "background.accentA", 162 + "background.accentB", 163 + "modal.background", 164 + "mediaCard.shadow", 165 + "mediaCard.hoverBackground", 166 + "mediaCard.hoverAccent", 167 + "mediaCard.hoverShadow", 168 + "search.background", 169 + "search.hoverBackground", 170 + "search.focused", 171 + "dropdown.background", 172 + "dropdown.altBackground", 173 + "dropdown.hoverBackground", 174 + "dropdown.contentBackground", 175 + "dropdown.highlight", 176 + "dropdown.highlightHover", 177 + "largeCard.background", 178 + "settings.card.background", 179 + "settings.card.altBackground", 180 + "settings.saveBar.background", 181 + "onboarding.card", 182 + "onboarding.cardHover", 183 + "errors.card", 184 + "themePreview.primary", 185 + "themePreview.secondary", 186 + "themePreview.ghost", 187 + "video.scraping.card", 188 + "video.context.background", 189 + "video.context.flagBg", 190 + ], 191 + }; 192 + 193 + function getNestedValue(obj: any, path: string) { 194 + return path.split(".").reduce((o, i) => (o ? o[i] : undefined), obj); 195 + } 196 + 197 + function extractColors(theme: any, keys: string[]) { 198 + const colors: Record<string, string> = {}; 199 + // We need to flatten the structure to css vars 200 + keys.forEach((key) => { 201 + const value = getNestedValue(theme.extend.colors, key); 202 + if (value) { 203 + colors[cssVarName(key.replace(/\./g, "-"))] = colorToRgbString(value); 204 + } 205 + }); 206 + return colors; 207 + } 208 + 209 + // Generate options for each part 210 + export const primaryOptions = availableThemes.map((t) => { 211 + const merged = merge({}, defaultTheme, t.theme); 212 + return { 213 + id: t.id, 214 + colors: extractColors(merged, parts.primary), 215 + }; 216 + }); 217 + 218 + export const secondaryOptions = availableThemes.map((t) => { 219 + const merged = merge({}, defaultTheme, t.theme); 220 + return { 221 + id: t.id, 222 + colors: extractColors(merged, parts.secondary), 223 + }; 224 + }); 225 + 226 + export const tertiaryOptions = availableThemes.map((t) => { 227 + const merged = merge({}, defaultTheme, t.theme); 228 + return { 229 + id: t.id, 230 + colors: extractColors(merged, parts.tertiary), 231 + }; 232 + });
+2 -1
themes/index.ts
··· 1 1 import { allThemes } from "./all"; 2 + import { customTheme } from "./custom"; 2 3 3 4 export { defaultTheme } from "./default"; 4 5 export { allThemes } from "./all"; 5 6 6 - export const safeThemeList = allThemes 7 + export const safeThemeList = [customTheme, ...allThemes] 7 8 .flatMap((v) => v.selectors) 8 9 .filter((v) => v.startsWith(".")) 9 10 .map((v) => v.slice(1)); // remove dot from selector
+4 -1
themes/types.ts
··· 1 - import { DeepPartial } from "vite-plugin-checker/dist/esm/types"; 2 1 import { defaultTheme } from "./default"; 2 + 3 + export type DeepPartial<T> = { 4 + [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]; 5 + }; 3 6 4 7 export interface Theme { 5 8 name: string;
+2 -1
tsconfig.json
··· 18 18 "baseUrl": "./src", 19 19 "paths": { 20 20 "@/*": ["./*"], 21 + "@themes/*": ["../themes/*"], 21 22 "@sozialhelden/ietf-language-tags": [ 22 23 "../node_modules/@sozialhelden/ietf-language-tags/dist/cjs" 23 24 ] 24 25 }, 25 26 "typeRoots": ["node_modules/@types"] 26 27 }, 27 - "include": ["src"] 28 + "include": ["src", "themes"] 28 29 }
+1
vite.config.mts
··· 166 166 resolve: { 167 167 alias: { 168 168 "@": path.resolve(__dirname, "./src"), 169 + "@themes": path.resolve(__dirname, "./themes"), 169 170 "@sozialhelden/ietf-language-tags": path.resolve( 170 171 __dirname, 171 172 "./node_modules/@sozialhelden/ietf-language-tags/dist/cjs",