diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index d0e66a16..50758c74 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -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", diff --git a/src/backend/accounts/settings.ts b/src/backend/accounts/settings.ts index 304f360a..d11eff5d 100644 --- a/src/backend/accounts/settings.ts +++ b/src/backend/accounts/settings.ts @@ -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( diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts index 101a476b..2a385615 100644 --- a/src/hooks/useSettingsState.ts +++ b/src/hooks/useSettingsState.ts @@ -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( 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, + }, }; } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6d00bd06..f3bb1add 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 ( @@ -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} /> )} diff --git a/src/pages/parts/settings/AppearancePart.tsx b/src/pages/parts/settings/AppearancePart.tsx index fb72bc28..a3fdda1e 100644 --- a/src/pages/parts/settings/AppearancePart.tsx +++ b/src/pages/parts/settings/AppearancePart.tsx @@ -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; + 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 ( +
+
+
+
+
+
+ ); +} + 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(null); const activeThemeRef = useRef(null); const [isAtTop, setIsAtTop] = useState(true); @@ -628,6 +692,65 @@ export function AppearancePart(props: {
))}
+ + {props.active === "custom" && ( +
+
+

+ {t("settings.appearance.customParts.primary")} +

+
+ {primaryOptions.map((opt) => ( + + setCustomTheme({ ...customTheme, primary: opt.id }) + } + title={t(`settings.appearance.themes.${opt.id}`)} + /> + ))} +
+
+
+

+ {t("settings.appearance.customParts.secondary")} +

+
+ {secondaryOptions.map((opt) => ( + + setCustomTheme({ ...customTheme, secondary: opt.id }) + } + title={t(`settings.appearance.themes.${opt.id}`)} + /> + ))} +
+
+
+

+ {t("settings.appearance.customParts.tertiary")} +

+
+ {tertiaryOptions.map((opt) => ( + + setCustomTheme({ ...customTheme, tertiary: opt.id }) + } + title={t(`settings.appearance.themes.${opt.id}`)} + /> + ))} +
+
+
+ )} diff --git a/src/stores/theme/index.tsx b/src/stores/theme/index.tsx index 5fa278b7..6c154e6a 100644 --- a/src/stores/theme/index.tsx +++ b/src/stores/theme/index.tsx @@ -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((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 (
+ {styleContent ? ( + + + + ) : null} {props.applyGlobal ? ( diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 00000000..fedb6e05 --- /dev/null +++ b/src/utils/color.ts @@ -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; +} diff --git a/themes/custom.ts b/themes/custom.ts new file mode 100644 index 00000000..e8fb5084 --- /dev/null +++ b/themes/custom.ts @@ -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}`)}) / )`; + } + } + 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 = {}; + // 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), + }; +}); diff --git a/themes/index.ts b/themes/index.ts index 9bd8c422..72dc4496 100644 --- a/themes/index.ts +++ b/themes/index.ts @@ -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 diff --git a/themes/types.ts b/themes/types.ts index 25320a51..70925709 100644 --- a/themes/types.ts +++ b/themes/types.ts @@ -1,6 +1,9 @@ -import { DeepPartial } from "vite-plugin-checker/dist/esm/types"; import { defaultTheme } from "./default"; +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + export interface Theme { name: string; extend: DeepPartial<(typeof defaultTheme)["extend"]>; diff --git a/tsconfig.json b/tsconfig.json index 51f40121..4ad34a79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } diff --git a/vite.config.mts b/vite.config.mts index 66013c2f..248457e0 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -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",