add custom theme

This commit is contained in:
Pas 2026-02-20 16:31:07 -07:00
parent 87851c1167
commit cc029c8c0a
12 changed files with 671 additions and 9 deletions

View file

@ -1200,6 +1200,7 @@
"activeTheme": "Active",
"themes": {
"blue": "Blue",
"custom": "Custom",
"default": "Default",
"gray": "Gray",
"red": "Red",
@ -1223,6 +1224,11 @@
"frost": "Frost",
"christmas": "Christmas"
},
"customParts": {
"primary": "Primary",
"secondary": "Secondary",
"tertiary": "Tertiary"
},
"title": "Appearance",
"options": {
"discover": "Discover section",

View file

@ -4,6 +4,12 @@ import { getAuthHeaders } from "@/backend/accounts/auth";
import { AccountWithToken } from "@/stores/auth";
import { KeyboardShortcuts } from "@/utils/keyboardShortcuts";
export interface CustomThemeSettings {
primary: string;
secondary: string;
tertiary: string;
}
export interface SettingsInput {
applicationLanguage?: string;
applicationTheme?: string | null;
@ -38,6 +44,7 @@ export interface SettingsInput {
enableDoubleClickToSeek?: boolean;
enableAutoResumeOnPlaybackError?: boolean;
keyboardShortcuts?: KeyboardShortcuts;
customTheme?: CustomThemeSettings;
}
export interface SettingsResponse {
@ -74,6 +81,7 @@ export interface SettingsResponse {
enableDoubleClickToSeek?: boolean;
enableAutoResumeOnPlaybackError?: boolean;
keyboardShortcuts?: KeyboardShortcuts;
customTheme?: CustomThemeSettings;
}
export function updateSettings(

View file

@ -8,7 +8,7 @@ import {
} from "react";
import { SubtitleStyling } from "@/stores/subtitles";
import { usePreviewThemeStore } from "@/stores/theme";
import { usePreviewThemeStore, useThemeStore } from "@/stores/theme";
export function useDerived<T>(
initial: T,
@ -81,6 +81,11 @@ export function useSettingsState(
manualSourceSelection: boolean,
enableDoubleClickToSeek: boolean,
enableAutoResumeOnPlaybackError: boolean,
customTheme: {
primary: string;
secondary: string;
tertiary: string;
},
) {
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
useDerived(proxyUrls);
@ -272,6 +277,13 @@ export function useSettingsState(
resetEnableAutoResumeOnPlaybackError,
enableAutoResumeOnPlaybackErrorChanged,
] = useDerived(enableAutoResumeOnPlaybackError);
const [
customThemeState,
setCustomThemeState,
resetCustomTheme,
customThemeChanged,
] = useDerived(customTheme);
const setCustomThemeStore = useThemeStore((s) => s.setCustomTheme);
function reset() {
resetTheme();
@ -311,6 +323,7 @@ export function useSettingsState(
resetManualSourceSelection();
resetEnableDoubleClickToSeek();
resetEnableAutoResumeOnPlaybackError();
resetCustomTheme();
}
const changed =
@ -350,7 +363,8 @@ export function useSettingsState(
homeSectionOrderChanged ||
manualSourceSelectionChanged ||
enableDoubleClickToSeekChanged ||
enableAutoResumeOnPlaybackErrorChanged;
enableAutoResumeOnPlaybackErrorChanged ||
customThemeChanged;
return {
reset,
@ -540,5 +554,13 @@ export function useSettingsState(
set: setEnableAutoResumeOnPlaybackErrorState,
changed: enableAutoResumeOnPlaybackErrorChanged,
},
customTheme: {
state: customThemeState,
set: (v: { primary: string; secondary: string; tertiary: string }) => {
setCustomThemeState(v);
setCustomThemeStore(v);
},
changed: customThemeChanged,
},
};
}

View file

@ -290,9 +290,25 @@ export function SettingsPage() {
const { t } = useTranslation();
const activeTheme = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
const customTheme = useThemeStore((s) => s.customTheme);
const setCustomTheme = useThemeStore((s) => s.setCustomTheme);
const previewTheme = usePreviewThemeStore((s) => s.previewTheme);
const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme);
// Baseline for custom theme so "changed" is detected when only colors change.
// Only updated on load from backend or after save; prevents useDerived from
// resetting when we update the store for preview.
const [customThemeBaseline, setCustomThemeBaseline] = useState<{
primary: string;
secondary: string;
tertiary: string;
} | null>(null);
useEffect(() => {
if (customThemeBaseline === null) {
setCustomThemeBaseline(customTheme);
}
}, [customTheme, customThemeBaseline]);
// Simple text search with highlighting
const handleSearchChange = useCallback((value: string, _force: boolean) => {
setSearchQuery(value);
@ -539,16 +555,140 @@ export function SettingsPage() {
const loadSettings = async () => {
if (account && backendUrl) {
const settings = await getSettings(backendUrl, account);
if (settings.febboxKey) {
if (settings.applicationTheme !== undefined) {
setTheme(settings.applicationTheme);
}
if (settings.applicationLanguage) {
setAppLanguage(settings.applicationLanguage);
}
if (settings.proxyUrls !== undefined) {
setProxySet(settings.proxyUrls?.filter((v) => v !== "") ?? null);
}
if (settings.febboxKey !== undefined) {
setFebboxKey(settings.febboxKey);
}
if (settings.debridToken) {
if (settings.debridToken !== undefined) {
setdebridToken(settings.debridToken);
}
if (settings.debridService) {
setdebridService(settings.debridService);
}
if (settings.enableThumbnails !== undefined) {
setEnableThumbnails(settings.enableThumbnails);
}
if (settings.enableAutoplay !== undefined) {
setEnableAutoplay(settings.enableAutoplay);
}
if (settings.enableSkipCredits !== undefined) {
setEnableSkipCredits(settings.enableSkipCredits);
}
if (settings.enableAutoSkipSegments !== undefined) {
setEnableAutoSkipSegments(settings.enableAutoSkipSegments);
}
if (settings.enableDiscover !== undefined) {
setEnableDiscover(settings.enableDiscover);
}
if (settings.enableFeatured !== undefined) {
setEnableFeatured(settings.enableFeatured);
}
if (settings.enableDetailsModal !== undefined) {
setEnableDetailsModal(settings.enableDetailsModal);
}
if (settings.enableImageLogos !== undefined) {
setEnableImageLogos(settings.enableImageLogos);
}
if (
settings.sourceOrder !== undefined &&
Array.isArray(settings.sourceOrder)
) {
setSourceOrder(settings.sourceOrder);
}
if (settings.enableSourceOrder !== undefined) {
setEnableSourceOrder(settings.enableSourceOrder);
}
if (settings.lastSuccessfulSource !== undefined) {
setLastSuccessfulSource(settings.lastSuccessfulSource);
}
if (settings.enableLastSuccessfulSource !== undefined) {
setEnableLastSuccessfulSource(settings.enableLastSuccessfulSource);
}
if (settings.proxyTmdb !== undefined) {
setProxyTmdb(settings.proxyTmdb);
}
if (settings.enableCarouselView !== undefined) {
setEnableCarouselView(settings.enableCarouselView);
}
if (settings.enableMinimalCards !== undefined) {
setEnableMinimalCards(settings.enableMinimalCards);
}
if (settings.forceCompactEpisodeView !== undefined) {
setForceCompactEpisodeView(settings.forceCompactEpisodeView);
}
if (settings.enableLowPerformanceMode !== undefined) {
setEnableLowPerformanceMode(settings.enableLowPerformanceMode);
}
if (settings.enableHoldToBoost !== undefined) {
setEnableHoldToBoost(settings.enableHoldToBoost);
}
if (
settings.homeSectionOrder !== undefined &&
Array.isArray(settings.homeSectionOrder)
) {
setHomeSectionOrder(settings.homeSectionOrder);
}
if (settings.manualSourceSelection !== undefined) {
setManualSourceSelection(settings.manualSourceSelection);
}
if (settings.enableDoubleClickToSeek !== undefined) {
setEnableDoubleClickToSeek(settings.enableDoubleClickToSeek);
}
if (settings.enableAutoResumeOnPlaybackError !== undefined) {
setEnableAutoResumeOnPlaybackError(
settings.enableAutoResumeOnPlaybackError,
);
}
if (settings.customTheme) {
setCustomTheme(settings.customTheme);
setCustomThemeBaseline(settings.customTheme);
} else {
setCustomThemeBaseline(useThemeStore.getState().customTheme);
}
}
};
loadSettings();
}, [account, backendUrl, setFebboxKey, setdebridToken, setdebridService]);
}, [
account,
backendUrl,
setTheme,
setAppLanguage,
setProxySet,
setFebboxKey,
setdebridToken,
setdebridService,
setEnableThumbnails,
setEnableAutoplay,
setEnableSkipCredits,
setEnableAutoSkipSegments,
setEnableDiscover,
setEnableFeatured,
setEnableDetailsModal,
setEnableImageLogos,
setSourceOrder,
setEnableSourceOrder,
setLastSuccessfulSource,
setEnableLastSuccessfulSource,
setProxyTmdb,
setEnableCarouselView,
setEnableMinimalCards,
setForceCompactEpisodeView,
setEnableLowPerformanceMode,
setEnableHoldToBoost,
setHomeSectionOrder,
setManualSourceSelection,
setEnableDoubleClickToSeek,
setEnableAutoResumeOnPlaybackError,
setCustomTheme,
]);
const state = useSettingsState(
activeTheme,
@ -588,6 +728,7 @@ export function SettingsPage() {
manualSourceSelection,
enableDoubleClickToSeek,
enableAutoResumeOnPlaybackError,
customThemeBaseline ?? customTheme,
);
const availableSources = useMemo(() => {
@ -655,7 +796,8 @@ export function SettingsPage() {
state.homeSectionOrder.changed ||
state.manualSourceSelection.changed ||
state.enableDoubleClickToSeek.changed ||
state.enableAutoResumeOnPlaybackError
state.enableAutoResumeOnPlaybackError.changed ||
state.customTheme.changed
) {
await updateSettings(backendUrl, account, {
applicationLanguage: state.appLanguage.state,
@ -687,6 +829,7 @@ export function SettingsPage() {
enableDoubleClickToSeek: state.enableDoubleClickToSeek.state,
enableAutoResumeOnPlaybackError:
state.enableAutoResumeOnPlaybackError.state,
customTheme: state.customTheme.state,
});
}
if (state.deviceName.changed) {
@ -746,6 +889,8 @@ export function SettingsPage() {
setEnableAutoResumeOnPlaybackError(
state.enableAutoResumeOnPlaybackError.state,
);
setCustomTheme(state.customTheme.state);
setCustomThemeBaseline(state.customTheme.state);
if (state.profile.state) {
updateProfile(state.profile.state);
@ -806,6 +951,7 @@ export function SettingsPage() {
setManualSourceSelection,
setEnableDoubleClickToSeek,
setEnableAutoResumeOnPlaybackError,
setCustomTheme,
]);
return (
<SubPageLayout>
@ -921,6 +1067,8 @@ export function SettingsPage() {
homeSectionOrder={state.homeSectionOrder.state}
setHomeSectionOrder={state.homeSectionOrder.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
customTheme={state.customTheme.state}
setCustomTheme={state.customTheme.set}
/>
</div>
)}

View file

@ -13,6 +13,11 @@ import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useGroupOrderStore } from "@/stores/groupOrder";
import {
primaryOptions,
secondaryOptions,
tertiaryOptions,
} from "@themes/custom";
const availableThemes = [
{
@ -130,6 +135,11 @@ const availableThemes = [
selector: "theme-christmas",
key: "settings.appearance.themes.christmas",
},
{
id: "custom",
selector: "theme-custom",
key: "settings.appearance.themes.custom",
},
];
function ThemePreview(props: {
@ -225,6 +235,46 @@ function ThemePreview(props: {
);
}
function ColorOption(props: {
active: boolean;
colors: Record<string, string>;
onClick: () => void;
title: string;
}) {
const c1 =
props.colors["--colors-type-logo"] ||
props.colors["--colors-background-main"] ||
props.colors["--colors-type-text"];
const c2 =
props.colors["--colors-lightBar-light"] ||
props.colors["--colors-modal-background"] ||
props.colors["--colors-utils-divider"];
return (
<div
className={classNames(
"cursor-pointer p-1 rounded-full border-2 transition-all",
props.active
? "border-type-link scale-110"
: "border-transparent hover:border-white/20 hover:scale-105",
)}
onClick={props.onClick}
title={props.title}
>
<div className="w-8 h-8 rounded-full overflow-hidden flex transform rotate-45">
<div
className="flex-1 h-full"
style={{ backgroundColor: `rgb(${c1})` }}
/>
<div
className="flex-1 h-full"
style={{ backgroundColor: `rgb(${c2})` }}
/>
</div>
</div>
);
}
export function AppearancePart(props: {
active: string;
inUse: string;
@ -255,9 +305,23 @@ export function AppearancePart(props: {
setHomeSectionOrder: (v: string[]) => void;
enableLowPerformanceMode: boolean;
customTheme: {
primary: string;
secondary: string;
tertiary: string;
};
setCustomTheme: (v: {
primary: string;
secondary: string;
tertiary: string;
}) => void;
}) {
const { t } = useTranslation();
const customTheme = props.customTheme;
const setCustomTheme = props.setCustomTheme;
const carouselRef = useRef<HTMLDivElement>(null);
const activeThemeRef = useRef<HTMLDivElement>(null);
const [isAtTop, setIsAtTop] = useState(true);
@ -628,6 +692,65 @@ export function AppearancePart(props: {
</div>
))}
</div>
{props.active === "custom" && (
<div className="animate-fade-in space-y-6 pt-4 border-t border-utils-divider">
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.customParts.primary")}
</p>
<div className="flex flex-wrap gap-3">
{primaryOptions.map((opt) => (
<ColorOption
key={opt.id}
active={customTheme.primary === opt.id}
colors={opt.colors}
onClick={() =>
setCustomTheme({ ...customTheme, primary: opt.id })
}
title={t(`settings.appearance.themes.${opt.id}`)}
/>
))}
</div>
</div>
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.customParts.secondary")}
</p>
<div className="flex flex-wrap gap-3">
{secondaryOptions.map((opt) => (
<ColorOption
key={opt.id}
active={customTheme.secondary === opt.id}
colors={opt.colors}
onClick={() =>
setCustomTheme({ ...customTheme, secondary: opt.id })
}
title={t(`settings.appearance.themes.${opt.id}`)}
/>
))}
</div>
</div>
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.customParts.tertiary")}
</p>
<div className="flex flex-wrap gap-3">
{tertiaryOptions.map((opt) => (
<ColorOption
key={opt.id}
active={customTheme.tertiary === opt.id}
colors={opt.colors}
onClick={() =>
setCustomTheme({ ...customTheme, tertiary: opt.id })
}
title={t(`settings.appearance.themes.${opt.id}`)}
/>
))}
</div>
</div>
</div>
)}
</div>
</div>

View file

@ -4,9 +4,25 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import {
primaryOptions,
secondaryOptions,
tertiaryOptions,
} from "@themes/custom";
export interface ThemeStore {
theme: string | null;
customTheme: {
primary: string;
secondary: string;
tertiary: string;
};
setTheme(v: string | null): void;
setCustomTheme(v: {
primary: string;
secondary: string;
tertiary: string;
}): void;
}
const currentDate = new Date();
@ -19,11 +35,21 @@ export const useThemeStore = create(
persist(
immer<ThemeStore>((set) => ({
theme: is420 ? "green" : isHalloween ? "autumn" : null,
customTheme: {
primary: "classic",
secondary: "classic",
tertiary: "classic",
},
setTheme(v) {
set((s) => {
s.theme = v;
});
},
setCustomTheme(v) {
set((s) => {
s.customTheme = v;
});
},
})),
{
name: "__MW::theme",
@ -53,12 +79,36 @@ export function ThemeProvider(props: {
}) {
const previewTheme = usePreviewThemeStore((s) => s.previewTheme);
const theme = useThemeStore((s) => s.theme);
const customTheme = useThemeStore((s) => s.customTheme);
const themeToDisplay = previewTheme ?? theme;
const themeSelector = themeToDisplay ? `theme-${themeToDisplay}` : undefined;
let styleContent = "";
if (themeToDisplay === "custom" && customTheme) {
const primary =
primaryOptions.find((o) => o.id === customTheme.primary)?.colors || {};
const secondary =
secondaryOptions.find((o) => o.id === customTheme.secondary)?.colors ||
{};
const tertiary =
tertiaryOptions.find((o) => o.id === customTheme.tertiary)?.colors || {};
const vars = { ...primary, ...secondary, ...tertiary };
const cssVars = Object.entries(vars)
.map(([k, v]) => `${k}: ${v};`)
.join(" ");
styleContent = `.theme-custom { ${cssVars} }`;
}
return (
<div className={themeSelector}>
{styleContent ? (
<Helmet>
<style>{styleContent}</style>
</Helmet>
) : null}
{props.applyGlobal ? (
<Helmet>
<body className={themeSelector} />

67
src/utils/color.ts Normal file
View file

@ -0,0 +1,67 @@
export function hexToRgb(hex: string): string | null {
// Remove hash
hex = hex.replace(/^#/, "");
// Convert 3-char hex to 6-char
if (hex.length === 3) {
hex = hex
.split("")
.map((c) => c + c)
.join("");
}
// Parse hex
const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(
hex,
);
return result
? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(
result[3],
16,
)}`
: null;
}
// Convert HSL/HSLA to RGB
// hsla(240, 25%, 6%, 1) -> 15 15 19 (approx)
function hslToRgb(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return `${Math.round(255 * f(0))} ${Math.round(255 * f(8))} ${Math.round(
255 * f(4),
)}`;
}
function parseHsla(hsla: string): string | null {
// matches hsla(H, S%, L%, A) or hsl(H, S%, L%)
// simple regex, assuming comma separation and valid syntax
const match = hsla.match(/hsla?\((\d+),\s*(\d+)%,\s*(\d+)%(?:,\s*[\d.]+)?\)/);
if (match) {
return hslToRgb(
parseInt(match[1], 10),
parseInt(match[2], 10),
parseInt(match[3], 10),
);
}
return null;
}
export function colorToRgbString(color: string): string {
if (color.startsWith("#")) {
const rgb = hexToRgb(color);
if (rgb) return rgb;
} else if (color.startsWith("hsl")) {
const rgb = parseHsla(color);
if (rgb) return rgb;
}
// If parsing fails, assume it's already in RGB or named color
// However, returning "red" for Tailwind opacity utility will likely fail (as it expects RGB components)
// But returning original string allows basic non-opacity usage to potentially work or fail gracefully.
return color;
}

232
themes/custom.ts Normal file
View file

@ -0,0 +1,232 @@
import merge from "lodash.merge";
import { createTheme } from "./types";
import { defaultTheme } from "./default";
import classic from "./list/classic";
import blue from "./list/blue";
import red from "./list/red";
import teal from "./list/teal";
import green from "./list/green";
import pink from "./list/pink";
import autumn from "./list/autumn";
import frost from "./list/frost";
import grape from "./list/grape";
import { colorToRgbString } from "../src/utils/color";
const availableThemes = [
{ id: "classic", theme: classic },
{ id: "blue", theme: blue },
{ id: "red", theme: red },
{ id: "teal", theme: teal },
{ id: "green", theme: green },
{ id: "pink", theme: pink },
{ id: "autumn", theme: autumn },
{ id: "frost", theme: frost },
{ id: "grape", theme: grape },
];
function cssVarName(path: string) {
return `--colors-${path}`;
}
// Generate the custom theme structure with CSS variables
function generateCustomThemeStructure(theme: any, prefix = ""): any {
const result: any = {};
for (const key in theme) {
if (typeof theme[key] === "object" && theme[key] !== null) {
result[key] = generateCustomThemeStructure(theme[key], `${prefix}${key}-`);
} else {
result[key] = `rgb(var(${cssVarName(`${prefix}${key}`)}) / <alpha-value>)`;
}
}
return result;
}
export const customTheme = createTheme({
name: "custom",
extend: {
colors: generateCustomThemeStructure(defaultTheme.extend.colors),
},
});
// Define parts
const parts = {
primary: [
"lightBar.light",
"type.logo",
"buttons.primary",
"buttons.primaryText",
"buttons.primaryHover",
"buttons.toggle",
"buttons.toggleDisabled",
"buttons.purple",
"buttons.purpleHover",
"global.accentA",
"global.accentB",
"pill.highlight",
"progress.filled",
"video.audio.set",
"video.context.type.accent",
"video.context.sliderFilled",
"video.scraping.loading",
"onboarding.good",
"onboarding.best",
"onboarding.link",
"onboarding.barFilled",
"settings.sidebar.type.iconActivated",
"settings.sidebar.type.activated",
"type.link",
"type.linkHover",
"largeCard.icon",
"mediaCard.barFillColor",
],
secondary: [
"type.text",
"type.dimmed",
"type.secondary",
"type.emphasis",
"type.divider",
"type.danger",
"type.success",
"buttons.secondary",
"buttons.secondaryText",
"buttons.secondaryHover",
"buttons.danger",
"buttons.dangerHover",
"buttons.cancel",
"buttons.cancelHover",
"utils.divider",
"search.text",
"search.placeholder",
"search.icon",
"dropdown.text",
"dropdown.secondary",
"dropdown.border",
"authentication.border",
"authentication.inputBg",
"authentication.inputBgHover",
"authentication.wordBackground",
"authentication.copyText",
"authentication.copyTextHover",
"authentication.errorText",
"settings.sidebar.activeLink",
"settings.sidebar.badge",
"settings.sidebar.type.secondary",
"settings.sidebar.type.inactive",
"settings.sidebar.type.icon",
"settings.card.border",
"onboarding.bar",
"onboarding.divider",
"onboarding.border",
"errors.border",
"errors.type.secondary",
"about.circle",
"about.circleText",
"editBadge.bg",
"editBadge.bgHover",
"editBadge.text",
"progress.background",
"progress.preloaded",
"pill.background",
"pill.backgroundHover",
"pill.activeBackground",
"video.buttonBackground",
"video.autoPlay.background",
"video.autoPlay.hover",
"video.scraping.error",
"video.scraping.success",
"video.scraping.noresult",
"video.context.light",
"video.context.border",
"video.context.hoverColor",
"video.context.buttonFocus",
"video.context.inputBg",
"video.context.buttonOverInputHover",
"video.context.inputPlaceholder",
"video.context.cardBorder",
"video.context.slider",
"video.context.error",
"video.context.buttons.list",
"video.context.buttons.active",
"video.context.closeHover",
"video.context.type.main",
"video.context.type.secondary",
"mediaCard.barColor",
"mediaCard.badge",
"mediaCard.badgeText",
],
tertiary: [
"background.main",
"background.secondary",
"background.secondaryHover",
"background.accentA",
"background.accentB",
"modal.background",
"mediaCard.shadow",
"mediaCard.hoverBackground",
"mediaCard.hoverAccent",
"mediaCard.hoverShadow",
"search.background",
"search.hoverBackground",
"search.focused",
"dropdown.background",
"dropdown.altBackground",
"dropdown.hoverBackground",
"dropdown.contentBackground",
"dropdown.highlight",
"dropdown.highlightHover",
"largeCard.background",
"settings.card.background",
"settings.card.altBackground",
"settings.saveBar.background",
"onboarding.card",
"onboarding.cardHover",
"errors.card",
"themePreview.primary",
"themePreview.secondary",
"themePreview.ghost",
"video.scraping.card",
"video.context.background",
"video.context.flagBg",
],
};
function getNestedValue(obj: any, path: string) {
return path.split(".").reduce((o, i) => (o ? o[i] : undefined), obj);
}
function extractColors(theme: any, keys: string[]) {
const colors: Record<string, string> = {};
// We need to flatten the structure to css vars
keys.forEach((key) => {
const value = getNestedValue(theme.extend.colors, key);
if (value) {
colors[cssVarName(key.replace(/\./g, "-"))] = colorToRgbString(value);
}
});
return colors;
}
// Generate options for each part
export const primaryOptions = availableThemes.map((t) => {
const merged = merge({}, defaultTheme, t.theme);
return {
id: t.id,
colors: extractColors(merged, parts.primary),
};
});
export const secondaryOptions = availableThemes.map((t) => {
const merged = merge({}, defaultTheme, t.theme);
return {
id: t.id,
colors: extractColors(merged, parts.secondary),
};
});
export const tertiaryOptions = availableThemes.map((t) => {
const merged = merge({}, defaultTheme, t.theme);
return {
id: t.id,
colors: extractColors(merged, parts.tertiary),
};
});

View file

@ -1,9 +1,10 @@
import { allThemes } from "./all";
import { customTheme } from "./custom";
export { defaultTheme } from "./default";
export { allThemes } from "./all";
export const safeThemeList = allThemes
export const safeThemeList = [customTheme, ...allThemes]
.flatMap((v) => v.selectors)
.filter((v) => v.startsWith("."))
.map((v) => v.slice(1)); // remove dot from selector

View file

@ -1,6 +1,9 @@
import { DeepPartial } from "vite-plugin-checker/dist/esm/types";
import { defaultTheme } from "./default";
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
export interface Theme {
name: string;
extend: DeepPartial<(typeof defaultTheme)["extend"]>;

View file

@ -18,11 +18,12 @@
"baseUrl": "./src",
"paths": {
"@/*": ["./*"],
"@themes/*": ["../themes/*"],
"@sozialhelden/ietf-language-tags": [
"../node_modules/@sozialhelden/ietf-language-tags/dist/cjs"
]
},
"typeRoots": ["node_modules/@types"]
},
"include": ["src"]
"include": ["src", "themes"]
}

View file

@ -166,6 +166,7 @@ export default defineConfig(({ mode }) => {
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@themes": path.resolve(__dirname, "./themes"),
"@sozialhelden/ietf-language-tags": path.resolve(
__dirname,
"./node_modules/@sozialhelden/ietf-language-tags/dist/cjs",