mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-09-24 23:31:41 +08:00
@@ -28,28 +28,22 @@ export function ActionBarButtons({
|
||||
onShareClick,
|
||||
isSaving,
|
||||
}: ActionBarButtonsProps) {
|
||||
const { themeState, restoreThemeCheckpoint, hasThemeChangedFromCheckpoint } =
|
||||
useEditorStore();
|
||||
const { themeState, resetToCurrentPreset, hasThemeChangedFromCheckpoint } = useEditorStore();
|
||||
|
||||
const { getPreset } = useThemePresetStore();
|
||||
const currentPreset = themeState?.preset
|
||||
? getPreset(themeState?.preset)
|
||||
: undefined;
|
||||
const currentPreset = themeState?.preset ? getPreset(themeState?.preset) : undefined;
|
||||
const isSavedPreset = !!currentPreset && currentPreset.source === "SAVED";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<MoreOptions />
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
<ThemeToggle />
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<ResetButton
|
||||
onReset={restoreThemeCheckpoint}
|
||||
isDisabled={!hasThemeChangedFromCheckpoint()}
|
||||
/>
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
<ResetButton onReset={resetToCurrentPreset} isDisabled={!hasThemeChangedFromCheckpoint()} />
|
||||
<ImportButton onImportClick={onImportClick} />
|
||||
<AIGenerateButton onClick={onAiGenerateClick} />
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<Separator orientation="vertical" className="mx-1 h-8" />
|
||||
{isSavedPreset && <EditButton themeId={themeState.preset as string} />}
|
||||
<ShareButton onShareClick={() => onShareClick(themeState.preset)} />
|
||||
<SaveButton onSaveClick={onSaveClick} isSaving={isSaving} />
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, use } from "react";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EditorConfig } from "@/types/editor";
|
||||
import { Theme, ThemeStyles } from "@/types/theme";
|
||||
@@ -33,15 +29,10 @@ const isThemeStyles = (styles: unknown): styles is ThemeStyles => {
|
||||
const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
const themeState = useEditorStore((state) => state.themeState);
|
||||
const setThemeState = useEditorStore((state) => state.setThemeState);
|
||||
const saveThemeCheckpoint = useEditorStore(
|
||||
(state) => state.saveThemeCheckpoint
|
||||
);
|
||||
const Controls = config.controls;
|
||||
const Preview = config.preview;
|
||||
|
||||
const loadSavedPresets = useThemePresetStore(
|
||||
(state) => state.loadSavedPresets
|
||||
);
|
||||
const loadSavedPresets = useThemePresetStore((state) => state.loadSavedPresets);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
@@ -64,14 +55,17 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
useEffect(() => {
|
||||
if (initialTheme && isThemeStyles(initialTheme.styles)) {
|
||||
const prev = useEditorStore.getState().themeState;
|
||||
setThemeState({ ...prev, styles: initialTheme.styles });
|
||||
saveThemeCheckpoint();
|
||||
setThemeState({
|
||||
...prev,
|
||||
styles: initialTheme.styles,
|
||||
preset: initialTheme.id,
|
||||
});
|
||||
}
|
||||
}, [initialTheme, setThemeState, saveThemeCheckpoint]);
|
||||
}, [initialTheme, setThemeState]);
|
||||
|
||||
if (initialTheme && !isThemeStyles(initialTheme.styles)) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full text-destructive">
|
||||
<div className="text-destructive flex h-full items-center justify-center">
|
||||
Fetched theme data is invalid.
|
||||
</div>
|
||||
);
|
||||
@@ -82,10 +76,10 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
{/* Desktop Layout */}
|
||||
<div className="h-full hidden md:block">
|
||||
<div className="hidden h-full md:block">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
<ResizablePanel defaultSize={30} minSize={20} maxSize={40}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex h-full flex-col">
|
||||
<Controls
|
||||
styles={styles}
|
||||
onChange={handleStyleChange}
|
||||
@@ -96,8 +90,8 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={70} minSize={20}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ActionBar />
|
||||
<Preview styles={styles} currentMode={themeState.currentMode} />
|
||||
</div>
|
||||
@@ -111,15 +105,15 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
<Tabs defaultValue="controls" className="h-full">
|
||||
<TabsList className="w-full rounded-none">
|
||||
<TabsTrigger value="controls" className="flex-1">
|
||||
<Sliders className="h-4 w-4 mr-2" />
|
||||
<Sliders className="mr-2 h-4 w-4" />
|
||||
Controls
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex-1">
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="controls" className="h-[calc(100%-2.5rem)] mt-0">
|
||||
<div className="h-full flex flex-col">
|
||||
<TabsContent value="controls" className="mt-0 h-[calc(100%-2.5rem)]">
|
||||
<div className="flex h-full flex-col">
|
||||
<Controls
|
||||
styles={styles}
|
||||
onChange={handleStyleChange}
|
||||
@@ -128,8 +122,8 @@ const Editor: React.FC<EditorProps> = ({ config, themePromise }) => {
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview" className="h-[calc(100%-2.5rem)] mt-0">
|
||||
<div className="h-full flex flex-col">
|
||||
<TabsContent value="preview" className="mt-0 h-[calc(100%-2.5rem)]">
|
||||
<div className="flex h-full flex-col">
|
||||
<ActionBar />
|
||||
<Preview styles={styles} currentMode={themeState.currentMode} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useEffect } from "react";
|
||||
import { SliderWithInput } from "./slider-with-input";
|
||||
import { useEditorStore } from "../../store/editor-store";
|
||||
import { COMMON_STYLES, defaultThemeState } from "../../config/theme";
|
||||
import { ThemeEditorState } from "@/types/editor";
|
||||
import { converter, formatHex, Hsl } from "culori";
|
||||
import { debounce } from "@/utils/debounce";
|
||||
import { isDeepEqual } from "@/lib/utils";
|
||||
|
||||
// Adjusts a color by modifying HSL values
|
||||
function adjustColorByHsl(
|
||||
color: string,
|
||||
hueShift: number,
|
||||
saturationScale: number,
|
||||
lightnessScale: number
|
||||
): string {
|
||||
const hsl = converter("hsl")(color);
|
||||
const h = hsl?.h;
|
||||
const s = hsl?.s;
|
||||
const l = hsl?.l;
|
||||
|
||||
if (h === undefined || s === undefined || l === undefined) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const adjustedHsl = {
|
||||
h: (((h + hueShift) % 360) + 360) % 360,
|
||||
s: Math.min(1, Math.max(0, s * saturationScale)),
|
||||
l: Math.min(1, Math.max(0.1, l * lightnessScale)),
|
||||
};
|
||||
|
||||
const out = converter("hsl")(adjustedHsl as Hsl);
|
||||
return formatHex(out);
|
||||
}
|
||||
|
||||
const HslAdjustmentControls = () => {
|
||||
const { themeState, setThemeState, saveThemeCheckpoint, themeCheckpoint } = useEditorStore();
|
||||
const debouncedUpdateRef = useRef<ReturnType<typeof debounce> | null>(null);
|
||||
|
||||
// Get current HSL adjustments with fallback to defaults
|
||||
const currentHslAdjustments = useMemo(
|
||||
() => themeState.hslAdjustments ?? defaultThemeState.hslAdjustments!,
|
||||
[themeState.hslAdjustments]
|
||||
);
|
||||
|
||||
// Save checkpoint if HSL adjustments are at default values
|
||||
useEffect(() => {
|
||||
if (isDeepEqual(themeState.hslAdjustments, defaultThemeState.hslAdjustments)) {
|
||||
saveThemeCheckpoint();
|
||||
}
|
||||
}, [themeState.hslAdjustments, saveThemeCheckpoint]);
|
||||
|
||||
// Setup debounced update function
|
||||
useEffect(() => {
|
||||
debouncedUpdateRef.current = debounce((hslAdjustments: ThemeEditorState["hslAdjustments"]) => {
|
||||
const {
|
||||
hueShift = defaultThemeState.hslAdjustments!.hueShift,
|
||||
saturationScale = defaultThemeState.hslAdjustments!.saturationScale,
|
||||
lightnessScale = defaultThemeState.hslAdjustments!.lightnessScale,
|
||||
} = hslAdjustments ?? {};
|
||||
|
||||
const adjustments = { hueShift, saturationScale, lightnessScale };
|
||||
const state = themeCheckpoint ?? themeState;
|
||||
const { light: lightStyles, dark: darkStyles } = state.styles;
|
||||
|
||||
const updatedLightStyles = Object.keys(lightStyles)
|
||||
.filter((key) => !COMMON_STYLES.includes(key))
|
||||
.reduce<Record<string, string>>((acc, key) => {
|
||||
const colorKey = key as keyof typeof lightStyles;
|
||||
return {
|
||||
...acc,
|
||||
[key]: adjustColorByHsl(
|
||||
lightStyles[colorKey],
|
||||
adjustments.hueShift,
|
||||
adjustments.saturationScale,
|
||||
adjustments.lightnessScale
|
||||
),
|
||||
};
|
||||
}, {});
|
||||
|
||||
const updatedDarkStyles = Object.keys(darkStyles)
|
||||
.filter((key) => !COMMON_STYLES.includes(key))
|
||||
.reduce<Record<string, string>>((acc, key) => {
|
||||
const colorKey = key as keyof typeof darkStyles;
|
||||
return {
|
||||
...acc,
|
||||
[key]: adjustColorByHsl(
|
||||
darkStyles[colorKey],
|
||||
adjustments.hueShift,
|
||||
adjustments.saturationScale,
|
||||
adjustments.lightnessScale
|
||||
),
|
||||
};
|
||||
}, {});
|
||||
|
||||
// Update theme state with all changes
|
||||
setThemeState({
|
||||
...state,
|
||||
hslAdjustments,
|
||||
styles: {
|
||||
light: { ...lightStyles, ...updatedLightStyles },
|
||||
dark: { ...darkStyles, ...updatedDarkStyles },
|
||||
},
|
||||
});
|
||||
}, 10);
|
||||
|
||||
return () => debouncedUpdateRef.current?.cancel();
|
||||
}, [themeState, setThemeState, themeCheckpoint]);
|
||||
|
||||
// Handle HSL value changes
|
||||
const handleHslChange = useCallback(
|
||||
(property: keyof typeof currentHslAdjustments, value: number) => {
|
||||
if (debouncedUpdateRef.current) {
|
||||
debouncedUpdateRef.current({
|
||||
...currentHslAdjustments,
|
||||
[property]: value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentHslAdjustments]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SliderWithInput
|
||||
value={currentHslAdjustments.hueShift}
|
||||
onChange={(value) => handleHslChange("hueShift", value)}
|
||||
unit="deg"
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
label="Hue Shift"
|
||||
/>
|
||||
<SliderWithInput
|
||||
value={currentHslAdjustments.saturationScale}
|
||||
onChange={(value) => handleHslChange("saturationScale", value)}
|
||||
unit="x"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
label="Saturation Multiplier"
|
||||
/>
|
||||
<SliderWithInput
|
||||
value={currentHslAdjustments.lightnessScale}
|
||||
onChange={(value) => handleHslChange("lightnessScale", value)}
|
||||
unit="x"
|
||||
min={0.2}
|
||||
max={2}
|
||||
step={0.01}
|
||||
label="Lightness Multiplier"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HslAdjustmentControls;
|
||||
@@ -30,6 +30,7 @@ import ShadowControl from "./shadow-control";
|
||||
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
|
||||
import ThemeEditActions from "./theme-edit-actions";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
import HslAdjustmentControls from "./hsl-adjustment-controls";
|
||||
|
||||
const ThemeControlPanel = ({
|
||||
styles,
|
||||
@@ -49,10 +50,7 @@ const ThemeControlPanel = ({
|
||||
);
|
||||
|
||||
const updateStyle = React.useCallback(
|
||||
<K extends keyof typeof currentStyles>(
|
||||
key: K,
|
||||
value: (typeof currentStyles)[K]
|
||||
) => {
|
||||
<K extends keyof typeof currentStyles>(key: K, value: (typeof currentStyles)[K]) => {
|
||||
// apply common styles to both light and dark modes
|
||||
if (COMMON_STYLES.includes(key)) {
|
||||
onChange({
|
||||
@@ -96,26 +94,19 @@ const ThemeControlPanel = ({
|
||||
<ThemeEditActions theme={theme} />
|
||||
)}
|
||||
</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">
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-4">
|
||||
<Tabs defaultValue="colors" className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<div className="mt-2 px-4">
|
||||
<TabsList className="bg-background text-muted-foreground inline-flex w-fit items-center justify-center rounded-full px-0">
|
||||
<TabsTriggerPill value="colors">Colors</TabsTriggerPill>
|
||||
<TabsTriggerPill value="typography">Typography</TabsTriggerPill>
|
||||
<TabsTriggerPill value="other">Other</TabsTriggerPill>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-full pb-40 p-4 pt-0 flex-1">
|
||||
<ScrollArea className="h-full flex-1 p-4 pt-0 pb-40">
|
||||
<TabsContent value="colors">
|
||||
<ControlSection
|
||||
title="Primary Colors"
|
||||
id="primary-colors"
|
||||
expanded
|
||||
>
|
||||
<ControlSection title="Primary Colors" id="primary-colors" expanded>
|
||||
<ColorPicker
|
||||
color={currentStyles.primary}
|
||||
onChange={(color) => updateStyle("primary", color)}
|
||||
@@ -136,9 +127,7 @@ const ThemeControlPanel = ({
|
||||
/>
|
||||
<ColorPicker
|
||||
color={currentStyles["secondary-foreground"]}
|
||||
onChange={(color) =>
|
||||
updateStyle("secondary-foreground", color)
|
||||
}
|
||||
onChange={(color) => updateStyle("secondary-foreground", color)}
|
||||
label="Secondary Foreground"
|
||||
/>
|
||||
</ControlSection>
|
||||
@@ -216,9 +205,7 @@ const ThemeControlPanel = ({
|
||||
/>
|
||||
<ColorPicker
|
||||
color={currentStyles["destructive-foreground"]}
|
||||
onChange={(color) =>
|
||||
updateStyle("destructive-foreground", color)
|
||||
}
|
||||
onChange={(color) => updateStyle("destructive-foreground", color)}
|
||||
label="Destructive Foreground"
|
||||
/>
|
||||
</ControlSection>
|
||||
@@ -287,9 +274,7 @@ const ThemeControlPanel = ({
|
||||
/>
|
||||
<ColorPicker
|
||||
color={currentStyles["sidebar-primary-foreground"]}
|
||||
onChange={(color) =>
|
||||
updateStyle("sidebar-primary-foreground", color)
|
||||
}
|
||||
onChange={(color) => updateStyle("sidebar-primary-foreground", color)}
|
||||
label="Sidebar Primary Foreground"
|
||||
/>
|
||||
<ColorPicker
|
||||
@@ -299,9 +284,7 @@ const ThemeControlPanel = ({
|
||||
/>
|
||||
<ColorPicker
|
||||
color={currentStyles["sidebar-accent-foreground"]}
|
||||
onChange={(color) =>
|
||||
updateStyle("sidebar-accent-foreground", color)
|
||||
}
|
||||
onChange={(color) => updateStyle("sidebar-accent-foreground", color)}
|
||||
label="Sidebar Accent Foreground"
|
||||
/>
|
||||
<ColorPicker
|
||||
@@ -318,16 +301,16 @@ const ThemeControlPanel = ({
|
||||
</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">
|
||||
<div className="bg-muted/50 mb-2 flex items-start gap-2.5 rounded-md border p-3">
|
||||
<AlertCircle className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<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"
|
||||
className="hover:text-muted-foreground/90 underline underline-offset-2"
|
||||
>
|
||||
Tailwind docs
|
||||
</a>{" "}
|
||||
@@ -338,7 +321,7 @@ const ThemeControlPanel = ({
|
||||
|
||||
<ControlSection title="Font Family" expanded>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="font-sans" className="text-xs mb-1.5 block">
|
||||
<Label htmlFor="font-sans" className="mb-1.5 block text-xs">
|
||||
Sans-Serif Font
|
||||
</Label>
|
||||
<ThemeFontSelect
|
||||
@@ -352,7 +335,7 @@ const ThemeControlPanel = ({
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="font-serif" className="text-xs mb-1.5 block">
|
||||
<Label htmlFor="font-serif" className="mb-1.5 block text-xs">
|
||||
Serif Font
|
||||
</Label>
|
||||
<ThemeFontSelect
|
||||
@@ -365,7 +348,7 @@ const ThemeControlPanel = ({
|
||||
|
||||
<Separator className="my-4" />
|
||||
<div>
|
||||
<Label htmlFor="font-mono" className="text-xs mb-1.5 block">
|
||||
<Label htmlFor="font-mono" className="mb-1.5 block text-xs">
|
||||
Monospace Font
|
||||
</Label>
|
||||
<ThemeFontSelect
|
||||
@@ -379,12 +362,8 @@ const ThemeControlPanel = ({
|
||||
|
||||
<ControlSection title="Letter Spacing" expanded>
|
||||
<SliderWithInput
|
||||
value={parseFloat(
|
||||
currentStyles["letter-spacing"]?.replace("em", "")
|
||||
)}
|
||||
onChange={(value) =>
|
||||
updateStyle("letter-spacing", `${value}em`)
|
||||
}
|
||||
value={parseFloat(currentStyles["letter-spacing"]?.replace("em", ""))}
|
||||
onChange={(value) => updateStyle("letter-spacing", `${value}em`)}
|
||||
min={-0.5}
|
||||
max={0.5}
|
||||
step={0.025}
|
||||
@@ -395,6 +374,10 @@ const ThemeControlPanel = ({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="other">
|
||||
<ControlSection title="HSL Adjustments" expanded>
|
||||
<HslAdjustmentControls />
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Radius" expanded>
|
||||
<SliderWithInput
|
||||
value={radius}
|
||||
@@ -422,18 +405,10 @@ const ThemeControlPanel = ({
|
||||
<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", "")
|
||||
)}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user