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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/*
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
queueMicrotask(() => {
|
||||||
setShowReconnected(true);
|
setShowReconnected(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
|
queueMicrotask(() => {
|
||||||
setShowReconnected(false);
|
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";
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
const regenCreditCost = getModel(modelId)?.creditCost ?? 4;
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
generateImage({
|
||||||
canvasId,
|
canvasId,
|
||||||
nodeId: id as Id<"nodes">,
|
nodeId: id as Id<"nodes">,
|
||||||
prompt,
|
prompt,
|
||||||
referenceStorageId,
|
referenceStorageId,
|
||||||
model: nodeData.model ?? DEFAULT_MODEL_ID,
|
model: modelId,
|
||||||
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
generateImage({
|
||||||
canvasId,
|
canvasId,
|
||||||
nodeId: aiNodeId,
|
nodeId: aiNodeId,
|
||||||
prompt: promptToUse,
|
prompt: promptToUse,
|
||||||
referenceStorageId,
|
referenceStorageId,
|
||||||
model: DEFAULT_MODEL_ID,
|
model: DEFAULT_MODEL_ID,
|
||||||
aspectRatio,
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
22
instrumentation-client.ts
Normal 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
13
instrumentation.ts
Normal 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;
|
||||||
@@ -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
16
lib/rate-limit.ts
Normal 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
29
lib/redis.ts
Normal 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
139
lib/toast-messages.ts
Normal 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;
|
||||||
169
lib/toast.ts
169
lib/toast.ts
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
1941
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
16
sentry.edge.config.ts
Normal file
16
sentry.edge.config.ts
Normal 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
15
sentry.server.config.ts
Normal 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,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user