feat: Show saved presets in theme-preset-select

This commit is contained in:
Sahaj Jain
2025-04-27 01:21:30 +05:30
parent f90e401309
commit 9ec24f2f11
5 changed files with 201 additions and 63 deletions
+8 -1
View File
@@ -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(
() => ({
+151 -58
View File
@@ -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>
+16
View File
@@ -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",
});
+24
View File
@@ -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
View File
@@ -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>;