refactor: Themes API layer + error handling

This commit is contained in:
Sahaj Jain
2025-06-26 00:24:32 +05:30
parent 27209af024
commit 69662dc282
13 changed files with 523 additions and 420 deletions
+109 -131
View File
@@ -1,163 +1,150 @@
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { theme as themeTable } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import cuid from "cuid";
import { auth } from "@/lib/auth";
import { headers } from "next/headers"; // Keep for session, but actions handle auth differently
import { headers } from "next/headers";
import { themeStylesSchema, type ThemeStyles } from "@/types/theme";
import { cache } from "react";
import {
UnauthorizedError,
ValidationError,
ThemeNotFoundError,
ThemeLimitError,
} from "@/types/errors";
// Helper to get user ID (Consider centralizing auth checks)
async function getCurrentUserId(): Promise<string | null> {
// Helper to get user ID with better error handling
async function getCurrentUserId(): Promise<string> {
const session = await auth.api.getSession({
headers: await headers(), // you need to pass the headers object.
headers: await headers(),
});
return session?.user?.id ?? null;
if (!session?.user?.id) {
throw new UnauthorizedError();
}
return session.user.id;
}
// Log errors for observability
function logError(error: Error, context: Record<string, any>) {
console.error("Theme action error:", error, context);
// TODO: Add server-side error reporting to PostHog or your preferred service
// For production, you'd want to send critical errors to an external service
if (error.name === "UnauthorizedError" || error.name === "ValidationError") {
// These are expected errors, log but don't report
console.warn("Expected error:", { error: error.message, context });
} else {
// Unexpected errors should be reported
console.error("Unexpected error:", { error: error.message, stack: error.stack, context });
}
}
const createThemeSchema = z.object({
name: z.string().min(1, "Theme name cannot be empty"),
name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long"),
styles: themeStylesSchema,
});
const updateThemeSchema = z.object({
id: z.string(), // ID is needed to know which theme to update
name: z.string().min(1, "Theme name cannot be empty").optional(),
id: z.string().min(1, "Theme ID required"),
name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long").optional(),
styles: themeStylesSchema.optional(),
});
// Layer 1: Clean server actions with proper error handling
export async function getThemes() {
const userId = await getCurrentUserId();
if (!userId) {
throw new Error("Unauthorized");
}
try {
const userThemes = await db
.select()
.from(themeTable)
.where(eq(themeTable.userId, userId));
const userId = await getCurrentUserId();
const userThemes = await db.select().from(themeTable).where(eq(themeTable.userId, userId));
return userThemes;
} catch (error) {
console.error("Error fetching themes:", error);
throw new Error("Failed to fetch themes."); // Propagate a generic error
logError(error as Error, { action: "getThemes" });
throw error;
}
}
// Wrap getTheme with React.cache
export const getTheme = cache(async (themeId: string) => {
try {
const [theme] = await db
.select()
.from(themeTable)
.where(eq(themeTable.id, themeId))
.limit(1);
if (!themeId) {
throw new ValidationError("Theme ID required");
}
const [theme] = await db.select().from(themeTable).where(eq(themeTable.id, themeId)).limit(1);
if (!theme) {
throw new ThemeNotFoundError();
}
return theme;
} catch (error) {
console.error("Error fetching theme:", error);
throw new Error("Failed to fetch theme.");
logError(error as Error, { action: "getTheme", themeId });
throw error;
}
});
// Action to create a new theme
export async function createTheme(formData: {
name: string;
styles: ThemeStyles;
}) {
const userId = await getCurrentUserId();
if (!userId) {
throw new Error("Unauthorized");
}
const validation = createThemeSchema.safeParse(formData);
if (!validation.success) {
// Return validation errors for the client to handle
return {
success: false,
error: "Invalid input",
details: validation.error.format(),
};
}
// Check if user already has 10 themes
const userThemes = await db
.select()
.from(themeTable)
.where(eq(themeTable.userId, userId));
if (userThemes.length >= 10) {
return {
success: false,
error: "Theme limit reached",
message: "You cannot have more than 10 themes yet.",
};
}
const { name, styles } = validation.data;
const newThemeId = cuid();
const now = new Date();
export async function createTheme(formData: { name: string; styles: ThemeStyles }) {
try {
const userId = await getCurrentUserId();
const validation = createThemeSchema.safeParse(formData);
if (!validation.success) {
throw new ValidationError("Invalid input", validation.error.format());
}
// Check theme limit
const userThemes = await db.select().from(themeTable).where(eq(themeTable.userId, userId));
if (userThemes.length >= 10) {
throw new ThemeLimitError("You cannot have more than 10 themes yet.");
}
const { name, styles } = validation.data;
const newThemeId = cuid();
const now = new Date();
const [insertedTheme] = await db
.insert(themeTable)
.values({
id: newThemeId,
userId: userId,
name: name,
styles: styles, // Already validated
styles: styles,
createdAt: now,
updatedAt: now,
})
.returning();
revalidatePath("/"); // Or a more specific path where themes are displayed
return {
success: true,
theme: insertedTheme,
};
return insertedTheme;
} catch (error) {
console.error("Error creating theme:", error);
return { success: false, error: "Internal Server Error" };
logError(error as Error, { action: "createTheme", formData: { name: formData.name } });
throw error;
}
}
// Action to update an existing theme
export async function updateTheme(formData: {
id: string;
name?: string;
styles?: ThemeStyles;
}) {
const userId = await getCurrentUserId();
if (!userId) {
throw new Error("Unauthorized");
}
const validation = updateThemeSchema.safeParse(formData);
if (!validation.success) {
return {
success: false,
error: "Invalid input",
details: validation.error.format(),
};
}
const { id: themeId, name, styles } = validation.data;
if (!name && !styles) {
return { success: false, error: "No update data provided" };
}
const updateData: Partial<typeof themeTable.$inferInsert> = {
updatedAt: new Date(),
};
if (name) updateData.name = name;
if (styles) updateData.styles = styles; // Already validated
export async function updateTheme(formData: { id: string; name?: string; styles?: ThemeStyles }) {
try {
const userId = await getCurrentUserId();
const validation = updateThemeSchema.safeParse(formData);
if (!validation.success) {
throw new ValidationError("Invalid input", validation.error.format());
}
const { id: themeId, name, styles } = validation.data;
if (!name && !styles) {
throw new ValidationError("No update data provided");
}
const updateData: Partial<typeof themeTable.$inferInsert> = {
updatedAt: new Date(),
};
if (name) updateData.name = name;
if (styles) updateData.styles = styles;
const [updatedTheme] = await db
.update(themeTable)
.set(updateData)
@@ -165,45 +152,36 @@ export async function updateTheme(formData: {
.returning();
if (!updatedTheme) {
return { success: false, error: "Theme not found or not owned by user" };
throw new ThemeNotFoundError("Theme not found or not owned by user");
}
revalidatePath("/"); // Or a more specific path
return {
success: true,
theme: updatedTheme,
};
return updatedTheme;
} catch (error) {
console.error(`Error updating theme ${themeId}:`, error);
return { success: false, error: "Internal Server Error" };
logError(error as Error, { action: "updateTheme", themeId: formData.id });
throw error;
}
}
// Action to delete a theme
export async function deleteTheme(themeId: string) {
const userId = await getCurrentUserId();
if (!userId) {
throw new Error("Unauthorized");
}
if (!themeId) {
return { success: false, error: "Theme ID required" };
}
try {
const [deletedInfo] = await db
const userId = await getCurrentUserId();
if (!themeId) {
throw new ValidationError("Theme ID required");
}
const [deletedTheme] = await db
.delete(themeTable)
.where(and(eq(themeTable.id, themeId), eq(themeTable.userId, userId)))
.returning({ id: themeTable.id });
.returning({ id: themeTable.id, name: themeTable.name });
if (!deletedInfo) {
return { success: false, error: "Theme not found or not owned by user" };
if (!deletedTheme) {
throw new ThemeNotFoundError("Theme not found or not owned by user");
}
revalidatePath("/dashboard"); // Or a more specific path
return { success: true, deletedId: themeId };
return deletedTheme;
} catch (error) {
console.error(`Error deleting theme ${themeId}:`, error);
return { success: false, error: "Internal Server Error" };
logError(error as Error, { action: "deleteTheme", themeId });
throw error;
}
}
+18 -31
View File
@@ -10,18 +10,10 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
MoreVertical,
Trash2,
Edit,
Loader2,
Zap,
ExternalLink,
Copy,
} from "lucide-react";
import { MoreVertical, Trash2, Edit, Loader2, Zap, ExternalLink, Copy } from "lucide-react";
import { useMemo } from "react";
import { useEditorStore } from "@/store/editor-store";
import { useThemeActions } from "@/hooks/use-theme-actions";
import { useDeleteTheme } from "@/hooks/themes";
import Link from "next/link";
import { toast } from "@/components/ui/use-toast";
interface ThemeCardProps {
@@ -46,11 +38,11 @@ const swatchDefinitions: SwatchDefinition[] = [
export function ThemeCard({ theme, className }: ThemeCardProps) {
const { themeState, setThemeState } = useEditorStore();
const { deleteTheme, isDeletingTheme } = useThemeActions();
const deleteThemeMutation = useDeleteTheme();
const mode = themeState.currentMode;
const handleDelete = () => {
deleteTheme(theme.id);
deleteThemeMutation.mutate(theme.id);
};
const handleQuickApply = () => {
@@ -74,27 +66,24 @@ export function ThemeCard({ theme, className }: ThemeCardProps) {
// Get background color, fallback to a default if necessary (e.g., white)
bg: theme.styles[mode][def.bgKey] || "#ffffff",
// Get foreground color, fallback to main foreground or a default (e.g., black)
fg:
theme.styles[mode][def.fgKey] ||
theme.styles[mode].foreground ||
"#000000",
fg: theme.styles[mode][def.fgKey] || theme.styles[mode].foreground || "#000000",
}));
}, [mode, theme.styles]);
return (
<Card
className={cn(
"group overflow-hidden border shadow-sm hover:shadow-md transition-all duration-300",
"group overflow-hidden border shadow-sm transition-all duration-300 hover:shadow-md",
className
)}
>
<div className="flex h-36 relative">
<div className="relative flex h-36">
{colorSwatches.map((swatch) => (
<div
// Use a combination for a more robust key
key={swatch.name + swatch.bg}
className={cn(
"group/swatch relative flex-1 h-full transition-all duration-300 ease-in-out",
"group/swatch relative h-full flex-1 transition-all duration-300 ease-in-out",
"hover:flex-grow-[1.5]"
)}
style={{ backgroundColor: swatch.bg }}
@@ -104,7 +93,7 @@ export function ThemeCard({ theme, className }: ThemeCardProps) {
"absolute inset-0 flex items-center justify-center",
"opacity-0 group-hover/swatch:opacity-100",
"transition-opacity duration-300 ease-in-out",
"text-xs font-medium pointer-events-none"
"pointer-events-none text-xs font-medium"
)}
style={{ color: swatch.fg }}
>
@@ -114,12 +103,10 @@ export function ThemeCard({ theme, className }: ThemeCardProps) {
))}
</div>
<div className="p-4 flex items-center justify-between bg-background">
<div className="bg-background flex items-center justify-between p-4">
<div>
<h3 className={cn("text-sm font-medium text-foreground")}>
{theme.name}
</h3>
<p className="text-xs text-muted-foreground">
<h3 className={cn("text-foreground text-sm font-medium")}>{theme.name}</h3>
<p className="text-muted-foreground text-xs">
{new Date(theme.createdAt).toLocaleDateString("en-US", {
day: "numeric",
month: "short",
@@ -129,11 +116,11 @@ export function ThemeCard({ theme, className }: ThemeCardProps) {
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<div className="p-2 hover:bg-accent rounded-md">
<MoreVertical className="h-4 w-4 text-muted-foreground" />
<div className="hover:bg-accent rounded-md p-2">
<MoreVertical className="text-muted-foreground h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-popover">
<DropdownMenuContent align="end" className="bg-popover w-48">
<DropdownMenuItem onClick={handleQuickApply} className="gap-2">
<Zap className="h-4 w-4" />
Quick Apply
@@ -157,10 +144,10 @@ export function ThemeCard({ theme, className }: ThemeCardProps) {
<DropdownMenuSeparator className="mx-2" />
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive gap-2 focus:text-destructive"
disabled={isDeletingTheme}
className="text-destructive focus:text-destructive gap-2"
disabled={deleteThemeMutation.isPending}
>
{isDeletingTheme ? (
{deleteThemeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
+10 -7
View File
@@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import { ThemeScript } from "@/components/theme-script";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryProvider } from "@/lib/query-client";
import type { Metadata, Viewport } from "next";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Suspense } from "react";
@@ -74,13 +75,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body>
<NuqsAdapter>
<Suspense>
<ThemeProvider defaultTheme="light">
<TooltipProvider>
<AuthDialogWrapper />
<Toaster />
{children}
</TooltipProvider>
</ThemeProvider>
<QueryProvider>
<ThemeProvider defaultTheme="light">
<TooltipProvider>
<AuthDialogWrapper />
<Toaster />
{children}
</TooltipProvider>
</ThemeProvider>
</QueryProvider>
</Suspense>
</NuqsAdapter>
<PostHogInit />
+12 -15
View File
@@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useThemeActions } from "@/hooks/use-theme-actions";
import { useUpdateTheme } from "@/hooks/themes";
import { useEditorStore } from "@/store/editor-store";
import { Theme } from "@/types/theme";
import { Check, X } from "lucide-react";
@@ -17,10 +17,9 @@ interface ThemeEditActionsProps {
const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme, disabled = false }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { updateTheme } = useThemeActions();
const updateThemeMutation = useUpdateTheme();
const { themeState, applyThemePreset } = useEditorStore();
const [isNameDialogOpen, setIsNameDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const mainEditorUrl = `/editor/theme?${searchParams}`;
@@ -31,7 +30,6 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme, disabled = f
};
const handleSaveTheme = async (newName: string) => {
setIsSaving(true);
const dataToUpdate: {
id: string;
name?: string;
@@ -52,19 +50,18 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme, disabled = f
if (!dataToUpdate.name && !dataToUpdate.styles) {
setIsNameDialogOpen(false);
setIsSaving(false);
return;
}
const result = await updateTheme(dataToUpdate);
setIsSaving(false);
if (result) {
setIsNameDialogOpen(false);
router.push(mainEditorUrl);
applyThemePreset(result?.id || themeState?.preset || "default");
} else {
console.error("Failed to update theme");
try {
const result = await updateThemeMutation.mutateAsync(dataToUpdate);
if (result) {
setIsNameDialogOpen(false);
router.push(mainEditorUrl);
applyThemePreset(result?.id || themeState?.preset || "default");
}
} catch (error) {
console.error("Failed to update theme:", error);
}
};
@@ -126,7 +123,7 @@ const ThemeEditActions: React.FC<ThemeEditActionsProps> = ({ theme, disabled = f
open={isNameDialogOpen}
onOpenChange={setIsNameDialogOpen}
onSave={handleSaveTheme}
isSaving={isSaving}
isSaving={updateThemeMutation.isPending}
initialThemeName={theme.name}
title="Save Theme Changes"
description="Confirm or update the theme name before saving."
+2
View File
@@ -0,0 +1,2 @@
export { useThemesData, useThemeData, usePrefetchThemes, themeKeys } from "./use-themes-data";
export { useCreateTheme, useUpdateTheme, useDeleteTheme } from "./use-theme-mutations";
+179
View File
@@ -0,0 +1,179 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createTheme, updateTheme, deleteTheme } from "@/actions/themes";
import { themeKeys } from "./use-themes-data";
import { ThemeStyles, Theme } from "@/types/theme";
import { toast } from "@/components/ui/use-toast";
import { useThemePresetStore } from "@/store/theme-preset-store";
import posthog from "posthog-js";
function handleMutationError(error: Error, operation: string) {
console.error(`Theme ${operation} error:`, error);
if (error.name !== "UnauthorizedError" && error.name !== "ValidationError") {
try {
posthog.capture("theme_mutation_error", {
operation,
error: error.message,
errorName: error.name,
});
} catch (posthogError) {
console.error("Failed to log to PostHog:", posthogError);
}
}
const getErrorMessage = (error: Error) => {
switch (error.name) {
case "UnauthorizedError":
return "Please sign in to continue.";
case "ValidationError":
return error.message || "Invalid input provided.";
case "ThemeNotFoundError":
return "Theme not found.";
case "ThemeLimitError":
return error.message || "Theme limit reached.";
default:
return "An unexpected error occurred. Please try again.";
}
};
toast({
title: `Failed to ${operation} theme`,
description: getErrorMessage(error),
variant: "destructive",
});
return error;
}
export function useCreateTheme() {
const queryClient = useQueryClient();
const { registerPreset } = useThemePresetStore();
return useMutation({
mutationFn: (data: { name: string; styles: ThemeStyles }) => createTheme(data),
onSuccess: (data) => {
queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => {
return old ? [...old, data] : [data];
});
registerPreset(data.id, {
label: data.name,
source: "SAVED",
createdAt: data.createdAt.toISOString(),
styles: data.styles,
});
toast({
title: "Theme created",
description: `"${data.name}" has been created successfully.`,
});
},
onError: (error) => {
handleMutationError(error as Error, "create");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: themeKeys.lists() });
},
});
}
export function useUpdateTheme() {
const queryClient = useQueryClient();
const { updatePreset } = useThemePresetStore();
return useMutation({
mutationFn: (data: { id: string; name?: string; styles?: ThemeStyles }) => updateTheme(data),
onMutate: async (updatedTheme) => {
await queryClient.cancelQueries({ queryKey: themeKeys.lists() });
await queryClient.cancelQueries({ queryKey: themeKeys.detail(updatedTheme.id) });
const previousThemes = queryClient.getQueryData(themeKeys.lists());
const previousTheme = queryClient.getQueryData(themeKeys.detail(updatedTheme.id));
queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => {
if (!old) return [];
return old.map((theme) =>
theme.id === updatedTheme.id
? {
...theme,
...(updatedTheme.name && { name: updatedTheme.name }),
...(updatedTheme.styles && { styles: updatedTheme.styles }),
updatedAt: new Date(),
}
: theme
);
});
return { previousThemes, previousTheme };
},
onSuccess: (data) => {
queryClient.setQueryData(themeKeys.detail(data.id), data);
queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => {
if (!old) return [data];
return old.map((theme) => (theme.id === data.id ? data : theme));
});
updatePreset(data.id, {
label: data.name,
source: "SAVED",
createdAt: data.createdAt.toISOString(),
styles: data.styles,
});
toast({
title: "Theme updated",
description: `"${data.name}" has been updated successfully.`,
});
},
onError: (error, variables, context) => {
if (context?.previousThemes) {
queryClient.setQueryData(themeKeys.lists(), context.previousThemes);
}
if (context?.previousTheme) {
queryClient.setQueryData(themeKeys.detail(variables.id), context.previousTheme);
}
handleMutationError(error as Error, "update");
},
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({ queryKey: themeKeys.lists() });
queryClient.invalidateQueries({ queryKey: themeKeys.detail(variables.id) });
},
});
}
export function useDeleteTheme() {
const queryClient = useQueryClient();
const { unregisterPreset } = useThemePresetStore();
return useMutation({
mutationFn: (themeId: string) => deleteTheme(themeId),
onMutate: async (themeId) => {
await queryClient.cancelQueries({ queryKey: themeKeys.lists() });
const previousThemes = queryClient.getQueryData(themeKeys.lists());
queryClient.setQueryData(themeKeys.lists(), (old: Theme[] | undefined) => {
return old ? old.filter((theme) => theme.id !== themeId) : [];
});
return { previousThemes, themeId };
},
onSuccess: (data, themeId) => {
unregisterPreset(themeId);
queryClient.removeQueries({ queryKey: themeKeys.detail(themeId) });
toast({
title: "Theme deleted",
description: `"${data.name}" has been deleted successfully.`,
});
},
onError: (error, _themeId, context) => {
if (context?.previousThemes) {
queryClient.setQueryData(themeKeys.lists(), context.previousThemes);
}
handleMutationError(error as Error, "delete");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: themeKeys.lists() });
},
});
}
+44
View File
@@ -0,0 +1,44 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getThemes, getTheme } from "@/actions/themes";
import { Theme } from "@/types/theme";
export const themeKeys = {
all: ["themes"] as const,
lists: () => [...themeKeys.all, "list"] as const,
list: (filters: Record<string, any>) => [...themeKeys.lists(), { filters }] as const,
details: () => [...themeKeys.all, "detail"] as const,
detail: (id: string) => [...themeKeys.details(), { id }] as const,
};
export function useThemesData(initialData?: Theme[]) {
return useQuery({
queryKey: themeKeys.lists(),
queryFn: getThemes,
initialData,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
export function useThemeData(themeId: string | null, initialData?: Theme) {
return useQuery({
queryKey: themeKeys.detail(themeId!),
queryFn: () => getTheme(themeId!),
enabled: !!themeId,
initialData,
staleTime: 1000 * 60 * 10, // 10 minutes for individual themes
});
}
export function usePrefetchThemes() {
const queryClient = useQueryClient();
return (themeIds: string[]) => {
themeIds.forEach((id) => {
queryClient.prefetchQuery({
queryKey: themeKeys.detail(id),
queryFn: () => getTheme(id),
staleTime: 1000 * 60 * 10,
});
});
};
}
+4 -4
View File
@@ -8,7 +8,7 @@ import { ThemeSaveDialog } from "@/components/editor/theme-save-dialog";
import { toast } from "@/components/ui/use-toast";
import { useAIThemeGeneration } from "@/hooks/use-ai-theme-generation";
import { usePostLoginAction } from "@/hooks/use-post-login-action";
import { useThemeActions } from "@/hooks/use-theme-actions";
import { useCreateTheme } from "@/hooks/themes";
import { authClient } from "@/lib/auth-client";
import { useAuthStore } from "@/store/auth-store";
import { useEditorStore } from "@/store/editor-store";
@@ -53,7 +53,7 @@ function useDialogActionsStore(): DialogActionsContextType {
const { getPreset } = useThemePresetStore();
const { data: session } = authClient.useSession();
const { openAuthDialog } = useAuthStore();
const { createTheme, isCreatingTheme } = useThemeActions();
const createThemeMutation = useCreateTheme();
const { loading: aiGenerateLoading } = useAIThemeGeneration();
const posthog = usePostHog();
@@ -104,7 +104,7 @@ function useDialogActionsStore(): DialogActionsContextType {
};
try {
const theme = await createTheme(themeData);
const theme = await createThemeMutation.mutateAsync(themeData);
posthog.capture("CREATE_THEME", {
theme_id: theme?.id,
theme_name: theme?.name,
@@ -162,7 +162,7 @@ function useDialogActionsStore(): DialogActionsContextType {
shareDialogOpen,
shareUrl,
dialogKey,
isCreatingTheme,
isCreatingTheme: createThemeMutation.isPending,
aiGenerateLoading,
// Dialog actions
-232
View File
@@ -1,232 +0,0 @@
import { useState, useCallback } from "react";
import { type ThemeStyles } from "@/types/theme";
import { toast } from "@/components/ui/use-toast";
import {
createTheme as createThemeAction,
updateTheme as updateThemeAction,
deleteTheme as deleteThemeAction,
} 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;
error: Error | null;
data: T | null;
};
type ThemeMutationResult = {
success: boolean;
theme?: Theme;
error?: string;
details?: any;
};
const handleMutationError = (
error: any,
setError: (error: Error | null) => void,
setIsAuthRequired: (value: boolean) => void
) => {
console.error("Mutation error:", error);
if (error.message === "Unauthorized") {
setIsAuthRequired(true);
toast({
title: "Authentication Required",
description: "Please sign in to continue.",
variant: "default",
});
} else {
setError(error);
toast({
title: "Operation Failed",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
}
};
const handleMutationSuccess = (theme: Theme | undefined, operation: string) => {
if (theme) {
toast({
title: `Theme ${operation}`,
description: `Theme "${
theme.name
}" ${operation.toLowerCase()} successfully.`,
});
}
};
// --- Generic Fetcher ---
// A generic fetcher function to handle different methods and bodies
async function fetcher<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) {
// If response is not JSON, use status text
errorData = { error: response.statusText || "Request failed" };
}
// Include status code in the error object for better handling
const error: any = new Error(
errorData?.error || `An error occurred: ${response.status}`
);
error.status = response.status;
error.info = errorData; // Attach full error info if available
throw error;
}
// Handle cases where the response might be empty (e.g., DELETE 204)
if (
response.status === 204 ||
response.headers.get("content-length") === "0"
) {
return undefined as unknown as T;
}
return response.json();
}
export function useThemeActions() {
const { registerPreset, updatePreset, unregisterPreset } =
useThemePresetStore();
const [isAuthRequired, setIsAuthRequired] = useState(false);
const [createState, setCreateState] = useState<MutationState<Theme>>({
isLoading: false,
error: null,
data: null,
});
const [updateState, setUpdateState] = useState<MutationState<Theme>>({
isLoading: false,
error: null,
data: null,
});
const [deleteState, setDeleteState] = useState<MutationState<boolean>>({
isLoading: false,
error: null,
data: null,
});
const executeMutation = async <T>(
action: () => Promise<ThemeMutationResult>,
setState: React.Dispatch<React.SetStateAction<MutationState<T>>>,
successHandler: (result: ThemeMutationResult) => T | null
) => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
setIsAuthRequired(false);
const [error, result] = await tryCatch(action());
if (error) {
handleMutationError(
error,
(err) =>
setState((prev) => ({ ...prev, error: err, isLoading: false })),
setIsAuthRequired
);
return null;
}
if (result.success) {
const data = successHandler(result);
setState((prev) => ({ ...prev, isLoading: false, data }));
return data;
} else {
const error = new Error(result.error || "Operation failed");
setState((prev) => ({ ...prev, isLoading: false, error }));
toast({
title: "Operation Failed",
description: result.error || "Could not complete the operation.",
variant: "destructive",
});
return null;
}
};
const createTheme = useCallback(
async (data: { name: string; styles: ThemeStyles }) => {
return executeMutation<Theme>(
() => createThemeAction(data),
setCreateState,
(result) => {
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;
}
);
},
[]
);
const updateTheme = useCallback(
async (data: { id: string; name?: string; styles?: ThemeStyles }) => {
return executeMutation<Theme>(
() => updateThemeAction(data),
setUpdateState,
(result) => {
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;
}
);
},
[]
);
const deleteTheme = useCallback(async (themeId: string) => {
return executeMutation<boolean>(
() => deleteThemeAction(themeId),
setDeleteState,
(result) => {
if (result.success) {
handleMutationSuccess(result.theme, "Deleted");
unregisterPreset(themeId);
toast({
title: "Theme Deleted Successfully",
});
return true;
}
return false;
}
);
}, []);
return {
createTheme,
updateTheme,
deleteTheme,
isCreatingTheme: createState.isLoading,
isUpdatingTheme: updateState.isLoading,
isDeletingTheme: deleteState.isLoading,
createError: createState.error,
updateError: updateState.error,
deleteError: deleteState.error,
isMutating:
createState.isLoading || updateState.isLoading || deleteState.isLoading,
mutationError: createState.error || updateState.error || deleteState.error,
isAuthRequired,
setIsAuthRequired,
};
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, ReactNode } from "react";
import posthog from "posthog-js";
function logClientError(error: Error, context: Record<string, unknown>) {
console.error("Query error:", error, context);
if (error.name !== "UnauthorizedError" && error.name !== "ValidationError") {
try {
posthog.capture("query_error", {
error: error.message,
errorName: error.name,
stack: error.stack,
...context,
});
} catch (posthogError) {
console.error("Failed to log to PostHog:", posthogError);
}
}
}
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: (failureCount, error: Error) => {
// Don't retry on authentication or validation errors
if (error?.name === "UnauthorizedError" || error?.name === "ValidationError") {
return false;
}
// Retry up to 3 times for other errors
return failureCount < 3;
},
refetchOnWindowFocus: false,
},
mutations: {
retry: (failureCount, error: Error) => {
// Don't retry mutations on client errors (4xx)
if (error?.name === "UnauthorizedError" || error?.name === "ValidationError") {
return false;
}
// Only retry once for server errors (5xx)
return failureCount < 1;
},
onError: (error: Error, variables: unknown, context: unknown) => {
logClientError(error, {
type: "mutation",
variables: JSON.stringify(variables),
context,
});
},
},
},
});
}
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(() => createQueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}
+2
View File
@@ -47,6 +47,8 @@
"@radix-ui/react-toggle": "^1.1.6",
"@radix-ui/react-toggle-group": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.3",
"@tanstack/react-query": "^5.81.2",
"@tanstack/react-query-devtools": "^5.81.2",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-character-count": "^2.12.0",
"@tiptap/extension-mention": "^2.11.9",
+38
View File
@@ -116,6 +116,12 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@tanstack/react-query':
specifier: ^5.81.2
version: 5.81.2(react@19.1.0)
'@tanstack/react-query-devtools':
specifier: ^5.81.2
version: 5.81.2(@tanstack/react-query@5.81.2(react@19.1.0))(react@19.1.0)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -2600,6 +2606,23 @@ packages:
'@tailwindcss/postcss@4.1.4':
resolution: {integrity: sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==}
'@tanstack/query-core@5.81.2':
resolution: {integrity: sha512-QLYkPdrudoMATDFa3MiLEwRhNnAlzHWDf0LKaXUqJd0/+QxN8uTPi7bahRlxoAyH0UbLMBdeDbYzWALj7THOtw==}
'@tanstack/query-devtools@5.81.2':
resolution: {integrity: sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==}
'@tanstack/react-query-devtools@5.81.2':
resolution: {integrity: sha512-TX0OQ4cbgX6z2uN8c9x0QUNbyePGyUGdcgrGnV6TYEJc7KPT8PqeASuzoA5NGw1CiMGvyFAkIGA2KipvhM9d1g==}
peerDependencies:
'@tanstack/react-query': ^5.81.2
react: ^18 || ^19
'@tanstack/react-query@5.81.2':
resolution: {integrity: sha512-pe8kFlTrL2zFLlcAj2kZk9UaYYHDk9/1hg9EBaoO3cxDhOZf1FRGJeziSXKrVZyxIfs7b3aoOj/bw7Lie0mDUg==}
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
@@ -8409,6 +8432,21 @@ snapshots:
postcss: 8.5.3
tailwindcss: 4.1.4
'@tanstack/query-core@5.81.2': {}
'@tanstack/query-devtools@5.81.2': {}
'@tanstack/react-query-devtools@5.81.2(@tanstack/react-query@5.81.2(react@19.1.0))(react@19.1.0)':
dependencies:
'@tanstack/query-devtools': 5.81.2
'@tanstack/react-query': 5.81.2(react@19.1.0)
react: 19.1.0
'@tanstack/react-query@5.81.2(react@19.1.0)':
dependencies:
'@tanstack/query-core': 5.81.2
react: 19.1.0
'@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@tanstack/table-core': 8.21.3
+30
View File
@@ -0,0 +1,30 @@
export class UnauthorizedError extends Error {
constructor(message = "Unauthorized") {
super(message);
this.name = "UnauthorizedError";
}
}
export class ValidationError extends Error {
constructor(
message: string,
public details?: any
) {
super(message);
this.name = "ValidationError";
}
}
export class ThemeNotFoundError extends Error {
constructor(message = "Theme not found") {
super(message);
this.name = "ThemeNotFoundError";
}
}
export class ThemeLimitError extends Error {
constructor(message = "Theme limit reached") {
super(message);
this.name = "ThemeLimitError";
}
}