Skip to content

Commit 7bf81de

Browse files
authored
Add custom theme import and overrides (#111)
- Add a dialog to import tweakcn CSS, JSON, or URLs - Persist and apply custom themes with font and radius overrides - Surface custom theme selection in settings
1 parent f43f959 commit 7bf81de

4 files changed

Lines changed: 1019 additions & 22 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { LinkIcon, PaletteIcon, SparklesIcon } from "lucide-react";
2+
import { useCallback, useRef, useState } from "react";
3+
import {
4+
type CustomThemeData,
5+
getStoredCustomTheme,
6+
isTweakcnURL,
7+
parseThemeInput,
8+
setStoredCustomTheme,
9+
} from "../lib/customTheme";
10+
import { cn } from "../lib/utils";
11+
import { Button } from "./ui/button";
12+
import {
13+
Dialog,
14+
DialogDescription,
15+
DialogFooter,
16+
DialogHeader,
17+
DialogPanel,
18+
DialogPopup,
19+
DialogTitle,
20+
} from "./ui/dialog";
21+
22+
// ---------------------------------------------------------------------------
23+
// Color Preview Swatch
24+
// ---------------------------------------------------------------------------
25+
26+
function ColorSwatch({
27+
label,
28+
bg,
29+
fg,
30+
}: {
31+
label: string;
32+
bg: string | undefined;
33+
fg: string | undefined;
34+
}) {
35+
if (!bg) return null;
36+
return (
37+
<div className="flex items-center gap-2">
38+
<div
39+
className="size-5 shrink-0 rounded-md border border-border/50"
40+
style={{ background: bg }}
41+
>
42+
{fg ? (
43+
<span
44+
className="flex size-full items-center justify-center text-[8px] font-bold"
45+
style={{ color: fg }}
46+
>
47+
A
48+
</span>
49+
) : null}
50+
</div>
51+
<span className="text-[11px] text-muted-foreground">{label}</span>
52+
</div>
53+
);
54+
}
55+
56+
// ---------------------------------------------------------------------------
57+
// Preview Panel
58+
// ---------------------------------------------------------------------------
59+
60+
function ThemePreview({ theme }: { theme: CustomThemeData | null }) {
61+
if (!theme || Object.keys(theme.light).length === 0) return null;
62+
63+
const colors = [
64+
{ label: "Background", bg: theme.light.background, fg: theme.light.foreground },
65+
{ label: "Primary", bg: theme.light.primary, fg: theme.light["primary-foreground"] },
66+
{ label: "Secondary", bg: theme.light.secondary, fg: theme.light["secondary-foreground"] },
67+
{ label: "Accent", bg: theme.light.accent, fg: theme.light["accent-foreground"] },
68+
{ label: "Muted", bg: theme.light.muted, fg: theme.light["muted-foreground"] },
69+
{ label: "Card", bg: theme.light.card, fg: theme.light["card-foreground"] },
70+
{
71+
label: "Destructive",
72+
bg: theme.light.destructive,
73+
fg: theme.light["destructive-foreground"],
74+
},
75+
{ label: "Border", bg: theme.light.border, fg: undefined },
76+
];
77+
78+
const radius = theme.light.radius;
79+
const fontSans = theme.light["font-sans"];
80+
const fontMono = theme.light["font-mono"];
81+
82+
return (
83+
<div className="space-y-3">
84+
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
85+
<SparklesIcon className="size-3.5" />
86+
Preview {theme.name ? `- ${theme.name}` : ""}
87+
</div>
88+
89+
{/* Color swatches */}
90+
<div className="grid grid-cols-4 gap-2">
91+
{colors.map((c) => (
92+
<ColorSwatch key={c.label} {...c} />
93+
))}
94+
</div>
95+
96+
{/* Design tokens */}
97+
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
98+
{radius ? <span>Radius: {radius}</span> : null}
99+
{fontSans ? (
100+
<span className="max-w-40 truncate">Font: {fontSans.split(",")[0]?.trim()}</span>
101+
) : null}
102+
{fontMono ? (
103+
<span className="max-w-40 truncate">Mono: {fontMono.split(",")[0]?.trim()}</span>
104+
) : null}
105+
</div>
106+
107+
{/* Variable count */}
108+
<p className="text-[11px] text-muted-foreground">
109+
{Object.keys(theme.light).length} light + {Object.keys(theme.dark).length} dark variables
110+
</p>
111+
</div>
112+
);
113+
}
114+
115+
// ---------------------------------------------------------------------------
116+
// Main Dialog
117+
// ---------------------------------------------------------------------------
118+
119+
export function CustomThemeDialog({
120+
open,
121+
onOpenChange,
122+
onApply,
123+
}: {
124+
open: boolean;
125+
onOpenChange: (open: boolean) => void;
126+
onApply: (theme: CustomThemeData) => void;
127+
}) {
128+
const [input, setInput] = useState("");
129+
const [error, setError] = useState<string | null>(null);
130+
const [loading, setLoading] = useState(false);
131+
const [preview, setPreview] = useState<CustomThemeData | null>(null);
132+
const textareaRef = useRef<HTMLTextAreaElement>(null);
133+
134+
// Pre-populate with existing custom theme
135+
const handleOpen = useCallback(() => {
136+
const existing = getStoredCustomTheme();
137+
if (existing) {
138+
setPreview(existing);
139+
}
140+
setError(null);
141+
setInput("");
142+
}, []);
143+
144+
// Parse input on change (debounced feel via paste handling)
145+
const handleParse = useCallback(async () => {
146+
const trimmed = input.trim();
147+
if (!trimmed) {
148+
setPreview(null);
149+
setError(null);
150+
return;
151+
}
152+
153+
setLoading(true);
154+
setError(null);
155+
156+
try {
157+
const theme = await parseThemeInput(trimmed);
158+
setPreview(theme);
159+
} catch (e) {
160+
setError(e instanceof Error ? e.message : "Failed to parse theme");
161+
setPreview(null);
162+
} finally {
163+
setLoading(false);
164+
}
165+
}, [input]);
166+
167+
const handleApply = useCallback(() => {
168+
if (!preview) return;
169+
setStoredCustomTheme(preview);
170+
onApply(preview);
171+
onOpenChange(false);
172+
setInput("");
173+
setPreview(null);
174+
setError(null);
175+
}, [preview, onApply, onOpenChange]);
176+
177+
const isUrl = isTweakcnURL(input.trim());
178+
179+
return (
180+
<Dialog
181+
open={open}
182+
onOpenChange={(nextOpen) => {
183+
if (nextOpen) handleOpen();
184+
onOpenChange(nextOpen);
185+
}}
186+
>
187+
<DialogPopup className="max-w-lg">
188+
<DialogHeader>
189+
<DialogTitle className="flex items-center gap-2">
190+
<PaletteIcon className="size-4.5" />
191+
Import Custom Theme
192+
</DialogTitle>
193+
<DialogDescription>
194+
Paste CSS or a{" "}
195+
<a
196+
href="https://tweakcn.com"
197+
target="_blank"
198+
rel="noopener noreferrer"
199+
className="text-info-foreground underline"
200+
>
201+
tweakcn.com
202+
</a>{" "}
203+
theme URL below.
204+
</DialogDescription>
205+
</DialogHeader>
206+
207+
<DialogPanel className="space-y-4">
208+
{/* Input area */}
209+
<div className="relative">
210+
<textarea
211+
ref={textareaRef}
212+
value={input}
213+
onChange={(e) => {
214+
setInput(e.target.value);
215+
setError(null);
216+
setPreview(null);
217+
}}
218+
onPaste={() => {
219+
// Auto-parse after paste with a small delay so the value is set
220+
setTimeout(() => {
221+
handleParse();
222+
}, 50);
223+
}}
224+
placeholder={`Paste theme CSS, JSON, or a tweakcn.com URL...\n\nExample:\nhttps://tweakcn.com/themes/catppuccin\n\nor\n\n:root {\n --background: oklch(1 0 0);\n --primary: oklch(0.58 0.2 277);\n ...\n}`}
225+
className="min-h-36 w-full resize-y rounded-lg border border-input bg-background p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/24"
226+
spellCheck={false}
227+
/>
228+
{isUrl ? (
229+
<div className="absolute top-2 right-2 flex items-center gap-1 rounded-md bg-accent/80 px-1.5 py-0.5 text-[10px] text-accent-foreground">
230+
<LinkIcon className="size-2.5" />
231+
URL
232+
</div>
233+
) : null}
234+
</div>
235+
236+
{/* Parse button */}
237+
<div className="flex items-center gap-2">
238+
<Button
239+
type="button"
240+
size="sm"
241+
variant="outline"
242+
disabled={!input.trim() || loading}
243+
onClick={handleParse}
244+
>
245+
{loading ? "Loading..." : "Parse Theme"}
246+
</Button>
247+
{error ? <p className="text-xs text-destructive">{error}</p> : null}
248+
</div>
249+
250+
{/* Preview */}
251+
{preview ? (
252+
<div className="rounded-xl border border-border/70 bg-muted/30 p-3">
253+
<ThemePreview theme={preview} />
254+
</div>
255+
) : null}
256+
</DialogPanel>
257+
258+
<DialogFooter>
259+
<Button
260+
type="button"
261+
size="sm"
262+
variant="outline"
263+
onClick={() => {
264+
onOpenChange(false);
265+
setInput("");
266+
setPreview(null);
267+
setError(null);
268+
}}
269+
>
270+
Cancel
271+
</Button>
272+
<Button type="button" size="sm" disabled={!preview || loading} onClick={handleApply}>
273+
Apply Theme
274+
</Button>
275+
</DialogFooter>
276+
</DialogPopup>
277+
</Dialog>
278+
);
279+
}

apps/web/src/hooks/useTheme.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
import { useCallback, useEffect, useSyncExternalStore } from "react";
2+
import {
3+
applyCustomTheme,
4+
applyFontOverride,
5+
applyRadiusOverride,
6+
getStoredCustomTheme,
7+
initCustomTheme,
8+
removeCustomTheme,
9+
} from "../lib/customTheme";
210

311
type Theme = "light" | "dark" | "system";
412
type ColorTheme =
@@ -27,6 +35,7 @@ export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [
2735
{ id: "carbon", label: "Carbon" },
2836
{ id: "vapor", label: "Vapor" },
2937
{ id: "cathedral-circuit", label: "Cathedral Circuit" },
38+
{ id: "custom", label: "Custom" },
3039
];
3140

3241
export const FONT_FAMILIES: { id: FontFamily; label: string }[] = [
@@ -142,6 +151,9 @@ function syncDesktopTheme(theme: Theme) {
142151
});
143152
}
144153

154+
// Initialize custom theme + overrides on module load
155+
initCustomTheme();
156+
145157
// Apply immediately on module load to prevent flash
146158
applyTheme(getStored());
147159

@@ -238,3 +250,5 @@ export function useTheme() {
238250
setFontFamily,
239251
} as const;
240252
}
253+
254+
export type { Theme, ColorTheme };

0 commit comments

Comments
 (0)