Merge pull request #35 from jnsahaj/feature/editor-ui-revamp

Feature/editor UI revamp
This commit is contained in:
Sahaj Jain
2025-04-18 22:18:51 +05:30
committed by GitHub
16 changed files with 1206 additions and 624 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ npm install
npm run dev
```
4. Open [http://localhost:8080](http://localhost:8080) in your browser.
4. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Contributors
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useEditorStore } from "@/store/editor-store";
import { Button } from "../ui/button";
import { FileCode, RefreshCw, Code, Moon, Sun } from "lucide-react";
import CssImportDialog from "./css-import-dialog";
import { useState } from "react";
import { parseCssInput } from "@/utils/parse-css-input";
import { toast } from "../ui/use-toast";
import { CodePanelDialog } from "./code-panel-dialog";
import { Separator } from "../ui/separator";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { useTheme } from "../theme-provider";
import ContrastChecker from "./contrast-checker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function ActionBar() {
const {
themeState,
resetToCurrentPreset,
setThemeState,
hasCurrentPresetChanged,
} = useEditorStore();
const [cssImportOpen, setCssImportOpen] = useState(false);
const [codePanelOpen, setCodePanelOpen] = useState(false);
const handleCssImport = (css: string) => {
const { lightColors, darkColors } = parseCssInput(css);
const styles = {
...themeState.styles,
light: { ...themeState.styles.light, ...lightColors },
dark: { ...themeState.styles.dark, ...darkColors },
};
setThemeState({
...themeState,
styles,
});
toast({
title: "CSS imported",
description: "Your custom CSS has been imported successfully",
});
};
const { theme, toggleTheme } = useTheme();
const handleThemeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
const { clientX: x, clientY: y } = event;
toggleTheme({ x, y });
};
return (
<div className="border-b">
<div className="flex h-14 items-center justify-end gap-4 px-4">
<div className="flex items-center gap-2">
<div className="px-2">
<Tooltip>
<TooltipTrigger>
<SwitchPrimitives.Root
checked={theme === "dark"}
onClick={handleThemeToggle}
className="peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent data-[state=unchecked]:bg-input"
>
<SwitchPrimitives.Thumb className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 flex items-center justify-center">
{theme === "dark" ? (
<Moon className="size-3" />
) : (
<Sun className="size-3" />
)}
</SwitchPrimitives.Thumb>
</SwitchPrimitives.Root>
</TooltipTrigger>
<TooltipContent>Toggle light/dark mode</TooltipContent>
</Tooltip>
</div>
<Separator orientation="vertical" className="h-8" />
<ContrastChecker
currentStyles={themeState.styles[themeState.currentMode]}
/>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
onClick={() => setCssImportOpen(true)}
>
<FileCode className="size-3.5" />
<span className="text-sm hidden md:block">Import</span>
</Button>
</TooltipTrigger>
<TooltipContent>Import CSS variables</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
onClick={resetToCurrentPreset}
disabled={!hasCurrentPresetChanged()}
>
<RefreshCw className="size-3.5" />
<span className="text-sm hidden md:block">Reset</span>
</Button>
</TooltipTrigger>
<TooltipContent>Reset to preset defaults</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-8" />
<Tooltip>
<TooltipTrigger>
<Button
variant="secondary"
size="sm"
className="h-8 px-2 gap-1.5"
onClick={() => setCodePanelOpen(true)}
>
<Code className="size-3.5" />
<span className="text-sm">Code</span>
</Button>
</TooltipTrigger>
<TooltipContent>View theme code</TooltipContent>
</Tooltip>
</div>
</div>
<CssImportDialog
open={cssImportOpen}
onOpenChange={setCssImportOpen}
onImport={handleCssImport}
/>
<CodePanelDialog
open={codePanelOpen}
onOpenChange={setCodePanelOpen}
themeEditorState={themeState}
/>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Dialog, DialogContent } from "@/components/ui/dialog";
import CodePanel from "./code-panel";
import { ThemeEditorState } from "@/types/editor";
interface CodePanelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
themeEditorState: ThemeEditorState;
}
export function CodePanelDialog({
open,
onOpenChange,
themeEditorState,
}: CodePanelDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[80vh] overflow-hidden">
<div className="h-full overflow-auto">
<CodePanel themeEditorState={themeEditorState} />
</div>
</DialogContent>
</Dialog>
);
}
+4 -25
View File
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check, PanelRight } from "lucide-react";
import { Copy, Check } from "lucide-react";
import { ThemeEditorState } from "@/types/editor";
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { ColorFormat } from "../../types";
@@ -14,21 +14,13 @@ import {
import { usePostHog } from "posthog-js/react";
import { useEditorStore } from "@/store/editor-store";
import { usePreferencesStore } from "@/store/preferences-store";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { generateThemeCode } from "@/utils/theme-style-generator";
interface CodePanelProps {
themeEditorState: ThemeEditorState;
onCodePanelToggle: () => void;
}
const CodePanel: React.FC<CodePanelProps> = ({
themeEditorState,
onCodePanelToggle,
}) => {
const CodePanel: React.FC<CodePanelProps> = ({ themeEditorState }) => {
const [registryCopied, setRegistryCopied] = useState(false);
const [copied, setCopied] = useState(false);
const posthog = usePostHog();
@@ -102,20 +94,7 @@ const CodePanel: React.FC<CodePanelProps> = ({
<div className="h-full flex flex-col p-4">
<div className="flex-none mb-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Code</h2>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onCodePanelToggle}
className="h-8 invisible md:visible group"
>
<PanelRight className="size-4 group-hover:scale-120 transition-all" />
</Button>
</TooltipTrigger>
<TooltipContent>Collapse Code Panel</TooltipContent>
</Tooltip>
<h2 className="text-lg font-semibold">Theme Code</h2>
</div>
{preset && preset !== "default" && (
<div className="mt-4 rounded-md overflow-hidden border">
+3 -4
View File
@@ -40,11 +40,10 @@ const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => {
>
{label}
</Label>
<div className="text-xs text-muted-foreground">{localColor}</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div
className="h-8 w-8 rounded border cursor-pointer overflow-hidden relative flex items-center justify-center"
className="h-8 w-8 border cursor-pointer overflow-hidden relative flex items-center justify-center rounded"
style={{ backgroundColor: localColor }}
onClick={() => setIsOpen(!isOpen)}
>
@@ -60,7 +59,7 @@ const ColorPicker = ({ color, onChange, label }: ColorPickerProps) => {
type="text"
value={localColor}
onChange={handleColorChange}
className="flex-1 h-8 px-2 text-sm rounded-md border bg-background"
className="flex-1 h-8 px-2 text-sm rounded bg-input/25 border border-border/20"
/>
</div>
</div>
+407
View File
@@ -0,0 +1,407 @@
import React, { useState } from "react";
import { useContrastChecker } from "../../hooks/use-contrast-checker";
import { ThemeStyleProps } from "@/types/theme";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "../ui/dialog";
import { Contrast, Check, AlertTriangle, Info, Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "../ui/card";
import { Badge } from "../ui/badge";
import { ScrollArea } from "../ui/scroll-area";
import { Separator } from "../ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useTheme } from "@/components/theme-provider";
type ContrastCheckerProps = {
currentStyles: ThemeStyleProps;
};
const MIN_CONTRAST_RATIO = 4.5;
type ColorCategory = "content" | "interactive" | "functional";
type ColorPair = {
id: string;
foregroundId: keyof ThemeStyleProps;
backgroundId: keyof ThemeStyleProps;
foreground: string | undefined;
background: string | undefined;
label: string;
category: ColorCategory;
};
const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
const [filter, setFilter] = useState<"all" | "issues">("all");
const { theme, toggleTheme } = useTheme();
const colorPairsToCheck: ColorPair[] = [
// Content - Base, background, cards, containers
{
id: "base",
foregroundId: "foreground",
backgroundId: "background",
foreground: currentStyles?.["foreground"],
background: currentStyles?.["background"],
label: "Base",
category: "content",
},
{
id: "card",
foregroundId: "card-foreground",
backgroundId: "card",
foreground: currentStyles?.["card-foreground"],
background: currentStyles?.["card"],
label: "Card",
category: "content",
},
{
id: "popover",
foregroundId: "popover-foreground",
backgroundId: "popover",
foreground: currentStyles?.["popover-foreground"],
background: currentStyles?.["popover"],
label: "Popover",
category: "content",
},
{
id: "muted",
foregroundId: "muted-foreground",
backgroundId: "muted",
foreground: currentStyles?.["muted-foreground"],
background: currentStyles?.["muted"],
label: "Muted",
category: "content",
},
// Interactive - Buttons, links, actions
{
id: "primary",
foregroundId: "primary-foreground",
backgroundId: "primary",
foreground: currentStyles?.["primary-foreground"],
background: currentStyles?.["primary"],
label: "Primary",
category: "interactive",
},
{
id: "secondary",
foregroundId: "secondary-foreground",
backgroundId: "secondary",
foreground: currentStyles?.["secondary-foreground"],
background: currentStyles?.["secondary"],
label: "Secondary",
category: "interactive",
},
{
id: "accent",
foregroundId: "accent-foreground",
backgroundId: "accent",
foreground: currentStyles?.["accent-foreground"],
background: currentStyles?.["accent"],
label: "Accent",
category: "interactive",
},
// Functional - Sidebar, destructive, special purposes
{
id: "destructive",
foregroundId: "destructive-foreground",
backgroundId: "destructive",
foreground: currentStyles?.["destructive-foreground"],
background: currentStyles?.["destructive"],
label: "Destructive",
category: "functional",
},
{
id: "sidebar",
foregroundId: "sidebar-foreground",
backgroundId: "sidebar",
foreground: currentStyles?.["sidebar-foreground"],
background: currentStyles?.["sidebar"],
label: "Sidebar Base",
category: "functional",
},
{
id: "sidebar-primary",
foregroundId: "sidebar-primary-foreground",
backgroundId: "sidebar-primary",
foreground: currentStyles?.["sidebar-primary-foreground"],
background: currentStyles?.["sidebar-primary"],
label: "Sidebar Primary",
category: "functional",
},
{
id: "sidebar-accent",
foregroundId: "sidebar-accent-foreground",
backgroundId: "sidebar-accent",
foreground: currentStyles?.["sidebar-accent-foreground"],
background: currentStyles?.["sidebar-accent"],
label: "Sidebar Accent",
category: "functional",
},
];
const validColorPairsToCheck = colorPairsToCheck.filter(
(pair): pair is ColorPair & { foreground: string; background: string } =>
!!pair.foreground && !!pair.background
);
const contrastResults = useContrastChecker(validColorPairsToCheck);
const getContrastResult = (pairId: string) => {
return contrastResults?.find((res) => res.id === pairId);
};
const totalIssues = contrastResults?.filter(
(result) => result.contrastRatio < MIN_CONTRAST_RATIO
).length;
const filteredPairs =
filter === "all"
? colorPairsToCheck
: colorPairsToCheck.filter((pair) => {
const result = getContrastResult(pair.id);
return result && result.contrastRatio < MIN_CONTRAST_RATIO;
});
// Group color pairs by category
const categoryLabels: Record<ColorCategory, string> = {
content: "Content & Containers",
interactive: "Interactive Elements",
functional: "Navigation & Functional",
};
const categories: ColorCategory[] = ["content", "interactive", "functional"];
const groupedPairs = categories
.map((category) => ({
category,
label: categoryLabels[category],
pairs: filteredPairs.filter((pair) => pair.category === category),
}))
.filter((group) => group.pairs.length > 0);
return (
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="relative h-8 px-2 gap-1.5 text-muted-foreground hover:text-foreground hover:bg-accent/50"
>
<Contrast className="h-4 w-4" />
<span className="text-sm hidden md:block">Contrast</span>
</Button>
<TooltipContent>Check contrast accessibility</TooltipContent>
</TooltipTrigger>
</Tooltip>
</DialogTrigger>
<DialogContent className="max-w-screen-lg max-h-[90vh]">
<DialogHeader className="mb-4">
<div className="flex justify-between items-center">
<div>
<DialogTitle className="text-xl font-bold">
Contrast Checker
</DialogTitle>
<DialogDescription className="text-sm mt-1">
WCAG 2.0 AA requires a contrast ratio of at least{" "}
{MIN_CONTRAST_RATIO}:1{" • "}
<a
href="https://www.w3.org/TR/WCAG21/"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:text-primary/80 transition-colors"
>
Learn more
</a>
</DialogDescription>
</div>
<div className="items-center gap-2 hidden md:flex">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={(e) => toggleTheme({ x: e.clientX, y: e.clientY })}
>
{theme === "light" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">Toggle theme</p>
</TooltipContent>
</Tooltip>
<Button
variant={filter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("all")}
>
All
</Button>
<Button
variant={filter === "issues" ? "default" : "outline"}
size="sm"
onClick={() => setFilter("issues")}
>
<AlertTriangle className={cn("h-3 w-3 mr-1")} />
Issues ({totalIssues})
</Button>
</div>
</div>
</DialogHeader>
<ScrollArea className="h-[calc(90vh-12rem)]">
<div className="space-y-6">
{groupedPairs.map((group) => (
<div key={group.category} className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-md font-semibold">{group.label}</h2>
<Separator className="flex-1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.pairs.map((pair) => {
const result = getContrastResult(pair.id);
const isValid =
result?.contrastRatio !== undefined &&
result?.contrastRatio >= MIN_CONTRAST_RATIO;
const contrastRatio =
result?.contrastRatio?.toFixed(2) ?? "N/A";
return (
<Card
key={pair.id}
className={cn(
"transition-all duration-200",
!isValid && " border-dashed"
)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h3
className={cn(
"font-medium flex items-center",
!isValid && "text-destructive"
)}
>
{pair.label}
{!isValid && (
<AlertTriangle className="size-3.5 ml-1" />
)}
</h3>
<Badge
variant={isValid ? "default" : "destructive"}
className={cn(
"flex items-center gap-1 text-xs",
isValid
? "bg-muted text-muted-foreground"
: "bg-destructive text-destructive-foreground"
)}
>
{isValid ? (
<>
<Check className="h-3 w-3" />
{contrastRatio}
</>
) : (
<>
<AlertTriangle className="h-3 w-3" />
{contrastRatio}
</>
)}
</Badge>
</div>
<div className="flex gap-2 items-center">
<div className="flex flex-col items-center gap-3 flex-1">
<div className="flex w-full items-center gap-3">
<div
style={{
backgroundColor:
pair.background ?? "#000000",
}}
className="h-12 w-12 rounded-md border shadow-sm flex-shrink-0"
></div>
<div className="flex flex-col">
<span className="text-xs font-medium">
Background
</span>
<span className="text-xs text-muted-foreground font-mono">
{pair.background}
</span>
</div>
</div>
<div className="flex w-full items-center gap-3">
<div
style={{
backgroundColor:
pair.foreground ?? "#ffffff",
}}
className="h-12 w-12 rounded-md border shadow-sm flex-shrink-0"
></div>
<div className="flex flex-col">
<span className="text-xs font-medium">
Foreground
</span>
<span className="text-xs text-muted-foreground font-mono">
{pair.foreground}
</span>
</div>
</div>
</div>
<div
style={{
backgroundColor:
pair.background ?? "transparent",
}}
className="flex-1 h-full min-h-[120px] rounded-lg border shadow-sm flex items-center justify-center overflow-hidden"
>
{pair.foreground && pair.background ? (
<div className="text-center p-4">
<p
style={{ color: pair.foreground }}
className="text-4xl font-bold tracking-wider mb-2"
>
Aa
</p>
<p
style={{ color: pair.foreground }}
className="text-sm font-medium"
>
Sample Text
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
Preview
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
export default ContrastChecker;
+5 -2
View File
@@ -13,7 +13,10 @@ const ControlSection = ({
const [isExpanded, setIsExpanded] = useState(expanded);
return (
<div id={id} className={cn("mb-4 border rounded-lg overflow-hidden", className)}>
<div
id={id}
className={cn("mb-4 border rounded-lg overflow-hidden", className)}
>
<div
className="flex items-center justify-between p-3 cursor-pointer bg-background hover:bg-muted"
onClick={() => setIsExpanded(!isExpanded)}
@@ -35,7 +38,7 @@ const ControlSection = ({
<div
className={cn(
"overflow-hidden transition-all duration-200",
isExpanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0",
isExpanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="p-3 bg-background border-t">{children}</div>
+10 -42
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React from "react";
import {
ResizablePanelGroup,
ResizablePanel,
@@ -13,7 +13,6 @@ import {
ThemeEditorState,
} from "@/types/editor";
import { ThemeStyles } from "@/types/theme";
import CodePanel from "./code-panel";
import { Sliders } from "lucide-react";
import { useEditorStore } from "@/store/editor-store";
@@ -36,7 +35,6 @@ const Editor: React.FC<EditorProps> = ({ config }) => {
const { themeState, setThemeState } = useEditorStore();
const Controls = config.controls;
const Preview = config.preview;
const [isCodePanelOpen, setIsCodePanelOpen] = useState(true);
const handleStyleChange = (newStyles: ThemeStyles) => {
setThemeState({ ...themeState, styles: newStyles });
@@ -53,7 +51,7 @@ const Editor: React.FC<EditorProps> = ({ config }) => {
<div className="h-full hidden md:block">
<ResizablePanelGroup direction="horizontal" className="h-full">
<ResizablePanel defaultSize={30} minSize={20} maxSize={30}>
<div className="h-full p-4">
<div className="h-full flex flex-col">
<Controls
styles={styles}
onChange={handleStyleChange}
@@ -64,34 +62,18 @@ const Editor: React.FC<EditorProps> = ({ config }) => {
<ResizableHandle />
<ResizablePanel defaultSize={45} minSize={20}>
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0 p-4">
<Preview
styles={styles}
currentMode={themeState.currentMode}
isCodePanelOpen={isCodePanelOpen}
onCodePanelToggle={() => setIsCodePanelOpen(!isCodePanelOpen)}
/>
<div className="flex-1 min-h-0 flex flex-col">
<Preview styles={styles} currentMode={themeState.currentMode} />
</div>
</div>
</ResizablePanel>
{isCodePanelOpen && (
<>
<ResizableHandle />
<ResizablePanel defaultSize={25} minSize={10}>
<CodePanel
themeEditorState={themeState}
onCodePanelToggle={() => setIsCodePanelOpen(!isCodePanelOpen)}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
{/* Mobile Layout */}
<div className="h-full md:hidden">
<Tabs defaultValue="controls" className="h-full">
<TabsList className="w-full">
<TabsList className="w-full rounded-none">
<TabsTrigger value="controls" className="flex-1">
<Sliders className="h-4 w-4 mr-2" />
Controls
@@ -99,12 +81,9 @@ const Editor: React.FC<EditorProps> = ({ config }) => {
<TabsTrigger value="preview" className="flex-1">
Preview
</TabsTrigger>
<TabsTrigger value="code" className="flex-1">
Code
</TabsTrigger>
</TabsList>
<TabsContent value="controls" className="h-[calc(100%-2.5rem)]">
<div className="h-full p-4">
<TabsContent value="controls" className="h-[calc(100%-2.5rem)] mt-0">
<div className="h-full flex flex-col">
<Controls
styles={styles}
onChange={handleStyleChange}
@@ -112,22 +91,11 @@ const Editor: React.FC<EditorProps> = ({ config }) => {
/>
</div>
</TabsContent>
<TabsContent value="preview" className="h-[calc(100%-2.5rem)]">
<div className="h-full p-4">
<Preview
styles={styles}
currentMode={themeState.currentMode}
isCodePanelOpen={isCodePanelOpen}
onCodePanelToggle={() => setIsCodePanelOpen(!isCodePanelOpen)}
/>
<TabsContent value="preview" className="h-[calc(100%-2.5rem)] mt-0">
<div className="h-full flex flex-col">
<Preview styles={styles} currentMode={themeState.currentMode} />
</div>
</TabsContent>
<TabsContent value="code" className="h-[calc(100%-2.5rem)]">
<CodePanel
themeEditorState={themeState}
onCodePanelToggle={() => setIsCodePanelOpen(!isCodePanelOpen)}
/>
</TabsContent>
</Tabs>
</div>
</div>
+3 -25
View File
@@ -1,29 +1,21 @@
"use client";
import Link from "next/link";
import { Moon, Sun, Heart } from "lucide-react";
import { Heart } from "lucide-react";
import GitHubIcon from "@/assets/github.svg";
import TwitterIcon from "@/assets/twitter.svg";
import DiscordIcon from "@/assets/discord.svg";
import { useTheme } from "@/components/theme-provider";
import Logo from "@/assets/logo.svg";
import { useGithubStars } from "@/hooks/use-github-stars";
import { SocialLink } from "@/components/social-link";
import { Separator } from "@/components/ui/separator";
import * as SwitchPrimitives from "@radix-ui/react-switch";
export function Header() {
const { theme, toggleTheme } = useTheme();
const { stargazersCount } = useGithubStars("jnsahaj", "tweakcn");
const handleThemeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
const { clientX: x, clientY: y } = event;
toggleTheme({ x, y });
};
return (
<header className="border-b">
<div className="px-2 md:px-4 py-4 flex items-center gap-2 justify-between">
<div className="p-4 flex items-center gap-2 justify-between">
<div className="flex items-center gap-1">
<Link href="/" className="flex items-center gap-2">
<Logo className="size-6" title="tweakcn" />
@@ -38,7 +30,7 @@ export function Header() {
<GitHubIcon className="size-4" />
{stargazersCount > 0 && stargazersCount.toLocaleString()}
</SocialLink>
<Separator orientation="vertical" className="h-5" />
<Separator orientation="vertical" className="h-8" />
<div className="hidden md:flex items-center gap-3.5">
<SocialLink
href="https://github.com/sponsors/jnsahaj"
@@ -54,20 +46,6 @@ export function Header() {
<SocialLink href="https://x.com/iamsahaj_xyz">
<TwitterIcon className="size-4" />
</SocialLink>
<Separator orientation="vertical" className="h-5" />
<SwitchPrimitives.Root
checked={theme === "dark"}
onClick={handleThemeToggle}
className="peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent data-[state=unchecked]:bg-input"
>
<SwitchPrimitives.Thumb className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 flex items-center justify-center">
{theme === "dark" ? (
<Moon className="size-3" />
) : (
<Sun className="size-3" />
)}
</SwitchPrimitives.Thumb>
</SwitchPrimitives.Root>
</div>
</div>
</header>
+354 -388
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React from "react";
import { ThemeEditorControlsProps, ThemeStyleProps } from "@/types/theme";
import ControlSection from "./control-section";
import ColorPicker from "./color-picker";
@@ -16,37 +16,26 @@ import {
import { useEditorStore } from "../../store/editor-store";
import { Label } from "../ui/label";
import { SliderWithInput } from "./slider-with-input";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
import { Tabs, TabsList, TabsContent } from "../ui/tabs";
import ThemeFontSelect from "./theme-font-select";
import {
COMMON_STYLES,
DEFAULT_FONT_MONO,
DEFAULT_FONT_SANS,
DEFAULT_FONT_SERIF,
COMMON_STYLES,
defaultThemeState,
} from "../../config/theme";
import { Separator } from "../ui/separator";
import { AlertCircle } from "lucide-react";
import CssImportDialog from "./css-import-dialog";
import { toast } from "../ui/use-toast";
import { parseCssInput } from "../../utils/parse-css-input";
import ShadowControl from "./shadow-control";
import ThemeControlActions from "./theme-control-actions";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
const ThemeControlPanel = ({
styles,
currentMode,
onChange,
}: ThemeEditorControlsProps) => {
const {
applyThemePreset,
themeState,
resetToCurrentPreset,
resetToDefault,
hasDefaultThemeChanged,
hasCurrentPresetChanged,
} = useEditorStore();
const [cssImportOpen, setCssImportOpen] = useState(false);
const { applyThemePreset, themeState } = useEditorStore();
const currentStyles = React.useMemo(
() => ({
@@ -82,22 +71,6 @@ const ThemeControlPanel = ({
[onChange, styles, currentMode, currentStyles]
);
const handleCssImport = (css: string) => {
// This just shows a success toast for now
const { lightColors, darkColors } = parseCssInput(css);
onChange({
...styles,
light: { ...styles.light, ...lightColors },
dark: { ...styles.dark, ...darkColors },
});
// The actual CSS parsing and theme application logic would be implemented later
toast({
title: "CSS imported",
description: "Your custom CSS has been imported successfully",
});
};
// Ensure we have valid styles for the current mode
if (!currentStyles) {
return null; // Or some fallback UI
@@ -106,375 +79,368 @@ const ThemeControlPanel = ({
const radius = parseFloat(currentStyles.radius.replace("rem", ""));
return (
<div className="space-y-4 h-full">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold">Theme Editor</h2>
</div>
<ThemeControlActions
hasChanges={hasDefaultThemeChanged()}
hasPresetChanges={hasCurrentPresetChanged()}
onReset={resetToDefault}
onResetToPreset={resetToCurrentPreset}
onImportClick={() => setCssImportOpen(true)}
/>
</div>
<div className="mb-6 ml-1">
<>
<div className="border-b">
<ThemePresetSelect
presets={presets}
currentPreset={themeState.preset || null}
onPresetChange={applyThemePreset}
/>
</div>
<div className="space-y-4 min-h-0 flex-1 flex flex-col">
<Tabs
defaultValue="colors"
className="w-full flex-1 flex flex-col min-h-0"
>
<div className="px-4 mt-2">
<TabsList className="inline-flex w-fit items-center justify-center rounded-full bg-background px-0 text-muted-foreground">
<TabsTriggerPill value="colors">Colors</TabsTriggerPill>
<TabsTriggerPill value="typography">Typography</TabsTriggerPill>
<TabsTriggerPill value="other">Other</TabsTriggerPill>
</TabsList>
</div>
<Tabs defaultValue="colors" className="w-full h-full">
<TabsList className="grid grid-cols-3 mb-3 w-full">
<TabsTrigger value="colors">Colors</TabsTrigger>
<TabsTrigger value="typography">Typography</TabsTrigger>
<TabsTrigger value="other">Other</TabsTrigger>
</TabsList>
<ScrollArea className="h-full pb-40">
<TabsContent value="colors">
<ControlSection title="Primary Colors" id="primary-colors" expanded>
<ColorPicker
color={currentStyles.primary}
onChange={(color) => updateStyle("primary", color)}
label="Primary"
/>
<ColorPicker
color={currentStyles["primary-foreground"]}
onChange={(color) => updateStyle("primary-foreground", color)}
label="Primary Foreground"
/>
</ControlSection>
<ControlSection title="Secondary Colors" expanded>
<ColorPicker
color={currentStyles.secondary}
onChange={(color) => updateStyle("secondary", color)}
label="Secondary"
/>
<ColorPicker
color={currentStyles["secondary-foreground"]}
onChange={(color) => updateStyle("secondary-foreground", color)}
label="Secondary Foreground"
/>
</ControlSection>
<ControlSection title="Accent Colors" expanded>
<ColorPicker
color={currentStyles.accent}
onChange={(color) => updateStyle("accent", color)}
label="Accent"
/>
<ColorPicker
color={currentStyles["accent-foreground"]}
onChange={(color) => updateStyle("accent-foreground", color)}
label="Accent Foreground"
/>
</ControlSection>
<ControlSection title="Base Colors">
<ColorPicker
color={currentStyles.background}
onChange={(color) => updateStyle("background", color)}
label="Background"
/>
<ColorPicker
color={currentStyles.foreground}
onChange={(color) => updateStyle("foreground", color)}
label="Foreground"
/>
</ControlSection>
<ControlSection title="Card Colors">
<ColorPicker
color={currentStyles.card}
onChange={(color) => updateStyle("card", color)}
label="Card Background"
/>
<ColorPicker
color={currentStyles["card-foreground"]}
onChange={(color) => updateStyle("card-foreground", color)}
label="Card Foreground"
/>
</ControlSection>
<ControlSection title="Popover Colors">
<ColorPicker
color={currentStyles.popover}
onChange={(color) => updateStyle("popover", color)}
label="Popover Background"
/>
<ColorPicker
color={currentStyles["popover-foreground"]}
onChange={(color) => updateStyle("popover-foreground", color)}
label="Popover Foreground"
/>
</ControlSection>
<ControlSection title="Muted Colors">
<ColorPicker
color={currentStyles.muted}
onChange={(color) => updateStyle("muted", color)}
label="Muted"
/>
<ColorPicker
color={currentStyles["muted-foreground"]}
onChange={(color) => updateStyle("muted-foreground", color)}
label="Muted Foreground"
/>
</ControlSection>
<ControlSection title="Destructive Colors">
<ColorPicker
color={currentStyles.destructive}
onChange={(color) => updateStyle("destructive", color)}
label="Destructive"
/>
<ColorPicker
color={currentStyles["destructive-foreground"]}
onChange={(color) =>
updateStyle("destructive-foreground", color)
}
label="Destructive Foreground"
/>
</ControlSection>
<ControlSection title="Border & Input Colors">
<ColorPicker
color={currentStyles.border}
onChange={(color) => updateStyle("border", color)}
label="Border"
/>
<ColorPicker
color={currentStyles.input}
onChange={(color) => updateStyle("input", color)}
label="Input"
/>
<ColorPicker
color={currentStyles.ring}
onChange={(color) => updateStyle("ring", color)}
label="Ring"
/>
</ControlSection>
<ControlSection title="Chart Colors">
<ColorPicker
color={currentStyles["chart-1"]}
onChange={(color) => updateStyle("chart-1", color)}
label="Chart 1"
/>
<ColorPicker
color={currentStyles["chart-2"]}
onChange={(color) => updateStyle("chart-2", color)}
label="Chart 2"
/>
<ColorPicker
color={currentStyles["chart-3"]}
onChange={(color) => updateStyle("chart-3", color)}
label="Chart 3"
/>
<ColorPicker
color={currentStyles["chart-4"]}
onChange={(color) => updateStyle("chart-4", color)}
label="Chart 4"
/>
<ColorPicker
color={currentStyles["chart-5"]}
onChange={(color) => updateStyle("chart-5", color)}
label="Chart 5"
/>
</ControlSection>
<ControlSection title="Sidebar Colors">
<ColorPicker
color={currentStyles.sidebar}
onChange={(color) => updateStyle("sidebar", color)}
label="Sidebar Background"
/>
<ColorPicker
color={currentStyles["sidebar-foreground"]}
onChange={(color) => updateStyle("sidebar-foreground", color)}
label="Sidebar Foreground"
/>
<ColorPicker
color={currentStyles["sidebar-primary"]}
onChange={(color) => updateStyle("sidebar-primary", color)}
label="Sidebar Primary"
/>
<ColorPicker
color={currentStyles["sidebar-primary-foreground"]}
onChange={(color) =>
updateStyle("sidebar-primary-foreground", color)
}
label="Sidebar Primary Foreground"
/>
<ColorPicker
color={currentStyles["sidebar-accent"]}
onChange={(color) => updateStyle("sidebar-accent", color)}
label="Sidebar Accent"
/>
<ColorPicker
color={currentStyles["sidebar-accent-foreground"]}
onChange={(color) =>
updateStyle("sidebar-accent-foreground", color)
}
label="Sidebar Accent Foreground"
/>
<ColorPicker
color={currentStyles["sidebar-border"]}
onChange={(color) => updateStyle("sidebar-border", color)}
label="Sidebar Border"
/>
<ColorPicker
color={currentStyles["sidebar-ring"]}
onChange={(color) => updateStyle("sidebar-ring", color)}
label="Sidebar Ring"
/>
</ControlSection>
</TabsContent>
<TabsContent value="typography" className="flex flex-col gap-4">
<div className="p-3 bg-muted/50 rounded-md border mb-2 flex items-start gap-2.5">
<AlertCircle className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="text-sm text-muted-foreground">
<p>
To use custom fonts, embed them in your project. <br />
See{" "}
<a
href="https://tailwindcss.com/docs/font-family"
target="_blank"
className="underline underline-offset-2 hover:text-muted-foreground/90"
>
Tailwind docs
</a>{" "}
for details.
</p>
</div>
</div>
<ControlSection title="Font Family" expanded>
<div className="mb-4">
<Label htmlFor="font-sans" className="text-xs mb-1.5 block">
Sans-Serif Font
</Label>
<ThemeFontSelect
fonts={{ ...sansSerifFonts, ...serifFonts, ...monoFonts }}
defaultValue={DEFAULT_FONT_SANS}
currentFont={getAppliedThemeFont(themeState, "font-sans")}
onFontChange={(value) => updateStyle("font-sans", value)}
<ScrollArea className="h-full pb-40 p-4 pt-0 flex-1">
<TabsContent value="colors">
<ControlSection
title="Primary Colors"
id="primary-colors"
expanded
>
<ColorPicker
color={currentStyles.primary}
onChange={(color) => updateStyle("primary", color)}
label="Primary"
/>
</div>
<Separator className="my-4" />
<div className="mb-4">
<Label htmlFor="font-serif" className="text-xs mb-1.5 block">
Serif Font
</Label>
<ThemeFontSelect
fonts={{ ...serifFonts, ...sansSerifFonts, ...monoFonts }}
defaultValue={DEFAULT_FONT_SERIF}
currentFont={getAppliedThemeFont(themeState, "font-serif")}
onFontChange={(value) => updateStyle("font-serif", value)}
<ColorPicker
color={currentStyles["primary-foreground"]}
onChange={(color) => updateStyle("primary-foreground", color)}
label="Primary Foreground"
/>
</div>
</ControlSection>
<Separator className="my-4" />
<div>
<Label htmlFor="font-mono" className="text-xs mb-1.5 block">
Monospace Font
</Label>
<ThemeFontSelect
fonts={{ ...monoFonts, ...sansSerifFonts, ...serifFonts }}
defaultValue={DEFAULT_FONT_MONO}
currentFont={getAppliedThemeFont(themeState, "font-mono")}
onFontChange={(value) => updateStyle("font-mono", value)}
<ControlSection title="Secondary Colors" expanded>
<ColorPicker
color={currentStyles.secondary}
onChange={(color) => updateStyle("secondary", color)}
label="Secondary"
/>
</div>
</ControlSection>
<ControlSection title="Letter Spacing" expanded>
<SliderWithInput
value={parseFloat(
currentStyles["letter-spacing"]?.replace("em", "")
)}
onChange={(value) =>
updateStyle("letter-spacing", `${value}em`)
}
min={-0.5}
max={0.5}
step={0.025}
unit="em"
label="Letter Spacing"
/>
</ControlSection>
</TabsContent>
<TabsContent value="other">
<ControlSection title="Radius" expanded>
<SliderWithInput
value={radius}
onChange={(value) => updateStyle("radius", `${value}rem`)}
min={0}
max={5}
step={0.025}
unit="rem"
label="Radius"
/>
</ControlSection>
<ControlSection title="Spacing" expanded>
<SliderWithInput
value={parseFloat(currentStyles.spacing?.replace("rem", ""))}
onChange={(value) => updateStyle("spacing", `${value}rem`)}
min={0.15}
max={0.35}
step={0.01}
unit="rem"
label="Spacing"
/>
</ControlSection>
<div className="mt-6">
<ShadowControl
shadowColor={currentStyles["shadow-color"]}
shadowOpacity={parseFloat(currentStyles["shadow-opacity"])}
shadowBlur={parseFloat(
currentStyles["shadow-blur"]?.replace("px", "")
)}
shadowSpread={parseFloat(
currentStyles["shadow-spread"]?.replace("px", "")
)}
shadowOffsetX={parseFloat(
currentStyles["shadow-offset-x"]?.replace("px", "")
)}
shadowOffsetY={parseFloat(
currentStyles["shadow-offset-y"]?.replace("px", "")
)}
onChange={(key, value) => {
if (key === "shadow-color") {
updateStyle(key, value as string);
} else if (key === "shadow-opacity") {
updateStyle(key, value.toString());
} else {
updateStyle(key as keyof ThemeStyleProps, `${value}px`);
<ColorPicker
color={currentStyles["secondary-foreground"]}
onChange={(color) =>
updateStyle("secondary-foreground", color)
}
}}
/>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
label="Secondary Foreground"
/>
</ControlSection>
<CssImportDialog
open={cssImportOpen}
onOpenChange={setCssImportOpen}
onImport={handleCssImport}
/>
</div>
<ControlSection title="Accent Colors">
<ColorPicker
color={currentStyles.accent}
onChange={(color) => updateStyle("accent", color)}
label="Accent"
/>
<ColorPicker
color={currentStyles["accent-foreground"]}
onChange={(color) => updateStyle("accent-foreground", color)}
label="Accent Foreground"
/>
</ControlSection>
<ControlSection title="Base Colors">
<ColorPicker
color={currentStyles.background}
onChange={(color) => updateStyle("background", color)}
label="Background"
/>
<ColorPicker
color={currentStyles.foreground}
onChange={(color) => updateStyle("foreground", color)}
label="Foreground"
/>
</ControlSection>
<ControlSection title="Card Colors">
<ColorPicker
color={currentStyles.card}
onChange={(color) => updateStyle("card", color)}
label="Card Background"
/>
<ColorPicker
color={currentStyles["card-foreground"]}
onChange={(color) => updateStyle("card-foreground", color)}
label="Card Foreground"
/>
</ControlSection>
<ControlSection title="Popover Colors">
<ColorPicker
color={currentStyles.popover}
onChange={(color) => updateStyle("popover", color)}
label="Popover Background"
/>
<ColorPicker
color={currentStyles["popover-foreground"]}
onChange={(color) => updateStyle("popover-foreground", color)}
label="Popover Foreground"
/>
</ControlSection>
<ControlSection title="Muted Colors">
<ColorPicker
color={currentStyles.muted}
onChange={(color) => updateStyle("muted", color)}
label="Muted"
/>
<ColorPicker
color={currentStyles["muted-foreground"]}
onChange={(color) => updateStyle("muted-foreground", color)}
label="Muted Foreground"
/>
</ControlSection>
<ControlSection title="Destructive Colors">
<ColorPicker
color={currentStyles.destructive}
onChange={(color) => updateStyle("destructive", color)}
label="Destructive"
/>
<ColorPicker
color={currentStyles["destructive-foreground"]}
onChange={(color) =>
updateStyle("destructive-foreground", color)
}
label="Destructive Foreground"
/>
</ControlSection>
<ControlSection title="Border & Input Colors">
<ColorPicker
color={currentStyles.border}
onChange={(color) => updateStyle("border", color)}
label="Border"
/>
<ColorPicker
color={currentStyles.input}
onChange={(color) => updateStyle("input", color)}
label="Input"
/>
<ColorPicker
color={currentStyles.ring}
onChange={(color) => updateStyle("ring", color)}
label="Ring"
/>
</ControlSection>
<ControlSection title="Chart Colors">
<ColorPicker
color={currentStyles["chart-1"]}
onChange={(color) => updateStyle("chart-1", color)}
label="Chart 1"
/>
<ColorPicker
color={currentStyles["chart-2"]}
onChange={(color) => updateStyle("chart-2", color)}
label="Chart 2"
/>
<ColorPicker
color={currentStyles["chart-3"]}
onChange={(color) => updateStyle("chart-3", color)}
label="Chart 3"
/>
<ColorPicker
color={currentStyles["chart-4"]}
onChange={(color) => updateStyle("chart-4", color)}
label="Chart 4"
/>
<ColorPicker
color={currentStyles["chart-5"]}
onChange={(color) => updateStyle("chart-5", color)}
label="Chart 5"
/>
</ControlSection>
<ControlSection title="Sidebar Colors">
<ColorPicker
color={currentStyles.sidebar}
onChange={(color) => updateStyle("sidebar", color)}
label="Sidebar Background"
/>
<ColorPicker
color={currentStyles["sidebar-foreground"]}
onChange={(color) => updateStyle("sidebar-foreground", color)}
label="Sidebar Foreground"
/>
<ColorPicker
color={currentStyles["sidebar-primary"]}
onChange={(color) => updateStyle("sidebar-primary", color)}
label="Sidebar Primary"
/>
<ColorPicker
color={currentStyles["sidebar-primary-foreground"]}
onChange={(color) =>
updateStyle("sidebar-primary-foreground", color)
}
label="Sidebar Primary Foreground"
/>
<ColorPicker
color={currentStyles["sidebar-accent"]}
onChange={(color) => updateStyle("sidebar-accent", color)}
label="Sidebar Accent"
/>
<ColorPicker
color={currentStyles["sidebar-accent-foreground"]}
onChange={(color) =>
updateStyle("sidebar-accent-foreground", color)
}
label="Sidebar Accent Foreground"
/>
<ColorPicker
color={currentStyles["sidebar-border"]}
onChange={(color) => updateStyle("sidebar-border", color)}
label="Sidebar Border"
/>
<ColorPicker
color={currentStyles["sidebar-ring"]}
onChange={(color) => updateStyle("sidebar-ring", color)}
label="Sidebar Ring"
/>
</ControlSection>
</TabsContent>
<TabsContent value="typography" className="flex flex-col gap-4">
<div className="p-3 bg-muted/50 rounded-md border mb-2 flex items-start gap-2.5">
<AlertCircle className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="text-sm text-muted-foreground">
<p>
To use custom fonts, embed them in your project. <br />
See{" "}
<a
href="https://tailwindcss.com/docs/font-family"
target="_blank"
className="underline underline-offset-2 hover:text-muted-foreground/90"
>
Tailwind docs
</a>{" "}
for details.
</p>
</div>
</div>
<ControlSection title="Font Family" expanded>
<div className="mb-4">
<Label htmlFor="font-sans" className="text-xs mb-1.5 block">
Sans-Serif Font
</Label>
<ThemeFontSelect
fonts={{ ...sansSerifFonts, ...serifFonts, ...monoFonts }}
defaultValue={DEFAULT_FONT_SANS}
currentFont={getAppliedThemeFont(themeState, "font-sans")}
onFontChange={(value) => updateStyle("font-sans", value)}
/>
</div>
<Separator className="my-4" />
<div className="mb-4">
<Label htmlFor="font-serif" className="text-xs mb-1.5 block">
Serif Font
</Label>
<ThemeFontSelect
fonts={{ ...serifFonts, ...sansSerifFonts, ...monoFonts }}
defaultValue={DEFAULT_FONT_SERIF}
currentFont={getAppliedThemeFont(themeState, "font-serif")}
onFontChange={(value) => updateStyle("font-serif", value)}
/>
</div>
<Separator className="my-4" />
<div>
<Label htmlFor="font-mono" className="text-xs mb-1.5 block">
Monospace Font
</Label>
<ThemeFontSelect
fonts={{ ...monoFonts, ...sansSerifFonts, ...serifFonts }}
defaultValue={DEFAULT_FONT_MONO}
currentFont={getAppliedThemeFont(themeState, "font-mono")}
onFontChange={(value) => updateStyle("font-mono", value)}
/>
</div>
</ControlSection>
<ControlSection title="Letter Spacing" expanded>
<SliderWithInput
value={parseFloat(
currentStyles["letter-spacing"]?.replace("em", "")
)}
onChange={(value) =>
updateStyle("letter-spacing", `${value}em`)
}
min={-0.5}
max={0.5}
step={0.025}
unit="em"
label="Letter Spacing"
/>
</ControlSection>
</TabsContent>
<TabsContent value="other">
<ControlSection title="Radius" expanded>
<SliderWithInput
value={radius}
onChange={(value) => updateStyle("radius", `${value}rem`)}
min={0}
max={5}
step={0.025}
unit="rem"
label="Radius"
/>
</ControlSection>
<ControlSection title="Spacing" expanded>
<SliderWithInput
value={parseFloat(currentStyles.spacing?.replace("rem", ""))}
onChange={(value) => updateStyle("spacing", `${value}rem`)}
min={0.15}
max={0.35}
step={0.01}
unit="rem"
label="Spacing"
/>
</ControlSection>
<div className="mt-6">
<ShadowControl
shadowColor={currentStyles["shadow-color"]}
shadowOpacity={parseFloat(currentStyles["shadow-opacity"])}
shadowBlur={parseFloat(
currentStyles["shadow-blur"]?.replace("px", "")
)}
shadowSpread={parseFloat(
currentStyles["shadow-spread"]?.replace("px", "")
)}
shadowOffsetX={parseFloat(
currentStyles["shadow-offset-x"]?.replace("px", "")
)}
shadowOffsetY={parseFloat(
currentStyles["shadow-offset-y"]?.replace("px", "")
)}
onChange={(key, value) => {
if (key === "shadow-color") {
updateStyle(key, value as string);
} else if (key === "shadow-opacity") {
updateStyle(key, value.toString());
} else {
updateStyle(key as keyof ThemeStyleProps, `${value}px`);
}
}}
/>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
</>
);
};
+82 -46
View File
@@ -1,14 +1,18 @@
import React, { useCallback, useMemo, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ThemePreset } from "../../types/theme";
import { useEditorStore } from "../../store/editor-store";
import { getPresetThemeStyles } from "../../utils/theme-presets";
import { Button } from "../ui/button";
import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Moon,
Search,
Shuffle,
@@ -17,7 +21,12 @@ import {
import { useTheme } from "@/components/theme-provider";
import { Separator } from "../ui/separator";
import { ScrollArea } from "../ui/scroll-area";
import { Command, CommandEmpty, CommandGroup, CommandItem } from "../ui/command";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
} from "../ui/command";
import {
Tooltip,
TooltipContent,
@@ -62,12 +71,16 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
const mode = themeState.currentMode;
const [search, setSearch] = useState("");
const presetNames = useMemo(() => ["default", ...Object.keys(presets)], [presets]);
const value = presetNames?.find((name) => name === currentPreset);
const currentIndex = useMemo(
() => presetNames.indexOf(value || "default"),
[presetNames, value]
const presetNames = useMemo(
() => ["default", ...Object.keys(presets)],
[presets]
);
const value = presetNames?.find((name) => name === currentPreset);
const currentIndex =
useMemo(
() => presetNames.indexOf(value || "default"),
[presetNames, value]
) ?? 0;
const randomize = useCallback(() => {
const random = Math.floor(Math.random() * presetNames.length);
@@ -107,14 +120,14 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
};
return (
<div className="flex items-center gap-1">
<div className="flex items-center">
<TooltipProvider>
<Popover>
<PopoverTrigger asChild>
<PopoverTrigger className="bg-muted/10" asChild>
<Button
variant="outline"
variant="ghost"
className={cn(
"w-full md:min-w-64 h-10 justify-between group relative",
"w-full md:min-w-56 min-h-14 rounded-none justify-between group relative",
(!value || value === "default") &&
!hasChangedThemeFromDefault &&
"ring-2 ring-offset-1 ring-offset-background ring-primary/30 animate-pulse"
@@ -123,26 +136,34 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
<div className="flex items-center gap-3">
<div className="flex gap-0.5">
<ColorBox
color={getPresetThemeStyles(value || "default")[mode].primary}
color={
getPresetThemeStyles(value || "default")[mode].primary
}
/>
<ColorBox
color={getPresetThemeStyles(value || "default")[mode].accent}
color={
getPresetThemeStyles(value || "default")[mode].accent
}
/>
<ColorBox
color={getPresetThemeStyles(value || "default")[mode].secondary}
color={
getPresetThemeStyles(value || "default")[mode].secondary
}
/>
<ColorBox
color={getPresetThemeStyles(value || "default")[mode].border}
color={
getPresetThemeStyles(value || "default")[mode].border
}
/>
</div>
<span className="capitalize font-medium">
{presets[value || "default"]?.label || "default"}
</span>
</div>
<ChevronDown className="size-4 shrink-0 opacity-50" />
<ChevronDown className="size-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]" align="start">
<PopoverContent className="p-0 w-[300px] ml-4" align="center">
<Command className="rounded-lg border shadow-md w-full">
<div className="flex items-center w-full">
<div className="flex items-center w-full border-b px-3 py-1">
@@ -219,7 +240,9 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
color={getPresetThemeStyles(presetName)[mode].accent}
/>
<ColorBox
color={getPresetThemeStyles(presetName)[mode].secondary}
color={
getPresetThemeStyles(presetName)[mode].secondary
}
/>
<ColorBox
color={getPresetThemeStyles(presetName)[mode].border}
@@ -229,14 +252,15 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
<span className="capitalize text-sm font-medium">
{presets[presetName]?.label || presetName}
</span>
{presets[presetName] && isThemeNew(presets[presetName]) && (
<Badge
variant="secondary"
className="text-xs rounded-full"
>
New
</Badge>
)}
{presets[presetName] &&
isThemeNew(presets[presetName]) && (
<Badge
variant="secondary"
className="text-xs rounded-full"
>
New
</Badge>
)}
</div>
{presetName === value && (
<Check className="h-4 w-4 shrink-0 opacity-70" />
@@ -250,25 +274,37 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
</Popover>
</TooltipProvider>
<Button
variant="outline"
size="icon"
className="h-10 w-10 shrink-0"
title="Previous theme"
onClick={() => cycleTheme("prev")}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-8" />
<Button
variant="outline"
size="icon"
className="h-10 w-10 shrink-0"
title="Next theme"
onClick={() => cycleTheme("next")}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
className="size-14 shrink-0 rounded-none bg-muted/10"
onClick={() => cycleTheme("prev")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Previous theme</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-8" />
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
className="size-14 shrink-0 rounded-none bg-muted/10"
onClick={() => cycleTheme("next")}
>
<ArrowRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Next theme</TooltipContent>
</Tooltip>
</div>
);
};
+68 -88
View File
@@ -8,7 +8,7 @@ import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
import ExamplesPreviewContainer from "./theme-preview/examples-preview-container";
import { lazy } from "react";
import { Button } from "@/components/ui/button";
import { Maximize, Minimize, PanelRight, Moon, Sun } from "lucide-react";
import { Maximize, Minimize, Moon, Sun } from "lucide-react";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { cn } from "@/lib/utils";
import { useTheme } from "@/components/theme-provider";
@@ -17,6 +17,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ActionBar } from "./action-bar";
const DemoCards = lazy(() => import("@/components/examples/demo-cards"));
const DemoMail = lazy(() => import("@/components/examples/mail"));
@@ -27,8 +28,6 @@ const DemoDashboard = lazy(() => import("@/components/examples/dashboard"));
const ThemePreviewPanel = ({
styles,
currentMode,
isCodePanelOpen,
onCodePanelToggle,
}: ThemeEditorPreviewProps) => {
const { isFullscreen, toggleFullscreen } = useFullscreen();
const { theme, toggleTheme } = useTheme();
@@ -43,91 +42,72 @@ const ThemePreviewPanel = ({
};
return (
<div
className={cn(
"max-h-full flex flex-col",
isFullscreen && "fixed inset-0 z-50 bg-background p-4"
)}
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Theme Preview</h2>
<div className="flex items-center gap-0">
{isFullscreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleThemeToggle}
className="h-8 group"
>
{theme === "light" ? (
<Sun className="size-4 group-hover:scale-120 transition-all" />
) : (
<Moon className="size-4 group-hover:scale-120 transition-all" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Toggle Theme</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleFullscreen}
className="h-8 group"
>
{isFullscreen ? (
<Minimize className="size-4 group-hover:scale-120 transition-all" />
) : (
<Maximize className="size-4 group-hover:scale-120 transition-all" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isFullscreen ? "Exit full screen" : "Full screen"}
</TooltipContent>
</Tooltip>
{!isCodePanelOpen && !isFullscreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onCodePanelToggle(!isCodePanelOpen)}
className="h-8 invisible md:visible group"
aria-label="Show Code Panel"
>
<PanelRight className="size-4 group-hover:scale-120 transition-all" />
</Button>
</TooltipTrigger>
<TooltipContent>Hide Code Panel</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="flex flex-col flex-1 overflow-hidden">
<>
<ActionBar />
<div
className={cn(
"min-h-0 flex flex-col flex-1",
isFullscreen && "fixed inset-0 z-50 bg-background"
)}
>
<Tabs defaultValue="cards" className="flex flex-col overflow-hidden">
<TabsList className="inline-flex w-fit h-9 items-center justify-center rounded-full bg-background px-0 text-muted-foreground">
<TabsTriggerPill value="cards">Cards</TabsTriggerPill>
<div className="hidden md:flex">
<TabsTriggerPill value="mail">Mail</TabsTriggerPill>
<TabsTriggerPill value="tasks">Tasks</TabsTriggerPill>
<TabsTriggerPill value="music">Music</TabsTriggerPill>
<TabsTriggerPill value="dashboard">Dashboard</TabsTriggerPill>
</div>
<TabsTriggerPill value="colors">Color Palette</TabsTriggerPill>
</TabsList>
<div className="flex items-center justify-between px-4 mt-2">
<TabsList className="inline-flex w-fit items-center justify-center rounded-full bg-background px-0 text-muted-foreground">
<TabsTriggerPill value="cards">Cards</TabsTriggerPill>
<div className="hidden md:flex">
<TabsTriggerPill value="mail">Mail</TabsTriggerPill>
<TabsTriggerPill value="tasks">Tasks</TabsTriggerPill>
<TabsTriggerPill value="music">Music</TabsTriggerPill>
<TabsTriggerPill value="dashboard">Dashboard</TabsTriggerPill>
</div>
<TabsTriggerPill value="colors">Color Palette</TabsTriggerPill>
</TabsList>
<ScrollArea className="rounded-lg border mt-2 flex flex-col flex-1">
<div className="flex flex-col flex-1">
<TabsContent
value="cards"
className="space-y-6 mt-0 py-4 px-4 h-full"
>
<div className="flex items-center gap-0">
{isFullscreen && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleThemeToggle}
className="h-8 group"
>
{theme === "light" ? (
<Sun className="size-4 group-hover:scale-120 transition-all" />
) : (
<Moon className="size-4 group-hover:scale-120 transition-all" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Toggle Theme</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleFullscreen}
className="h-8 group"
>
{isFullscreen ? (
<Minimize className="size-4 group-hover:scale-120 transition-all" />
) : (
<Maximize className="size-4 group-hover:scale-120 transition-all" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isFullscreen ? "Exit full screen" : "Full screen"}
</TooltipContent>
</Tooltip>
</div>
</div>
<ScrollArea className="rounded-lg flex flex-col flex-1 border m-4 mt-2 overflow-hidden">
<div className="flex flex-col flex-1 h-full">
<TabsContent value="cards" className="space-y-6 my-4 px-4 h-full">
<ExamplesPreviewContainer>
<DemoCards />
</ExamplesPreviewContainer>
@@ -146,7 +126,7 @@ const ThemePreviewPanel = ({
value="tasks"
className="space-y-6 mt-0 h-full @container"
>
<ExamplesPreviewContainer className="min-w-[1300px]">
<ExamplesPreviewContainer className="min-w-[1300px] ">
<DemoTasks />
</ExamplesPreviewContainer>
</TabsContent>
@@ -178,7 +158,7 @@ const ThemePreviewPanel = ({
</ScrollArea>
</Tabs>
</div>
</div>
</>
);
};
+56
View File
@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback } from "react";
import { getContrastRatio } from "../utils/contrast-checker";
import { debounce } from "../utils/debounce";
type ColorPair = {
id: string;
foreground: string;
background: string;
};
type ContrastResult = {
id: string;
contrastRatio: number;
};
/**
* Hook that calculates the contrast ratio between foreground and background colors for a list of pairs.
* @param colorPairs - An array of color pairs, each with an id, foreground color, and background color.
* @returns An array of objects, each containing the id and calculated contrast ratio for a pair.
*/
export function useContrastChecker(colorPairs: ColorPair[]) {
const [contrastResults, setContrastResults] = useState<ContrastResult[]>([]);
const debouncedCalculation = useCallback(
debounce((pairs: ColorPair[]) => {
if (!pairs.length) {
setContrastResults([]);
return;
}
try {
const results = pairs.map((pair) => {
const ratio = parseFloat(
getContrastRatio(pair.foreground, pair.background)
);
return {
id: pair.id,
contrastRatio: ratio,
};
});
setContrastResults(results);
} catch (error) {
console.error("Error checking contrast:", error);
setContrastResults([]);
}
}, 750),
[]
);
useEffect(() => {
debouncedCalculation(colorPairs);
}, [colorPairs, debouncedCalculation]);
return contrastResults;
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { ThemeEditorState } from "@/types/editor";
import { ThemeStyleProps } from "@/types/theme";
// @ts-expect-error: owned by ngard
import { isEqual } from "@ngard/tiny-isequal";
import { defaultThemeState } from "@/config/theme";
import { defaultThemeState, COMMON_STYLES } from "@/config/theme";
import { getPresetThemeStyles } from "@/utils/theme-presets";
interface EditorStore {
-2
View File
@@ -58,8 +58,6 @@ export interface ThemeEditorState {
export interface ThemeEditorPreviewProps {
styles: ThemeStyles;
currentMode: "light" | "dark";
isCodePanelOpen: boolean;
onCodePanelToggle: (open: boolean) => void;
}
export interface ThemeEditorControlsProps {
+45
View File
@@ -0,0 +1,45 @@
import * as culori from "culori";
/**
* Calculates the luminance of a color according to WCAG standard
* @param colorValue - The color in any supported format
* @returns The relative luminance of the color (0-1)
*/
function getLuminance(colorValue: string): number {
try {
const color = culori.parse(colorValue);
if (!color) {
console.warn(`Invalid color: ${colorValue}`);
return 0;
}
// Culori directly provides the luminance according to WCAG standard
return culori.wcagLuminance(color);
} catch (error) {
console.error(`Error calculating luminance: ${colorValue}`, error);
return 0;
}
}
/**
* Calculates the contrast ratio between two colors according to WCAG guidelines
* @param color1 - First color (in any format)
* @param color2 - Second color (in any format)
* @returns The contrast ratio as a string (e.g. "4.50")
*/
export function getContrastRatio(color1: string, color2: string): string {
try {
const lum1 = getLuminance(color1);
const lum2 = getLuminance(color2);
// WCAG contrast ratio formula
const ratio = (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05);
return ratio.toFixed(2);
} catch (error) {
console.error(
`Error calculating contrast between ${color1} and ${color2}:`,
error
);
return "1.00"; // Fallback value indicating low contrast
}
}