From 0d6e0280161adb3b72c6514aa08f412988f01f2c Mon Sep 17 00:00:00 2001 From: Alex Franzen Date: Fri, 28 Feb 2025 16:09:58 +0100 Subject: [PATCH] Save Themes --- app/themes/layout.tsx | 11 + app/themes/page.tsx | 20 + components/page-header.tsx | 17 + components/picker/theme-creator.tsx | 141 ++++++- components/picker/theme-manager.tsx | 385 ++++++++++++++++++++ components/themes/theme-manager-client.tsx | 64 ++++ lib/db/migrations/0001_add_themes_table.sql | 10 + lib/db/queries.ts | 95 ++++- lib/db/schema.ts | 34 +- lib/services/theme-service.ts | 163 +++++++++ 10 files changed, 936 insertions(+), 4 deletions(-) create mode 100644 app/themes/layout.tsx create mode 100644 app/themes/page.tsx create mode 100644 components/page-header.tsx create mode 100644 components/picker/theme-manager.tsx create mode 100644 components/themes/theme-manager-client.tsx create mode 100644 lib/db/migrations/0001_add_themes_table.sql create mode 100644 lib/services/theme-service.ts diff --git a/app/themes/layout.tsx b/app/themes/layout.tsx new file mode 100644 index 0000000..1e81d5b --- /dev/null +++ b/app/themes/layout.tsx @@ -0,0 +1,11 @@ +import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; + +export default function PickerLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/app/themes/page.tsx b/app/themes/page.tsx new file mode 100644 index 0000000..6aa8486 --- /dev/null +++ b/app/themes/page.tsx @@ -0,0 +1,20 @@ +import { getUserThemesList, ThemeData } from '@/lib/services/theme-service'; +import ThemeCreator from '@/components/picker/theme-creator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Suspense } from 'react'; +import ThemeManagerClient from '@/components/themes/theme-manager-client'; + +export const metadata = { + title: 'My Themes', + description: 'Create, customize, and manage your themes', +}; + +export default async function ThemesPage() { + const themes = await getUserThemesList(); + + return ( + Loading your themes...}> + + + ); +} diff --git a/components/page-header.tsx b/components/page-header.tsx new file mode 100644 index 0000000..950056f --- /dev/null +++ b/components/page-header.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface PageHeaderProps { + heading: string; + text?: string; + children?: React.ReactNode; +} + +export function PageHeader({ heading, text, children }: PageHeaderProps) { + return ( +
+

{heading}

