Implement internationalization support across components
- Integrated `next-intl` for toast messages and locale handling in various components, including `Providers`, `CanvasUserMenu`, and `CreditOverview`. - Replaced hardcoded strings with translation keys to enhance localization capabilities. - Updated `RootLayout` to dynamically set the language attribute based on the user's locale. - Ensured consistent user feedback through localized toast messages in actions such as sign-out, canvas operations, and billing notifications.
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Monitor,
|
||||
Moon,
|
||||
@@ -36,7 +37,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
|
||||
type CanvasAppMenuProps = {
|
||||
@@ -44,6 +44,7 @@ type CanvasAppMenuProps = {
|
||||
};
|
||||
|
||||
export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const canvas = useAuthQuery(api.canvases.get, { canvasId });
|
||||
const removeCanvas = useMutation(api.canvases.remove);
|
||||
@@ -65,8 +66,7 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
|
||||
const handleRename = async () => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
const { title, desc } = msg.dashboard.renameEmpty;
|
||||
toast.error(title, desc);
|
||||
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === canvas?.name) {
|
||||
@@ -76,10 +76,10 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
|
||||
setRenameSaving(true);
|
||||
try {
|
||||
await renameCanvas({ canvasId, name: trimmed });
|
||||
toast.success(msg.dashboard.renameSuccess.title);
|
||||
toast.success(t('dashboard.renameSuccess'));
|
||||
setRenameOpen(false);
|
||||
} catch {
|
||||
toast.error(msg.dashboard.renameFailed.title);
|
||||
toast.error(t('dashboard.renameFailed'));
|
||||
} finally {
|
||||
setRenameSaving(false);
|
||||
}
|
||||
|
||||
@@ -6,15 +6,19 @@ import {
|
||||
type Node as RFNode,
|
||||
type OnBeforeDelete,
|
||||
} from "@xyflow/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||
import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||
|
||||
import { getNodeDeleteBlockReason } from "./canvas-helpers";
|
||||
|
||||
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
||||
|
||||
type UseCanvasDeleteHandlersParams = {
|
||||
t: ToastTranslations;
|
||||
canvasId: Id<"canvases">;
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
@@ -32,6 +36,7 @@ type UseCanvasDeleteHandlersParams = {
|
||||
};
|
||||
|
||||
export function useCanvasDeleteHandlers({
|
||||
t,
|
||||
canvasId,
|
||||
nodes,
|
||||
edges,
|
||||
@@ -71,16 +76,20 @@ export function useCanvasDeleteHandlers({
|
||||
}
|
||||
|
||||
if (allowed.length === 0) {
|
||||
const { title, desc } = msg.canvas.nodeDeleteBlockedExplain(blockedReasons);
|
||||
const title = t('canvas.nodeDeleteBlockedTitle');
|
||||
const desc = t('canvas.nodeDeleteBlockedDesc');
|
||||
toast.warning(title, desc);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blocked.length > 0) {
|
||||
const { title, desc } = msg.canvas.nodeDeleteBlockedPartial(
|
||||
blocked.length,
|
||||
blockedReasons,
|
||||
);
|
||||
const title = t('canvas.nodeDeleteBlockedPartialTitle');
|
||||
const whyDesc = t('canvas.nodeDeleteBlockedDesc');
|
||||
const suffix =
|
||||
blocked.length === 1
|
||||
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
|
||||
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blocked.length });
|
||||
const desc = `${whyDesc} ${suffix}`;
|
||||
toast.warning(title, desc);
|
||||
return {
|
||||
nodes: allowed,
|
||||
@@ -140,10 +149,11 @@ export function useCanvasDeleteHandlers({
|
||||
}
|
||||
});
|
||||
|
||||
const { title } = msg.canvas.nodesRemoved(count);
|
||||
const title = t('canvas.nodesRemoved', { count });
|
||||
toast.info(title);
|
||||
},
|
||||
[
|
||||
t,
|
||||
canvasId,
|
||||
deletingNodeIds,
|
||||
edges,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
import {
|
||||
GENERATION_FAILURE_THRESHOLD,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./canvas-helpers";
|
||||
|
||||
export function useGenerationFailureWarnings(
|
||||
t: ReturnType<typeof useTranslations<'toasts'>>,
|
||||
convexNodes: Doc<"nodes">[] | undefined,
|
||||
): void {
|
||||
const recentGenerationFailureTimestampsRef = useRef<number[]>([]);
|
||||
@@ -60,11 +61,11 @@ export function useGenerationFailureWarnings(
|
||||
}
|
||||
|
||||
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) {
|
||||
toast.warning(msg.ai.openrouterIssues.title, msg.ai.openrouterIssues.desc);
|
||||
toast.warning(t('ai.openrouterIssuesTitle'), t('ai.openrouterIssuesDesc'));
|
||||
recentGenerationFailureTimestampsRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
recentGenerationFailureTimestampsRef.current = recentFailures;
|
||||
}, [convexNodes]);
|
||||
}, [t, convexNodes]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow
|
||||
|
||||
import { readCanvasOps } from "@/lib/canvas-local-persistence";
|
||||
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||
|
||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||
|
||||
@@ -38,12 +38,11 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden overscroll-none">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
orientation="horizontal"
|
||||
className="h-full w-full min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="canvas-sidebar-panel"
|
||||
order={1}
|
||||
defaultSize={SIDEBAR_DEFAULT_SIZE}
|
||||
minSize={SIDEBAR_COLLAPSE_THRESHOLD}
|
||||
maxSize={SIDEBAR_MAX_SIZE}
|
||||
@@ -62,7 +61,6 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
|
||||
|
||||
<ResizablePanel
|
||||
id="canvas-main-panel"
|
||||
order={2}
|
||||
minSize={MAIN_PANEL_MIN_SIZE}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LayoutDashboard, LogOut } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
function getInitials(nameOrEmail: string) {
|
||||
const normalized = nameOrEmail.trim();
|
||||
@@ -25,6 +25,7 @@ type CanvasUserMenuProps = {
|
||||
};
|
||||
|
||||
export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
@@ -32,7 +33,7 @@ export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
|
||||
const initials = getInitials(displayName);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
toast.info(msg.auth.signedOut.title);
|
||||
toast.info(t('auth.signedOut'));
|
||||
await authClient.signOut();
|
||||
router.replace("/auth/sign-in");
|
||||
router.refresh();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import {
|
||||
dropCanvasOpsByClientRequestIds,
|
||||
dropCanvasOpsByEdgeIds,
|
||||
@@ -153,6 +153,7 @@ function isLikelyTransientSyncError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
||||
@@ -1583,7 +1584,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useGenerationFailureWarnings(convexNodes);
|
||||
useGenerationFailureWarnings(t, convexNodes);
|
||||
|
||||
const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({
|
||||
scissorsMode,
|
||||
@@ -1596,6 +1597,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
});
|
||||
|
||||
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
|
||||
t,
|
||||
canvasId,
|
||||
nodes,
|
||||
edges,
|
||||
@@ -2456,7 +2458,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to upload dropped file:", err);
|
||||
toast.error(msg.canvas.uploadFailed.title, err instanceof Error ? err.message : undefined);
|
||||
toast.error(t('canvas.uploadFailed'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConvexConnectionState } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast, toastDuration } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
|
||||
|
||||
const RECONNECTED_HIDE_DELAY_MS = 1800;
|
||||
|
||||
export default function ConnectionBanner() {
|
||||
const t = useTranslations('toasts');
|
||||
const connectionState = useConvexConnectionState();
|
||||
const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
|
||||
const disconnectToastIdRef = useRef<string | number | undefined>(undefined);
|
||||
@@ -77,8 +78,8 @@ export default function ConnectionBanner() {
|
||||
if (shouldAlertDisconnect) {
|
||||
if (disconnectToastIdRef.current === undefined) {
|
||||
disconnectToastIdRef.current = toast.error(
|
||||
msg.system.connectionLost.title,
|
||||
msg.system.connectionLost.desc,
|
||||
t('system.connectionLostTitle'),
|
||||
t('system.connectionLostDesc'),
|
||||
{ duration: Number.POSITIVE_INFINITY },
|
||||
);
|
||||
}
|
||||
@@ -88,11 +89,12 @@ export default function ConnectionBanner() {
|
||||
if (connected && disconnectToastIdRef.current !== undefined) {
|
||||
toast.dismiss(disconnectToastIdRef.current);
|
||||
disconnectToastIdRef.current = undefined;
|
||||
toast.success(msg.system.reconnected.title, undefined, {
|
||||
toast.success(t('system.reconnected'), undefined, {
|
||||
duration: toastDuration.successShort,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
t,
|
||||
connectionState.connectionRetries,
|
||||
connectionState.hasEverConnected,
|
||||
connectionState.isWebSocketConnected,
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useMutation } from "convex/react";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
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",
|
||||
@@ -28,6 +28,7 @@ const showTestCreditGrant =
|
||||
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
|
||||
|
||||
export function CreditDisplay() {
|
||||
const t = useTranslations('toasts');
|
||||
const balance = useAuthQuery(api.credits.getBalance);
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const grantTestCredits = useMutation(api.credits.grantTestCredits);
|
||||
@@ -92,15 +93,14 @@ export function CreditDisplay() {
|
||||
onClick={() => {
|
||||
void grantTestCredits({ amount: 2000 })
|
||||
.then((r) => {
|
||||
const { title, desc } = msg.billing.creditsAdded(2000);
|
||||
toast.success(
|
||||
title,
|
||||
`${desc} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
|
||||
t('billing.creditsAddedTitle'),
|
||||
`${t('billing.creditsAddedDesc', { credits: 2000 })} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
|
||||
);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
toast.error(
|
||||
msg.billing.testGrantFailed.title,
|
||||
t('billing.testGrantFailedTitle'),
|
||||
e instanceof Error ? e.message : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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;
|
||||
}
|
||||
|
||||
export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const { getNodes } = useReactFlow();
|
||||
const exportFrame = useAction(api.export.exportFrame);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
@@ -72,19 +73,19 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
|
||||
try {
|
||||
await toast.promise(runExport(), {
|
||||
loading: msg.export.exportingFrames.title,
|
||||
success: msg.export.zipReady.title,
|
||||
loading: t('export.exportingFrames'),
|
||||
success: t('export.zipReady'),
|
||||
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;
|
||||
if (m === NO_FRAMES) return t('export.noFramesOnCanvasTitle');
|
||||
if (m.includes("No images found")) return t('export.frameEmptyTitle');
|
||||
return t('export.exportFailed');
|
||||
},
|
||||
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;
|
||||
if (m === NO_FRAMES) return t('export.noFramesOnCanvasDesc');
|
||||
if (m.includes("No images found")) return t('export.frameEmptyDesc');
|
||||
return m || undefined;
|
||||
},
|
||||
},
|
||||
@@ -92,17 +93,17 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : "";
|
||||
if (m === NO_FRAMES) {
|
||||
setError(msg.export.noFramesOnCanvas.desc);
|
||||
setError(t('export.noFramesOnCanvasDesc'));
|
||||
} else if (m.includes("No images found")) {
|
||||
setError(msg.export.frameEmpty.desc);
|
||||
setError(t('export.frameEmptyDesc'));
|
||||
} else {
|
||||
setError(m || msg.export.exportFailed.title);
|
||||
setError(m || t('export.exportFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setProgress(null);
|
||||
}
|
||||
}, [canvasName, exportFrame, getNodes, isExporting]);
|
||||
}, [t, canvasName, exportFrame, getNodes, isExporting]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
|
||||
import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
|
||||
import { classifyError, type ErrorType } from "@/lib/ai-errors";
|
||||
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import {
|
||||
Loader2,
|
||||
@@ -59,6 +59,7 @@ export default function AiImageNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<AiImageNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const nodeData = data as AiImageNodeData;
|
||||
const { getEdges, getNode } = useReactFlow();
|
||||
const { status: syncStatus } = useCanvasSync();
|
||||
@@ -135,17 +136,17 @@ export default function AiImageNode({
|
||||
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
|
||||
}),
|
||||
{
|
||||
loading: msg.ai.generating.title,
|
||||
success: msg.ai.generationQueued.title,
|
||||
error: msg.ai.generationFailed.title,
|
||||
loading: t('ai.generating'),
|
||||
success: t('ai.generationQueued'),
|
||||
error: t('ai.generationFailed'),
|
||||
description: {
|
||||
success: msg.ai.generationQueuedDesc,
|
||||
error: msg.ai.creditsNotCharged,
|
||||
success: t('ai.generationQueuedDesc'),
|
||||
error: t('ai.creditsNotCharged'),
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : msg.ai.generationFailed.title);
|
||||
setLocalError(err instanceof Error ? err.message : t('ai.generationFailed'));
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
@@ -154,16 +155,16 @@ export default function AiImageNode({
|
||||
const modelName =
|
||||
getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI";
|
||||
|
||||
const renderErrorIcon = (category: AiErrorCategory) => {
|
||||
switch (category) {
|
||||
case "insufficient_credits":
|
||||
const renderErrorIcon = (type: ErrorType) => {
|
||||
switch (type) {
|
||||
case "insufficientCredits":
|
||||
return <Coins className="h-8 w-8 text-amber-500" />;
|
||||
case "rate_limited":
|
||||
case "rateLimited":
|
||||
case "timeout":
|
||||
return <Clock3 className="h-8 w-8 text-amber-500" />;
|
||||
case "content_policy":
|
||||
case "contentPolicy":
|
||||
return <ShieldAlert className="h-8 w-8 text-destructive" />;
|
||||
case "network":
|
||||
case "networkError":
|
||||
return <WifiOff className="h-8 w-8 text-destructive" />;
|
||||
default:
|
||||
return <AlertCircle className="h-8 w-8 text-destructive" />;
|
||||
@@ -226,15 +227,10 @@ export default function AiImageNode({
|
||||
|
||||
{status === "error" && !isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-muted">
|
||||
{renderErrorIcon(classifiedError.category)}
|
||||
{renderErrorIcon(classifiedError.type)}
|
||||
<p className="px-4 text-center text-xs font-medium text-destructive">
|
||||
{classifiedError.message}
|
||||
{classifiedError.rawMessage}
|
||||
</p>
|
||||
{classifiedError.detail && (
|
||||
<p className="px-6 text-center text-[10px] text-muted-foreground">
|
||||
{classifiedError.detail}
|
||||
</p>
|
||||
)}
|
||||
{classifiedError.creditsNotCharged && (
|
||||
<p className="px-6 text-center text-[10px] text-muted-foreground">
|
||||
Credits not charged
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
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";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
|
||||
interface FrameNodeData {
|
||||
@@ -19,6 +19,7 @@ interface FrameNodeData {
|
||||
}
|
||||
|
||||
export default function FrameNode({ id, data, selected, width, height }: NodeProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const nodeData = data as FrameNodeData;
|
||||
const { queueNodeDataUpdate, status } = useCanvasSync();
|
||||
const exportFrame = useAction(api.export.exportFrame);
|
||||
@@ -54,23 +55,23 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
||||
try {
|
||||
const result = await exportFrame({ frameNodeId: id as Id<"nodes"> });
|
||||
const fileLabel = `${label.trim() || "frame"}.png`;
|
||||
toast.action(msg.export.frameExported.title, {
|
||||
toast.action(t('export.frameExported'), {
|
||||
description: fileLabel,
|
||||
label: msg.export.download,
|
||||
label: t('export.download'),
|
||||
onClick: () => {
|
||||
window.open(result.url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
successLabel: msg.export.downloaded,
|
||||
successLabel: t('export.downloaded'),
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
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);
|
||||
toast.error(t('export.frameEmptyTitle'), t('export.frameEmptyDesc'));
|
||||
setExportError(t('export.frameEmptyDesc'));
|
||||
} else {
|
||||
toast.error(msg.export.exportFailed.title, m || undefined);
|
||||
setExportError(m || msg.export.exportFailed.title);
|
||||
toast.error(t('export.exportFailed'), m || undefined);
|
||||
setExportError(m || t('export.exportFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import { useMutation } from "convex/react";
|
||||
@@ -73,6 +73,7 @@ export default function ImageNode({
|
||||
width,
|
||||
height,
|
||||
}: NodeProps<ImageNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -121,17 +122,17 @@ export default function ImageNode({
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||
const { title, desc } = msg.canvas.uploadFormatError(
|
||||
file.type || file.name.split(".").pop() || "—",
|
||||
toast.error(
|
||||
t('canvas.uploadFailed'),
|
||||
t('canvas.uploadFormatError', { format: 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(
|
||||
t('canvas.uploadFailed'),
|
||||
t('canvas.uploadSizeError', { maxMb: Math.round(MAX_IMAGE_BYTES / (1024 * 1024)) }),
|
||||
);
|
||||
toast.error(title, desc);
|
||||
return;
|
||||
}
|
||||
if (status.isOffline) {
|
||||
@@ -188,11 +189,11 @@ export default function ImageNode({
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(msg.canvas.imageUploaded.title);
|
||||
toast.success(t('canvas.imageUploaded'));
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
toast.error(
|
||||
msg.canvas.uploadFailed.title,
|
||||
t('canvas.uploadFailed'),
|
||||
err instanceof Error ? err.message : undefined,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
import { Sparkles, Loader2, Coins } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { classifyError } from "@/lib/ai-errors";
|
||||
|
||||
type PromptNodeData = {
|
||||
@@ -57,6 +57,7 @@ export default function PromptNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<PromptNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const nodeData = data as PromptNodeData;
|
||||
const router = useRouter();
|
||||
const { getEdges, getNode } = useReactFlow();
|
||||
@@ -166,13 +167,9 @@ export default function PromptNode({
|
||||
}
|
||||
|
||||
if (availableCredits !== null && !hasEnoughCredits) {
|
||||
const { title, desc } = msg.ai.insufficientCredits(
|
||||
creditCost,
|
||||
availableCredits,
|
||||
);
|
||||
toast.action(title, {
|
||||
description: desc,
|
||||
label: msg.billing.topUp,
|
||||
toast.action(t('ai.insufficientCreditsTitle'), {
|
||||
description: t('ai.insufficientCreditsDesc', { needed: creditCost, available: availableCredits }),
|
||||
label: t('billing.topUp'),
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
type: "warning",
|
||||
});
|
||||
@@ -256,30 +253,30 @@ export default function PromptNode({
|
||||
aspectRatio,
|
||||
}),
|
||||
{
|
||||
loading: msg.ai.generating.title,
|
||||
success: msg.ai.generationQueued.title,
|
||||
error: msg.ai.generationFailed.title,
|
||||
loading: t('ai.generating'),
|
||||
success: t('ai.generationQueued'),
|
||||
error: t('ai.generationFailed'),
|
||||
description: {
|
||||
success: msg.ai.generationQueuedDesc,
|
||||
error: msg.ai.creditsNotCharged,
|
||||
success: t('ai.generationQueuedDesc'),
|
||||
error: t('ai.creditsNotCharged'),
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const classified = classifyError(err);
|
||||
|
||||
if (classified.category === "daily_cap") {
|
||||
if (classified.type === "dailyCap") {
|
||||
toast.error(
|
||||
msg.billing.dailyLimitReached(0).title,
|
||||
t('billing.dailyLimitReachedTitle'),
|
||||
"Morgen stehen wieder Generierungen zur Verfügung.",
|
||||
);
|
||||
} else if (classified.category === "concurrency") {
|
||||
} else if (classified.type === "concurrency") {
|
||||
toast.warning(
|
||||
msg.ai.concurrentLimitReached.title,
|
||||
msg.ai.concurrentLimitReached.desc,
|
||||
t('ai.concurrentLimitReachedTitle'),
|
||||
t('ai.concurrentLimitReachedDesc'),
|
||||
);
|
||||
} else {
|
||||
setError(classified.message || msg.ai.generationFailed.title);
|
||||
setError(classified.rawMessage || t('ai.generationFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
|
||||
Reference in New Issue
Block a user