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