+ {text &&

{text}

} + {children} +
+ ); +} diff --git a/components/picker/theme-creator.tsx b/components/picker/theme-creator.tsx index b37f96a..1b732d8 100644 --- a/components/picker/theme-creator.tsx +++ b/components/picker/theme-creator.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Save } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { Toaster } from '@/components/ui/sonner'; @@ -30,6 +30,17 @@ import { ModeToggle } from '../mode-toggle'; import { SidebarTrigger } from '../ui/sidebar'; import { ScrollArea } from '../ui/scroll-area'; import { FontOption, applyFontToDocument, fontOptions } from '@/lib/picker/font-utils'; +import { saveTheme, getUserThemesList, ThemeData } from '@/lib/services/theme-service'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; // Type for theme color key type ThemeColorKey = keyof typeof defaultTheme.light; @@ -53,6 +64,32 @@ export default function ThemeCreator() { const [forceEditorUpdate, setForceEditorUpdate] = useState(0); const [importModalOpen, setImportModalOpen] = useState(false); + // Add state for saving to database + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [themeName, setThemeName] = useState('My Custom Theme'); + const [isDefault, setIsDefault] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isThemeSaved, setIsThemeSaved] = useState(false); + + // Check if theme is already saved + const checkIfThemeIsSaved = useCallback(async () => { + try { + const userThemes = await getUserThemesList(); + // Compare current theme with saved themes + // This is a simple check - you might want a more sophisticated comparison + const themeFound = userThemes.some( + savedTheme => + JSON.stringify(savedTheme.light) === JSON.stringify(themeColorsRef.current.light) && + JSON.stringify(savedTheme.dark) === JSON.stringify(themeColorsRef.current.dark) + ); + setIsThemeSaved(themeFound); + return themeFound; + } catch (error) { + console.error('Error checking if theme is saved:', error); + return false; + } + }, []); + // Handle hydration mismatch useEffect(() => { setMounted(true); @@ -75,7 +112,21 @@ export default function ThemeCreator() { if (defaultFont) { applyFontToDocument(defaultFont); } - }, [mounted, currentTheme, currentFont]); + + // Check if theme is already saved + checkIfThemeIsSaved(); + }, [mounted, currentTheme, currentFont, checkIfThemeIsSaved]); + + // Check if theme is saved when it changes significantly + useEffect(() => { + if (!mounted) return; + // Debounce the check to avoid too many API calls + const timer = setTimeout(() => { + checkIfThemeIsSaved(); + }, 1000); + + return () => clearTimeout(timer); + }, [forceEditorUpdate, checkIfThemeIsSaved, mounted]); // Update all hues with new value without re-rendering const handleHueChange = useCallback( @@ -224,6 +275,41 @@ export default function ThemeCreator() { // Font will be applied by the FontSelector component through the FontProvider }, []); + // Handle save to database + const handleSaveToDatabase = useCallback(async () => { + try { + // First check if theme is already saved + const alreadySaved = await checkIfThemeIsSaved(); + if (alreadySaved) { + toast('This theme is already saved in your account'); + return; + } + + setIsSaving(true); + + const themeData: ThemeData = { + name: themeName, + isDefault: isDefault, + light: themeColorsRef.current.light, + dark: themeColorsRef.current.dark, + }; + + await saveTheme(themeData); + + // Close the dialog + setSaveDialogOpen(false); + setIsThemeSaved(true); + + // Show success message + toast('Theme saved successfully'); + } catch (error) { + console.error('Error saving theme:', error); + toast.error('Failed to save theme'); + } finally { + setIsSaving(false); + } + }, [themeName, isDefault, checkIfThemeIsSaved]); + // Handle selecting a default theme const handleSelectDefaultTheme = useCallback( (themeName: string, theme: any) => { @@ -296,6 +382,15 @@ export default function ThemeCreator() { + @@ -329,6 +424,48 @@ export default function ThemeCreator() { onOpenChange={setImportModalOpen} onImport={handleImportTheme} /> + + + + + Save Theme + Save your current theme to your account + + +
+
+ + setThemeName(e.target.value)} + placeholder='My Custom Theme' + /> +
+ +
+ setIsDefault(e.target.checked)} + className='h-4 w-4 rounded border-gray-300' + /> + +
+
+ + + + + +
+
+ ); diff --git a/components/picker/theme-manager.tsx b/components/picker/theme-manager.tsx new file mode 100644 index 0000000..f2d6383 --- /dev/null +++ b/components/picker/theme-manager.tsx @@ -0,0 +1,385 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { toast } from 'sonner'; +import { Star, Trash2, Edit, Check, X, Plus } from 'lucide-react'; +import { saveTheme, updateTheme, removeTheme, ThemeData } from '@/lib/services/theme-service'; +import { + ThemeMode, + ThemeColors, + getActiveThemeMode, + applyThemeToDOM, + toCamelCase, +} from '@/lib/picker/theme-utils'; +import { useTheme } from 'next-themes'; + +interface ThemeManagerProps { + themes: ThemeData[]; + currentTheme: Record; + onSelectTheme: (theme: ThemeData) => void; +} + +export default function ThemeManager({ themes, currentTheme, onSelectTheme }: ThemeManagerProps) { + const [selectedTheme, setSelectedTheme] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [themeName, setThemeName] = useState(''); + const [isDefault, setIsDefault] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [localThemes, setLocalThemes] = useState(themes); + const { theme: nextTheme } = useTheme(); + + // Update local themes when prop changes + useEffect(() => { + setLocalThemes(themes); + }, [themes]); + + const handleSaveTheme = async () => { + if (!themeName.trim()) { + toast.error('Theme name required', { + description: 'Please enter a name for your theme', + }); + return; + } + + setIsSubmitting(true); + try { + const activeMode = getActiveThemeMode(nextTheme); + + const themeToSave: ThemeData = { + name: themeName, + isDefault, + light: currentTheme.light as unknown as Record, + dark: currentTheme.dark as unknown as Record, + }; + + const savedTheme = await saveTheme(themeToSave); + + if (savedTheme) { + setLocalThemes(prev => [...prev, savedTheme]); + toast.success('Theme saved', { + description: `"${themeName}" has been saved to your themes`, + }); + setIsSaveDialogOpen(false); + setThemeName(''); + setIsDefault(false); + } else { + toast.error('Error saving theme', { + description: 'There was a problem saving your theme. Please try again.', + }); + } + } catch (error) { + toast.error('Error saving theme', { + description: 'There was a problem saving your theme. Please try again.', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleUpdateTheme = async () => { + if (!selectedTheme || !selectedTheme.id) return; + if (!themeName.trim()) { + toast.error('Theme name required', { + description: 'Please enter a name for your theme', + }); + return; + } + + setIsSubmitting(true); + try { + const updates: Partial = { + name: themeName, + isDefault, + }; + + const updatedTheme = await updateTheme(selectedTheme.id, updates); + + if (updatedTheme) { + setLocalThemes(prev => prev.map(t => (t.id === updatedTheme.id ? updatedTheme : t))); + toast.success('Theme updated', { + description: `"${themeName}" has been updated`, + }); + setIsEditDialogOpen(false); + setSelectedTheme(null); + setThemeName(''); + setIsDefault(false); + } else { + toast.error('Error updating theme', { + description: 'There was a problem updating your theme. Please try again.', + }); + } + } catch (error) { + toast.error('Error updating theme', { + description: 'There was a problem updating your theme. Please try again.', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteTheme = async () => { + if (!selectedTheme || !selectedTheme.id) return; + + setIsSubmitting(true); + try { + const success = await removeTheme(selectedTheme.id); + + if (success) { + setLocalThemes(prev => prev.filter(t => t.id !== selectedTheme.id)); + toast.success('Theme deleted', { + description: `"${selectedTheme.name}" has been deleted`, + }); + setIsDeleteDialogOpen(false); + setSelectedTheme(null); + } else { + toast.error('Error deleting theme', { + description: 'There was a problem deleting your theme. Please try again.', + }); + } + } catch (error) { + toast.error('Error deleting theme', { + description: 'There was a problem deleting your theme. Please try again.', + }); + } finally { + setIsSubmitting(false); + } + }; + + const openEditDialog = (theme: ThemeData) => { + setSelectedTheme(theme); + setThemeName(theme.name); + setIsDefault(theme.isDefault || false); + setIsEditDialogOpen(true); + }; + + const openDeleteDialog = (theme: ThemeData) => { + setSelectedTheme(theme); + setIsDeleteDialogOpen(true); + }; + + const openSaveDialog = () => { + setThemeName('My Custom Theme'); + setIsDefault(false); + setIsSaveDialogOpen(true); + }; + + return ( +
+
+

My Themes

+ +
+ + + + {localThemes.length === 0 ? ( +
+

+ No saved themes yet. Create and save your first theme! +

+
+ ) : ( +
+ {localThemes.map(theme => ( + + +
+ {theme.name} + {theme.isDefault && ( +
+ +
+ )} +
+ + {theme.isDefault ? 'Default theme' : 'Custom theme'} + +
+ + +
+ {/* Primary color swatch */} +
+
+ Primary +
+ + {/* Secondary color swatch */} +
+
+ Secondary +
+ + {/* Background color swatch */} +
+
+ Background +
+
+ + + + +
+ + +
+
+ + ))} +
+ )} + + {/* Save Theme Dialog */} + + + + Save Theme + Save your current theme to your collection. + + +
+
+ + setThemeName(e.target.value)} + placeholder='My Custom Theme' + /> +
+ +
+ setIsDefault(e.target.checked)} + className='h-4 w-4 rounded border-gray-300' + /> + +
+
+ + + + + +
+
+ + {/* Edit Theme Dialog */} + + + + Edit Theme + Update your theme details. + + +
+
+ + setThemeName(e.target.value)} + /> +
+ +
+ setIsDefault(e.target.checked)} + className='h-4 w-4 rounded border-gray-300' + /> + +
+
+ + + + + +
+
+ + {/* Delete Theme Dialog */} + + + + Delete Theme + + Are you sure you want to delete "{selectedTheme?.name}"? This action cannot be undone. + + + + + + + + + +
+ ); +} diff --git a/components/themes/theme-manager-client.tsx b/components/themes/theme-manager-client.tsx new file mode 100644 index 0000000..1c60330 --- /dev/null +++ b/components/themes/theme-manager-client.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import ThemeManager from '@/components/picker/theme-manager'; +import { ThemeData } from '@/lib/services/theme-service'; +import { useTheme } from 'next-themes'; +import { ThemeMode, getActiveThemeMode, applyThemeToDOM } from '@/lib/picker/theme-utils'; +import { defaultTheme } from '@/components/picker/defaults/defaultTheme'; + +interface ThemeManagerClientProps { + initialThemes: ThemeData[]; +} + +export default function ThemeManagerClient({ initialThemes }: ThemeManagerClientProps) { + const [themes, setThemes] = useState(initialThemes); + const { theme: currentTheme, setTheme } = useTheme(); + + // Use a ref to store the current theme without triggering re-renders + const themeColorsRef = useRef>({ + light: { ...defaultTheme.light }, + dark: { ...defaultTheme.dark }, + }); + + // Handle applying a saved theme + const handleSelectTheme = (theme: ThemeData) => { + // Update the theme colors reference + themeColorsRef.current = { + light: { ...theme.light }, + dark: { ...theme.dark }, + }; + + // Get the current active mode + const activeMode = getActiveThemeMode(currentTheme); + + // Apply the active theme to DOM + applyThemeToDOM(themeColorsRef.current, activeMode, document.documentElement, false); + + // For the inactive theme, we'll use data attributes + const inactiveMode: ThemeMode = activeMode === 'light' ? 'dark' : 'light'; + + // Store the inactive theme using data attributes + const inactiveTheme = theme[inactiveMode]; + Object.entries(inactiveTheme).forEach(([key, value]) => { + // Handle data-attributes appropriately based on the key + const dataKey = key.replace(/-([a-z])/g, (_, p1) => p1.toUpperCase()); + document.documentElement.style.setProperty(`--${key}`, `hsl(${value})`); + }); + }; + + return ( +
+
+
+

My Themes

+
+
+ +
+ ); +} diff --git a/lib/db/migrations/0001_add_themes_table.sql b/lib/db/migrations/0001_add_themes_table.sql new file mode 100644 index 0000000..443bd62 --- /dev/null +++ b/lib/db/migrations/0001_add_themes_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS themes ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + name VARCHAR(100) NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + light_theme JSONB NOT NULL, + dark_theme JSONB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 30431a3..f9d9f1e 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,6 +1,6 @@ import { desc, and, eq, isNull } from 'drizzle-orm'; import { db } from './drizzle'; -import { activityLogs, teamMembers, teams, users } from './schema'; +import { activityLogs, teamMembers, teams, themes, users } from './schema'; import { cookies } from 'next/headers'; import { verifyToken } from '@/lib/auth/session'; @@ -123,3 +123,96 @@ export async function getTeamForUser(userId: number) { return result?.teamMembers[0]?.team || null; } + +// Theme related queries +export async function getUserThemes(userId: number) { + return await db + .select() + .from(themes) + .where(eq(themes.userId, userId)) + .orderBy(desc(themes.updatedAt)); +} + +export async function getUserDefaultTheme(userId: number) { + const result = await db + .select() + .from(themes) + .where(and(eq(themes.userId, userId), eq(themes.isDefault, true))) + .limit(1); + + return result.length > 0 ? result[0] : null; +} + +export async function saveUserTheme( + userId: number, + themeName: string, + lightTheme: any, + darkTheme: any, + isDefault: boolean = false +) { + // If this theme is set as default, update any existing default themes + if (isDefault) { + await db + .update(themes) + .set({ isDefault: false }) + .where(and(eq(themes.userId, userId), eq(themes.isDefault, true))); + } + + return await db + .insert(themes) + .values({ + userId, + name: themeName, + lightTheme, + darkTheme, + isDefault, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +export async function updateUserTheme( + themeId: number, + userId: number, + updates: { + name?: string; + lightTheme?: any; + darkTheme?: any; + isDefault?: boolean; + } +) { + // If this theme is set as default, update any existing default themes + if (updates.isDefault) { + await db + .update(themes) + .set({ isDefault: false }) + .where(and(eq(themes.userId, userId), eq(themes.isDefault, true))); + } + + return await db + .update(themes) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(and(eq(themes.id, themeId), eq(themes.userId, userId))) + .returning(); +} + +export async function deleteUserTheme(themeId: number, userId: number) { + const themeToDelete = await db + .select() + .from(themes) + .where(and(eq(themes.id, themeId), eq(themes.userId, userId))) + .limit(1); + + if (themeToDelete.length === 0) { + return null; + } + + // Delete the theme + await db.delete(themes).where(and(eq(themes.id, themeId), eq(themes.userId, userId))); + + return themeToDelete[0]; +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index f2915b6..ddb8664 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,4 +1,13 @@ -import { pgTable, serial, varchar, text, timestamp, integer } from 'drizzle-orm/pg-core'; +import { + pgTable, + serial, + varchar, + text, + timestamp, + integer, + json, + boolean, +} from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; export const users = pgTable('users', { @@ -61,6 +70,19 @@ export const invitations = pgTable('invitations', { status: varchar('status', { length: 20 }).notNull().default('pending'), }); +export const themes = pgTable('themes', { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id), + name: varchar('name', { length: 100 }).notNull(), + isDefault: boolean('is_default').notNull().default(false), + lightTheme: json('light_theme').notNull(), + darkTheme: json('dark_theme').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}); + export const teamsRelations = relations(teams, ({ many }) => ({ teamMembers: many(teamMembers), activityLogs: many(activityLogs), @@ -70,6 +92,7 @@ export const teamsRelations = relations(teams, ({ many }) => ({ export const usersRelations = relations(users, ({ many }) => ({ teamMembers: many(teamMembers), invitationsSent: many(invitations), + themes: many(themes), })); export const invitationsRelations = relations(invitations, ({ one }) => ({ @@ -105,6 +128,13 @@ export const activityLogsRelations = relations(activityLogs, ({ one }) => ({ }), })); +export const themesRelations = relations(themes, ({ one }) => ({ + user: one(users, { + fields: [themes.userId], + references: [users.id], + }), +})); + export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Team = typeof teams.$inferSelect; @@ -115,6 +145,8 @@ export type ActivityLog = typeof activityLogs.$inferSelect; export type NewActivityLog = typeof activityLogs.$inferInsert; export type Invitation = typeof invitations.$inferSelect; export type NewInvitation = typeof invitations.$inferInsert; +export type Theme = typeof themes.$inferSelect; +export type NewTheme = typeof themes.$inferInsert; export type TeamDataWithMembers = Team & { teamMembers: (TeamMember & { user: Pick; diff --git a/lib/services/theme-service.ts b/lib/services/theme-service.ts new file mode 100644 index 0000000..93aa7d1 --- /dev/null +++ b/lib/services/theme-service.ts @@ -0,0 +1,163 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { + getUserThemes, + getUserDefaultTheme, + saveUserTheme, + updateUserTheme, + deleteUserTheme, +} from '@/lib/db/queries'; +import { getUser } from '@/lib/db/queries'; +import { NewTheme, Theme } from '@/lib/db/schema'; +import { ThemeMode } from '@/lib/picker/theme-utils'; + +export type ThemeData = { + id?: number; + name: string; + isDefault?: boolean; + light: Record; + dark: Record; +}; + +/** + * Get all themes for the current user + */ +export async function getUserThemesList(): Promise { + const user = await getUser(); + if (!user) { + return []; + } + + const themes = await getUserThemes(user.id); + + return themes.map(theme => ({ + id: theme.id, + name: theme.name, + isDefault: theme.isDefault, + light: theme.lightTheme as Record, + dark: theme.darkTheme as Record, + })); +} + +/** + * Get the default theme for the current user + */ +export async function getCurrentUserDefaultTheme(): Promise { + const user = await getUser(); + if (!user) { + return null; + } + + const theme = await getUserDefaultTheme(user.id); + if (!theme) { + return null; + } + + return { + id: theme.id, + name: theme.name, + isDefault: theme.isDefault, + light: theme.lightTheme as Record, + dark: theme.darkTheme as Record, + }; +} + +/** + * Save a theme for the current user + */ +export async function saveTheme(themeData: ThemeData): Promise { + const user = await getUser(); + if (!user) { + return null; + } + + const result = await saveUserTheme( + user.id, + themeData.name, + themeData.light, + themeData.dark, + themeData.isDefault || false + ); + + if (!result || result.length === 0) { + return null; + } + + const savedTheme = result[0]; + + revalidatePath('/settings/themes'); + + return { + id: savedTheme.id, + name: savedTheme.name, + isDefault: savedTheme.isDefault, + light: savedTheme.lightTheme as Record, + dark: savedTheme.darkTheme as Record, + }; +} + +/** + * Update an existing theme + */ +export async function updateTheme( + themeId: number, + updates: Partial +): Promise { + const user = await getUser(); + if (!user) { + return null; + } + + const updateData: any = {}; + + if (updates.name) { + updateData.name = updates.name; + } + + if (updates.isDefault !== undefined) { + updateData.isDefault = updates.isDefault; + } + + if (updates.light) { + updateData.lightTheme = updates.light; + } + + if (updates.dark) { + updateData.darkTheme = updates.dark; + } + + const result = await updateUserTheme(themeId, user.id, updateData); + + if (!result || result.length === 0) { + return null; + } + + const updatedTheme = result[0]; + + revalidatePath('/settings/themes'); + + return { + id: updatedTheme.id, + name: updatedTheme.name, + isDefault: updatedTheme.isDefault, + light: updatedTheme.lightTheme as Record, + dark: updatedTheme.darkTheme as Record, + }; +} + +/** + * Delete a theme + */ +export async function removeTheme(themeId: number): Promise { + const user = await getUser(); + if (!user) { + return false; + } + + const result = await deleteUserTheme(themeId, user.id); + + revalidatePath('/settings/themes'); + + return !!result; +}