mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 09:45:33 +00:00
add custom theme
This commit is contained in:
parent
87851c1167
commit
cc029c8c0a
12 changed files with 671 additions and 9 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
67
src/utils/color.ts
Normal 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
232
themes/custom.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]>;
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue