feat: integrate Sentry for error tracking and enhance user notifications

- Added Sentry integration for error tracking across various components, including error boundaries and user actions.
- Updated global error handling to capture exceptions and provide detailed feedback to users.
- Enhanced user notifications with toast messages for actions such as credit management, image generation, and canvas exports.
- Improved user experience by displaying relevant messages during interactions, ensuring better visibility of system states and errors.
This commit is contained in:
Matthias
2026-03-27 18:14:04 +01:00
parent 5da0204163
commit 2f89465e82
35 changed files with 2822 additions and 186 deletions

5
.gitignore vendored
View File

@@ -39,3 +39,8 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin
.cursor
.cursor/*

View File

@@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useMutation, useQuery } from "convex/react"; import { useMutation, useQuery } from "convex/react";
import { import {
@@ -33,6 +33,8 @@ import { authClient } from "@/lib/auth-client";
import { CreditOverview } from "@/components/dashboard/credit-overview"; import { CreditOverview } from "@/components/dashboard/credit-overview";
import { RecentTransactions } from "@/components/dashboard/recent-transactions"; import { RecentTransactions } from "@/components/dashboard/recent-transactions";
import CanvasCard from "@/components/dashboard/canvas-card"; import CanvasCard from "@/components/dashboard/canvas-card";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
function getInitials(nameOrEmail: string) { function getInitials(nameOrEmail: string) {
@@ -49,6 +51,7 @@ function getInitials(nameOrEmail: string) {
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const welcomeToastSentRef = useRef(false);
const { theme = "system", setTheme } = useTheme(); const { theme = "system", setTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession(); const { data: session, isPending: isSessionPending } = authClient.useSession();
const canvases = useQuery( const canvases = useQuery(
@@ -61,7 +64,17 @@ export default function DashboardPage() {
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer"; const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
const initials = getInitials(displayName); const initials = getInitials(displayName);
useEffect(() => {
if (!session?.user || welcomeToastSentRef.current) return;
const key = `ls-dashboard-welcome-${session.user.id}`;
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
welcomeToastSentRef.current = true;
sessionStorage.setItem(key, "1");
toast.success(msg.auth.welcomeOnDashboard.title);
}, [session?.user]);
const handleSignOut = async () => { const handleSignOut = async () => {
toast.info(msg.auth.signedOut.title);
await authClient.signOut(); await authClient.signOut();
router.replace("/auth/sign-in"); router.replace("/auth/sign-in");
router.refresh(); router.refresh();

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react"; import { useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,6 +12,8 @@ type AppErrorProps = {
export default function AppError({ error, unstable_retry }: AppErrorProps) { export default function AppError({ error, unstable_retry }: AppErrorProps) {
useEffect(() => { useEffect(() => {
Sentry.captureException(error);
const safeError = { const safeError = {
name: error.name, name: error.name,
message: message:

View File

@@ -1,5 +1,8 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
type GlobalErrorProps = { type GlobalErrorProps = {
@@ -11,6 +14,10 @@ export default function GlobalError({
error, error,
unstable_retry, unstable_retry,
}: GlobalErrorProps) { }: GlobalErrorProps) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return ( return (
<html lang="de" className="h-full antialiased font-sans"> <html lang="de" className="h-full antialiased font-sans">
<body className="min-h-full bg-background text-foreground"> <body className="min-h-full bg-background text-foreground">

View File

@@ -1,10 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Manrope } from "next/font/google"; import { Manrope } from "next/font/google";
import * as Sentry from "@sentry/nextjs";
import "./globals.css"; import "./globals.css";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Providers } from "@/components/providers"; import { Providers } from "@/components/providers";
import { InitUser } from "@/components/init-user"; import { InitUser } from "@/components/init-user";
import { getToken } from "@/lib/auth-server"; import { getAuthUser, getToken } from "@/lib/auth-server";
const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" }); const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" });
@@ -19,6 +20,16 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const initialToken = await getToken(); const initialToken = await getToken();
const user = await getAuthUser();
if (user) {
const id = user.userId ?? String(user._id);
Sentry.setUser({
id,
email: user.email ?? undefined,
});
} else {
Sentry.setUser(null);
}
return ( return (
<html <html

View File

@@ -3,6 +3,8 @@
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
export default function Home() { export default function Home() {
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
@@ -32,7 +34,10 @@ export default function Home() {
Zum Dashboard Zum Dashboard
</Link> </Link>
<button <button
onClick={() => authClient.signOut().then(() => router.refresh())} onClick={() => {
toast.info(msg.auth.signedOut.title);
void authClient.signOut().then(() => router.refresh());
}}
className="rounded-lg border border-border px-6 py-3 text-sm hover:bg-accent" className="rounded-lg border border-border px-6 py-3 text-sm hover:bg-accent"
> >
Abmelden Abmelden

View File

@@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { normalizeTier, TIER_MONTHLY_CREDITS } from "@/lib/polar-products"; import { normalizeTier, TIER_MONTHLY_CREDITS } from "@/lib/polar-products";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const TIER_LABELS: Record<keyof typeof TIER_MONTHLY_CREDITS, string> = { const TIER_LABELS: Record<keyof typeof TIER_MONTHLY_CREDITS, string> = {
free: "Free", free: "Free",
@@ -39,7 +41,16 @@ export function ManageSubscription() {
</div> </div>
{tier !== "free" && ( {tier !== "free" && (
<Button variant="outline" onClick={() => authClient.customer.portal()}> <Button
variant="outline"
onClick={() => {
toast.info(
msg.billing.openingPortal.title,
msg.billing.openingPortal.desc,
);
void authClient.customer.portal();
}}
>
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" />
Manage Manage
</Button> </Button>

View File

@@ -12,6 +12,8 @@ import {
SUBSCRIPTION_PRODUCTS, SUBSCRIPTION_PRODUCTS,
TIER_MONTHLY_CREDITS, TIER_MONTHLY_CREDITS,
} from "@/lib/polar-products"; } from "@/lib/polar-products";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const TIER_ORDER = ["free", "starter", "pro", "max"] as const; const TIER_ORDER = ["free", "starter", "pro", "max"] as const;
@@ -20,6 +22,10 @@ export function PricingCards() {
const currentTier = normalizeTier(subscription?.tier); const currentTier = normalizeTier(subscription?.tier);
async function handleCheckout(polarProductId: string) { async function handleCheckout(polarProductId: string) {
toast.info(
msg.billing.redirectingToCheckout.title,
msg.billing.redirectingToCheckout.desc,
);
await authClient.checkout({ products: [polarProductId] }); await authClient.checkout({ products: [polarProductId] });
} }

View File

@@ -8,6 +8,8 @@ import { Slider } from "@/components/ui/slider";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { TOPUP_PRODUCTS } from "@/lib/polar-products"; import { TOPUP_PRODUCTS } from "@/lib/polar-products";
import { calculateCustomTopup } from "@/lib/topup-calculator"; import { calculateCustomTopup } from "@/lib/topup-calculator";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const CUSTOM_TOPUP_PRODUCT_ID = "POLAR_PRODUCT_ID_TOPUP_CUSTOM"; const CUSTOM_TOPUP_PRODUCT_ID = "POLAR_PRODUCT_ID_TOPUP_CUSTOM";
@@ -16,6 +18,10 @@ export function TopupPanel() {
const { credits, bonusRate } = calculateCustomTopup(customAmount); const { credits, bonusRate } = calculateCustomTopup(customAmount);
async function handleTopup(polarProductId: string) { async function handleTopup(polarProductId: string) {
toast.info(
msg.billing.redirectingToCheckout.title,
msg.billing.redirectingToCheckout.desc,
);
await authClient.checkout({ products: [polarProductId] }); await authClient.checkout({ products: [polarProductId] });
} }

View File

@@ -11,6 +11,7 @@ import {
applyNodeChanges, applyNodeChanges,
applyEdgeChanges, applyEdgeChanges,
useReactFlow, useReactFlow,
useStoreApi,
reconnectEdge, reconnectEdge,
type Node as RFNode, type Node as RFNode,
type Edge as RFEdge, type Edge as RFEdge,
@@ -22,6 +23,7 @@ import {
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
@@ -170,8 +172,67 @@ function normalizeHandle(handle: string | null | undefined): string | undefined
return handle ?? undefined; return handle ?? undefined;
} }
function shallowEqualRecord(
a: Record<string, unknown>,
b: Record<string, unknown>,
): boolean {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (a[key] !== b[key]) return false;
}
return true;
}
function mergeNodesPreservingLocalState(
previousNodes: RFNode[],
incomingNodes: RFNode[],
): RFNode[] {
const previousById = new Map(previousNodes.map((node) => [node.id, node]));
return incomingNodes.map((incomingNode) => {
const previousNode = previousById.get(incomingNode.id);
if (!previousNode) {
return incomingNode;
}
const previousData = previousNode.data as Record<string, unknown>;
const incomingData = incomingNode.data as Record<string, unknown>;
const previousWidth = previousNode.style?.width;
const previousHeight = previousNode.style?.height;
const incomingWidth = incomingNode.style?.width;
const incomingHeight = incomingNode.style?.height;
const isStructurallyEqual =
previousNode.type === incomingNode.type &&
previousNode.parentId === incomingNode.parentId &&
previousNode.zIndex === incomingNode.zIndex &&
previousNode.position.x === incomingNode.position.x &&
previousNode.position.y === incomingNode.position.y &&
previousWidth === incomingWidth &&
previousHeight === incomingHeight &&
shallowEqualRecord(previousData, incomingData);
if (isStructurallyEqual) {
return previousNode;
}
return {
...previousNode,
...incomingNode,
selected: previousNode.selected,
dragging: previousNode.dragging,
};
});
}
function CanvasInner({ canvasId }: CanvasInnerProps) { function CanvasInner({ canvasId }: CanvasInnerProps) {
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const storeApi = useStoreApi();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession(); const { data: session, isPending: isSessionPending } = authClient.useSession();
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth(); const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
@@ -301,8 +362,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) { if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) {
toast.error( toast.warning(
"Mehrere Generierungen sind fehlgeschlagen. Bitte Prompt, Modell oder Credits prüfen.", msg.ai.openrouterIssues.title,
msg.ai.openrouterIssues.desc,
); );
recentGenerationFailureTimestampsRef.current = []; recentGenerationFailureTimestampsRef.current = [];
return; return;
@@ -315,7 +377,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
useEffect(() => { useEffect(() => {
if (!convexNodes || isDragging.current) return; if (!convexNodes || isDragging.current) return;
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setNodes(withResolvedCompareData(convexNodes.map(convexNodeToRF), edges)); setNodes((previousNodes) => {
const incomingNodes = withResolvedCompareData(convexNodes.map(convexNodeToRF), edges);
return mergeNodesPreservingLocalState(previousNodes, incomingNodes);
});
}, [convexNodes, edges]); }, [convexNodes, edges]);
useEffect(() => { useEffect(() => {
@@ -367,6 +432,56 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
setEdges((eds) => applyEdgeChanges(changes, eds)); setEdges((eds) => applyEdgeChanges(changes, eds));
}, []); }, []);
const onFlowError = useCallback(
(code: string, message: string) => {
if (process.env.NODE_ENV === "production") return;
if (code !== "015") {
console.error("[ReactFlow error]", { canvasId, code, message });
return;
}
const state = storeApi.getState() as {
nodeLookup?: Map<
string,
{
id: string;
selected?: boolean;
type?: string;
measured?: { width?: number; height?: number };
internals?: { positionAbsolute?: { x: number; y: number } };
}
>;
};
const uninitializedNodes = Array.from(state.nodeLookup?.values() ?? [])
.filter(
(node) =>
node.measured?.width === undefined ||
node.measured?.height === undefined,
)
.map((node) => ({
id: node.id,
type: node.type ?? null,
selected: Boolean(node.selected),
measuredWidth: node.measured?.width,
measuredHeight: node.measured?.height,
positionAbsolute: node.internals?.positionAbsolute ?? null,
}));
console.error("[ReactFlow error 015 diagnostics]", {
canvasId,
message,
localNodeCount: nodes.length,
localSelectedNodeIds: nodes.filter((n) => n.selected).map((n) => n.id),
isDragging: isDragging.current,
uninitializedNodeCount: uninitializedNodes.length,
uninitializedNodes,
});
},
[canvasId, nodes, storeApi],
);
// ─── Delete Edge on Drop ────────────────────────────────────── // ─── Delete Edge on Drop ──────────────────────────────────────
const onReconnectStart = useCallback(() => { const onReconnectStart = useCallback(() => {
edgeReconnectSuccessful.current = false; edgeReconnectSuccessful.current = false;
@@ -614,6 +729,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Node löschen → Convex ──────────────────────────────────── // ─── Node löschen → Convex ────────────────────────────────────
const onNodesDelete = useCallback( const onNodesDelete = useCallback(
async (deletedNodes: RFNode[]) => { async (deletedNodes: RFNode[]) => {
const count = deletedNodes.length;
for (const node of deletedNodes) { for (const node of deletedNodes) {
const incomingEdges = edges.filter((e) => e.target === node.id); const incomingEdges = edges.filter((e) => e.target === node.id);
const outgoingEdges = edges.filter((e) => e.source === node.id); const outgoingEdges = edges.filter((e) => e.source === node.id);
@@ -634,6 +750,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
removeNode({ nodeId: node.id as Id<"nodes"> }); removeNode({ nodeId: node.id as Id<"nodes"> });
} }
if (count > 0) {
const { title } = msg.canvas.nodesRemoved(count);
toast.info(title);
}
}, },
[edges, removeNode, createEdge, canvasId], [edges, removeNode, createEdge, canvasId],
); );
@@ -732,6 +852,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
onReconnectEnd={onReconnectEnd} onReconnectEnd={onReconnectEnd}
onNodesDelete={onNodesDelete} onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete} onEdgesDelete={onEdgesDelete}
onError={onFlowError}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={onDrop} onDrop={onDrop}
fitView fitView

View File

@@ -4,6 +4,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useConvexConnectionState } from "convex/react"; import { useConvexConnectionState } from "convex/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast, toastDuration } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected"; type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
@@ -12,6 +14,7 @@ const RECONNECTED_HIDE_DELAY_MS = 1800;
export default function ConnectionBanner() { export default function ConnectionBanner() {
const connectionState = useConvexConnectionState(); const connectionState = useConvexConnectionState();
const previousConnectedRef = useRef(connectionState.isWebSocketConnected); const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
const disconnectToastIdRef = useRef<string | number | undefined>(undefined);
const [showReconnected, setShowReconnected] = useState(false); const [showReconnected, setShowReconnected] = useState(false);
const [isBrowserOnline, setIsBrowserOnline] = useState( const [isBrowserOnline, setIsBrowserOnline] = useState(
typeof navigator === "undefined" ? true : navigator.onLine, typeof navigator === "undefined" ? true : navigator.onLine,
@@ -33,14 +36,19 @@ export default function ConnectionBanner() {
useEffect(() => { useEffect(() => {
const wasConnected = previousConnectedRef.current; const wasConnected = previousConnectedRef.current;
const isConnected = connectionState.isWebSocketConnected; const isConnected = connectionState.isWebSocketConnected;
const didReconnect = !wasConnected && isConnected && connectionState.connectionCount > 1; const didReconnect =
!wasConnected && isConnected && connectionState.connectionCount > 1;
if (didReconnect) { if (didReconnect) {
setShowReconnected(true); queueMicrotask(() => {
setShowReconnected(true);
});
} }
if (!isConnected) { if (!isConnected) {
setShowReconnected(false); queueMicrotask(() => {
setShowReconnected(false);
});
} }
previousConnectedRef.current = isConnected; previousConnectedRef.current = isConnected;
@@ -58,6 +66,39 @@ export default function ConnectionBanner() {
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [showReconnected]); }, [showReconnected]);
useEffect(() => {
const connected = connectionState.isWebSocketConnected;
const shouldAlertDisconnect =
!connected &&
(!isBrowserOnline ||
connectionState.hasEverConnected ||
connectionState.connectionRetries > 0);
if (shouldAlertDisconnect) {
if (disconnectToastIdRef.current === undefined) {
disconnectToastIdRef.current = toast.error(
msg.system.connectionLost.title,
msg.system.connectionLost.desc,
{ duration: Number.POSITIVE_INFINITY },
);
}
return;
}
if (connected && disconnectToastIdRef.current !== undefined) {
toast.dismiss(disconnectToastIdRef.current);
disconnectToastIdRef.current = undefined;
toast.success(msg.system.reconnected.title, undefined, {
duration: toastDuration.successShort,
});
}
}, [
connectionState.connectionRetries,
connectionState.hasEverConnected,
connectionState.isWebSocketConnected,
isBrowserOnline,
]);
const bannerState = useMemo<BannerState>(() => { const bannerState = useMemo<BannerState>(() => {
if (connectionState.isWebSocketConnected) { if (connectionState.isWebSocketConnected) {
return showReconnected ? "reconnected" : "hidden"; return showReconnected ? "reconnected" : "hidden";

View File

@@ -4,6 +4,7 @@ import { useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { Coins } from "lucide-react"; import { Coins } from "lucide-react";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const TIER_LABELS: Record<string, string> = { const TIER_LABELS: Record<string, string> = {
free: "Free", free: "Free",
@@ -90,11 +91,16 @@ export function CreditDisplay() {
onClick={() => { onClick={() => {
void grantTestCredits({ amount: 2000 }) void grantTestCredits({ amount: 2000 })
.then((r) => { .then((r) => {
toast.success(`+2000 Cr — Stand: ${r.newBalance.toLocaleString("de-DE")}`); const { title, desc } = msg.billing.creditsAdded(2000);
toast.success(
title,
`${desc} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
);
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
toast.error( toast.error(
e instanceof Error ? e.message : "Gutschrift fehlgeschlagen", msg.billing.testGrantFailed.title,
e instanceof Error ? e.message : undefined,
); );
}); });
}} }}

View File

@@ -7,6 +7,8 @@ import JSZip from "jszip";
import { Archive, Loader2 } from "lucide-react"; import { Archive, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
interface ExportButtonProps { interface ExportButtonProps {
canvasName?: string; canvasName?: string;
@@ -24,12 +26,14 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
setIsExporting(true); setIsExporting(true);
setError(null); setError(null);
try { const NO_FRAMES = "NO_FRAMES";
const runExport = async () => {
const nodes = getNodes(); const nodes = getNodes();
const frameNodes = nodes.filter((node) => node.type === "frame"); const frameNodes = nodes.filter((node) => node.type === "frame");
if (frameNodes.length === 0) { if (frameNodes.length === 0) {
throw new Error("No frames on canvas - add a Frame node first"); throw new Error(NO_FRAMES);
} }
const zip = new JSZip(); const zip = new JSZip();
@@ -64,8 +68,36 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
anchor.click(); anchor.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
};
try {
await toast.promise(runExport(), {
loading: msg.export.exportingFrames.title,
success: msg.export.zipReady.title,
error: (err) => {
const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.title;
if (m.includes("No images found")) return msg.export.frameEmpty.title;
return msg.export.exportFailed.title;
},
description: {
error: (err) => {
const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.desc;
if (m.includes("No images found")) return msg.export.frameEmpty.desc;
return m || undefined;
},
},
});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Export failed"); const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) {
setError(msg.export.noFramesOnCanvas.desc);
} else if (m.includes("No images found")) {
setError(msg.export.frameEmpty.desc);
} else {
setError(m || msg.export.exportFailed.title);
}
} finally { } finally {
setIsExporting(false); setIsExporting(false);
setProgress(null); setProgress(null);

View File

@@ -10,6 +10,8 @@ import BaseNodeWrapper from "./base-node-wrapper";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import { classifyError, type AiErrorCategory } from "@/lib/ai-errors"; import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats"; import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { import {
Loader2, Loader2,
AlertCircle, AlertCircle,
@@ -106,16 +108,30 @@ export default function AiImageNode({
} }
} }
await generateImage({ const modelId = nodeData.model ?? DEFAULT_MODEL_ID;
canvasId, const regenCreditCost = getModel(modelId)?.creditCost ?? 4;
nodeId: id as Id<"nodes">,
prompt, await toast.promise(
referenceStorageId, generateImage({
model: nodeData.model ?? DEFAULT_MODEL_ID, canvasId,
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO, nodeId: id as Id<"nodes">,
}); prompt,
referenceStorageId,
model: modelId,
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
}),
{
loading: msg.ai.generating.title,
success: msg.ai.generated.title,
error: msg.ai.generationFailed.title,
description: {
success: msg.ai.generatedDesc(regenCreditCost),
error: msg.ai.creditsNotCharged,
},
},
);
} catch (err) { } catch (err) {
setLocalError(err instanceof Error ? err.message : "Generation failed"); setLocalError(err instanceof Error ? err.message : msg.ai.generationFailed.title);
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }

View File

@@ -8,6 +8,8 @@ import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
interface FrameNodeData { interface FrameNodeData {
label?: string; label?: string;
@@ -43,16 +45,29 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
try { try {
const result = await exportFrame({ frameNodeId: id as Id<"nodes"> }); const result = await exportFrame({ frameNodeId: id as Id<"nodes"> });
const a = document.createElement("a"); const fileLabel = `${label.trim() || "frame"}.png`;
a.href = result.url; toast.action(msg.export.frameExported.title, {
a.download = result.filename; description: fileLabel,
a.click(); label: msg.export.download,
onClick: () => {
window.open(result.url, "_blank", "noopener,noreferrer");
},
successLabel: msg.export.downloaded,
type: "success",
});
} catch (error) { } catch (error) {
setExportError(error instanceof Error ? error.message : "Export failed"); const m = error instanceof Error ? error.message : "";
if (m.includes("No images found")) {
toast.error(msg.export.frameEmpty.title, msg.export.frameEmpty.desc);
setExportError(msg.export.frameEmpty.desc);
} else {
toast.error(msg.export.exportFailed.title, m || undefined);
setExportError(m || msg.export.exportFailed.title);
}
} finally { } finally {
setIsExporting(false); setIsExporting(false);
} }
}, [exportFrame, id, isExporting]); }, [exportFrame, id, isExporting, label]);
const frameW = Math.round(width ?? 400); const frameW = Math.round(width ?? 400);
const frameH = Math.round(height ?? 300); const frameH = Math.round(height ?? 300);

View File

@@ -13,6 +13,15 @@ import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import Image from "next/image"; import Image from "next/image";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const ALLOWED_IMAGE_TYPES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
type ImageNodeData = { type ImageNodeData = {
storageId?: string; storageId?: string;
@@ -34,7 +43,21 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
const uploadFile = useCallback( const uploadFile = useCallback(
async (file: File) => { async (file: File) => {
if (!file.type.startsWith("image/")) return; if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
const { title, desc } = msg.canvas.uploadFormatError(
file.type || file.name.split(".").pop() || "—",
);
toast.error(title, desc);
return;
}
if (file.size > MAX_IMAGE_BYTES) {
const { title, desc } = msg.canvas.uploadSizeError(
Math.round(MAX_IMAGE_BYTES / (1024 * 1024)),
);
toast.error(title, desc);
return;
}
setIsUploading(true); setIsUploading(true);
try { try {
@@ -59,8 +82,13 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
mimeType: file.type, mimeType: file.type,
}, },
}); });
toast.success(msg.canvas.imageUploaded.title);
} catch (err) { } catch (err) {
console.error("Upload failed:", err); console.error("Upload failed:", err);
toast.error(
msg.canvas.uploadFailed.title,
err instanceof Error ? err.message : undefined,
);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs";
import type { ErrorInfo, ReactNode } from "react"; import type { ErrorInfo, ReactNode } from "react";
import { Component } from "react"; import { Component } from "react";
@@ -29,6 +30,11 @@ export class NodeErrorBoundary extends Component<
} }
override componentDidCatch(error: Error, errorInfo: ErrorInfo) { override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
Sentry.captureException(error, {
tags: { nodeType: this.props.nodeType },
extra: { componentStack: errorInfo.componentStack },
});
console.error("Node rendering error", { console.error("Node rendering error", {
nodeType: this.props.nodeType, nodeType: this.props.nodeType,
error, error,

View File

@@ -34,6 +34,9 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Sparkles, Loader2, Coins } from "lucide-react"; import { Sparkles, Loader2, Coins } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
type PromptNodeData = { type PromptNodeData = {
prompt?: string; prompt?: string;
@@ -52,6 +55,7 @@ export default function PromptNode({
selected, selected,
}: NodeProps<PromptNode>) { }: NodeProps<PromptNode>) {
const nodeData = data as PromptNodeData; const nodeData = data as PromptNodeData;
const router = useRouter();
const { getEdges, getNode } = useReactFlow(); const { getEdges, getNode } = useReactFlow();
const [prompt, setPrompt] = useState(nodeData.prompt ?? ""); const [prompt, setPrompt] = useState(nodeData.prompt ?? "");
@@ -151,6 +155,21 @@ export default function PromptNode({
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!effectivePrompt.trim() || isGenerating) return; if (!effectivePrompt.trim() || isGenerating) return;
if (availableCredits !== null && !hasEnoughCredits) {
const { title, desc } = msg.ai.insufficientCredits(
creditCost,
availableCredits,
);
toast.action(title, {
description: desc,
label: msg.billing.topUp,
onClick: () => router.push("/settings/billing"),
type: "warning",
});
return;
}
setError(null); setError(null);
setIsGenerating(true); setIsGenerating(true);
@@ -214,16 +233,27 @@ export default function PromptNode({
targetHandle: "prompt-in", targetHandle: "prompt-in",
}); });
await generateImage({ await toast.promise(
canvasId, generateImage({
nodeId: aiNodeId, canvasId,
prompt: promptToUse, nodeId: aiNodeId,
referenceStorageId, prompt: promptToUse,
model: DEFAULT_MODEL_ID, referenceStorageId,
aspectRatio, model: DEFAULT_MODEL_ID,
}); aspectRatio,
}),
{
loading: msg.ai.generating.title,
success: msg.ai.generated.title,
error: msg.ai.generationFailed.title,
description: {
success: msg.ai.generatedDesc(creditCost),
error: msg.ai.creditsNotCharged,
},
},
);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Bildgenerierung fehlgeschlagen"); setError(err instanceof Error ? err.message : msg.ai.generationFailed.title);
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
@@ -239,6 +269,10 @@ export default function PromptNode({
createNodeWithIntersection, createNodeWithIntersection,
createEdge, createEdge,
generateImage, generateImage,
creditCost,
availableCredits,
hasEnoughCredits,
router,
]); ]);
return ( return (
@@ -328,14 +362,11 @@ export default function PromptNode({
type="button" type="button"
onClick={() => void handleGenerate()} onClick={() => void handleGenerate()}
disabled={ disabled={
!effectivePrompt.trim() || !effectivePrompt.trim() || isGenerating || balance === undefined
isGenerating ||
balance === undefined ||
(availableCredits !== null && !hasEnoughCredits)
} }
className={`nodrag flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed ${ className={`nodrag flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed ${
availableCredits !== null && !hasEnoughCredits availableCredits !== null && !hasEnoughCredits
? "bg-muted text-muted-foreground" ? "bg-amber-600/90 text-white hover:bg-amber-600"
: "bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50" : "bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
}`} }`}
> >

View File

@@ -4,6 +4,7 @@ import { useState, useCallback, useRef } from "react";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { ArrowUpRight, MoreHorizontal, Pencil } from "lucide-react"; import { ArrowUpRight, MoreHorizontal, Pencil } from "lucide-react";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -46,7 +47,8 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const trimmedName = editName.trim(); const trimmedName = editName.trim();
if (!trimmedName) { if (!trimmedName) {
toast.error("Name darf nicht leer sein"); const { title, desc } = msg.dashboard.renameEmpty;
toast.error(title, desc);
return; return;
} }
if (trimmedName === canvas.name) { if (trimmedName === canvas.name) {
@@ -59,10 +61,10 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
setIsSaving(true); setIsSaving(true);
try { try {
await updateCanvas({ canvasId: canvas._id, name: trimmedName }); await updateCanvas({ canvasId: canvas._id, name: trimmedName });
toast.success("Arbeitsbereich umbenannt"); toast.success(msg.dashboard.renameSuccess.title);
setIsEditing(false); setIsEditing(false);
} catch { } catch {
toast.error("Fehler beim Umbenennen"); toast.error(msg.dashboard.renameFailed.title);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
saveInFlightRef.current = false; saveInFlightRef.current = false;

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { useEffect } from "react";
import { useQuery } from "convex/react"; import { useQuery } from "convex/react";
import { CreditCard } from "lucide-react"; import { CreditCard } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -10,6 +12,8 @@ import { Progress } from "@/components/ui/progress";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { formatEurFromCents } from "@/lib/utils"; import { formatEurFromCents } from "@/lib/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tier-Config — monatliches Credit-Kontingent pro Tier (in Cent) // Tier-Config — monatliches Credit-Kontingent pro Tier (in Cent)
@@ -35,11 +39,32 @@ const TIER_BADGE_STYLES: Record<string, string> = {
// Component // Component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LOW_CREDITS_THRESHOLD = 20;
export function CreditOverview() { export function CreditOverview() {
const router = useRouter();
const balance = useQuery(api.credits.getBalance); const balance = useQuery(api.credits.getBalance);
const subscription = useQuery(api.credits.getSubscription); const subscription = useQuery(api.credits.getSubscription);
const usageStats = useQuery(api.credits.getUsageStats); const usageStats = useQuery(api.credits.getUsageStats);
useEffect(() => {
if (balance === undefined) return;
const available = balance.available;
if (available <= 0 || available >= LOW_CREDITS_THRESHOLD) return;
const key = "ls-low-credits-dashboard";
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
sessionStorage.setItem(key, "1");
const { title, desc } = msg.billing.lowCredits(available);
toast.action(title, {
description: desc,
label: msg.billing.topUp,
onClick: () => router.push("/settings/billing"),
type: "warning",
});
}, [balance, router]);
// ── Loading State ────────────────────────────────────────────────────── // ── Loading State ──────────────────────────────────────────────────────
if ( if (
balance === undefined || balance === undefined ||

View File

@@ -3,7 +3,9 @@
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { useMutation, useQuery } from "convex/react"; import { useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
/** /**
* Initialisiert die Credit-Balance für neue User. * Initialisiert die Credit-Balance für neue User.
@@ -17,16 +19,27 @@ export function InitUser() {
session?.user ? {} : "skip" session?.user ? {} : "skip"
); );
const initBalance = useMutation(api.credits.initBalance); const initBalance = useMutation(api.credits.initBalance);
const initStartedRef = useRef(false);
useEffect(() => { useEffect(() => {
if ( if (
session?.user && !session?.user ||
balance && !balance ||
balance.balance === 0 && balance.balance !== 0 ||
balance.monthlyAllocation === 0 balance.monthlyAllocation !== 0
) { ) {
initBalance(); return;
} }
if (initStartedRef.current) return;
initStartedRef.current = true;
void initBalance()
.then(() => {
toast.success(msg.auth.initialSetup.title, msg.auth.initialSetup.desc);
})
.catch(() => {
initStartedRef.current = false;
});
}, [session?.user, balance, initBalance]); }, [session?.user, balance, initBalance]);
return null; return null;

View File

@@ -1,18 +1,37 @@
"use client"; "use client";
import { ReactNode } from "react"; import * as Sentry from "@sentry/nextjs";
import { ReactNode, useEffect } from "react";
import { ConvexReactClient } from "convex/react"; import { ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"; import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { AuthUIProvider } from "@daveyplate/better-auth-ui"; import { AuthUIProvider } from "@daveyplate/better-auth-ui";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { GooeyToaster } from "goey-toast";
import "goey-toast/styles.css";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { Toaster } from "@/components/ui/sonner";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
function SentryAuthUserSync() {
const { data: session } = authClient.useSession();
useEffect(() => {
if (session?.user) {
Sentry.setUser({
id: session.user.id,
email: session.user.email ?? undefined,
});
} else {
Sentry.setUser(null);
}
}, [session?.user]);
return null;
}
export function Providers({ export function Providers({
children, children,
initialToken, initialToken,
@@ -29,6 +48,7 @@ export function Providers({
authClient={authClient} authClient={authClient}
initialToken={initialToken} initialToken={initialToken}
> >
<SentryAuthUserSync />
<AuthUIProvider <AuthUIProvider
authClient={authClient} authClient={authClient}
navigate={router.push} navigate={router.push}
@@ -37,7 +57,13 @@ export function Providers({
Link={Link} Link={Link}
> >
{children} {children}
<Toaster position="bottom-right" /> <GooeyToaster
position="bottom-right"
theme="dark"
visibleToasts={4}
maxQueue={8}
queueOverflow="drop-oldest"
/>
</AuthUIProvider> </AuthUIProvider>
</ConvexBetterAuthProvider> </ConvexBetterAuthProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,53 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { toastOptions, ...restProps } = props
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
...toastOptions,
classNames: {
toast: "cn-toast",
error: "border-destructive",
...toastOptions?.classNames,
},
}}
{...restProps}
/>
)
}
export { Toaster }

22
instrumentation-client.ts Normal file
View File

@@ -0,0 +1,22 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
integrations: [Sentry.replayIntegration()],
tracesSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0,
sendDefaultPii: true,
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

13
instrumentation.ts Normal file
View File

@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;

View File

@@ -1,5 +1,7 @@
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs"; import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
import { api } from "@/convex/_generated/api";
export const { export const {
handler, // Route Handler für /api/auth/* handler, // Route Handler für /api/auth/*
preloadAuthQuery, // SSR: Query mit Auth vorladen preloadAuthQuery, // SSR: Query mit Auth vorladen
@@ -17,3 +19,8 @@ export const {
isAuthError: (error) => /auth/i.test(String(error)), isAuthError: (error) => /auth/i.test(String(error)),
}, },
}); });
/** Aktueller User für SSR (z. B. Sentry `setUser`), oder `null`. */
export async function getAuthUser() {
return fetchAuthQuery(api.auth.safeGetAuthUser, {});
}

