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:
2026-04-01 18:16:52 +02:00
parent 6ce1d4a82e
commit 79d9092d43
44 changed files with 1385 additions and 507 deletions

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import {
ChevronDown,
Coins,
@@ -29,12 +30,12 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { api } from "@/convex/_generated/api";
import type { Doc } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import { CreditOverview } from "@/components/dashboard/credit-overview";
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
import CanvasCard from "@/components/dashboard/canvas-card";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useAuthQuery } from "@/hooks/use-auth-query";
@@ -51,6 +52,7 @@ function getInitials(nameOrEmail: string) {
}
export default function DashboardPage() {
const t = useTranslations('toasts');
const router = useRouter();
const welcomeToastSentRef = useRef(false);
const { theme = "system", setTheme } = useTheme();
@@ -82,11 +84,11 @@ export default function DashboardPage() {
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
welcomeToastSentRef.current = true;
sessionStorage.setItem(key, "1");
toast.success(msg.auth.welcomeOnDashboard.title);
}, [session?.user]);
toast.success(t('auth.welcomeOnDashboard'));
}, [t, session?.user]);
const handleSignOut = async () => {
toast.info(msg.auth.signedOut.title);
toast.info(t('auth.signedOut'));
await authClient.signOut();
router.replace("/auth/sign-in");
router.refresh();
@@ -240,7 +242,7 @@ export default function DashboardPage() {
</div>
) : (
<div className="grid gap-3 sm:grid-cols-3">
{canvases.map((canvas) => (
{canvases.map((canvas: Doc<"canvases">) => (
<CanvasCard
key={canvas._id}
canvas={canvas}

View File

@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { Providers } from "@/components/providers";
import { InitUser } from "@/components/init-user";
import { getAuthUser, getToken } from "@/lib/auth-server";
import { getLocale, getMessages } from "next-intl/server";
const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" });
@@ -20,6 +21,8 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const initialToken = await getToken();
const locale = await getLocale();
const messages = await getMessages();
const user = await getAuthUser();
if (user) {
const id = user.userId ?? String(user._id);
@@ -33,7 +36,7 @@ export default async function RootLayout({
return (
<html
lang="de"
lang={locale}
suppressHydrationWarning
className={cn("h-full", "antialiased", "font-sans", manrope.variable)}
>
@@ -56,7 +59,7 @@ export default async function RootLayout({
></script>
</head>
<body className="min-h-full flex flex-col">
<Providers initialToken={initialToken}>
<Providers initialToken={initialToken} locale={locale} messages={messages}>
<InitUser />
{children}
</Providers>

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useTranslations } from "next-intl";
import { ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -9,7 +10,6 @@ import { api } from "@/convex/_generated/api";
import { authClient } from "@/lib/auth-client";
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> = {
free: "Free",
@@ -19,6 +19,7 @@ const TIER_LABELS: Record<keyof typeof TIER_MONTHLY_CREDITS, string> = {
};
export function ManageSubscription() {
const t = useTranslations('toasts');
const subscription = useAuthQuery(api.credits.getSubscription);
const tier = normalizeTier(subscription?.tier);
@@ -45,8 +46,8 @@ export function ManageSubscription() {
variant="outline"
onClick={() => {
toast.info(
msg.billing.openingPortal.title,
msg.billing.openingPortal.desc,
t('billing.openingPortalTitle'),
t('billing.openingPortalDesc'),
);
void authClient.customer.portal();
}}

View File

@@ -1,6 +1,7 @@
"use client";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useTranslations } from "next-intl";
import { Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -13,18 +14,18 @@ import {
TIER_MONTHLY_CREDITS,
} from "@/lib/polar-products";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const TIER_ORDER = ["free", "starter", "pro", "max"] as const;
export function PricingCards() {
const t = useTranslations('toasts');
const subscription = useAuthQuery(api.credits.getSubscription);
const currentTier = normalizeTier(subscription?.tier);
async function handleCheckout(polarProductId: string) {
toast.info(
msg.billing.redirectingToCheckout.title,
msg.billing.redirectingToCheckout.desc,
t('billing.redirectingToCheckoutTitle'),
t('billing.redirectingToCheckoutDesc'),
);
await authClient.checkout({ products: [polarProductId] });
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { CreditCard, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -9,18 +10,18 @@ import { authClient } from "@/lib/auth-client";
import { TOPUP_PRODUCTS } from "@/lib/polar-products";
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";
export function TopupPanel() {
const t = useTranslations('toasts');
const [customAmount, setCustomAmount] = useState(20);
const { credits, bonusRate } = calculateCustomTopup(customAmount);
async function handleTopup(polarProductId: string) {
toast.info(
msg.billing.redirectingToCheckout.title,
msg.billing.redirectingToCheckout.desc,
t('billing.redirectingToCheckoutTitle'),
t('billing.redirectingToCheckoutDesc'),
);
await authClient.checkout({ products: [polarProductId] });
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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]);
}

View File

@@ -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_";

View File

@@ -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"
>

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,
);
});

View File

@@ -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">

View File

@@ -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

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -2,9 +2,9 @@
import { useState, useCallback, useRef } from "react";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import { ArrowUpRight, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { Button } from "@/components/ui/button";
import {
@@ -34,6 +34,7 @@ interface CanvasCardProps {
}
export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
const t = useTranslations('toasts');
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(canvas.name);
const [isSaving, setIsSaving] = useState(false);
@@ -60,8 +61,7 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
const handleSave = useCallback(async () => {
const trimmedName = editName.trim();
if (!trimmedName) {
const { title, desc } = msg.dashboard.renameEmpty;
toast.error(title, desc);
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
return;
}
if (trimmedName === canvas.name) {
@@ -74,15 +74,15 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
setIsSaving(true);
try {
await updateCanvas({ canvasId: canvas._id, name: trimmedName });
toast.success(msg.dashboard.renameSuccess.title);
toast.success(t('dashboard.renameSuccess'));
setIsEditing(false);
} catch {
toast.error(msg.dashboard.renameFailed.title);
toast.error(t('dashboard.renameFailed'));
} finally {
setIsSaving(false);
saveInFlightRef.current = false;
}
}, [editName, canvas.name, canvas._id, updateCanvas]);
}, [t, editName, canvas.name, canvas._id, updateCanvas]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -115,14 +115,14 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
setDeleteBusy(true);
try {
await removeCanvas({ canvasId: canvas._id });
toast.success(msg.dashboard.deleteSuccess.title);
toast.success(t('dashboard.deleteSuccess'));
setDeleteOpen(false);
} catch {
toast.error(msg.dashboard.deleteFailed.title);
toast.error(t('dashboard.deleteFailed'));
} finally {
setDeleteBusy(false);
}
}, [canvas._id, removeCanvas]);
}, [t, canvas._id, removeCanvas]);
return (
<>

View File

@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useFormatter, useTranslations } from "next-intl";
import { CreditCard } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -10,10 +11,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { api } from "@/convex/_generated/api";
import { formatEurFromCents } 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)
@@ -42,7 +41,12 @@ const TIER_BADGE_STYLES: Record<string, string> = {
const LOW_CREDITS_THRESHOLD = 20;
export function CreditOverview() {
const t = useTranslations('toasts');
const router = useRouter();
const format = useFormatter();
const formatEurFromCents = (cents: number) =>
format.number(cents / 100, { style: "currency", currency: "EUR" });
const balance = useAuthQuery(api.credits.getBalance);
const subscription = useAuthQuery(api.credits.getSubscription);
const usageStats = useAuthQuery(api.credits.getUsageStats);
@@ -56,14 +60,13 @@ export function CreditOverview() {
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,
toast.action(t('billing.lowCreditsTitle'), {
description: t('billing.lowCreditsDesc', { remaining: available }),
label: t('billing.topUp'),
onClick: () => router.push("/settings/billing"),
type: "warning",
});
}, [balance, router]);
}, [t, balance, router]);
// ── Loading State ──────────────────────────────────────────────────────
if (

View File

@@ -1,12 +1,12 @@
"use client";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useFormatter } from "next-intl";
import { Activity, Coins } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { api } from "@/convex/_generated/api";
import { formatEurFromCents, cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/format-time";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Helpers
@@ -45,10 +45,28 @@ function truncatedDescription(text: string, maxLen = 40) {
// ---------------------------------------------------------------------------
export function RecentTransactions() {
const format = useFormatter();
const transactions = useAuthQuery(api.credits.getRecentTransactions, {
limit: 10,
});
const formatEurFromCents = (cents: number) =>
format.number(cents / 100, { style: "currency", currency: "EUR" });
const formatRelativeTime = (timestamp: number) => {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Gerade eben";
if (minutes < 60) return `vor ${minutes} Min.`;
if (hours < 24) return `vor ${hours} Std.`;
if (days < 7) return days === 1 ? "vor 1 Tag" : `vor ${days} Tagen`;
return format.dateTime(timestamp, { day: "numeric", month: "short" });
};
// ── Loading State ──────────────────────────────────────────────────────
if (transactions === undefined) {
return (
@@ -102,7 +120,7 @@ export function RecentTransactions() {
Letzte Aktivität
</div>
<div className="divide-y">
{transactions.map((t) => {
{transactions.map((t: NonNullable<typeof transactions>[number]) => {
const isCredit = t.amount > 0;
return (
<div

View File

@@ -5,8 +5,8 @@ import { useMutation } from "convex/react";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { api } from "@/convex/_generated/api";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
/**
* Initialisiert die Credit-Balance für neue User.
@@ -14,6 +14,7 @@ import { msg } from "@/lib/toast-messages";
* dass jeder eingeloggte User eine Balance + Free-Subscription hat.
*/
export function InitUser() {
const t = useTranslations('toasts');
const { data: session } = authClient.useSession();
const balance = useAuthQuery(api.credits.getBalance);
@@ -34,12 +35,12 @@ export function InitUser() {
void initBalance()
.then(() => {
toast.success(msg.auth.initialSetup.title, msg.auth.initialSetup.desc);
toast.success(t('auth.initialSetupTitle'), t('auth.initialSetupDesc'));
})
.catch(() => {
initStartedRef.current = false;
});
}, [session?.user, balance, initBalance]);
}, [t, session?.user, balance, initBalance]);
return null;
}

View File

@@ -0,0 +1,24 @@
'use client';
import { useLocale } from 'next-intl';
import { useRouter } from 'next/navigation';
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
function switchLocale() {
const next = locale === 'de' ? 'en' : 'de';
document.cookie = `NEXT_LOCALE=${next}; path=/; max-age=31536000; SameSite=Lax`;
router.refresh();
}
return (
<button
onClick={switchLocale}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
title={locale === 'de' ? 'Switch to English' : 'Auf Deutsch wechseln'}
>
{locale === 'de' ? 'EN' : 'DE'}
</button>
);
}

View File

@@ -6,6 +6,8 @@ import { ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { AuthUIProvider } from "@daveyplate/better-auth-ui";
import { ThemeProvider } from "next-themes";
import { NextIntlClientProvider } from "next-intl";
import type { AbstractIntlMessages } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { GooeyToaster } from "goey-toast";
@@ -35,14 +37,19 @@ function SentryAuthUserSync() {
export function Providers({
children,
initialToken,
locale,
messages,
}: {
children: ReactNode;
initialToken?: string | null;
locale?: string;
messages?: AbstractIntlMessages;
}) {
const router = useRouter();
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<NextIntlClientProvider locale={locale} messages={messages}>
<ConvexBetterAuthProvider
client={convex}
authClient={authClient}
@@ -66,6 +73,7 @@ export function Providers({
/>
</AuthUIProvider>
</ConvexBetterAuthProvider>
</NextIntlClientProvider>
</ThemeProvider>
);
}

View File

@@ -22,6 +22,7 @@ import type * as openrouter from "../openrouter.js";
import type * as pexels from "../pexels.js";
import type * as polar from "../polar.js";
import type * as storage from "../storage.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
@@ -44,6 +45,7 @@ declare const fullApi: ApiFromModules<{
pexels: typeof pexels;
polar: typeof polar;
storage: typeof storage;
users: typeof users;
}>;
/**

View File

@@ -1,4 +1,4 @@
import { v } from "convex/values";
import { v, ConvexError } from "convex/values";
import { action, internalAction, internalMutation } from "./_generated/server";
import { api, internal } from "./_generated/api";
import {
@@ -18,6 +18,19 @@ type ErrorCategory =
| "provider"
| "unknown";
interface ErrorData {
code?: string;
[key: string]: unknown;
}
function getErrorCode(error: unknown): string | undefined {
if (error instanceof ConvexError) {
const data = error.data as ErrorData;
return data?.code;
}
return undefined;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error ?? "Generation failed");
@@ -34,20 +47,23 @@ function categorizeError(error: unknown): {
category: ErrorCategory;
retryable: boolean;
} {
const code = getErrorCode(error);
const message = errorMessage(error);
const lower = message.toLowerCase();
const status = parseOpenRouterStatus(message);
if (
lower.includes("insufficient credits") ||
lower.includes("daily generation limit") ||
lower.includes("concurrent job limit")
code === "CREDITS_TEST_DISABLED" ||
code === "CREDITS_INVALID_AMOUNT" ||
code === "CREDITS_BALANCE_NOT_FOUND" ||
code === "CREDITS_DAILY_CAP_REACHED" ||
code === "CREDITS_CONCURRENCY_LIMIT"
) {
return { category: "credits", retryable: false };
}
if (
lower.includes("modell lehnt ab") ||
code === "OPENROUTER_MODEL_REFUSAL" ||
lower.includes("content policy") ||
lower.includes("policy") ||
lower.includes("moderation") ||
@@ -94,6 +110,11 @@ function categorizeError(error: unknown): {
}
function formatTerminalStatusMessage(error: unknown): string {
const code = getErrorCode(error);
if (code) {
return code;
}
const message = errorMessage(error).trim() || "Generation failed";
const { category } = categorizeError(error);

View File

@@ -1,5 +1,5 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { v, ConvexError } from "convex/values";
import { optionalAuth, requireAuth } from "./helpers";
import { internal } from "./_generated/api";
@@ -275,10 +275,10 @@ export const grantTestCredits = mutation({
},
handler: async (ctx, { amount = 2000 }) => {
if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") {
throw new Error("Test-Gutschriften sind deaktiviert (ALLOW_TEST_CREDIT_GRANT).");
throw new ConvexError({ code: "CREDITS_TEST_DISABLED" });
}
if (amount <= 0 || amount > 1_000_000) {
throw new Error("Ungültiger Betrag.");
throw new ConvexError({ code: "CREDITS_INVALID_AMOUNT" });
}
const user = await requireAuth(ctx);
const balance = await ctx.db
@@ -287,7 +287,7 @@ export const grantTestCredits = mutation({
.unique();
if (!balance) {
throw new Error("Keine Credit-Balance — zuerst einloggen / initBalance.");
throw new ConvexError({ code: "CREDITS_BALANCE_NOT_FOUND" });
}
const next = balance.balance + amount;
@@ -362,16 +362,18 @@ export const reserve = mutation({
.unique();
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
throw new Error(
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
);
throw new ConvexError({
code: "CREDITS_DAILY_CAP_REACHED",
data: { limit: config.dailyGenerationCap, tier },
});
}
// Concurrency Limit prüfen
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
throw new Error(
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
);
throw new ConvexError({
code: "CREDITS_CONCURRENCY_LIMIT",
data: { limit: config.concurrencyLimit },
});
}
// Credits reservieren
@@ -487,7 +489,14 @@ export const commit = mutation({
actualCost: v.number(),
openRouterCost: v.optional(v.number()),
},
handler: async (ctx, { transactionId, actualCost, openRouterCost }) => {
handler: async (
ctx,
{ transactionId, actualCost, openRouterCost }
): Promise<
{ status: "already_committed" } |
{ status: "already_released" } |
{ status: "committed" }
> => {
const user = await requireAuth(ctx);
const transaction = await ctx.db.get(transactionId);
if (!transaction || transaction.userId !== user.userId) {
@@ -571,7 +580,14 @@ export const release = mutation({
args: {
transactionId: v.id("creditTransactions"),
},
handler: async (ctx, { transactionId }) => {
handler: async (
ctx,
{ transactionId }
): Promise<
{ status: "already_released" } |
{ status: "already_committed" } |
{ status: "released" }
> => {
const user = await requireAuth(ctx);
const transaction = await ctx.db.get(transactionId);
if (!transaction || transaction.userId !== user.userId) {
@@ -761,16 +777,18 @@ export const checkAbuseLimits = internalMutation({
const dailyCount = usage?.generationCount ?? 0;
if (dailyCount >= config.dailyGenerationCap) {
throw new Error(
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
);
throw new ConvexError({
code: "CREDITS_DAILY_CAP_REACHED",
data: { limit: config.dailyGenerationCap, tier },
});
}
const currentConcurrency = usage?.concurrentJobs ?? 0;
if (currentConcurrency >= config.concurrencyLimit) {
throw new Error(
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
);
throw new ConvexError({
code: "CREDITS_CONCURRENCY_LIMIT",
data: { limit: config.concurrencyLimit },
});
}
},
});

View File

@@ -44,7 +44,7 @@ export const exportFrame = action({
});
// Find image/ai-image nodes visually within the frame
const imageNodes = allNodes.filter((node) => {
const imageNodes = allNodes.filter((node: (typeof allNodes)[number]) => {
if (node.type !== "image" && node.type !== "ai-image") return false;
const data = node.data as { storageId?: string };
if (!data.storageId) return false;

View File

@@ -1,3 +1,5 @@
import { ConvexError } from "convex/values";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
export interface OpenRouterModel {
@@ -144,7 +146,7 @@ export async function generateImageViaOpenRouter(
const message = data?.choices?.[0]?.message as Record<string, unknown> | undefined;
if (!message) {
throw new Error("OpenRouter: choices[0].message fehlt");
throw new ConvexError({ code: "OPENROUTER_MISSING_MESSAGE" });
}
let rawImage: string | undefined;
@@ -186,7 +188,10 @@ export async function generateImageViaOpenRouter(
) {
const r =
typeof refusal === "string" ? refusal : JSON.stringify(refusal);
throw new Error(`OpenRouter: Modell lehnt ab — ${r.slice(0, 500)}`);
throw new ConvexError({
code: "OPENROUTER_MODEL_REFUSAL",
data: { reason: r.slice(0, 500) },
});
}
if (
@@ -205,19 +210,23 @@ export async function generateImageViaOpenRouter(
: Array.isArray(content)
? JSON.stringify(content).slice(0, 400)
: "";
throw new Error(
`OpenRouter: kein Bild in der Antwort. Keys: ${Object.keys(message).join(", ")}. ` +
(reasoning ? `reasoning: ${reasoning}` : `content: ${contentPreview}`),
);
throw new ConvexError({
code: "OPENROUTER_NO_IMAGE_IN_RESPONSE",
data: {
keys: Object.keys(message).join(", "),
reasoningOrContent: reasoning || contentPreview,
},
});
}
let dataUri = rawImage;
if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) {
const imgRes = await fetch(rawImage);
if (!imgRes.ok) {
throw new Error(
`OpenRouter: Bild-URL konnte nicht geladen werden (${imgRes.status})`,
);
throw new ConvexError({
code: "OPENROUTER_IMAGE_URL_LOAD_FAILED",
data: { status: imgRes.status },
});
}
const mimeTypeFromRes =
imgRes.headers.get("content-type") ?? "image/png";
@@ -237,12 +246,12 @@ export async function generateImageViaOpenRouter(
}
if (!dataUri.startsWith("data:")) {
throw new Error("OpenRouter: Bild konnte nicht als data-URI erstellt werden");
throw new ConvexError({ code: "OPENROUTER_DATA_URI_CREATION_FAILED" });
}
const comma = dataUri.indexOf(",");
if (comma === -1) {
throw new Error("OpenRouter: data-URI ohne Base64-Teil");
throw new ConvexError({ code: "OPENROUTER_DATA_URI_MISSING_BASE64" });
}
const meta = dataUri.slice(0, comma);
const base64Data = dataUri.slice(comma + 1);

View File

@@ -310,4 +310,16 @@ export default defineSchema({
concurrentJobs: v.number(), // Aktuell laufende Jobs
})
.index("by_user_date", ["userId", "date"]),
// ==========================================================================
// User Settings
// ==========================================================================
userSettings: defineTable({
userId: v.string(), // Better Auth User ID
locale: v.optional(v.union(v.literal('de'), v.literal('en'))),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_user", ["userId"]),
});

47
convex/users.ts Normal file
View File

@@ -0,0 +1,47 @@
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireAuth } from "./helpers";
export const setLocale = mutation({
args: {
locale: v.union(v.literal("de"), v.literal("en")),
},
handler: async (ctx, args) => {
const { userId } = await requireAuth(ctx);
const existing = await ctx.db
.query("userSettings")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
const now = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
locale: args.locale,
updatedAt: now,
});
} else {
await ctx.db.insert("userSettings", {
userId,
locale: args.locale,
createdAt: now,
updatedAt: now,
});
}
},
});
export const getLocale = mutation({
args: {},
handler: async (ctx) => {
const { userId } = await requireAuth(ctx);
const existing = await ctx.db
.query("userSettings")
.withIndex("by_user", (q) => q.eq("userId", userId))
.unique();
return existing?.locale ?? null;
},
});

18
i18n/request.ts Normal file
View File

@@ -0,0 +1,18 @@
import { getRequestConfig } from 'next-intl/server';
import { routing } from '../routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
const locales = routing.locales;
type Locale = (typeof locales)[number];
if (!locale || !locales.includes(locale as Locale)) {
locale = routing.defaultLocale;
}
return {
locale,
timeZone: 'Europe/Berlin',
messages: (await import(`../messages/${locale}.json`)).default,
};
});

View File

@@ -1,23 +1,22 @@
export type AiErrorCategory =
| "insufficient_credits"
| "rate_limited"
| "content_policy"
export type ErrorType =
| "timeout"
| "network"
| "server"
| "invalid_request"
| "daily_cap"
| "concurrency"
| "unknown";
| "insufficientCredits"
| "networkError"
| "rateLimited"
| "modelUnavailable"
| "generic"
| "contentPolicy"
| "invalidRequest"
| "dailyCap"
| "concurrency";
export interface AiError {
category: AiErrorCategory;
message: string;
detail?: string;
type: ErrorType;
retryable: boolean;
creditsNotCharged: boolean;
showTopUp: boolean;
retryCount?: number;
rawMessage?: string;
}
type RawErrorObject = {
@@ -27,45 +26,50 @@ type RawErrorObject = {
retryCount?: unknown;
};
const CATEGORY_ALIASES: Record<string, AiErrorCategory> = {
insufficient_credits: "insufficient_credits",
insufficientcredits: "insufficient_credits",
not_enough_credits: "insufficient_credits",
notenoughcredits: "insufficient_credits",
credits: "insufficient_credits",
payment_required: "insufficient_credits",
paymentrequired: "insufficient_credits",
rate_limit: "rate_limited",
ratelimit: "rate_limited",
rate_limited: "rate_limited",
ratelimited: "rate_limited",
too_many_requests: "rate_limited",
toomanyrequests: "rate_limited",
content_policy: "content_policy",
contentpolicy: "content_policy",
safety: "content_policy",
const TYPE_ALIASES: Record<string, ErrorType> = {
insufficient_credits: "insufficientCredits",
insufficientcredits: "insufficientCredits",
not_enough_credits: "insufficientCredits",
notenoughcredits: "insufficientCredits",
credits: "insufficientCredits",
payment_required: "insufficientCredits",
paymentrequired: "insufficientCredits",
rate_limit: "rateLimited",
ratelimit: "rateLimited",
rate_limited: "rateLimited",
ratelimited: "rateLimited",
too_many_requests: "rateLimited",
toomanyrequests: "rateLimited",
content_policy: "contentPolicy",
contentpolicy: "contentPolicy",
safety: "contentPolicy",
timeout: "timeout",
timed_out: "timeout",
timedout: "timeout",
network: "network",
connection: "network",
server: "server",
invalid_request: "invalid_request",
invalidrequest: "invalid_request",
bad_request: "invalid_request",
badrequest: "invalid_request",
daily_cap: "daily_cap",
dailycap: "daily_cap",
daily_limit: "daily_cap",
dailylimit: "daily_cap",
network: "networkError",
connection: "networkError",
networkerror: "networkError",
server: "modelUnavailable",
model_unavailable: "modelUnavailable",
modelunavailable: "modelUnavailable",
invalid_request: "invalidRequest",
invalidrequest: "invalidRequest",
bad_request: "invalidRequest",
badrequest: "invalidRequest",
unknown_model: "invalidRequest",
daily_cap: "dailyCap",
dailycap: "dailyCap",
daily_limit: "dailyCap",
dailylimit: "dailyCap",
concurrency: "concurrency",
concurrent: "concurrency",
unknown: "generic",
};
function normalizeCategory(value: string | undefined): AiErrorCategory | undefined {
function normalizeType(value: string | undefined): ErrorType | undefined {
if (!value) return undefined;
const normalized = value.toLowerCase().replace(/[^a-z]/g, "");
return CATEGORY_ALIASES[normalized];
return TYPE_ALIASES[normalized];
}
function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): number | undefined {
@@ -86,15 +90,15 @@ function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): numb
return undefined;
}
function cleanPrefixMessage(text: string): { category?: AiErrorCategory; message: string } {
function cleanPrefixMessage(text: string): { type?: ErrorType; message: string } {
const trimmed = text.trim();
const bracketPrefix = trimmed.match(/^\[([a-zA-Z_\- ]+)\]\s*[:\-]?\s*(.+)$/);
if (bracketPrefix?.[1] && bracketPrefix[2]) {
const category = normalizeCategory(bracketPrefix[1]);
if (category) {
const type = normalizeType(bracketPrefix[1]);
if (type) {
return {
category,
type,
message: bracketPrefix[2].trim(),
};
}
@@ -102,10 +106,10 @@ function cleanPrefixMessage(text: string): { category?: AiErrorCategory; message
const plainPrefix = trimmed.match(/^([a-zA-Z_\- ]{3,40})\s*[:|\-]\s*(.+)$/);
if (plainPrefix?.[1] && plainPrefix[2]) {
const category = normalizeCategory(plainPrefix[1]);
if (category) {
const type = normalizeType(plainPrefix[1]);
if (type) {
return {
category,
type,
message: plainPrefix[2].trim(),
};
}
@@ -129,17 +133,17 @@ function splitMessageAndDetail(message: string): { message: string; detail?: str
return { message };
}
function inferCategoryFromText(text: string): AiErrorCategory {
function inferTypeFromText(text: string): ErrorType {
const lower = text.toLowerCase();
const openRouterStatus = lower.match(/openrouter api error\s*(\d{3})/i);
if (openRouterStatus?.[1]) {
const status = Number.parseInt(openRouterStatus[1], 10);
if (status === 402) return "insufficient_credits";
if (status === 402) return "insufficientCredits";
if (status === 408 || status === 504) return "timeout";
if (status === 429) return "rate_limited";
if (status >= 500) return "server";
if (status >= 400) return "invalid_request";
if (status === 429) return "rateLimited";
if (status >= 500) return "modelUnavailable";
if (status >= 400) return "invalidRequest";
}
if (
@@ -149,7 +153,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("guthaben") ||
lower.includes("nicht genug credits")
) {
return "insufficient_credits";
return "insufficientCredits";
}
if (
@@ -158,7 +162,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("ratelimit") ||
lower.includes("429")
) {
return "rate_limited";
return "rateLimited";
}
if (
@@ -166,7 +170,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("tageslimit erreicht") ||
lower.includes("daily generation limit")
) {
return "daily_cap";
return "dailyCap";
}
if (
@@ -191,7 +195,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("fetch failed") ||
lower.includes("econn")
) {
return "network";
return "networkError";
}
if (
@@ -200,7 +204,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("refusal") ||
lower.includes("modell lehnt ab")
) {
return "content_policy";
return "contentPolicy";
}
if (
@@ -209,85 +213,75 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("unknown model") ||
lower.includes("missing")
) {
return "invalid_request";
return "invalidRequest";
}
if (lower.includes("server") || lower.includes("5xx")) {
return "server";
return "modelUnavailable";
}
return "unknown";
return "generic";
}
function defaultsForCategory(category: AiErrorCategory): Omit<AiError, "category" | "detail" | "retryCount"> {
switch (category) {
case "insufficient_credits":
function defaultsForType(type: ErrorType): Omit<AiError, "type" | "retryCount" | "rawMessage"> {
switch (type) {
case "insufficientCredits":
return {
message: "Not enough credits for this generation",
retryable: false,
creditsNotCharged: true,
showTopUp: true,
};
case "rate_limited":
case "rateLimited":
return {
message: "The model is busy right now",
retryable: true,
creditsNotCharged: true,
showTopUp: false,
};
case "content_policy":
case "contentPolicy":
return {
message: "The request was blocked by model safety rules",
retryable: false,
creditsNotCharged: true,
showTopUp: false,
};
case "timeout":
return {
message: "The generation timed out",
retryable: true,
creditsNotCharged: true,
showTopUp: false,
};
case "network":
case "networkError":
return {
message: "Network issue while contacting the model",
retryable: true,
creditsNotCharged: true,
showTopUp: false,
};
case "server":
case "modelUnavailable":
return {
message: "The AI service returned a server error",
retryable: true,
creditsNotCharged: true,
showTopUp: false,
};
case "invalid_request":
case "invalidRequest":
return {
message: "The request could not be processed",
retryable: false,
creditsNotCharged: true,
showTopUp: false,
};
case "daily_cap":
case "dailyCap":
return {
message: "Tageslimit erreicht",
retryable: false,
creditsNotCharged: true,
showTopUp: false,
};
case "concurrency":
return {
message: "Generierung bereits aktiv",
retryable: true,
creditsNotCharged: true,
showTopUp: false,
};
case "unknown":
case "generic":
default:
return {
message: "Generation failed",
retryable: true,
creditsNotCharged: true,
showTopUp: false,
@@ -313,22 +307,20 @@ export function classifyError(rawError: unknown): AiError {
const rawDetail = typeof rawObj?.detail === "string" ? rawObj.detail.trim() : undefined;
const prefixed = cleanPrefixMessage(rawMessage);
const explicitCategory =
normalizeCategory(typeof rawObj?.category === "string" ? rawObj.category : undefined) ??
prefixed.category;
const category = explicitCategory ?? inferCategoryFromText(prefixed.message);
const explicitType =
normalizeType(typeof rawObj?.category === "string" ? rawObj.category : undefined) ??
prefixed.type;
const type = explicitType ?? inferTypeFromText(prefixed.message);
const defaults = defaultsForCategory(category);
const defaults = defaultsForType(type);
const split = splitMessageAndDetail(prefixed.message);
const message = split.message || defaults.message;
return {
category,
message,
detail: split.detail ?? rawDetail,
type,
retryable: defaults.retryable,
creditsNotCharged: defaults.creditsNotCharged,
showTopUp: defaults.showTopUp,
retryCount: extractRetryCount(rawMessage, rawObj),
rawMessage: split.message || rawMessage || undefined,
};
}

View File

@@ -1,20 +0,0 @@
/**
* Formatiert einen Timestamp als relative Zeitangabe.
* Beispiele: "Gerade eben", "vor 5 Min.", "vor 3 Std.", "vor 2 Tagen", "12. Mär"
*/
export function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Gerade eben";
if (minutes < 60) return `vor ${minutes} Min.`;
if (hours < 24) return `vor ${hours} Std.`;
if (days < 7) return days === 1 ? "vor 1 Tag" : `vor ${days} Tagen`;
return new Date(timestamp).toLocaleDateString("de-DE", {
day: "numeric",
month: "short",
});
}

View File

@@ -1,194 +1,207 @@
// Zentrales Dictionary für alle Toast-Strings.
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
'use client';
/** Grund, warum ein Node-Löschen noch blockiert ist. */
export type CanvasNodeDeleteBlockReason = "optimistic";
import { useTranslations } from 'next-intl';
import { toast, type ToastDurationOverrides } from './toast';
import type { CanvasNodeDeleteBlockReason } from './toast';
const DURATION = {
success: 4000,
successShort: 2000,
error: 6000,
warning: 5000,
info: 4000,
} as const;
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
function canvasNodeDeleteWhy(
t: ToastTranslations,
reasons: Set<CanvasNodeDeleteBlockReason>,
): { title: string; desc: string } {
if (reasons.size === 0) {
return {
title: "Löschen momentan nicht möglich",
desc: "Bitte kurz warten und erneut versuchen.",
title: t('canvas.nodeDeleteBlockedTitle'),
desc: t('canvas.nodeDeleteBlockedDesc'),
};
}
if (reasons.size === 1) {
const only = [...reasons][0]!;
if (only === "optimistic") {
if (only === 'optimistic') {
return {
title: "Element wird noch angelegt",
desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.",
title: t('canvas.nodeDeleteOptimisticTitle'),
desc: t('canvas.nodeDeleteOptimisticDesc'),
};
}
return {
title: "Löschen momentan nicht möglich",
desc: "Bitte kurz warten und erneut versuchen.",
title: t('canvas.nodeDeleteBlockedTitle'),
desc: t('canvas.nodeDeleteBlockedDesc'),
};
}
return {
title: "Löschen momentan nicht möglich",
desc: "Mindestens ein Element wird noch angelegt. Bitte kurz warten und erneut versuchen.",
title: t('canvas.nodeDeleteBlockedTitle'),
desc: t('canvas.nodeDeleteBlockedMultiDesc'),
};
}
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.`,
imageUploaded: (t: ToastTranslations) => ({
title: t('canvas.imageUploaded'),
}),
uploadSizeError: (maxMb: number) => ({
title: "Upload fehlgeschlagen",
desc: `Maximale Dateigröße: ${maxMb} MB.`,
uploadFailed: (t: ToastTranslations) => ({
title: t('canvas.uploadFailed'),
}),
nodeRemoved: { title: "Element entfernt" },
nodesRemoved: (count: number) => ({
title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`,
uploadFormatError: (t: ToastTranslations, format: string) => ({
title: t('canvas.uploadFailed'),
desc: t('canvas.uploadFormatError', { format }),
}),
/** Warum gerade kein (vollständiges) Löschen möglich ist — aus den gesammelten Gründen der blockierten Nodes. */
nodeDeleteBlockedExplain: canvasNodeDeleteWhy,
nodeDeleteBlockedPartial: (
blockedCount: number,
reasons: Set<CanvasNodeDeleteBlockReason>,
) => {
const why = canvasNodeDeleteWhy(reasons);
uploadSizeError: (t: ToastTranslations, maxMb: number) => ({
title: t('canvas.uploadFailed'),
desc: t('canvas.uploadSizeError', { maxMb }),
}),
nodeRemoved: (t: ToastTranslations) => ({
title: t('canvas.nodeRemoved'),
}),
nodesRemoved: (t: ToastTranslations, count: number) => ({
title: t('canvas.nodesRemoved', { count }),
}),
nodeDeleteBlockedExplain: (t: ToastTranslations, reasons: Set<CanvasNodeDeleteBlockReason>) => canvasNodeDeleteWhy(t, reasons),
nodeDeleteBlockedPartial: (t: ToastTranslations, blockedCount: number, reasons: Set<CanvasNodeDeleteBlockReason>) => {
const why = canvasNodeDeleteWhy(t, reasons);
const suffix =
blockedCount === 1
? "Ein Element wurde deshalb nicht gelöscht; die übrige Auswahl wurde entfernt."
: `${blockedCount} Elemente wurden deshalb nicht gelöscht; die übrige Auswahl wurde entfernt.`;
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blockedCount });
return {
title: "Nicht alle Elemente entfernt",
title: t('canvas.nodeDeleteBlockedPartialTitle'),
desc: `${why.desc} ${suffix}`,
};
},
},
ai: {
generating: { title: "Bild wird generiert…" },
generated: { title: "Bild generiert" },
generatedDesc: (credits: number) => `${credits} Credits verbraucht`,
generationQueued: { title: "Generierung gestartet" },
generationQueuedDesc: "Das Bild erscheint automatisch, sobald es fertig ist.",
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.`,
generating: (t: ToastTranslations) => ({ title: t('ai.generating') }),
generated: (t: ToastTranslations, credits: number) => ({
title: t('ai.generated'),
desc: t('ai.generatedDesc', { credits }),
}),
generatedDesc: (t: ToastTranslations, credits: number) => t('ai.generatedDesc', { credits }),
generationQueued: (t: ToastTranslations) => ({ title: t('ai.generationQueued') }),
generationQueuedDesc: (t: ToastTranslations) => t('ai.generationQueuedDesc'),
generationFailed: (t: ToastTranslations) => ({ title: t('ai.generationFailed') }),
creditsNotCharged: (t: ToastTranslations) => t('ai.creditsNotCharged'),
insufficientCredits: (t: ToastTranslations, needed: number, available: number) => ({
title: t('ai.insufficientCreditsTitle'),
desc: t('ai.insufficientCreditsDesc', { needed, available }),
}),
modelUnavailable: (t: ToastTranslations) => ({
title: t('ai.modelUnavailableTitle'),
desc: t('ai.modelUnavailableDesc'),
}),
contentPolicy: (t: ToastTranslations) => ({
title: t('ai.contentPolicyTitle'),
desc: t('ai.contentPolicyDesc'),
}),
timeout: (t: ToastTranslations) => ({
title: t('ai.timeoutTitle'),
desc: t('ai.timeoutDesc'),
}),
openrouterIssues: (t: ToastTranslations) => ({
title: t('ai.openrouterIssuesTitle'),
desc: t('ai.openrouterIssuesDesc'),
}),
concurrentLimitReached: (t: ToastTranslations) => ({
title: t('ai.concurrentLimitReachedTitle'),
desc: t('ai.concurrentLimitReachedDesc'),
}),
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.",
},
concurrentLimitReached: {
title: "Generierung bereits aktiv",
desc: "Bitte warte, bis die laufende Generierung abgeschlossen ist.",
},
},
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.",
frameExported: (t: ToastTranslations) => ({ title: t('export.frameExported') }),
exportingFrames: (t: ToastTranslations) => ({ title: t('export.exportingFrames') }),
zipReady: (t: ToastTranslations) => ({ title: t('export.zipReady') }),
exportFailed: (t: ToastTranslations) => ({ title: t('export.exportFailed') }),
frameEmpty: (t: ToastTranslations) => ({
title: t('export.frameEmptyTitle'),
desc: t('export.frameEmptyDesc'),
}),
noFramesOnCanvas: (t: ToastTranslations) => ({
title: t('export.noFramesOnCanvasTitle'),
desc: t('export.noFramesOnCanvasDesc'),
}),
download: (t: ToastTranslations) => t('export.download'),
downloaded: (t: ToastTranslations) => t('export.downloaded'),
},
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.`,
welcomeBack: (t: ToastTranslations) => ({ title: t('auth.welcomeBack') }),
welcomeOnDashboard: (t: ToastTranslations) => ({ title: t('auth.welcomeOnDashboard') }),
checkEmail: (t: ToastTranslations, email: string) => ({
title: t('auth.checkEmailTitle'),
desc: t('auth.checkEmailDesc', { email }),
}),
sessionExpired: (t: ToastTranslations) => ({
title: t('auth.sessionExpiredTitle'),
desc: t('auth.sessionExpiredDesc'),
}),
signedOut: (t: ToastTranslations) => ({ title: t('auth.signedOut') }),
signIn: (t: ToastTranslations) => t('auth.signIn'),
initialSetup: (t: ToastTranslations) => ({
title: t('auth.initialSetupTitle'),
desc: t('auth.initialSetupDesc'),
}),
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.`,
subscriptionActivated: (t: ToastTranslations, credits: number) => ({
title: t('billing.subscriptionActivatedTitle'),
desc: t('billing.subscriptionActivatedDesc', { credits }),
}),
creditsAdded: (credits: number) => ({
title: "Credits hinzugefügt",
desc: `+${credits} Credits`,
creditsAdded: (t: ToastTranslations, credits: number) => ({
title: t('billing.creditsAddedTitle'),
desc: t('billing.creditsAddedDesc', { credits }),
}),
subscriptionCancelled: (periodEnd: string) => ({
title: "Abo gekündigt",
desc: `Deine Credits bleiben bis ${periodEnd} verfügbar.`,
subscriptionCancelled: (t: ToastTranslations, periodEnd: string) => ({
title: t('billing.subscriptionCancelledTitle'),
desc: t('billing.subscriptionCancelledDesc', { periodEnd }),
}),
paymentFailed: {
title: "Zahlung fehlgeschlagen",
desc: "Bitte Zahlungsmethode aktualisieren.",
paymentFailed: (t: ToastTranslations) => ({
title: t('billing.paymentFailedTitle'),
desc: t('billing.paymentFailedDesc'),
}),
dailyLimitReached: (t: ToastTranslations, limit: number) => ({
title: t('billing.dailyLimitReachedTitle'),
desc: t('billing.dailyLimitReachedDesc', { limit }),
}),
lowCredits: (t: ToastTranslations, remaining: number) => ({
title: t('billing.lowCreditsTitle'),
desc: t('billing.lowCreditsDesc', { remaining }),
}),
topUp: (t: ToastTranslations) => t('billing.topUp'),
upgrade: (t: ToastTranslations) => t('billing.upgrade'),
manage: (t: ToastTranslations) => t('billing.manage'),
redirectingToCheckout: (t: ToastTranslations) => ({
title: t('billing.redirectingToCheckoutTitle'),
desc: t('billing.redirectingToCheckoutDesc'),
}),
openingPortal: (t: ToastTranslations) => ({
title: t('billing.openingPortalTitle'),
desc: t('billing.openingPortalDesc'),
}),
testGrantFailed: (t: ToastTranslations) => ({ title: t('billing.testGrantFailedTitle') }),
},
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.",
reconnected: (t: ToastTranslations) => ({ title: t('system.reconnected') }),
connectionLost: (t: ToastTranslations) => ({
title: t('system.connectionLostTitle'),
desc: t('system.connectionLostDesc'),
}),
copiedToClipboard: (t: ToastTranslations) => ({ title: t('system.copiedToClipboard') }),
},
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" },
deleteSuccess: { title: "Arbeitsbereich gelöscht" },
deleteFailed: { title: "Löschen fehlgeschlagen" },
renameEmpty: (t: ToastTranslations) => ({
title: t('dashboard.renameEmptyTitle'),
desc: t('dashboard.renameEmptyDesc'),
}),
renameSuccess: (t: ToastTranslations) => ({ title: t('dashboard.renameSuccess') }),
renameFailed: (t: ToastTranslations) => ({ title: t('dashboard.renameFailed') }),
deleteSuccess: (t: ToastTranslations) => ({ title: t('dashboard.deleteSuccess') }),
deleteFailed: (t: ToastTranslations) => ({ title: t('dashboard.deleteFailed') }),
},
} as const;

View File

@@ -1,4 +1,7 @@
import { gooeyToast, type GooeyPromiseData } from "goey-toast";
'use client';
import { useTranslations } from 'next-intl';
import { gooeyToast, type GooeyPromiseData } from 'goey-toast';
const DURATION = {
success: 4000,
@@ -8,70 +11,48 @@ const DURATION = {
info: 4000,
} as const;
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
export type ToastDurationOverrides = {
duration?: number;
};
export const toast = {
success(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
success(message: string, description?: string, opts?: ToastDurationOverrides) {
return gooeyToast.success(message, {
description,
duration: opts?.duration ?? DURATION.success,
});
},
error(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
error(message: string, description?: string, opts?: ToastDurationOverrides) {
return gooeyToast.error(message, {
description,
duration: opts?.duration ?? DURATION.error,
});
},
warning(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
warning(message: string, description?: string, opts?: ToastDurationOverrides) {
return gooeyToast.warning(message, {
description,
duration: opts?.duration ?? DURATION.warning,
});
},
info(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
info(message: string, description?: string, opts?: ToastDurationOverrides) {
return gooeyToast.info(message, {
description,
duration: opts?.duration ?? DURATION.info,
});
},
promise<T>(promise: Promise<T>, data: GooeyPromiseData<T>) {
return gooeyToast.promise(promise, data);
},
action(
message: string,
opts: {
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,
@@ -83,18 +64,13 @@ export const toast = {
},
});
},
update(
id: string | number,
opts: {
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);
},
@@ -107,3 +83,277 @@ export const toastDuration = {
warning: DURATION.warning,
info: DURATION.info,
} as const;
export type CanvasNodeDeleteBlockReason = 'optimistic';
export function showImageUploadedToast(t: ToastTranslations) {
toast.success(t('canvas.imageUploaded'));
}
export function showUploadFailedToast(t: ToastTranslations, reason?: string) {
if (reason) {
toast.error(t('canvas.uploadFailed'), reason);
} else {
toast.error(t('canvas.uploadFailed'));
}
}
export function showUploadFormatError(t: ToastTranslations, format: string) {
toast.error(t('canvas.uploadFailed'), t('canvas.uploadFormatError', { format }));
}
export function showUploadSizeError(t: ToastTranslations, maxMb: number) {
toast.error(t('canvas.uploadFailed'), t('canvas.uploadSizeError', { maxMb }));
}
export function showNodeRemovedToast(t: ToastTranslations) {
toast.success(t('canvas.nodeRemoved'));
}
export function showNodesRemovedToast(t: ToastTranslations, count: number) {
const title = t('canvas.nodesRemoved', { count });
toast.success(title);
}
export function canvasNodeDeleteWhy(
t: ToastTranslations,
reasons: Set<CanvasNodeDeleteBlockReason>,
) {
if (reasons.size === 0) {
return {
title: t('canvas.nodeDeleteBlockedTitle'),
desc: t('canvas.nodeDeleteBlockedDesc'),
};
}
if (reasons.size === 1) {
const only = [...reasons][0]!;
if (only === 'optimistic') {
return {
title: t('canvas.nodeDeleteOptimisticTitle'),
desc: t('canvas.nodeDeleteOptimisticDesc'),
};
}
return {
title: t('canvas.nodeDeleteBlockedTitle'),
desc: t('canvas.nodeDeleteBlockedDesc'),
};
}
return {
title: t('canvas.nodeDeleteBlockedTitle'),
desc: t('canvas.nodeDeleteBlockedMultiDesc'),
};
}
export function canvasNodeDeleteBlockedPartial(
t: ToastTranslations,
blockedCount: number,
reasons: Set<CanvasNodeDeleteBlockReason>,
) {
const why = canvasNodeDeleteWhy(t, reasons);
const suffix =
blockedCount === 1
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blockedCount });
return {
title: t('canvas.nodeDeleteBlockedPartialTitle'),
desc: `${why.desc} ${suffix}`,
};
}
export function showGeneratingToast(t: ToastTranslations) {
gooeyToast.info(t('ai.generating'), { duration: Infinity });
}
export function showGeneratedToast(
t: ToastTranslations,
credits: number,
) {
toast.success(t('ai.generated'), t('ai.generatedDesc', { credits }));
}
export function showGenerationQueuedToast(t: ToastTranslations) {
toast.success(t('ai.generationQueued'), t('ai.generationQueuedDesc'));
}
export function showGenerationFailedToast(t: ToastTranslations) {
toast.error(t('ai.generationFailed'));
}
export function showCreditsNotChargedToast(t: ToastTranslations) {
toast.warning(t('ai.creditsNotCharged'));
}
export function showInsufficientCreditsToast(
t: ToastTranslations,
needed: number,
available: number,
) {
toast.error(t('ai.insufficientCreditsTitle'), t('ai.insufficientCreditsDesc', { needed, available }));
}
export function showModelUnavailableToast(t: ToastTranslations) {
toast.error(t('ai.modelUnavailableTitle'), t('ai.modelUnavailableDesc'));
}
export function showContentPolicyBlockedToast(t: ToastTranslations) {
toast.error(t('ai.contentPolicyTitle'), t('ai.contentPolicyDesc'));
}
export function showTimeoutToast(t: ToastTranslations) {
toast.error(t('ai.timeoutTitle'), t('ai.timeoutDesc'));
}
export function showOpenrouterIssuesToast(t: ToastTranslations) {
toast.error(t('ai.openrouterIssuesTitle'), t('ai.openrouterIssuesDesc'));
}
export function showConcurrentLimitReachedToast(t: ToastTranslations) {
toast.error(t('ai.concurrentLimitReachedTitle'), t('ai.concurrentLimitReachedDesc'));
}
export function showFrameExportedToast(t: ToastTranslations) {
toast.success(t('export.frameExported'));
}
export function showExportingFramesToast(t: ToastTranslations) {
gooeyToast.info(t('export.exportingFrames'), { duration: Infinity });
}
export function showZipReadyToast(t: ToastTranslations) {
toast.success(t('export.zipReady'));
}
export function showExportFailedToast(t: ToastTranslations) {
toast.error(t('export.exportFailed'));
}
export function showFrameEmptyToast(t: ToastTranslations) {
toast.error(t('export.frameEmptyTitle'), t('export.frameEmptyDesc'));
}
export function showNoFramesOnCanvasToast(t: ToastTranslations) {
toast.error(t('export.noFramesOnCanvasTitle'), t('export.noFramesOnCanvasDesc'));
}
export function showDownloadToast(t: ToastTranslations) {
toast.success(t('export.downloaded'), t('export.download'));
}
export function showWelcomeBackToast(t: ToastTranslations) {
toast.success(t('auth.welcomeBack'));
}
export function showWelcomeOnDashboardToast(t: ToastTranslations) {
toast.success(t('auth.welcomeOnDashboard'));
}
export function showCheckEmailToast(t: ToastTranslations, email: string) {
toast.success(t('auth.checkEmailTitle'), t('auth.checkEmailDesc', { email }));
}
export function showSessionExpiredToast(t: ToastTranslations) {
toast.error(t('auth.sessionExpiredTitle'), t('auth.sessionExpiredDesc'));
}
export function showSignedOutToast(t: ToastTranslations) {
toast.success(t('auth.signedOut'));
}
export function showSignInToast(t: ToastTranslations) {
toast.success(t('auth.signIn'));
}
export function showInitialSetupToast(t: ToastTranslations) {
toast.success(t('auth.initialSetupTitle'), t('auth.initialSetupDesc'));
}
export function showSubscriptionActivatedToast(
t: ToastTranslations,
credits: number,
) {
toast.success(t('billing.subscriptionActivatedTitle'), t('billing.subscriptionActivatedDesc', { credits }));
}
export function showCreditsAddedToast(t: ToastTranslations, credits: number) {
toast.success(t('billing.creditsAddedTitle'), t('billing.creditsAddedDesc', { credits }));
}
export function showSubscriptionCancelledToast(
t: ToastTranslations,
periodEnd: string,
) {
gooeyToast.info(t('billing.subscriptionCancelledTitle'), { description: t('billing.subscriptionCancelledDesc', { periodEnd }) });
}
export function showPaymentFailedToast(t: ToastTranslations) {
toast.error(t('billing.paymentFailedTitle'), t('billing.paymentFailedDesc'));
}
export function showDailyLimitReachedToast(t: ToastTranslations, limit: number) {
toast.error(t('billing.dailyLimitReachedTitle'), t('billing.dailyLimitReachedDesc', { limit }));
}
export function showLowCreditsToast(t: ToastTranslations, remaining: number) {
toast.warning(t('billing.lowCreditsTitle'), t('billing.lowCreditsDesc', { remaining }));
}
export function showTopUpToast(t: ToastTranslations) {
toast.success(t('billing.topUp'));
}
export function showUpgradeToast(t: ToastTranslations) {
toast.success(t('billing.upgrade'));
}
export function showManageToast(t: ToastTranslations) {
toast.success(t('billing.manage'));
}
export function showRedirectingToCheckoutToast(t: ToastTranslations) {
gooeyToast.info(t('billing.redirectingToCheckoutTitle'), { description: t('billing.redirectingToCheckoutDesc') });
}
export function showOpeningPortalToast(t: ToastTranslations) {
gooeyToast.info(t('billing.openingPortalTitle'), { description: t('billing.openingPortalDesc') });
}
export function showTestGrantFailedToast(t: ToastTranslations) {
toast.error(t('billing.testGrantFailedTitle'));
}
export function showReconnectedToast(t: ToastTranslations) {
toast.success(t('system.reconnected'));
}
export function showConnectionLostToast(t: ToastTranslations) {
toast.error(t('system.connectionLostTitle'), t('system.connectionLostDesc'));
}
export function showCopiedToClipboardToast(t: ToastTranslations) {
toast.success(t('system.copiedToClipboard'));
}
export function showRenameEmptyToast(t: ToastTranslations) {
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
}
export function showRenameSuccessToast(t: ToastTranslations) {
toast.success(t('dashboard.renameSuccess'));
}
export function showRenameFailedToast(t: ToastTranslations) {
toast.error(t('dashboard.renameFailed'));
}
export function showDeleteSuccessToast(t: ToastTranslations) {
toast.success(t('dashboard.deleteSuccess'));
}
export function showDeleteFailedToast(t: ToastTranslations) {
toast.error(t('dashboard.deleteFailed'));
}
export function getToastTranslations() {
const t = useTranslations('toasts');
return t as ToastTranslations;
}

View File

@@ -4,11 +4,3 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Credits / Preise: Werte sind Euro-Cent (siehe PRD, Manifest). */
export function formatEurFromCents(cents: number) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(cents / 100)
}

220
messages/de.json Normal file
View File

@@ -0,0 +1,220 @@
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"close": "Schließen",
"confirm": "Bestätigen",
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"back": "Zurück",
"next": "Weiter",
"upgrade": "Upgrade"
},
"auth": {
"signIn": "Anmelden",
"signOut": "Abmelden",
"magicLink": "Magischer Link",
"magicLinkSent": "Magischer Link gesendet",
"emailLabel": "E-Mail-Adresse",
"emailPlaceholder": "ihre@email.de"
},
"dashboard": {
"title": "Dashboard",
"newCanvas": "Neuer Canvas",
"recentlyOpened": "Zuletzt geöffnet",
"templates": "Vorlagen",
"noCanvases": "Keine Canvases gefunden",
"lastEdited": "Zuletzt bearbeitet",
"nodeCount": "{count, plural, one {# Node} other {# Nodes}}"
},
"canvas": {
"autosaved": "Automatisch gespeichert",
"share": "Teilen",
"credits": "Kreditpunkte",
"zoomFit": "Auf Fenster skalieren",
"addNode": "Node hinzufügen",
"sidebar": {
"properties": "Eigenschaften",
"layers": "Ebene",
"filters": "Filter",
"actions": "Aktionen"
}
},
"nodes": {
"image": "Bild",
"aiImage": "KI-Bild",
"curves": "Kurven",
"render": "Rendern",
"compare": "Vergleichen",
"compareAction": "Vergleichen",
"compareClose": "Schließen",
"compareCurrent": "Aktuell",
"compareOriginal": "Original",
"compareProcessed": "Verarbeitet",
"custom": "Benutzerdefiniert",
"customize": "Anpassen",
"prompts": {
"prompt1": "Prompt 1",
"prompt2": "Prompt 2",
"negative": "Negativ",
"style": "Stil",
"aspectRatio": "Formatverhältnis",
"imageSize": "Bildgröße",
"quality": "Qualität",
"seed": "Seed"
},
"status": {
"pending": "Ausstehend",
"processing": "Verarbeitung läuft",
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen"
}
},
"credits": {
"balance": "Guthaben",
"available": "Verfügbar",
"reserved": "Reserviert",
"topUp": "Aufladen",
"upgrade": "Upgrade",
"renewsOn": "Aktualisiert am",
"insufficientCredits": "Nicht genügend Guthaben",
"transactionHistory": "Transaktionshistorie"
},
"pricing": {
"free": "Kostenlos",
"starter": "Starter",
"pro": "Pro",
"max": "Max",
"perMonth": "/Monat",
"creditsPerMonth": "Kreditpunkte pro Monat",
"currentPlan": "Aktueller Plan",
"choosePlan": "Plan auswählen"
},
"errors": {
"generic": "Etwas ist schiefgelaufen — bitte versuche es erneut.",
"networkError": "Verbindungsfehler — prüfe deine Internetverbindung.",
"timeout": "Timeout — Credits wurden nicht abgebucht.",
"insufficientCredits": "Nicht genug Credits für diese Operation.",
"rateLimited": "Zu viele Anfragen — bitte kurz warten.",
"unauthorized": "Nicht angemeldet — bitte melde dich an.",
"modelUnavailable": "Modell aktuell nicht verfügbar — bitte ein anderes wählen.",
"contentPolicy": "Anfrage durch Inhaltsrichtlinie blockiert.",
"invalidRequest": "Ungültige Anfrage — bitte Eingaben prüfen.",
"dailyCap": "Tageslimit erreicht — morgen neu starten.",
"concurrency": "Generierung bereits aktiv — bitte warten.",
"nodeError": "Node-Fehler",
"uploadFailed": "Upload fehlgeschlagen",
"creditsTestDisabled": "Test-Gutschriften sind deaktiviert.",
"creditsInvalidAmount": "Ungültiger Betrag.",
"creditsBalanceNotFound": "Keine Credit-Balance gefunden.",
"creditsDailyCapReached": "Tageslimit erreicht — morgen neu starten.",
"creditsConcurrencyLimit": "Generierung bereits aktiv — bitte warten.",
"openrouterMissingMessage": "OpenRouter: Antwort ungültig.",
"openrouterModelRefusal": "Modell lehnt ab — {reason}.",
"openrouterNoImageInResponse": "OpenRouter: Kein Bild in der Antwort.",
"openrouterImageUrlLoadFailed": "OpenRouter: Bild-URL konnte nicht geladen werden.",
"openrouterDataUriCreationFailed": "OpenRouter: Bild konnte nicht verarbeitet werden.",
"openrouterDataUriMissingBase64": "OpenRouter: Bild konnte nicht verarbeitet werden."
},
"toasts": {
"canvas": {
"imageUploaded": "Bild hochgeladen",
"uploadFailed": "Upload fehlgeschlagen",
"uploadFormatError": "Format „{format}“ wird nicht unterstützt. Erlaubt: PNG, JPG, WebP.",
"uploadSizeError": "Maximale Dateigröße: {maxMb} MB.",
"nodeRemoved": "Element entfernt",
"nodesRemoved": "{count, plural, one {Element entfernt} other {# Elemente entfernt}}",
"nodeDeleteBlockedTitle": "Löschen momentan nicht möglich",
"nodeDeleteBlockedDesc": "Bitte kurz warten und erneut versuchen.",
"nodeDeleteOptimisticTitle": "Element wird noch angelegt",
"nodeDeleteOptimisticDesc": "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.",
"nodeDeleteBlockedMultiDesc": "Mindestens ein Element wird noch angelegt. Bitte kurz warten und erneut versuchen.",
"nodeDeleteBlockedPartialTitle": "Nicht alle Elemente entfernt",
"nodeDeleteBlockedPartialSuffixOne": "Ein Element wurde deshalb nicht gelöscht; die übrige Auswahl wurde entfernt.",
"nodeDeleteBlockedPartialSuffixOther": "{count} Elemente wurden deshalb nicht gelöscht; die übrige Auswahl wurde entfernt."
},
"ai": {
"generating": "Bild wird generiert…",
"generated": "Bild generiert",
"generatedDesc": "{credits} Credits verbraucht",
"generationQueued": "Generierung gestartet",
"generationQueuedDesc": "Das Bild erscheint automatisch, sobald es fertig ist.",
"generationFailed": "Generierung fehlgeschlagen",
"creditsNotCharged": "Credits wurden nicht abgebucht",
"insufficientCreditsTitle": "Nicht genügend Credits",
"insufficientCreditsDesc": "{needed} Credits benötigt, {available} verfügbar.",
"modelUnavailableTitle": "Modell vorübergehend nicht verfügbar",
"modelUnavailableDesc": "Versuche ein anderes Modell oder probiere es später erneut.",
"contentPolicyTitle": "Anfrage durch Inhaltsrichtlinie blockiert",
"contentPolicyDesc": "Versuche, den Prompt umzuformulieren.",
"timeoutTitle": "Generierung abgelaufen",
"timeoutDesc": "Credits wurden nicht abgebucht.",
"openrouterIssuesTitle": "OpenRouter möglicherweise gestört",
"openrouterIssuesDesc": "Mehrere Generierungen fehlgeschlagen.",
"concurrentLimitReachedTitle": "Generierung bereits aktiv",
"concurrentLimitReachedDesc": "Bitte warte, bis die laufende Generierung abgeschlossen ist."
},
"export": {
"frameExported": "Frame exportiert",
"exportingFrames": "Frames werden exportiert…",
"zipReady": "ZIP bereit",
"exportFailed": "Export fehlgeschlagen",
"frameEmptyTitle": "Export fehlgeschlagen",
"frameEmptyDesc": "Frame hat keinen sichtbaren Inhalt.",
"noFramesOnCanvasTitle": "Export fehlgeschlagen",
"noFramesOnCanvasDesc": "Keine Frames auf dem Canvas — zuerst einen Frame anlegen.",
"download": "Herunterladen",
"downloaded": "Heruntergeladen!"
},
"auth": {
"welcomeBack": "Willkommen zurück",
"welcomeOnDashboard": "Schön, dass du da bist",
"checkEmailTitle": "E-Mail prüfen",
"checkEmailDesc": "Bestätigungslink an {email} gesendet.",
"sessionExpiredTitle": "Sitzung abgelaufen",
"sessionExpiredDesc": "Bitte erneut anmelden.",
"signedOut": "Abgemeldet",
"signIn": "Anmelden",
"initialSetupTitle": "Startguthaben aktiv",
"initialSetupDesc": "Du kannst loslegen."
},
"billing": {
"subscriptionActivatedTitle": "Abo aktiviert",
"subscriptionActivatedDesc": "{credits} Credits deinem Guthaben hinzugefügt.",
"creditsAddedTitle": "Credits hinzugefügt",
"creditsAddedDesc": "+{credits} Credits",
"subscriptionCancelledTitle": "Abo gekündigt",
"subscriptionCancelledDesc": "Deine Credits bleiben bis {periodEnd} verfügbar.",
"paymentFailedTitle": "Zahlung fehlgeschlagen",
"paymentFailedDesc": "Bitte Zahlungsmethode aktualisieren.",
"dailyLimitReachedTitle": "Tageslimit erreicht",
"dailyLimitReachedDesc": "Maximal {limit} Generierungen pro Tag in deinem Tarif.",
"lowCreditsTitle": "Credits fast aufgebraucht",
"lowCreditsDesc": "Noch {remaining} Credits übrig.",
"topUp": "Aufladen",
"upgrade": "Upgrade",
"manage": "Verwalten",
"redirectingToCheckoutTitle": "Weiterleitung…",
"redirectingToCheckoutDesc": "Du wirst zum sicheren Checkout weitergeleitet.",
"openingPortalTitle": "Portal wird geöffnet…",
"openingPortalDesc": "Du wirst zur Aboverwaltung weitergeleitet.",
"testGrantFailedTitle": "Gutschrift fehlgeschlagen"
},
"system": {
"reconnected": "Verbindung wiederhergestellt",
"connectionLostTitle": "Verbindung verloren",
"connectionLostDesc": "Änderungen werden möglicherweise nicht gespeichert.",
"copiedToClipboard": "In Zwischenablage kopiert"
},
"dashboard": {
"renameEmptyTitle": "Name ungültig",
"renameEmptyDesc": "Name darf nicht leer sein.",
"renameSuccess": "Arbeitsbereich umbenannt",
"renameFailed": "Umbenennen fehlgeschlagen",
"deleteSuccess": "Arbeitsbereich gelöscht",
"deleteFailed": "Löschen fehlgeschlagen"
}
}
}

220
messages/en.json Normal file
View File

@@ -0,0 +1,220 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"close": "Close",
"confirm": "Confirm",
"loading": "Loading…",
"error": "Error",
"success": "Success",
"back": "Back",
"next": "Next",
"upgrade": "Upgrade"
},
"auth": {
"signIn": "Sign in",
"signOut": "Sign out",
"magicLink": "Magic link",
"magicLinkSent": "Magic link sent",
"emailLabel": "Email address",
"emailPlaceholder": "you@email.com"
},
"dashboard": {
"title": "Dashboard",
"newCanvas": "New canvas",
"recentlyOpened": "Recently opened",
"templates": "Templates",
"noCanvases": "No canvases found",
"lastEdited": "Last edited",
"nodeCount": "{count, plural, one {# Node} other {# Nodes}}"
},
"canvas": {
"autosaved": "Autosaved",
"share": "Share",
"credits": "Credits",
"zoomFit": "Fit to window",
"addNode": "Add node",
"sidebar": {
"properties": "Properties",
"layers": "Layer",
"filters": "Filters",
"actions": "Actions"
}
},
"nodes": {
"image": "Image",
"aiImage": "AI image",
"curves": "Curves",
"render": "Render",
"compare": "Compare",
"compareAction": "Compare",
"compareClose": "Close",
"compareCurrent": "Current",
"compareOriginal": "Original",
"compareProcessed": "Processed",
"custom": "Custom",
"customize": "Customize",
"prompts": {
"prompt1": "Prompt 1",
"prompt2": "Prompt 2",
"negative": "Negative",
"style": "Style",
"aspectRatio": "Aspect ratio",
"imageSize": "Image size",
"quality": "Quality",
"seed": "Seed"
},
"status": {
"pending": "Pending",
"processing": "Processing",
"completed": "Completed",
"failed": "Failed"
}
},
"credits": {
"balance": "Balance",
"available": "Available",
"reserved": "Reserved",
"topUp": "Top up",
"upgrade": "Upgrade",
"renewsOn": "Renews on",
"insufficientCredits": "Insufficient credits",
"transactionHistory": "Transaction history"
},
"pricing": {
"free": "Free",
"starter": "Starter",
"pro": "Pro",
"max": "Max",
"perMonth": "/month",
"creditsPerMonth": "Credits per month",
"currentPlan": "Current plan",
"choosePlan": "Choose plan"
},
"errors": {
"generic": "Something went wrong — please try again.",
"networkError": "Connection error — check your internet connection.",
"timeout": "Timeout — credits were not charged.",
"insufficientCredits": "Not enough credits for this operation.",
"rateLimited": "Too many requests — please wait a moment.",
"unauthorized": "Not signed in — please sign in.",
"modelUnavailable": "Model currently unavailable — please choose another.",
"contentPolicy": "Request blocked by content policy.",
"invalidRequest": "Invalid request — please check your input.",
"dailyCap": "Daily limit reached — try again tomorrow.",
"concurrency": "Generation already active — please wait.",
"nodeError": "Node error",
"uploadFailed": "Upload failed",
"creditsTestDisabled": "Test credits are disabled.",
"creditsInvalidAmount": "Invalid amount.",
"creditsBalanceNotFound": "No credit balance found.",
"creditsDailyCapReached": "Daily limit reached — try again tomorrow.",
"creditsConcurrencyLimit": "Generation already active — please wait.",
"openrouterMissingMessage": "OpenRouter: Invalid response.",
"openrouterModelRefusal": "Model refused — {reason}.",
"openrouterNoImageInResponse": "OpenRouter: No image in response.",
"openrouterImageUrlLoadFailed": "OpenRouter: Could not load image URL.",
"openrouterDataUriCreationFailed": "OpenRouter: Could not process image.",
"openrouterDataUriMissingBase64": "OpenRouter: Could not process image."
},
"toasts": {
"canvas": {
"imageUploaded": "Image uploaded",
"uploadFailed": "Upload failed",
"uploadFormatError": "Format \"{format}\" is not supported. Allowed: PNG, JPG, WebP.",
"uploadSizeError": "Maximum file size: {maxMb} MB.",
"nodeRemoved": "Item removed",
"nodesRemoved": "{count, plural, one {Item removed} other {# Items removed}}",
"nodeDeleteBlockedTitle": "Cannot delete right now",
"nodeDeleteBlockedDesc": "Please wait a moment and try again.",
"nodeDeleteOptimisticTitle": "Item is still being created",
"nodeDeleteOptimisticDesc": "This item hasn't finished syncing to the server yet. Once synchronization is complete, you can delete it.",
"nodeDeleteBlockedMultiDesc": "At least one item is still being created. Please wait and try again.",
"nodeDeleteBlockedPartialTitle": "Not all items removed",
"nodeDeleteBlockedPartialSuffixOne": "One item couldn't be deleted; the rest of the selection was removed.",
"nodeDeleteBlockedPartialSuffixOther": "{count} items couldn't be deleted; the rest of the selection was removed."
},
"ai": {
"generating": "Generating image…",
"generated": "Image generated",
"generatedDesc": "{credits} credits used",
"generationQueued": "Generation started",
"generationQueuedDesc": "The image will appear automatically when it's ready.",
"generationFailed": "Generation failed",
"creditsNotCharged": "Credits were not charged",
"insufficientCreditsTitle": "Insufficient credits",
"insufficientCreditsDesc": "{needed} credits needed, {available} available.",
"modelUnavailableTitle": "Model temporarily unavailable",
"modelUnavailableDesc": "Try a different model or try again later.",
"contentPolicyTitle": "Request blocked by content policy",
"contentPolicyDesc": "Try rephrasing your prompt.",
"timeoutTitle": "Generation timed out",
"timeoutDesc": "Credits were not charged.",
"openrouterIssuesTitle": "OpenRouter may be experiencing issues",
"openrouterIssuesDesc": "Several generations have failed.",
"concurrentLimitReachedTitle": "Generation already active",
"concurrentLimitReachedDesc": "Please wait until the current generation completes."
},
"export": {
"frameExported": "Frame exported",
"exportingFrames": "Exporting frames…",
"zipReady": "ZIP ready",
"exportFailed": "Export failed",
"frameEmptyTitle": "Export failed",
"frameEmptyDesc": "Frame has no visible content.",
"noFramesOnCanvasTitle": "Export failed",
"noFramesOnCanvasDesc": "No frames on canvas — create one first.",
"download": "Download",
"downloaded": "Downloaded!"
},
"auth": {
"welcomeBack": "Welcome back",
"welcomeOnDashboard": "Great to have you here",
"checkEmailTitle": "Check your email",
"checkEmailDesc": "Confirmation link sent to {email}.",
"sessionExpiredTitle": "Session expired",
"sessionExpiredDesc": "Please sign in again.",
"signedOut": "Signed out",
"signIn": "Sign in",
"initialSetupTitle": "Starting credits activated",
"initialSetupDesc": "You're ready to go."
},
"billing": {
"subscriptionActivatedTitle": "Subscription activated",
"subscriptionActivatedDesc": "{credits} credits added to your balance.",
"creditsAddedTitle": "Credits added",
"creditsAddedDesc": "+{credits} credits",
"subscriptionCancelledTitle": "Subscription cancelled",
"subscriptionCancelledDesc": "Your credits remain available until {periodEnd}.",
"paymentFailedTitle": "Payment failed",
"paymentFailedDesc": "Please update your payment method.",
"dailyLimitReachedTitle": "Daily limit reached",
"dailyLimitReachedDesc": "Maximum {limit} generations per day in your plan.",
"lowCreditsTitle": "Credits running low",
"lowCreditsDesc": "{remaining} credits remaining.",
"topUp": "Top up",
"upgrade": "Upgrade",
"manage": "Manage",
"redirectingToCheckoutTitle": "Redirecting…",
"redirectingToCheckoutDesc": "You're being redirected to secure checkout.",
"openingPortalTitle": "Opening portal…",
"openingPortalDesc": "You're being redirected to subscription management.",
"testGrantFailedTitle": "Credit grant failed"
},
"system": {
"reconnected": "Connection restored",
"connectionLostTitle": "Connection lost",
"connectionLostDesc": "Changes may not be saved.",
"copiedToClipboard": "Copied to clipboard"
},
"dashboard": {
"renameEmptyTitle": "Invalid name",
"renameEmptyDesc": "Name cannot be empty.",
"renameSuccess": "Workspace renamed",
"renameFailed": "Rename failed",
"deleteSuccess": "Workspace deleted",
"deleteFailed": "Delete failed"
}
}
}

View File

@@ -1,6 +1,9 @@
import { withSentryConfig } from "@sentry/nextjs";
import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
turbopack: {
root: __dirname,
@@ -26,7 +29,7 @@ const nextConfig: NextConfig = {
},
};
export default withSentryConfig(nextConfig, {
export default withSentryConfig(withNextIntl(nextConfig), {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options

7
routing.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['de', 'en'],
defaultLocale: 'de',
localePrefix: 'never',
});

1
src/i18n/index.ts Normal file
View File

@@ -0,0 +1 @@
export { routing } from '../../routing';