mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-30 18:10:28 +08:00
feat: Show saved presets in theme-preset-select
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { use } from "react";
|
||||
import React, { use, useEffect } from "react";
|
||||
import { ThemeEditorControlsProps, ThemeStyleProps } from "@/types/theme";
|
||||
import ControlSection from "./control-section";
|
||||
import ColorPicker from "./color-picker";
|
||||
@@ -39,6 +39,13 @@ const ThemeControlPanel = ({
|
||||
}: ThemeEditorControlsProps) => {
|
||||
const { applyThemePreset, themeState } = useEditorStore();
|
||||
const presets = useThemePresetStore((state) => state.getAllPresets());
|
||||
const loadSavedPresets = useThemePresetStore(
|
||||
(state) => state.loadSavedPresets
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSavedPresets();
|
||||
}, [loadSavedPresets]);
|
||||
|
||||
const currentStyles = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Search,
|
||||
Shuffle,
|
||||
Sun,
|
||||
Bookmark,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Separator } from "../ui/separator";
|
||||
@@ -172,53 +173,87 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
|
||||
const mode = themeState.currentMode;
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Placeholder condition for saved themes
|
||||
const isSavedTheme = (presetId: string) => {
|
||||
return presets[presetId]?.source === "SAVED";
|
||||
};
|
||||
|
||||
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);
|
||||
onPresetChange(presetNames[random]);
|
||||
}, [onPresetChange, presetNames]);
|
||||
|
||||
const cycleTheme = useCallback(
|
||||
(direction: "prev" | "next") => {
|
||||
const newIndex =
|
||||
direction === "next"
|
||||
? (currentIndex + 1) % presetNames.length
|
||||
: (currentIndex - 1 + presetNames.length) % presetNames.length;
|
||||
onPresetChange(presetNames[newIndex]);
|
||||
},
|
||||
[currentIndex, presetNames, onPresetChange]
|
||||
);
|
||||
|
||||
const filteredPresets = useMemo(() => {
|
||||
const filteredList =
|
||||
search.trim() === ""
|
||||
? presetNames
|
||||
: presetNames.filter((name) =>
|
||||
name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
: Object.entries(presets)
|
||||
.filter(([_, preset]) =>
|
||||
preset.label?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.map(([name]) => name);
|
||||
|
||||
return filteredList.sort((a, b) => {
|
||||
const labelA = presets[a]?.label || a;
|
||||
const labelB = presets[b]?.label || b;
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
// Separate saved and default themes
|
||||
const savedThemesList = filteredList.filter(
|
||||
(name) => name !== "default" && isSavedTheme(name)
|
||||
);
|
||||
const defaultThemesList = filteredList.filter(
|
||||
(name) => !savedThemesList.includes(name)
|
||||
);
|
||||
|
||||
// Sort each list
|
||||
const sortThemes = (list: string[]) =>
|
||||
list.sort((a, b) => {
|
||||
const labelA = presets[a]?.label || a;
|
||||
const labelB = presets[b]?.label || b;
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
|
||||
// Combine saved themes first, then default themes
|
||||
return [...sortThemes(savedThemesList), ...sortThemes(defaultThemesList)];
|
||||
}, [presetNames, search, presets]);
|
||||
|
||||
const currentIndex =
|
||||
useMemo(
|
||||
() => filteredPresets.indexOf(value || "default"),
|
||||
[filteredPresets, value]
|
||||
) ?? 0;
|
||||
|
||||
const randomize = useCallback(() => {
|
||||
const random = Math.floor(Math.random() * filteredPresets.length);
|
||||
onPresetChange(filteredPresets[random]);
|
||||
}, [onPresetChange, filteredPresets]);
|
||||
|
||||
const cycleTheme = useCallback(
|
||||
(direction: "prev" | "next") => {
|
||||
const newIndex =
|
||||
direction === "next"
|
||||
? (currentIndex + 1) % filteredPresets.length
|
||||
: (currentIndex - 1 + filteredPresets.length) %
|
||||
filteredPresets.length;
|
||||
onPresetChange(filteredPresets[newIndex]);
|
||||
},
|
||||
[currentIndex, filteredPresets, onPresetChange]
|
||||
);
|
||||
|
||||
const handleThemeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const { clientX: x, clientY: y } = event;
|
||||
toggleTheme({ x, y });
|
||||
};
|
||||
|
||||
const filteredSavedThemes = useMemo(() => {
|
||||
return filteredPresets.filter(
|
||||
(name) => name !== "default" && isSavedTheme(name)
|
||||
);
|
||||
}, [filteredPresets, isSavedTheme]);
|
||||
|
||||
const filteredDefaultThemes = useMemo(() => {
|
||||
return filteredPresets.filter(
|
||||
(name) => name === "default" || !isSavedTheme(name)
|
||||
);
|
||||
}, [filteredPresets, isSavedTheme]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<TooltipProvider>
|
||||
@@ -232,6 +267,9 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeColors presetName={value || "default"} mode={mode} />
|
||||
{value !== "default" && value && isSavedTheme(value) && (
|
||||
<Bookmark className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="capitalize font-medium">
|
||||
{presets[value || "default"]?.label || "default"}
|
||||
</span>
|
||||
@@ -266,37 +304,92 @@ const ThemePresetSelect: React.FC<ThemePresetSelectProps> = ({
|
||||
<Separator />
|
||||
<ScrollArea className="h-[500px] max-h-[70vh]">
|
||||
<CommandEmpty>No themes found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredPresets.map((presetName) => (
|
||||
<CommandItem
|
||||
key={presetName}
|
||||
onSelect={() => {
|
||||
onPresetChange(presetName);
|
||||
setSearch("");
|
||||
}}
|
||||
className="flex items-center gap-2 py-2 hover:bg-secondary/50"
|
||||
>
|
||||
<ThemeColors presetName={presetName} mode={mode} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<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"
|
||||
|
||||
{/* Saved Themes Group */}
|
||||
{filteredSavedThemes.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Saved Themes">
|
||||
{filteredSavedThemes
|
||||
.filter(
|
||||
(name) => name !== "default" && isSavedTheme(name)
|
||||
)
|
||||
.map((presetName) => (
|
||||
<>
|
||||
<CommandItem
|
||||
key={presetName}
|
||||
onSelect={() => {
|
||||
onPresetChange(presetName);
|
||||
setSearch("");
|
||||
}}
|
||||
className="flex items-center gap-2 py-2 hover:bg-secondary/50"
|
||||
>
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
<ThemeColors
|
||||
presetName={presetName}
|
||||
mode={mode}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{presetName === value && (
|
||||
<Check className="h-4 w-4 shrink-0 opacity-70" />
|
||||
)}
|
||||
</CommandItem>
|
||||
</>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredSavedThemes.length === 0 && search.trim() === "" && (
|
||||
<>
|
||||
<div className="pt-2 px-2 text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1 px-2 py-1 border rounded-md">
|
||||
<Bookmark className="size-3" />
|
||||
<span>Save</span>
|
||||
</div>
|
||||
{presetName === value && (
|
||||
<Check className="h-4 w-4 shrink-0 opacity-70" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<span>a theme to find it here.</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Default Theme Group */}
|
||||
{filteredDefaultThemes.length > 0 && (
|
||||
<CommandGroup heading="Built-in Themes">
|
||||
{filteredDefaultThemes.map((presetName) => (
|
||||
<CommandItem
|
||||
key={presetName}
|
||||
onSelect={() => {
|
||||
onPresetChange(presetName);
|
||||
setSearch("");
|
||||
}}
|
||||
className="flex items-center gap-2 py-2 hover:bg-secondary/50"
|
||||
>
|
||||
<ThemeColors presetName={presetName} mode={mode} />
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="capitalize text-sm font-medium">
|
||||
{presets[presetName]?.label || presetName}
|
||||
</span>
|
||||
</div>
|
||||
{presetName === value && (
|
||||
<Check className="h-4 w-4 shrink-0 opacity-70" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/actions/themes";
|
||||
import { Theme } from "@/types/theme";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
import { useThemePresetStore } from "@/store/theme-preset-store";
|
||||
|
||||
type MutationState<T> = {
|
||||
isLoading: boolean;
|
||||
@@ -88,6 +89,8 @@ async function fetcher<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export function useThemeActions() {
|
||||
const { registerPreset, updatePreset, unregisterPreset } =
|
||||
useThemePresetStore();
|
||||
const [isAuthRequired, setIsAuthRequired] = useState(false);
|
||||
|
||||
const [createState, setCreateState] = useState<MutationState<Theme>>({
|
||||
@@ -153,6 +156,12 @@ export function useThemeActions() {
|
||||
if (result.theme) {
|
||||
const theme: Theme = result.theme;
|
||||
handleMutationSuccess(theme, "Created");
|
||||
registerPreset(theme.id, {
|
||||
label: theme.name,
|
||||
source: "SAVED",
|
||||
createdAt: theme.createdAt.toISOString(),
|
||||
styles: theme.styles,
|
||||
});
|
||||
return theme;
|
||||
}
|
||||
return null;
|
||||
@@ -171,6 +180,12 @@ export function useThemeActions() {
|
||||
if (result.theme) {
|
||||
const theme: Theme = result.theme;
|
||||
handleMutationSuccess(theme, "Updated");
|
||||
updatePreset(theme.id, {
|
||||
label: theme.name,
|
||||
source: "SAVED",
|
||||
createdAt: theme.createdAt.toISOString(),
|
||||
styles: theme.styles,
|
||||
});
|
||||
return theme;
|
||||
}
|
||||
return null;
|
||||
@@ -187,6 +202,7 @@ export function useThemeActions() {
|
||||
(result) => {
|
||||
if (result.success) {
|
||||
handleMutationSuccess(result.theme, "Deleted");
|
||||
unregisterPreset(themeId);
|
||||
toast({
|
||||
title: "Theme Deleted Successfully",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { ThemePreset } from "@/types/theme";
|
||||
import { defaultPresets } from "@/utils/theme-presets";
|
||||
import { getThemes } from "@/actions/themes";
|
||||
|
||||
interface ThemePresetStore {
|
||||
presets: Record<string, ThemePreset>;
|
||||
@@ -9,6 +10,7 @@ interface ThemePresetStore {
|
||||
updatePreset: (name: string, preset: ThemePreset) => void;
|
||||
getPreset: (name: string) => ThemePreset | undefined;
|
||||
getAllPresets: () => Record<string, ThemePreset>;
|
||||
loadSavedPresets: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useThemePresetStore = create<ThemePresetStore>()((set, get) => ({
|
||||
@@ -29,6 +31,28 @@ export const useThemePresetStore = create<ThemePresetStore>()((set, get) => ({
|
||||
};
|
||||
});
|
||||
},
|
||||
loadSavedPresets: async () => {
|
||||
try {
|
||||
const savedThemes = await getThemes();
|
||||
const savedPresets = savedThemes.reduce((acc, theme) => {
|
||||
acc[theme.id] = {
|
||||
label: theme.name,
|
||||
styles: theme.styles,
|
||||
source: "SAVED",
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, ThemePreset>);
|
||||
|
||||
set((state) => ({
|
||||
presets: {
|
||||
...state.presets,
|
||||
...savedPresets,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load saved presets:", error);
|
||||
}
|
||||
},
|
||||
updatePreset: (name: string, preset: ThemePreset) => {
|
||||
set((state) => ({
|
||||
presets: {
|
||||
|
||||
+2
-4
@@ -75,12 +75,10 @@ export interface ThemeEditorControlsProps {
|
||||
}
|
||||
|
||||
export type ThemePreset = {
|
||||
source?: "SAVED" | "BUILT_IN";
|
||||
createdAt?: string;
|
||||
label?: string;
|
||||
styles: {
|
||||
light?: Partial<ThemeStyleProps>;
|
||||
dark?: Partial<ThemeStyleProps>;
|
||||
};
|
||||
styles: ThemeStyles;
|
||||
};
|
||||
|
||||
export type Theme = InferSelectModel<typeof theme>;
|
||||
|
||||
Reference in New Issue
Block a user