16
lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,16 @@
import { redis } from "./redis";
export async function rateLimit(
key: string,
limit: number,
windowSeconds: number
): Promise<{ success: boolean; remaining: number }> {
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return {
success: count <= limit,
remaining: Math.max(0, limit - count),
};
}

29
lib/redis.ts Normal file
View File

@@ -0,0 +1,29 @@
import Redis from "ioredis";
function createRedis(): Redis {
const url = process.env.REDIS_URL?.trim();
if (url) {
return new Redis(url, {
retryStrategy: (times) => Math.min(times * 100, 3000),
});
}
return new Redis({
host: process.env.REDIS_HOST ?? "127.0.0.1",
port: Number.parseInt(process.env.REDIS_PORT ?? "6379", 10),
password: process.env.REDIS_PASSWORD || undefined,
retryStrategy: (times) => Math.min(times * 100, 3000),
});
}
const globalForRedis = globalThis as unknown as { redis?: Redis };
export const redis = globalForRedis.redis ?? createRedis();
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err);
});
if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = redis;
}

139
lib/toast-messages.ts Normal file
View File

@@ -0,0 +1,139 @@
// Zentrales Dictionary für alle Toast-Strings.
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
export const msg = {
canvas: {
imageUploaded: { title: "Bild hochgeladen" },
uploadFailed: { title: "Upload fehlgeschlagen" },
uploadFormatError: (format: string) => ({
title: "Upload fehlgeschlagen",
desc: `Format „${format}“ wird nicht unterstützt. Erlaubt: PNG, JPG, WebP.`,
}),
uploadSizeError: (maxMb: number) => ({
title: "Upload fehlgeschlagen",
desc: `Maximale Dateigröße: ${maxMb} MB.`,
}),
nodeRemoved: { title: "Element entfernt" },
nodesRemoved: (count: number) => ({
title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`,
}),
},
ai: {
generating: { title: "Bild wird generiert…" },
generated: { title: "Bild generiert" },
generatedDesc: (credits: number) => `${credits} Credits verbraucht`,
generationFailed: { title: "Generierung fehlgeschlagen" },
creditsNotCharged: "Credits wurden nicht abgebucht",
insufficientCredits: (needed: number, available: number) => ({
title: "Nicht genügend Credits",
desc: `${needed} Credits benötigt, ${available} verfügbar.`,
}),
modelUnavailable: {
title: "Modell vorübergehend nicht verfügbar",
desc: "Versuche ein anderes Modell oder probiere es später erneut.",
},
contentPolicy: {
title: "Anfrage durch Inhaltsrichtlinie blockiert",
desc: "Versuche, den Prompt umzuformulieren.",
},
timeout: {
title: "Generierung abgelaufen",
desc: "Credits wurden nicht abgebucht.",
},
openrouterIssues: {
title: "OpenRouter möglicherweise gestört",
desc: "Mehrere Generierungen fehlgeschlagen.",
},
},
export: {
frameExported: { title: "Frame exportiert" },
exportingFrames: { title: "Frames werden exportiert…" },
zipReady: { title: "ZIP bereit" },
exportFailed: { title: "Export fehlgeschlagen" },
frameEmpty: {
title: "Export fehlgeschlagen",
desc: "Frame hat keinen sichtbaren Inhalt.",
},
noFramesOnCanvas: {
title: "Export fehlgeschlagen",
desc: "Keine Frames auf dem Canvas — zuerst einen Frame anlegen.",
},
download: "Herunterladen",
downloaded: "Heruntergeladen!",
},
auth: {
welcomeBack: { title: "Willkommen zurück" },
welcomeOnDashboard: { title: "Schön, dass du da bist" },
checkEmail: (email: string) => ({
title: "E-Mail prüfen",
desc: `Bestätigungslink an ${email} gesendet.`,
}),
sessionExpired: {
title: "Sitzung abgelaufen",
desc: "Bitte erneut anmelden.",
},
signedOut: { title: "Abgemeldet" },
signIn: "Anmelden",
initialSetup: {
title: "Startguthaben aktiv",
desc: "Du kannst loslegen.",
},
},
billing: {
subscriptionActivated: (credits: number) => ({
title: "Abo aktiviert",
desc: `${credits} Credits deinem Guthaben hinzugefügt.`,
}),
creditsAdded: (credits: number) => ({
title: "Credits hinzugefügt",
desc: `+${credits} Credits`,
}),
subscriptionCancelled: (periodEnd: string) => ({
title: "Abo gekündigt",
desc: `Deine Credits bleiben bis ${periodEnd} verfügbar.`,
}),
paymentFailed: {
title: "Zahlung fehlgeschlagen",
desc: "Bitte Zahlungsmethode aktualisieren.",
},
dailyLimitReached: (limit: number) => ({
title: "Tageslimit erreicht",
desc: `Maximal ${limit} Generierungen pro Tag in deinem Tarif.`,
}),
lowCredits: (remaining: number) => ({
title: "Credits fast aufgebraucht",
desc: `Noch ${remaining} Credits übrig.`,
}),
topUp: "Aufladen",
upgrade: "Upgrade",
manage: "Verwalten",
redirectingToCheckout: {
title: "Weiterleitung…",
desc: "Du wirst zum sicheren Checkout weitergeleitet.",
},
openingPortal: {
title: "Portal wird geöffnet…",
desc: "Du wirst zur Aboverwaltung weitergeleitet.",
},
testGrantFailed: { title: "Gutschrift fehlgeschlagen" },
},
system: {
reconnected: { title: "Verbindung wiederhergestellt" },
connectionLost: {
title: "Verbindung verloren",
desc: "Änderungen werden möglicherweise nicht gespeichert.",
},
copiedToClipboard: { title: "In Zwischenablage kopiert" },
},
dashboard: {
renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." },
renameSuccess: { title: "Arbeitsbereich umbenannt" },
renameFailed: { title: "Umbenennen fehlgeschlagen" },
},
} as const;

View File

@@ -1,82 +1,109 @@
import type { ReactNode } from "react" import { gooeyToast, type GooeyPromiseData } from "goey-toast";
import { isValidElement } from "react"
import { toast as sonnerToast, type ExternalToast } from "sonner"
const SUCCESS_DURATION = 4000 const DURATION = {
const ERROR_DURATION = 6000 success: 4000,
successShort: 2000,
error: 6000,
warning: 5000,
info: 4000,
} as const;
type SonnerPromiseInput<T> = Parameters<typeof sonnerToast.promise<T>>[0] export type ToastDurationOverrides = {
type SonnerPromiseOptions<T> = Parameters<typeof sonnerToast.promise<T>>[1] duration?: number;
type SonnerPromiseData<T> = NonNullable<SonnerPromiseOptions<T>> };
function hasMessage(
value: unknown,
): value is {
message: ReactNode
duration?: number
} {
return (
typeof value === "object" &&
value !== null &&
!isValidElement(value) &&
"message" in value
)
}
function withStateDuration<T>(state: unknown, duration: number): unknown {
if (state === undefined) {
return undefined
}
if (typeof state === "function") {
return async (value: T) => {
const result = await state(value)
return withStateDuration(result, duration)
}
}
if (hasMessage(state)) {
return {
...state,
duration: state.duration ?? duration,
}
}
return {
message: state as ReactNode,
duration,
}
}
export const toast = { export const toast = {
success(message: ReactNode, options?: ExternalToast) { success(
return sonnerToast.success(message, { message: string,
...options, description?: string,
duration: options?.duration ?? SUCCESS_DURATION, opts?: ToastDurationOverrides,
}) ) {
return gooeyToast.success(message, {
description,
duration: opts?.duration ?? DURATION.success,
});
}, },
error(message: ReactNode, options?: ExternalToast) {
return sonnerToast.error(message, { error(
...options, message: string,
duration: options?.duration ?? ERROR_DURATION, description?: string,
}) opts?: ToastDurationOverrides,
) {
return gooeyToast.error(message, {
description,
duration: opts?.duration ?? DURATION.error,
});
}, },
loading(message: ReactNode, options?: ExternalToast) {
return sonnerToast.loading(message, options) warning(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.warning(message, {
description,
duration: opts?.duration ?? DURATION.warning,
});
}, },
dismiss(id?: number | string) {
return sonnerToast.dismiss(id) info(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.info(message, {
description,
duration: opts?.duration ?? DURATION.info,
});
}, },
promise<T>(promise: SonnerPromiseInput<T>, options?: SonnerPromiseOptions<T>) {
return sonnerToast.promise(promise, { promise<T>(promise: Promise<T>, data: GooeyPromiseData<T>) {
...options, return gooeyToast.promise(promise, data);
success: withStateDuration<T>(options?.success, SUCCESS_DURATION) as SonnerPromiseData<T>["success"],
error: withStateDuration<T>(options?.error, ERROR_DURATION) as SonnerPromiseData<T>["error"],
})
}, },
}
action(
message: string,
opts: {
description?: string;
label: string;
onClick: () => void;
successLabel?: string;
type?: "success" | "info" | "warning";
duration?: number;
},
) {
const t = opts.type ?? "info";
return gooeyToast[t](message, {
description: opts.description,
duration: opts.duration ?? (t === "success" ? DURATION.success : DURATION.info),
action: {
label: opts.label,
onClick: opts.onClick,
successLabel: opts.successLabel,
},
});
},
update(
id: string | number,
opts: {
title?: string;
description?: string;
type?: "default" | "success" | "error" | "warning" | "info";
},
) {
gooeyToast.update(id, opts);
},
dismiss(id?: string | number) {
gooeyToast.dismiss(id);
},
};
export const toastDuration = { export const toastDuration = {
success: SUCCESS_DURATION, success: DURATION.success,
error: ERROR_DURATION, successShort: DURATION.successShort,
} as const error: DURATION.error,
warning: DURATION.warning,
info: DURATION.info,
} as const;

View File

@@ -1,3 +1,4 @@
import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
@@ -22,4 +23,40 @@ const nextConfig: NextConfig = {
}, },
}; };
export default nextConfig; export default withSentryConfig(nextConfig, {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: process.env.SENTRY_ORG ?? "lemonspace",
project: process.env.SENTRY_PROJECT ?? "lemonspace",
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
webpack: {
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
// Tree-shaking options for reducing bundle size
treeshake: {
// Automatically tree-shake Sentry logger statements to reduce bundle size
removeDebugLogging: true,
},
},
});

View File

@@ -17,11 +17,15 @@
"@napi-rs/canvas": "^0.1.97", "@napi-rs/canvas": "^0.1.97",
"@polar-sh/better-auth": "^1.8.3", "@polar-sh/better-auth": "^1.8.3",
"@polar-sh/sdk": "^0.46.7", "@polar-sh/sdk": "^0.46.7",
"@sentry/nextjs": "^10.46.0",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"better-auth": "^1.5.6", "better-auth": "^1.5.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.34.0", "convex": "^1.34.0",
"framer-motion": "^12.38.0",
"goey-toast": "^0.3.0",
"ioredis": "^5.10.1",
"jimp": "^1.6.0", "jimp": "^1.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^1.6.0", "lucide-react": "^1.6.0",
@@ -33,13 +37,13 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"resend": "^4.8.0", "resend": "^4.8.0",
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/ioredis": "^5.0.0",
"@types/jszip": "^3.4.1", "@types/jszip": "^3.4.1",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",

1941
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

16
sentry.edge.config.ts Normal file
View File

@@ -0,0 +1,16 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1,
sendDefaultPii: true,
});

15
sentry.server.config.ts Normal file
View File

@@ -0,0 +1,15 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1,
sendDefaultPii: true,
});