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:
@@ -11,6 +11,7 @@ import {
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
reconnectEdge,
|
||||
type Node as RFNode,
|
||||
type Edge as RFEdge,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -170,8 +172,67 @@ function normalizeHandle(handle: string | null | undefined): string | 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) {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const storeApi = useStoreApi();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
||||
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
||||
@@ -301,8 +362,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
|
||||
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) {
|
||||
toast.error(
|
||||
"Mehrere Generierungen sind fehlgeschlagen. Bitte Prompt, Modell oder Credits prüfen.",
|
||||
toast.warning(
|
||||
msg.ai.openrouterIssues.title,
|
||||
msg.ai.openrouterIssues.desc,
|
||||
);
|
||||
recentGenerationFailureTimestampsRef.current = [];
|
||||
return;
|
||||
@@ -315,7 +377,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
useEffect(() => {
|
||||
if (!convexNodes || isDragging.current) return;
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -367,6 +432,56 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
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 ──────────────────────────────────────
|
||||
const onReconnectStart = useCallback(() => {
|
||||
edgeReconnectSuccessful.current = false;
|
||||
@@ -614,6 +729,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
// ─── Node löschen → Convex ────────────────────────────────────
|
||||
const onNodesDelete = useCallback(
|
||||
async (deletedNodes: RFNode[]) => {
|
||||
const count = deletedNodes.length;
|
||||
for (const node of deletedNodes) {
|
||||
const incomingEdges = edges.filter((e) => e.target === 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"> });
|
||||
}
|
||||
if (count > 0) {
|
||||
const { title } = msg.canvas.nodesRemoved(count);
|
||||
toast.info(title);
|
||||
}
|
||||
},
|
||||
[edges, removeNode, createEdge, canvasId],
|
||||
);
|
||||
@@ -732,6 +852,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onError={onFlowError}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
fitView
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConvexConnectionState } from "convex/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast, toastDuration } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
|
||||
|
||||
@@ -12,6 +14,7 @@ const RECONNECTED_HIDE_DELAY_MS = 1800;
|
||||
export default function ConnectionBanner() {
|
||||
const connectionState = useConvexConnectionState();
|
||||
const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
|
||||
const disconnectToastIdRef = useRef<string | number | undefined>(undefined);
|
||||
const [showReconnected, setShowReconnected] = useState(false);
|
||||
const [isBrowserOnline, setIsBrowserOnline] = useState(
|
||||
typeof navigator === "undefined" ? true : navigator.onLine,
|
||||
@@ -33,14 +36,19 @@ export default function ConnectionBanner() {
|
||||
useEffect(() => {
|
||||
const wasConnected = previousConnectedRef.current;
|
||||
const isConnected = connectionState.isWebSocketConnected;
|
||||
const didReconnect = !wasConnected && isConnected && connectionState.connectionCount > 1;
|
||||
const didReconnect =
|
||||
!wasConnected && isConnected && connectionState.connectionCount > 1;
|
||||
|
||||
if (didReconnect) {
|
||||
setShowReconnected(true);
|
||||
queueMicrotask(() => {
|
||||
setShowReconnected(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
setShowReconnected(false);
|
||||
queueMicrotask(() => {
|
||||
setShowReconnected(false);
|
||||
});
|
||||
}
|
||||
|
||||
previousConnectedRef.current = isConnected;
|
||||
@@ -58,6 +66,39 @@ export default function ConnectionBanner() {
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [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>(() => {
|
||||
if (connectionState.isWebSocketConnected) {
|
||||
return showReconnected ? "reconnected" : "hidden";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Coins } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
free: "Free",
|
||||
@@ -90,11 +91,16 @@ export function CreditDisplay() {
|
||||
onClick={() => {
|
||||
void grantTestCredits({ amount: 2000 })
|
||||
.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) => {
|
||||
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 { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
interface ExportButtonProps {
|
||||
canvasName?: string;
|
||||
@@ -24,12 +26,14 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
setIsExporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const NO_FRAMES = "NO_FRAMES";
|
||||
|
||||
const runExport = async () => {
|
||||
const nodes = getNodes();
|
||||
const frameNodes = nodes.filter((node) => node.type === "frame");
|
||||
|
||||
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();
|
||||
@@ -64,8 +68,36 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
anchor.click();
|
||||
|
||||
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) {
|
||||
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 {
|
||||
setIsExporting(false);
|
||||
setProgress(null);
|
||||
|
||||
@@ -10,6 +10,8 @@ import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
|
||||
import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
|
||||
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
@@ -106,16 +108,30 @@ export default function AiImageNode({
|
||||
}
|
||||
}
|
||||
|
||||
await generateImage({
|
||||
canvasId,
|
||||
nodeId: id as Id<"nodes">,
|
||||
prompt,
|
||||
referenceStorageId,
|
||||
model: nodeData.model ?? DEFAULT_MODEL_ID,
|
||||
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
|
||||
});
|
||||
const modelId = nodeData.model ?? DEFAULT_MODEL_ID;
|
||||
const regenCreditCost = getModel(modelId)?.creditCost ?? 4;
|
||||
|
||||
await toast.promise(
|
||||
generateImage({
|
||||
canvasId,
|
||||
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) {
|
||||
setLocalError(err instanceof Error ? err.message : "Generation failed");
|
||||
setLocalError(err instanceof Error ? err.message : msg.ai.generationFailed.title);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
interface FrameNodeData {
|
||||
label?: string;
|
||||
@@ -43,16 +45,29 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
||||
|
||||
try {
|
||||
const result = await exportFrame({ frameNodeId: id as Id<"nodes"> });
|
||||
const a = document.createElement("a");
|
||||
a.href = result.url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
const fileLabel = `${label.trim() || "frame"}.png`;
|
||||
toast.action(msg.export.frameExported.title, {
|
||||
description: fileLabel,
|
||||
label: msg.export.download,
|
||||
onClick: () => {
|
||||
window.open(result.url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
successLabel: msg.export.downloaded,
|
||||
type: "success",
|
||||
});
|
||||
} 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 {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [exportFrame, id, isExporting]);
|
||||
}, [exportFrame, id, isExporting, label]);
|
||||
|
||||
const frameW = Math.round(width ?? 400);
|
||||
const frameH = Math.round(height ?? 300);
|
||||
|
||||
@@ -13,6 +13,15 @@ import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import Image from "next/image";
|
||||
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 = {
|
||||
storageId?: string;
|
||||
@@ -34,7 +43,21 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
|
||||
const uploadFile = useCallback(
|
||||
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);
|
||||
|
||||
try {
|
||||
@@ -59,8 +82,13 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
|
||||
mimeType: file.type,
|
||||
},
|
||||
});
|
||||
toast.success(msg.canvas.imageUploaded.title);
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
toast.error(
|
||||
msg.canvas.uploadFailed.title,
|
||||
err instanceof Error ? err.message : undefined,
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
|
||||
@@ -29,6 +30,11 @@ export class NodeErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
Sentry.captureException(error, {
|
||||
tags: { nodeType: this.props.nodeType },
|
||||
extra: { componentStack: errorInfo.componentStack },
|
||||
});
|
||||
|
||||
console.error("Node rendering error", {
|
||||
nodeType: this.props.nodeType,
|
||||
error,
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 = {
|
||||
prompt?: string;
|
||||
@@ -52,6 +55,7 @@ export default function PromptNode({
|
||||
selected,
|
||||
}: NodeProps<PromptNode>) {
|
||||
const nodeData = data as PromptNodeData;
|
||||
const router = useRouter();
|
||||
const { getEdges, getNode } = useReactFlow();
|
||||
|
||||
const [prompt, setPrompt] = useState(nodeData.prompt ?? "");
|
||||
@@ -151,6 +155,21 @@ export default function PromptNode({
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
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);
|
||||
setIsGenerating(true);
|
||||
|
||||
@@ -214,16 +233,27 @@ export default function PromptNode({
|
||||
targetHandle: "prompt-in",
|
||||
});
|
||||
|
||||
await generateImage({
|
||||
canvasId,
|
||||
nodeId: aiNodeId,
|
||||
prompt: promptToUse,
|
||||
referenceStorageId,
|
||||
model: DEFAULT_MODEL_ID,
|
||||
aspectRatio,
|
||||
});
|
||||
await toast.promise(
|
||||
generateImage({
|
||||
canvasId,
|
||||
nodeId: aiNodeId,
|
||||
prompt: promptToUse,
|
||||
referenceStorageId,
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : "Bildgenerierung fehlgeschlagen");
|
||||
setError(err instanceof Error ? err.message : msg.ai.generationFailed.title);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
@@ -239,6 +269,10 @@ export default function PromptNode({
|
||||
createNodeWithIntersection,
|
||||
createEdge,
|
||||
generateImage,
|
||||
creditCost,
|
||||
availableCredits,
|
||||
hasEnoughCredits,
|
||||
router,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -328,14 +362,11 @@ export default function PromptNode({
|
||||
type="button"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={
|
||||
!effectivePrompt.trim() ||
|
||||
isGenerating ||
|
||||
balance === undefined ||
|
||||
(availableCredits !== null && !hasEnoughCredits)
|
||||
!effectivePrompt.trim() || isGenerating || balance === undefined
|
||||
}
|
||||
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
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user