mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-28 23:02:07 +08:00
refactor: Themes API layer + error handling
This commit is contained in:
+109
-131
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useThemesData, useThemeData, usePrefetchThemes, themeKeys } from "./use-themes-data";
|
||||
export { useCreateTheme, useUpdateTheme, useDeleteTheme } from "./use-theme-mutations";
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+38
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user