diff --git a/apps/web/src/components/CustomThemeDialog.tsx b/apps/web/src/components/CustomThemeDialog.tsx new file mode 100644 index 00000000..c64401a8 --- /dev/null +++ b/apps/web/src/components/CustomThemeDialog.tsx @@ -0,0 +1,279 @@ +import { LinkIcon, PaletteIcon, SparklesIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { + type CustomThemeData, + getStoredCustomTheme, + isTweakcnURL, + parseThemeInput, + setStoredCustomTheme, +} from "../lib/customTheme"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; + +// --------------------------------------------------------------------------- +// Color Preview Swatch +// --------------------------------------------------------------------------- + +function ColorSwatch({ + label, + bg, + fg, +}: { + label: string; + bg: string | undefined; + fg: string | undefined; +}) { + if (!bg) return null; + return ( +
+
+ {fg ? ( + + A + + ) : null} +
+ {label} +
+ ); +} + +// --------------------------------------------------------------------------- +// Preview Panel +// --------------------------------------------------------------------------- + +function ThemePreview({ theme }: { theme: CustomThemeData | null }) { + if (!theme || Object.keys(theme.light).length === 0) return null; + + const colors = [ + { label: "Background", bg: theme.light.background, fg: theme.light.foreground }, + { label: "Primary", bg: theme.light.primary, fg: theme.light["primary-foreground"] }, + { label: "Secondary", bg: theme.light.secondary, fg: theme.light["secondary-foreground"] }, + { label: "Accent", bg: theme.light.accent, fg: theme.light["accent-foreground"] }, + { label: "Muted", bg: theme.light.muted, fg: theme.light["muted-foreground"] }, + { label: "Card", bg: theme.light.card, fg: theme.light["card-foreground"] }, + { + label: "Destructive", + bg: theme.light.destructive, + fg: theme.light["destructive-foreground"], + }, + { label: "Border", bg: theme.light.border, fg: undefined }, + ]; + + const radius = theme.light.radius; + const fontSans = theme.light["font-sans"]; + const fontMono = theme.light["font-mono"]; + + return ( +
+
+ + Preview {theme.name ? `- ${theme.name}` : ""} +
+ + {/* Color swatches */} +
+ {colors.map((c) => ( + + ))} +
+ + {/* Design tokens */} +
+ {radius ? Radius: {radius} : null} + {fontSans ? ( + Font: {fontSans.split(",")[0]?.trim()} + ) : null} + {fontMono ? ( + Mono: {fontMono.split(",")[0]?.trim()} + ) : null} +
+ + {/* Variable count */} +

+ {Object.keys(theme.light).length} light + {Object.keys(theme.dark).length} dark variables +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Main Dialog +// --------------------------------------------------------------------------- + +export function CustomThemeDialog({ + open, + onOpenChange, + onApply, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onApply: (theme: CustomThemeData) => void; +}) { + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [preview, setPreview] = useState(null); + const textareaRef = useRef(null); + + // Pre-populate with existing custom theme + const handleOpen = useCallback(() => { + const existing = getStoredCustomTheme(); + if (existing) { + setPreview(existing); + } + setError(null); + setInput(""); + }, []); + + // Parse input on change (debounced feel via paste handling) + const handleParse = useCallback(async () => { + const trimmed = input.trim(); + if (!trimmed) { + setPreview(null); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const theme = await parseThemeInput(trimmed); + setPreview(theme); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to parse theme"); + setPreview(null); + } finally { + setLoading(false); + } + }, [input]); + + const handleApply = useCallback(() => { + if (!preview) return; + setStoredCustomTheme(preview); + onApply(preview); + onOpenChange(false); + setInput(""); + setPreview(null); + setError(null); + }, [preview, onApply, onOpenChange]); + + const isUrl = isTweakcnURL(input.trim()); + + return ( + { + if (nextOpen) handleOpen(); + onOpenChange(nextOpen); + }} + > + + + + + Import Custom Theme + + + Paste CSS or a{" "} + + tweakcn.com + {" "} + theme URL below. + + + + + {/* Input area */} +
+