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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import { import {
Monitor, Monitor,
Moon, Moon,
@@ -36,7 +37,6 @@ import { Input } from "@/components/ui/input";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useAuthQuery } from "@/hooks/use-auth-query"; import { useAuthQuery } from "@/hooks/use-auth-query";
type CanvasAppMenuProps = { type CanvasAppMenuProps = {
@@ -44,6 +44,7 @@ type CanvasAppMenuProps = {
}; };
export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) { export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
const t = useTranslations('toasts');
const router = useRouter(); const router = useRouter();
const canvas = useAuthQuery(api.canvases.get, { canvasId }); const canvas = useAuthQuery(api.canvases.get, { canvasId });
const removeCanvas = useMutation(api.canvases.remove); const removeCanvas = useMutation(api.canvases.remove);
@@ -65,8 +66,7 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
const handleRename = async () => { const handleRename = async () => {
const trimmed = renameValue.trim(); const trimmed = renameValue.trim();
if (!trimmed) { if (!trimmed) {
const { title, desc } = msg.dashboard.renameEmpty; toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
toast.error(title, desc);
return; return;
} }
if (trimmed === canvas?.name) { if (trimmed === canvas?.name) {
@@ -76,10 +76,10 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
setRenameSaving(true); setRenameSaving(true);
try { try {
await renameCanvas({ canvasId, name: trimmed }); await renameCanvas({ canvasId, name: trimmed });
toast.success(msg.dashboard.renameSuccess.title); toast.success(t('dashboard.renameSuccess'));
setRenameOpen(false); setRenameOpen(false);
} catch { } catch {
toast.error(msg.dashboard.renameFailed.title); toast.error(t('dashboard.renameFailed'));
} finally { } finally {
setRenameSaving(false); setRenameSaving(false);
} }

View File

@@ -6,15 +6,19 @@ import {
type Node as RFNode, type Node as RFNode,
type OnBeforeDelete, type OnBeforeDelete,
} from "@xyflow/react"; } from "@xyflow/react";
import { useTranslations } from "next-intl";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils"; import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages"; import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
import { getNodeDeleteBlockReason } from "./canvas-helpers"; import { getNodeDeleteBlockReason } from "./canvas-helpers";
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
type UseCanvasDeleteHandlersParams = { type UseCanvasDeleteHandlersParams = {
t: ToastTranslations;
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
nodes: RFNode[]; nodes: RFNode[];
edges: RFEdge[]; edges: RFEdge[];
@@ -32,6 +36,7 @@ type UseCanvasDeleteHandlersParams = {
}; };
export function useCanvasDeleteHandlers({ export function useCanvasDeleteHandlers({
t,
canvasId, canvasId,
nodes, nodes,
edges, edges,
@@ -71,16 +76,20 @@ export function useCanvasDeleteHandlers({
} }
if (allowed.length === 0) { 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); toast.warning(title, desc);
return false; return false;
} }
if (blocked.length > 0) { if (blocked.length > 0) {
const { title, desc } = msg.canvas.nodeDeleteBlockedPartial( const title = t('canvas.nodeDeleteBlockedPartialTitle');
blocked.length, const whyDesc = t('canvas.nodeDeleteBlockedDesc');
blockedReasons, const suffix =
); blocked.length === 1
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blocked.length });
const desc = `${whyDesc} ${suffix}`;
toast.warning(title, desc); toast.warning(title, desc);
return { return {
nodes: allowed, nodes: allowed,
@@ -140,10 +149,11 @@ export function useCanvasDeleteHandlers({
} }
}); });
const { title } = msg.canvas.nodesRemoved(count); const title = t('canvas.nodesRemoved', { count });
toast.info(title); toast.info(title);
}, },
[ [
t,
canvasId, canvasId,
deletingNodeIds, deletingNodeIds,
edges, edges,

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import type { Doc } from "@/convex/_generated/dataModel"; import type { Doc } from "@/convex/_generated/dataModel";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { import {
GENERATION_FAILURE_THRESHOLD, GENERATION_FAILURE_THRESHOLD,
@@ -10,6 +10,7 @@ import {
} from "./canvas-helpers"; } from "./canvas-helpers";
export function useGenerationFailureWarnings( export function useGenerationFailureWarnings(
t: ReturnType<typeof useTranslations<'toasts'>>,
convexNodes: Doc<"nodes">[] | undefined, convexNodes: Doc<"nodes">[] | undefined,
): void { ): void {
const recentGenerationFailureTimestampsRef = useRef<number[]>([]); const recentGenerationFailureTimestampsRef = useRef<number[]>([]);
@@ -60,11 +61,11 @@ export function useGenerationFailureWarnings(
} }
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) { 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 = []; recentGenerationFailureTimestampsRef.current = [];
return; return;
} }
recentGenerationFailureTimestampsRef.current = recentFailures; 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 { readCanvasOps } from "@/lib/canvas-local-persistence";
import type { Doc, Id } from "@/convex/_generated/dataModel"; 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_NODE_PREFIX = "optimistic_";
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";

View File

@@ -38,12 +38,11 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
return ( return (
<div className="h-screen w-screen overflow-hidden overscroll-none"> <div className="h-screen w-screen overflow-hidden overscroll-none">
<ResizablePanelGroup <ResizablePanelGroup
direction="horizontal" orientation="horizontal"
className="h-full w-full min-h-0 min-w-0 overflow-hidden" className="h-full w-full min-h-0 min-w-0 overflow-hidden"
> >
<ResizablePanel <ResizablePanel
id="canvas-sidebar-panel" id="canvas-sidebar-panel"
order={1}
defaultSize={SIDEBAR_DEFAULT_SIZE} defaultSize={SIDEBAR_DEFAULT_SIZE}
minSize={SIDEBAR_COLLAPSE_THRESHOLD} minSize={SIDEBAR_COLLAPSE_THRESHOLD}
maxSize={SIDEBAR_MAX_SIZE} maxSize={SIDEBAR_MAX_SIZE}
@@ -62,7 +61,6 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
<ResizablePanel <ResizablePanel
id="canvas-main-panel" id="canvas-main-panel"
order={2}
minSize={MAIN_PANEL_MIN_SIZE} minSize={MAIN_PANEL_MIN_SIZE}
className="min-h-0 min-w-0" className="min-h-0 min-w-0"
> >

View File

@@ -2,13 +2,13 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { LayoutDashboard, LogOut } from "lucide-react"; import { LayoutDashboard, LogOut } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
function getInitials(nameOrEmail: string) { function getInitials(nameOrEmail: string) {
const normalized = nameOrEmail.trim(); const normalized = nameOrEmail.trim();
@@ -25,6 +25,7 @@ type CanvasUserMenuProps = {
}; };
export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) { export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
const t = useTranslations('toasts');
const router = useRouter(); const router = useRouter();
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
@@ -32,7 +33,7 @@ export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
const initials = getInitials(displayName); const initials = getInitials(displayName);
const handleSignOut = async () => { const handleSignOut = async () => {
toast.info(msg.auth.signedOut.title); toast.info(t('auth.signedOut'));
await authClient.signOut(); await authClient.signOut();
router.replace("/auth/sign-in"); router.replace("/auth/sign-in");
router.refresh(); router.refresh();

View File

@@ -10,6 +10,7 @@ import {
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
} from "react"; } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
@@ -30,7 +31,6 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { import {
dropCanvasOpsByClientRequestIds, dropCanvasOpsByClientRequestIds,
dropCanvasOpsByEdgeIds, dropCanvasOpsByEdgeIds,
@@ -153,6 +153,7 @@ function isLikelyTransientSyncError(error: unknown): boolean {
} }
function CanvasInner({ canvasId }: CanvasInnerProps) { function CanvasInner({ canvasId }: CanvasInnerProps) {
const t = useTranslations('toasts');
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const { data: session, isPending: isSessionPending } = authClient.useSession(); const { data: session, isPending: isSessionPending } = authClient.useSession();
@@ -1583,7 +1584,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>( const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
undefined, undefined,
); );
useGenerationFailureWarnings(convexNodes); useGenerationFailureWarnings(t, convexNodes);
const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({ const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({
scissorsMode, scissorsMode,
@@ -1596,6 +1597,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}); });
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({ const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
t,
canvasId, canvasId,
nodes, nodes,
edges, edges,
@@ -2456,7 +2458,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}); });
} catch (err) { } catch (err) {
console.error("Failed to upload dropped file:", 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; return;
} }

View File

@@ -2,16 +2,17 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useConvexConnectionState } from "convex/react"; import { useConvexConnectionState } from "convex/react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast, toastDuration } from "@/lib/toast"; import { toast, toastDuration } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected"; type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
const RECONNECTED_HIDE_DELAY_MS = 1800; const RECONNECTED_HIDE_DELAY_MS = 1800;
export default function ConnectionBanner() { export default function ConnectionBanner() {
const t = useTranslations('toasts');
const connectionState = useConvexConnectionState(); const connectionState = useConvexConnectionState();
const previousConnectedRef = useRef(connectionState.isWebSocketConnected); const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
const disconnectToastIdRef = useRef<string | number | undefined>(undefined); const disconnectToastIdRef = useRef<string | number | undefined>(undefined);
@@ -77,8 +78,8 @@ export default function ConnectionBanner() {
if (shouldAlertDisconnect) { if (shouldAlertDisconnect) {
if (disconnectToastIdRef.current === undefined) { if (disconnectToastIdRef.current === undefined) {
disconnectToastIdRef.current = toast.error( disconnectToastIdRef.current = toast.error(
msg.system.connectionLost.title, t('system.connectionLostTitle'),
msg.system.connectionLost.desc, t('system.connectionLostDesc'),
{ duration: Number.POSITIVE_INFINITY }, { duration: Number.POSITIVE_INFINITY },
); );
} }
@@ -88,11 +89,12 @@ export default function ConnectionBanner() {
if (connected && disconnectToastIdRef.current !== undefined) { if (connected && disconnectToastIdRef.current !== undefined) {
toast.dismiss(disconnectToastIdRef.current); toast.dismiss(disconnectToastIdRef.current);
disconnectToastIdRef.current = undefined; disconnectToastIdRef.current = undefined;
toast.success(msg.system.reconnected.title, undefined, { toast.success(t('system.reconnected'), undefined, {
duration: toastDuration.successShort, duration: toastDuration.successShort,
}); });
} }
}, [ }, [
t,
connectionState.connectionRetries, connectionState.connectionRetries,
connectionState.hasEverConnected, connectionState.hasEverConnected,
connectionState.isWebSocketConnected, connectionState.isWebSocketConnected,

View File

@@ -2,10 +2,10 @@
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { useAuthQuery } from "@/hooks/use-auth-query"; import { useAuthQuery } from "@/hooks/use-auth-query";
import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { Coins } from "lucide-react"; import { Coins } from "lucide-react";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
const TIER_LABELS: Record<string, string> = { const TIER_LABELS: Record<string, string> = {
free: "Free", free: "Free",
@@ -28,6 +28,7 @@ const showTestCreditGrant =
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true"; process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
export function CreditDisplay() { export function CreditDisplay() {
const t = useTranslations('toasts');
const balance = useAuthQuery(api.credits.getBalance); const balance = useAuthQuery(api.credits.getBalance);
const subscription = useAuthQuery(api.credits.getSubscription); const subscription = useAuthQuery(api.credits.getSubscription);
const grantTestCredits = useMutation(api.credits.grantTestCredits); const grantTestCredits = useMutation(api.credits.grantTestCredits);
@@ -92,15 +93,14 @@ export function CreditDisplay() {
onClick={() => { onClick={() => {
void grantTestCredits({ amount: 2000 }) void grantTestCredits({ amount: 2000 })
.then((r) => { .then((r) => {
const { title, desc } = msg.billing.creditsAdded(2000);
toast.success( toast.success(
title, t('billing.creditsAddedTitle'),
`${desc} — Stand: ${r.newBalance.toLocaleString("de-DE")}`, `${t('billing.creditsAddedDesc', { credits: 2000 })} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
); );
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
toast.error( toast.error(
msg.billing.testGrantFailed.title, t('billing.testGrantFailedTitle'),
e instanceof Error ? e.message : undefined, e instanceof Error ? e.message : undefined,
); );
}); });

View File

@@ -3,18 +3,19 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useReactFlow } from "@xyflow/react"; import { useReactFlow } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { useTranslations } from "next-intl";
import JSZip from "jszip"; import JSZip from "jszip";
import { Archive, Loader2 } from "lucide-react"; import { Archive, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
interface ExportButtonProps { interface ExportButtonProps {
canvasName?: string; canvasName?: string;
} }
export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) { export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
const t = useTranslations('toasts');
const { getNodes } = useReactFlow(); const { getNodes } = useReactFlow();
const exportFrame = useAction(api.export.exportFrame); const exportFrame = useAction(api.export.exportFrame);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@@ -72,19 +73,19 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
try { try {
await toast.promise(runExport(), { await toast.promise(runExport(), {
loading: msg.export.exportingFrames.title, loading: t('export.exportingFrames'),
success: msg.export.zipReady.title, success: t('export.zipReady'),
error: (err) => { error: (err) => {
const m = err instanceof Error ? err.message : ""; const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.title; if (m === NO_FRAMES) return t('export.noFramesOnCanvasTitle');
if (m.includes("No images found")) return msg.export.frameEmpty.title; if (m.includes("No images found")) return t('export.frameEmptyTitle');
return msg.export.exportFailed.title; return t('export.exportFailed');
}, },
description: { description: {
error: (err) => { error: (err) => {
const m = err instanceof Error ? err.message : ""; const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.desc; if (m === NO_FRAMES) return t('export.noFramesOnCanvasDesc');
if (m.includes("No images found")) return msg.export.frameEmpty.desc; if (m.includes("No images found")) return t('export.frameEmptyDesc');
return m || undefined; return m || undefined;
}, },
}, },
@@ -92,17 +93,17 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
} catch (err) { } catch (err) {
const m = err instanceof Error ? err.message : ""; const m = err instanceof Error ? err.message : "";
if (m === NO_FRAMES) { if (m === NO_FRAMES) {
setError(msg.export.noFramesOnCanvas.desc); setError(t('export.noFramesOnCanvasDesc'));
} else if (m.includes("No images found")) { } else if (m.includes("No images found")) {
setError(msg.export.frameEmpty.desc); setError(t('export.frameEmptyDesc'));
} else { } else {
setError(m || msg.export.exportFailed.title); setError(m || t('export.exportFailed'));
} }
} finally { } finally {
setIsExporting(false); setIsExporting(false);
setProgress(null); setProgress(null);
} }
}, [canvasName, exportFrame, getNodes, isExporting]); }, [t, canvasName, exportFrame, getNodes, isExporting]);
return ( return (
<div className="relative"> <div className="relative">

View File

@@ -2,16 +2,16 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react"; import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import { classifyError, type AiErrorCategory } from "@/lib/ai-errors"; import { classifyError, type ErrorType } from "@/lib/ai-errors";
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats"; import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { import {
Loader2, Loader2,
@@ -59,6 +59,7 @@ export default function AiImageNode({
data, data,
selected, selected,
}: NodeProps<AiImageNode>) { }: NodeProps<AiImageNode>) {
const t = useTranslations('toasts');
const nodeData = data as AiImageNodeData; const nodeData = data as AiImageNodeData;
const { getEdges, getNode } = useReactFlow(); const { getEdges, getNode } = useReactFlow();
const { status: syncStatus } = useCanvasSync(); const { status: syncStatus } = useCanvasSync();
@@ -135,17 +136,17 @@ export default function AiImageNode({
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO, aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
}), }),
{ {
loading: msg.ai.generating.title, loading: t('ai.generating'),
success: msg.ai.generationQueued.title, success: t('ai.generationQueued'),
error: msg.ai.generationFailed.title, error: t('ai.generationFailed'),
description: { description: {
success: msg.ai.generationQueuedDesc, success: t('ai.generationQueuedDesc'),
error: msg.ai.creditsNotCharged, error: t('ai.creditsNotCharged'),
}, },
}, },
); );
} catch (err) { } catch (err) {
setLocalError(err instanceof Error ? err.message : msg.ai.generationFailed.title); setLocalError(err instanceof Error ? err.message : t('ai.generationFailed'));
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
@@ -154,16 +155,16 @@ export default function AiImageNode({
const modelName = const modelName =
getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI"; getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI";
const renderErrorIcon = (category: AiErrorCategory) => { const renderErrorIcon = (type: ErrorType) => {
switch (category) { switch (type) {
case "insufficient_credits": case "insufficientCredits":
return <Coins className="h-8 w-8 text-amber-500" />; return <Coins className="h-8 w-8 text-amber-500" />;
case "rate_limited": case "rateLimited":
case "timeout": case "timeout":
return <Clock3 className="h-8 w-8 text-amber-500" />; return <Clock3 className="h-8 w-8 text-amber-500" />;
case "content_policy": case "contentPolicy":
return <ShieldAlert className="h-8 w-8 text-destructive" />; return <ShieldAlert className="h-8 w-8 text-destructive" />;
case "network": case "networkError":
return <WifiOff className="h-8 w-8 text-destructive" />; return <WifiOff className="h-8 w-8 text-destructive" />;
default: default:
return <AlertCircle className="h-8 w-8 text-destructive" />; return <AlertCircle className="h-8 w-8 text-destructive" />;
@@ -226,15 +227,10 @@ export default function AiImageNode({
{status === "error" && !isLoading && ( {status === "error" && !isLoading && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-muted"> <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"> <p className="px-4 text-center text-xs font-medium text-destructive">
{classifiedError.message} {classifiedError.rawMessage}
</p> </p>
{classifiedError.detail && (
<p className="px-6 text-center text-[10px] text-muted-foreground">
{classifiedError.detail}
</p>
)}
{classifiedError.creditsNotCharged && ( {classifiedError.creditsNotCharged && (
<p className="px-6 text-center text-[10px] text-muted-foreground"> <p className="px-6 text-center text-[10px] text-muted-foreground">
Credits not charged Credits not charged

View File

@@ -3,13 +3,13 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react"; import { Handle, Position, type NodeProps } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { useTranslations } from "next-intl";
import { Download, Loader2 } from "lucide-react"; import { Download, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
interface FrameNodeData { interface FrameNodeData {
@@ -19,6 +19,7 @@ interface FrameNodeData {
} }
export default function FrameNode({ id, data, selected, width, height }: NodeProps) { export default function FrameNode({ id, data, selected, width, height }: NodeProps) {
const t = useTranslations('toasts');
const nodeData = data as FrameNodeData; const nodeData = data as FrameNodeData;
const { queueNodeDataUpdate, status } = useCanvasSync(); const { queueNodeDataUpdate, status } = useCanvasSync();
const exportFrame = useAction(api.export.exportFrame); const exportFrame = useAction(api.export.exportFrame);
@@ -54,23 +55,23 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
try { try {
const result = await exportFrame({ frameNodeId: id as Id<"nodes"> }); const result = await exportFrame({ frameNodeId: id as Id<"nodes"> });
const fileLabel = `${label.trim() || "frame"}.png`; const fileLabel = `${label.trim() || "frame"}.png`;
toast.action(msg.export.frameExported.title, { toast.action(t('export.frameExported'), {
description: fileLabel, description: fileLabel,
label: msg.export.download, label: t('export.download'),
onClick: () => { onClick: () => {
window.open(result.url, "_blank", "noopener,noreferrer"); window.open(result.url, "_blank", "noopener,noreferrer");
}, },
successLabel: msg.export.downloaded, successLabel: t('export.downloaded'),
type: "success", type: "success",
}); });
} catch (error) { } catch (error) {
const m = error instanceof Error ? error.message : ""; const m = error instanceof Error ? error.message : "";
if (m.includes("No images found")) { if (m.includes("No images found")) {
toast.error(msg.export.frameEmpty.title, msg.export.frameEmpty.desc); toast.error(t('export.frameEmptyTitle'), t('export.frameEmptyDesc'));
setExportError(msg.export.frameEmpty.desc); setExportError(t('export.frameEmptyDesc'));
} else { } else {
toast.error(msg.export.exportFailed.title, m || undefined); toast.error(t('export.exportFailed'), m || undefined);
setExportError(m || msg.export.exportFailed.title); setExportError(m || t('export.exportFailed'));
} }
} finally { } finally {
setIsExporting(false); setIsExporting(false);

View File

@@ -9,11 +9,11 @@ import {
type DragEvent, type DragEvent,
} from "react"; } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useTranslations } from "next-intl";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { computeMediaNodeSize } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
@@ -73,6 +73,7 @@ export default function ImageNode({
width, width,
height, height,
}: NodeProps<ImageNode>) { }: NodeProps<ImageNode>) {
const t = useTranslations('toasts');
const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -121,17 +122,17 @@ export default function ImageNode({
const uploadFile = useCallback( const uploadFile = useCallback(
async (file: File) => { async (file: File) => {
if (!ALLOWED_IMAGE_TYPES.has(file.type)) { if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
const { title, desc } = msg.canvas.uploadFormatError( toast.error(
file.type || file.name.split(".").pop() || "—", t('canvas.uploadFailed'),
t('canvas.uploadFormatError', { format: file.type || file.name.split(".").pop() || "—" }),
); );
toast.error(title, desc);
return; return;
} }
if (file.size > MAX_IMAGE_BYTES) { if (file.size > MAX_IMAGE_BYTES) {
const { title, desc } = msg.canvas.uploadSizeError( toast.error(
Math.round(MAX_IMAGE_BYTES / (1024 * 1024)), t('canvas.uploadFailed'),
t('canvas.uploadSizeError', { maxMb: Math.round(MAX_IMAGE_BYTES / (1024 * 1024)) }),
); );
toast.error(title, desc);
return; return;
} }
if (status.isOffline) { if (status.isOffline) {
@@ -188,11 +189,11 @@ export default function ImageNode({
}); });
} }
toast.success(msg.canvas.imageUploaded.title); toast.success(t('canvas.imageUploaded'));
} catch (err) { } catch (err) {
console.error("Upload failed:", err); console.error("Upload failed:", err);
toast.error( toast.error(
msg.canvas.uploadFailed.title, t('canvas.uploadFailed'),
err instanceof Error ? err.message : undefined, err instanceof Error ? err.message : undefined,
); );
} finally { } finally {

View File

@@ -10,6 +10,7 @@ import {
type Node, type Node,
} from "@xyflow/react"; } from "@xyflow/react";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { useTranslations } from "next-intl";
import { useAuthQuery } from "@/hooks/use-auth-query"; import { useAuthQuery } from "@/hooks/use-auth-query";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
@@ -38,7 +39,6 @@ import {
import { Sparkles, Loader2, Coins } from "lucide-react"; import { Sparkles, Loader2, Coins } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { classifyError } from "@/lib/ai-errors"; import { classifyError } from "@/lib/ai-errors";
type PromptNodeData = { type PromptNodeData = {
@@ -57,6 +57,7 @@ export default function PromptNode({
data, data,
selected, selected,
}: NodeProps<PromptNode>) { }: NodeProps<PromptNode>) {
const t = useTranslations('toasts');
const nodeData = data as PromptNodeData; const nodeData = data as PromptNodeData;
const router = useRouter(); const router = useRouter();
const { getEdges, getNode } = useReactFlow(); const { getEdges, getNode } = useReactFlow();
@@ -166,13 +167,9 @@ export default function PromptNode({
} }
if (availableCredits !== null && !hasEnoughCredits) { if (availableCredits !== null && !hasEnoughCredits) {
const { title, desc } = msg.ai.insufficientCredits( toast.action(t('ai.insufficientCreditsTitle'), {
creditCost, description: t('ai.insufficientCreditsDesc', { needed: creditCost, available: availableCredits }),
availableCredits, label: t('billing.topUp'),
);
toast.action(title, {
description: desc,
label: msg.billing.topUp,
onClick: () => router.push("/settings/billing"), onClick: () => router.push("/settings/billing"),
type: "warning", type: "warning",
}); });
@@ -256,30 +253,30 @@ export default function PromptNode({
aspectRatio, aspectRatio,
}), }),
{ {
loading: msg.ai.generating.title, loading: t('ai.generating'),
success: msg.ai.generationQueued.title, success: t('ai.generationQueued'),
error: msg.ai.generationFailed.title, error: t('ai.generationFailed'),
description: { description: {
success: msg.ai.generationQueuedDesc, success: t('ai.generationQueuedDesc'),
error: msg.ai.creditsNotCharged, error: t('ai.creditsNotCharged'),
}, },
}, },
); );
} catch (err) { } catch (err) {
const classified = classifyError(err); const classified = classifyError(err);
if (classified.category === "daily_cap") { if (classified.type === "dailyCap") {
toast.error( toast.error(
msg.billing.dailyLimitReached(0).title, t('billing.dailyLimitReachedTitle'),
"Morgen stehen wieder Generierungen zur Verfügung.", "Morgen stehen wieder Generierungen zur Verfügung.",
); );
} else if (classified.category === "concurrency") { } else if (classified.type === "concurrency") {
toast.warning( toast.warning(
msg.ai.concurrentLimitReached.title, t('ai.concurrentLimitReachedTitle'),
msg.ai.concurrentLimitReached.desc, t('ai.concurrentLimitReachedDesc'),
); );
} else { } else {
setError(classified.message || msg.ai.generationFailed.title); setError(classified.rawMessage || t('ai.generationFailed'));
} }
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);

View File

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

View File

@@ -2,6 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuthQuery } from "@/hooks/use-auth-query"; import { useAuthQuery } from "@/hooks/use-auth-query";
import { useFormatter, useTranslations } from "next-intl";
import { CreditCard } from "lucide-react"; import { CreditCard } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -10,10 +11,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { formatEurFromCents } from "@/lib/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tier-Config — monatliches Credit-Kontingent pro Tier (in Cent) // Tier-Config — monatliches Credit-Kontingent pro Tier (in Cent)
@@ -42,7 +41,12 @@ const TIER_BADGE_STYLES: Record<string, string> = {
const LOW_CREDITS_THRESHOLD = 20; const LOW_CREDITS_THRESHOLD = 20;
export function CreditOverview() { export function CreditOverview() {
const t = useTranslations('toasts');
const router = useRouter(); 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 balance = useAuthQuery(api.credits.getBalance);
const subscription = useAuthQuery(api.credits.getSubscription); const subscription = useAuthQuery(api.credits.getSubscription);
const usageStats = useAuthQuery(api.credits.getUsageStats); const usageStats = useAuthQuery(api.credits.getUsageStats);
@@ -56,14 +60,13 @@ export function CreditOverview() {
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return; if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
sessionStorage.setItem(key, "1"); sessionStorage.setItem(key, "1");
const { title, desc } = msg.billing.lowCredits(available); toast.action(t('billing.lowCreditsTitle'), {
toast.action(title, { description: t('billing.lowCreditsDesc', { remaining: available }),
description: desc, label: t('billing.topUp'),
label: msg.billing.topUp,
onClick: () => router.push("/settings/billing"), onClick: () => router.push("/settings/billing"),
type: "warning", type: "warning",
}); });
}, [balance, router]); }, [t, balance, router]);
// ── Loading State ────────────────────────────────────────────────────── // ── Loading State ──────────────────────────────────────────────────────
if ( if (

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { useAuthQuery } from "@/hooks/use-auth-query"; import { useAuthQuery } from "@/hooks/use-auth-query";
import { useFormatter } from "next-intl";
import { Activity, Coins } from "lucide-react"; import { Activity, Coins } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { formatEurFromCents, cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/format-time";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -45,10 +45,28 @@ function truncatedDescription(text: string, maxLen = 40) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function RecentTransactions() { export function RecentTransactions() {
const format = useFormatter();
const transactions = useAuthQuery(api.credits.getRecentTransactions, { const transactions = useAuthQuery(api.credits.getRecentTransactions, {
limit: 10, 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 ────────────────────────────────────────────────────── // ── Loading State ──────────────────────────────────────────────────────
if (transactions === undefined) { if (transactions === undefined) {
return ( return (
@@ -102,7 +120,7 @@ export function RecentTransactions() {
Letzte Aktivität Letzte Aktivität
</div> </div>
<div className="divide-y"> <div className="divide-y">
{transactions.map((t) => { {transactions.map((t: NonNullable<typeof transactions>[number]) => {
const isCredit = t.amount > 0; const isCredit = t.amount > 0;
return ( return (
<div <div

View File

@@ -5,8 +5,8 @@ import { useMutation } from "convex/react";
import { useAuthQuery } from "@/hooks/use-auth-query"; import { useAuthQuery } from "@/hooks/use-auth-query";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
/** /**
* Initialisiert die Credit-Balance für neue User. * Initialisiert die Credit-Balance für neue User.
@@ -14,6 +14,7 @@ import { msg } from "@/lib/toast-messages";
* dass jeder eingeloggte User eine Balance + Free-Subscription hat. * dass jeder eingeloggte User eine Balance + Free-Subscription hat.
*/ */
export function InitUser() { export function InitUser() {
const t = useTranslations('toasts');
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const balance = useAuthQuery(api.credits.getBalance); const balance = useAuthQuery(api.credits.getBalance);
@@ -34,12 +35,12 @@ export function InitUser() {
void initBalance() void initBalance()
.then(() => { .then(() => {
toast.success(msg.auth.initialSetup.title, msg.auth.initialSetup.desc); toast.success(t('auth.initialSetupTitle'), t('auth.initialSetupDesc'));
}) })
.catch(() => { .catch(() => {
initStartedRef.current = false; initStartedRef.current = false;
}); });
}, [session?.user, balance, initBalance]); }, [t, session?.user, balance, initBalance]);
return null; 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 { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { AuthUIProvider } from "@daveyplate/better-auth-ui"; import { AuthUIProvider } from "@daveyplate/better-auth-ui";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { NextIntlClientProvider } from "next-intl";
import type { AbstractIntlMessages } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { GooeyToaster } from "goey-toast"; import { GooeyToaster } from "goey-toast";
@@ -35,37 +37,43 @@ function SentryAuthUserSync() {
export function Providers({ export function Providers({
children, children,
initialToken, initialToken,
locale,
messages,
}: { }: {
children: ReactNode; children: ReactNode;
initialToken?: string | null; initialToken?: string | null;
locale?: string;
messages?: AbstractIntlMessages;
}) { }) {
const router = useRouter(); const router = useRouter();
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ConvexBetterAuthProvider <NextIntlClientProvider locale={locale} messages={messages}>
client={convex} <ConvexBetterAuthProvider
authClient={authClient} client={convex}
initialToken={initialToken}
>
<SentryAuthUserSync />
<AuthUIProvider
authClient={authClient} authClient={authClient}
navigate={router.push} initialToken={initialToken}
replace={router.replace}
onSessionChange={() => router.refresh()}
Link={Link}
> >
{children} <SentryAuthUserSync />
<GooeyToaster <AuthUIProvider
position="bottom-right" authClient={authClient}
theme="dark" navigate={router.push}
visibleToasts={4} replace={router.replace}
maxQueue={8} onSessionChange={() => router.refresh()}
queueOverflow="drop-oldest" Link={Link}
/> >
</AuthUIProvider> {children}
</ConvexBetterAuthProvider> <GooeyToaster
position="bottom-right"
theme="dark"
visibleToasts={4}
maxQueue={8}
queueOverflow="drop-oldest"
/>
</AuthUIProvider>
</ConvexBetterAuthProvider>
</NextIntlClientProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -22,6 +22,7 @@ import type * as openrouter from "../openrouter.js";
import type * as pexels from "../pexels.js"; import type * as pexels from "../pexels.js";
import type * as polar from "../polar.js"; import type * as polar from "../polar.js";
import type * as storage from "../storage.js"; import type * as storage from "../storage.js";
import type * as users from "../users.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -44,6 +45,7 @@ declare const fullApi: ApiFromModules<{
pexels: typeof pexels; pexels: typeof pexels;
polar: typeof polar; polar: typeof polar;
storage: typeof storage; 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 { action, internalAction, internalMutation } from "./_generated/server";
import { api, internal } from "./_generated/api"; import { api, internal } from "./_generated/api";
import { import {
@@ -18,6 +18,19 @@ type ErrorCategory =
| "provider" | "provider"
| "unknown"; | "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 { function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
return String(error ?? "Generation failed"); return String(error ?? "Generation failed");
@@ -34,20 +47,23 @@ function categorizeError(error: unknown): {
category: ErrorCategory; category: ErrorCategory;
retryable: boolean; retryable: boolean;
} { } {
const code = getErrorCode(error);
const message = errorMessage(error); const message = errorMessage(error);
const lower = message.toLowerCase(); const lower = message.toLowerCase();
const status = parseOpenRouterStatus(message); const status = parseOpenRouterStatus(message);
if ( if (
lower.includes("insufficient credits") || code === "CREDITS_TEST_DISABLED" ||
lower.includes("daily generation limit") || code === "CREDITS_INVALID_AMOUNT" ||
lower.includes("concurrent job limit") code === "CREDITS_BALANCE_NOT_FOUND" ||
code === "CREDITS_DAILY_CAP_REACHED" ||
code === "CREDITS_CONCURRENCY_LIMIT"
) { ) {
return { category: "credits", retryable: false }; return { category: "credits", retryable: false };
} }
if ( if (
lower.includes("modell lehnt ab") || code === "OPENROUTER_MODEL_REFUSAL" ||
lower.includes("content policy") || lower.includes("content policy") ||
lower.includes("policy") || lower.includes("policy") ||
lower.includes("moderation") || lower.includes("moderation") ||
@@ -94,6 +110,11 @@ function categorizeError(error: unknown): {
} }
function formatTerminalStatusMessage(error: unknown): string { function formatTerminalStatusMessage(error: unknown): string {
const code = getErrorCode(error);
if (code) {
return code;
}
const message = errorMessage(error).trim() || "Generation failed"; const message = errorMessage(error).trim() || "Generation failed";
const { category } = categorizeError(error); const { category } = categorizeError(error);

View File

@@ -1,5 +1,5 @@
import { query, mutation, internalMutation } from "./_generated/server"; import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values"; import { v, ConvexError } from "convex/values";
import { optionalAuth, requireAuth } from "./helpers"; import { optionalAuth, requireAuth } from "./helpers";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
@@ -275,10 +275,10 @@ export const grantTestCredits = mutation({
}, },
handler: async (ctx, { amount = 2000 }) => { handler: async (ctx, { amount = 2000 }) => {
if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") { 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) { 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 user = await requireAuth(ctx);
const balance = await ctx.db const balance = await ctx.db
@@ -287,7 +287,7 @@ export const grantTestCredits = mutation({
.unique(); .unique();
if (!balance) { if (!balance) {
throw new Error("Keine Credit-Balance — zuerst einloggen / initBalance."); throw new ConvexError({ code: "CREDITS_BALANCE_NOT_FOUND" });
} }
const next = balance.balance + amount; const next = balance.balance + amount;
@@ -362,16 +362,18 @@ export const reserve = mutation({
.unique(); .unique();
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) { if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
throw new Error( throw new ConvexError({
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)` code: "CREDITS_DAILY_CAP_REACHED",
); data: { limit: config.dailyGenerationCap, tier },
});
} }
// Concurrency Limit prüfen // Concurrency Limit prüfen
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) { if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
throw new Error( throw new ConvexError({
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten` code: "CREDITS_CONCURRENCY_LIMIT",
); data: { limit: config.concurrencyLimit },
});
} }
// Credits reservieren // Credits reservieren
@@ -487,7 +489,14 @@ export const commit = mutation({
actualCost: v.number(), actualCost: v.number(),
openRouterCost: v.optional(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 user = await requireAuth(ctx);
const transaction = await ctx.db.get(transactionId); const transaction = await ctx.db.get(transactionId);
if (!transaction || transaction.userId !== user.userId) { if (!transaction || transaction.userId !== user.userId) {
@@ -571,7 +580,14 @@ export const release = mutation({
args: { args: {
transactionId: v.id("creditTransactions"), 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 user = await requireAuth(ctx);
const transaction = await ctx.db.get(transactionId); const transaction = await ctx.db.get(transactionId);
if (!transaction || transaction.userId !== user.userId) { if (!transaction || transaction.userId !== user.userId) {
@@ -761,16 +777,18 @@ export const checkAbuseLimits = internalMutation({
const dailyCount = usage?.generationCount ?? 0; const dailyCount = usage?.generationCount ?? 0;
if (dailyCount >= config.dailyGenerationCap) { if (dailyCount >= config.dailyGenerationCap) {
throw new Error( throw new ConvexError({
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)` code: "CREDITS_DAILY_CAP_REACHED",
); data: { limit: config.dailyGenerationCap, tier },
});
} }
const currentConcurrency = usage?.concurrentJobs ?? 0; const currentConcurrency = usage?.concurrentJobs ?? 0;
if (currentConcurrency >= config.concurrencyLimit) { if (currentConcurrency >= config.concurrencyLimit) {
throw new Error( throw new ConvexError({
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten` 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 // 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; if (node.type !== "image" && node.type !== "ai-image") return false;
const data = node.data as { storageId?: string }; const data = node.data as { storageId?: string };
if (!data.storageId) return false; 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 const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
export interface OpenRouterModel { export interface OpenRouterModel {
@@ -144,7 +146,7 @@ export async function generateImageViaOpenRouter(
const message = data?.choices?.[0]?.message as Record<string, unknown> | undefined; const message = data?.choices?.[0]?.message as Record<string, unknown> | undefined;
if (!message) { if (!message) {
throw new Error("OpenRouter: choices[0].message fehlt"); throw new ConvexError({ code: "OPENROUTER_MISSING_MESSAGE" });
} }
let rawImage: string | undefined; let rawImage: string | undefined;
@@ -186,7 +188,10 @@ export async function generateImageViaOpenRouter(
) { ) {
const r = const r =
typeof refusal === "string" ? refusal : JSON.stringify(refusal); 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 ( if (
@@ -205,19 +210,23 @@ export async function generateImageViaOpenRouter(
: Array.isArray(content) : Array.isArray(content)
? JSON.stringify(content).slice(0, 400) ? JSON.stringify(content).slice(0, 400)
: ""; : "";
throw new Error( throw new ConvexError({
`OpenRouter: kein Bild in der Antwort. Keys: ${Object.keys(message).join(", ")}. ` + code: "OPENROUTER_NO_IMAGE_IN_RESPONSE",
(reasoning ? `reasoning: ${reasoning}` : `content: ${contentPreview}`), data: {
); keys: Object.keys(message).join(", "),
reasoningOrContent: reasoning || contentPreview,
},
});
} }
let dataUri = rawImage; let dataUri = rawImage;
if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) { if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) {
const imgRes = await fetch(rawImage); const imgRes = await fetch(rawImage);
if (!imgRes.ok) { if (!imgRes.ok) {
throw new Error( throw new ConvexError({
`OpenRouter: Bild-URL konnte nicht geladen werden (${imgRes.status})`, code: "OPENROUTER_IMAGE_URL_LOAD_FAILED",
); data: { status: imgRes.status },
});
} }
const mimeTypeFromRes = const mimeTypeFromRes =
imgRes.headers.get("content-type") ?? "image/png"; imgRes.headers.get("content-type") ?? "image/png";
@@ -237,12 +246,12 @@ export async function generateImageViaOpenRouter(
} }
if (!dataUri.startsWith("data:")) { 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(","); const comma = dataUri.indexOf(",");
if (comma === -1) { 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 meta = dataUri.slice(0, comma);
const base64Data = dataUri.slice(comma + 1); const base64Data = dataUri.slice(comma + 1);

View File

@@ -310,4 +310,16 @@ export default defineSchema({
concurrentJobs: v.number(), // Aktuell laufende Jobs concurrentJobs: v.number(), // Aktuell laufende Jobs
}) })
.index("by_user_date", ["userId", "date"]), .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 = export type ErrorType =
| "insufficient_credits"
| "rate_limited"
| "content_policy"
| "timeout" | "timeout"
| "network" | "insufficientCredits"
| "server" | "networkError"
| "invalid_request" | "rateLimited"
| "daily_cap" | "modelUnavailable"
| "concurrency" | "generic"
| "unknown"; | "contentPolicy"
| "invalidRequest"
| "dailyCap"
| "concurrency";
export interface AiError { export interface AiError {
category: AiErrorCategory; type: ErrorType;
message: string;
detail?: string;
retryable: boolean; retryable: boolean;
creditsNotCharged: boolean; creditsNotCharged: boolean;
showTopUp: boolean; showTopUp: boolean;
retryCount?: number; retryCount?: number;
rawMessage?: string;
} }
type RawErrorObject = { type RawErrorObject = {
@@ -27,45 +26,50 @@ type RawErrorObject = {
retryCount?: unknown; retryCount?: unknown;
}; };
const CATEGORY_ALIASES: Record<string, AiErrorCategory> = { const TYPE_ALIASES: Record<string, ErrorType> = {
insufficient_credits: "insufficient_credits", insufficient_credits: "insufficientCredits",
insufficientcredits: "insufficient_credits", insufficientcredits: "insufficientCredits",
not_enough_credits: "insufficient_credits", not_enough_credits: "insufficientCredits",
notenoughcredits: "insufficient_credits", notenoughcredits: "insufficientCredits",
credits: "insufficient_credits", credits: "insufficientCredits",
payment_required: "insufficient_credits", payment_required: "insufficientCredits",
paymentrequired: "insufficient_credits", paymentrequired: "insufficientCredits",
rate_limit: "rate_limited", rate_limit: "rateLimited",
ratelimit: "rate_limited", ratelimit: "rateLimited",
rate_limited: "rate_limited", rate_limited: "rateLimited",
ratelimited: "rate_limited", ratelimited: "rateLimited",
too_many_requests: "rate_limited", too_many_requests: "rateLimited",
toomanyrequests: "rate_limited", toomanyrequests: "rateLimited",
content_policy: "content_policy", content_policy: "contentPolicy",
contentpolicy: "content_policy", contentpolicy: "contentPolicy",
safety: "content_policy", safety: "contentPolicy",
timeout: "timeout", timeout: "timeout",
timed_out: "timeout", timed_out: "timeout",
timedout: "timeout", timedout: "timeout",
network: "network", network: "networkError",
connection: "network", connection: "networkError",
server: "server", networkerror: "networkError",
invalid_request: "invalid_request", server: "modelUnavailable",
invalidrequest: "invalid_request", model_unavailable: "modelUnavailable",
bad_request: "invalid_request", modelunavailable: "modelUnavailable",
badrequest: "invalid_request", invalid_request: "invalidRequest",
daily_cap: "daily_cap", invalidrequest: "invalidRequest",
dailycap: "daily_cap", bad_request: "invalidRequest",
daily_limit: "daily_cap", badrequest: "invalidRequest",
dailylimit: "daily_cap", unknown_model: "invalidRequest",
daily_cap: "dailyCap",
dailycap: "dailyCap",
daily_limit: "dailyCap",
dailylimit: "dailyCap",
concurrency: "concurrency", concurrency: "concurrency",
concurrent: "concurrency", concurrent: "concurrency",
unknown: "generic",
}; };
function normalizeCategory(value: string | undefined): AiErrorCategory | undefined { function normalizeType(value: string | undefined): ErrorType | undefined {
if (!value) return undefined; if (!value) return undefined;
const normalized = value.toLowerCase().replace(/[^a-z]/g, ""); 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 { function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): number | undefined {
@@ -86,15 +90,15 @@ function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): numb
return undefined; return undefined;
} }
function cleanPrefixMessage(text: string): { category?: AiErrorCategory; message: string } { function cleanPrefixMessage(text: string): { type?: ErrorType; message: string } {
const trimmed = text.trim(); const trimmed = text.trim();
const bracketPrefix = trimmed.match(/^\[([a-zA-Z_\- ]+)\]\s*[:\-]?\s*(.+)$/); const bracketPrefix = trimmed.match(/^\[([a-zA-Z_\- ]+)\]\s*[:\-]?\s*(.+)$/);
if (bracketPrefix?.[1] && bracketPrefix[2]) { if (bracketPrefix?.[1] && bracketPrefix[2]) {
const category = normalizeCategory(bracketPrefix[1]); const type = normalizeType(bracketPrefix[1]);
if (category) { if (type) {
return { return {
category, type,
message: bracketPrefix[2].trim(), 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*(.+)$/); const plainPrefix = trimmed.match(/^([a-zA-Z_\- ]{3,40})\s*[:|\-]\s*(.+)$/);
if (plainPrefix?.[1] && plainPrefix[2]) { if (plainPrefix?.[1] && plainPrefix[2]) {
const category = normalizeCategory(plainPrefix[1]); const type = normalizeType(plainPrefix[1]);
if (category) { if (type) {
return { return {
category, type,
message: plainPrefix[2].trim(), message: plainPrefix[2].trim(),
}; };
} }
@@ -129,17 +133,17 @@ function splitMessageAndDetail(message: string): { message: string; detail?: str
return { message }; return { message };
} }
function inferCategoryFromText(text: string): AiErrorCategory { function inferTypeFromText(text: string): ErrorType {
const lower = text.toLowerCase(); const lower = text.toLowerCase();
const openRouterStatus = lower.match(/openrouter api error\s*(\d{3})/i); const openRouterStatus = lower.match(/openrouter api error\s*(\d{3})/i);
if (openRouterStatus?.[1]) { if (openRouterStatus?.[1]) {
const status = Number.parseInt(openRouterStatus[1], 10); 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 === 408 || status === 504) return "timeout";
if (status === 429) return "rate_limited"; if (status === 429) return "rateLimited";
if (status >= 500) return "server"; if (status >= 500) return "modelUnavailable";
if (status >= 400) return "invalid_request"; if (status >= 400) return "invalidRequest";
} }
if ( if (
@@ -149,7 +153,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("guthaben") || lower.includes("guthaben") ||
lower.includes("nicht genug credits") lower.includes("nicht genug credits")
) { ) {
return "insufficient_credits"; return "insufficientCredits";
} }
if ( if (
@@ -158,7 +162,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("ratelimit") || lower.includes("ratelimit") ||
lower.includes("429") lower.includes("429")
) { ) {
return "rate_limited"; return "rateLimited";
} }
if ( if (
@@ -166,7 +170,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("tageslimit erreicht") || lower.includes("tageslimit erreicht") ||
lower.includes("daily generation limit") lower.includes("daily generation limit")
) { ) {
return "daily_cap"; return "dailyCap";
} }
if ( if (
@@ -191,7 +195,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("fetch failed") || lower.includes("fetch failed") ||
lower.includes("econn") lower.includes("econn")
) { ) {
return "network"; return "networkError";
} }
if ( if (
@@ -200,7 +204,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("refusal") || lower.includes("refusal") ||
lower.includes("modell lehnt ab") lower.includes("modell lehnt ab")
) { ) {
return "content_policy"; return "contentPolicy";
} }
if ( if (
@@ -209,85 +213,75 @@ function inferCategoryFromText(text: string): AiErrorCategory {
lower.includes("unknown model") || lower.includes("unknown model") ||
lower.includes("missing") lower.includes("missing")
) { ) {
return "invalid_request"; return "invalidRequest";
} }
if (lower.includes("server") || lower.includes("5xx")) { if (lower.includes("server") || lower.includes("5xx")) {
return "server"; return "modelUnavailable";
} }
return "unknown"; return "generic";
} }
function defaultsForCategory(category: AiErrorCategory): Omit<AiError, "category" | "detail" | "retryCount"> { function defaultsForType(type: ErrorType): Omit<AiError, "type" | "retryCount" | "rawMessage"> {
switch (category) { switch (type) {
case "insufficient_credits": case "insufficientCredits":
return { return {
message: "Not enough credits for this generation",
retryable: false, retryable: false,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: true, showTopUp: true,
}; };
case "rate_limited": case "rateLimited":
return { return {
message: "The model is busy right now",
retryable: true, retryable: true,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "content_policy": case "contentPolicy":
return { return {
message: "The request was blocked by model safety rules",
retryable: false, retryable: false,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "timeout": case "timeout":
return { return {
message: "The generation timed out",
retryable: true, retryable: true,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "network": case "networkError":
return { return {
message: "Network issue while contacting the model",
retryable: true, retryable: true,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "server": case "modelUnavailable":
return { return {
message: "The AI service returned a server error",
retryable: true, retryable: true,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "invalid_request": case "invalidRequest":
return { return {
message: "The request could not be processed",
retryable: false, retryable: false,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "daily_cap": case "dailyCap":
return { return {
message: "Tageslimit erreicht",
retryable: false, retryable: false,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "concurrency": case "concurrency":
return { return {
message: "Generierung bereits aktiv",
retryable: true, retryable: true,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "unknown": case "generic":
default: default:
return { return {
message: "Generation failed",
retryable: true, retryable: true,
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
@@ -313,22 +307,20 @@ export function classifyError(rawError: unknown): AiError {
const rawDetail = typeof rawObj?.detail === "string" ? rawObj.detail.trim() : undefined; const rawDetail = typeof rawObj?.detail === "string" ? rawObj.detail.trim() : undefined;
const prefixed = cleanPrefixMessage(rawMessage); const prefixed = cleanPrefixMessage(rawMessage);
const explicitCategory = const explicitType =
normalizeCategory(typeof rawObj?.category === "string" ? rawObj.category : undefined) ?? normalizeType(typeof rawObj?.category === "string" ? rawObj.category : undefined) ??
prefixed.category; prefixed.type;
const category = explicitCategory ?? inferCategoryFromText(prefixed.message); const type = explicitType ?? inferTypeFromText(prefixed.message);
const defaults = defaultsForCategory(category); const defaults = defaultsForType(type);
const split = splitMessageAndDetail(prefixed.message); const split = splitMessageAndDetail(prefixed.message);
const message = split.message || defaults.message;
return { return {
category, type,
message,
detail: split.detail ?? rawDetail,
retryable: defaults.retryable, retryable: defaults.retryable,
creditsNotCharged: defaults.creditsNotCharged, creditsNotCharged: defaults.creditsNotCharged,
showTopUp: defaults.showTopUp, showTopUp: defaults.showTopUp,
retryCount: extractRetryCount(rawMessage, rawObj), 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. 'use client';
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
/** Grund, warum ein Node-Löschen noch blockiert ist. */ import { useTranslations } from 'next-intl';
export type CanvasNodeDeleteBlockReason = "optimistic"; 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( function canvasNodeDeleteWhy(
t: ToastTranslations,
reasons: Set<CanvasNodeDeleteBlockReason>, reasons: Set<CanvasNodeDeleteBlockReason>,
): { title: string; desc: string } { ): { title: string; desc: string } {
if (reasons.size === 0) { if (reasons.size === 0) {
return { return {
title: "Löschen momentan nicht möglich", title: t('canvas.nodeDeleteBlockedTitle'),
desc: "Bitte kurz warten und erneut versuchen.", desc: t('canvas.nodeDeleteBlockedDesc'),
}; };
} }
if (reasons.size === 1) { if (reasons.size === 1) {
const only = [...reasons][0]!; const only = [...reasons][0]!;
if (only === "optimistic") { if (only === 'optimistic') {
return { return {
title: "Element wird noch angelegt", title: t('canvas.nodeDeleteOptimisticTitle'),
desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.", desc: t('canvas.nodeDeleteOptimisticDesc'),
}; };
} }
return { return {
title: "Löschen momentan nicht möglich", title: t('canvas.nodeDeleteBlockedTitle'),
desc: "Bitte kurz warten und erneut versuchen.", desc: t('canvas.nodeDeleteBlockedDesc'),
}; };
} }
return { return {
title: "Löschen momentan nicht möglich", title: t('canvas.nodeDeleteBlockedTitle'),
desc: "Mindestens ein Element wird noch angelegt. Bitte kurz warten und erneut versuchen.", desc: t('canvas.nodeDeleteBlockedMultiDesc'),
}; };
} }
export const msg = { export const msg = {
canvas: { canvas: {
imageUploaded: { title: "Bild hochgeladen" }, imageUploaded: (t: ToastTranslations) => ({
uploadFailed: { title: "Upload fehlgeschlagen" }, title: t('canvas.imageUploaded'),
uploadFormatError: (format: string) => ({
title: "Upload fehlgeschlagen",
desc: `Format „${format}“ wird nicht unterstützt. Erlaubt: PNG, JPG, WebP.`,
}), }),
uploadSizeError: (maxMb: number) => ({ uploadFailed: (t: ToastTranslations) => ({
title: "Upload fehlgeschlagen", title: t('canvas.uploadFailed'),
desc: `Maximale Dateigröße: ${maxMb} MB.`,
}), }),
nodeRemoved: { title: "Element entfernt" }, uploadFormatError: (t: ToastTranslations, format: string) => ({
nodesRemoved: (count: number) => ({ title: t('canvas.uploadFailed'),
title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`, desc: t('canvas.uploadFormatError', { format }),
}), }),
/** Warum gerade kein (vollständiges) Löschen möglich ist — aus den gesammelten Gründen der blockierten Nodes. */ uploadSizeError: (t: ToastTranslations, maxMb: number) => ({
nodeDeleteBlockedExplain: canvasNodeDeleteWhy, title: t('canvas.uploadFailed'),
nodeDeleteBlockedPartial: ( desc: t('canvas.uploadSizeError', { maxMb }),
blockedCount: number, }),
reasons: Set<CanvasNodeDeleteBlockReason>, nodeRemoved: (t: ToastTranslations) => ({
) => { title: t('canvas.nodeRemoved'),
const why = canvasNodeDeleteWhy(reasons); }),
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 = const suffix =
blockedCount === 1 blockedCount === 1
? "Ein Element wurde deshalb nicht gelöscht; die übrige Auswahl wurde entfernt." ? t('canvas.nodeDeleteBlockedPartialSuffixOne')
: `${blockedCount} Elemente wurden deshalb nicht gelöscht; die übrige Auswahl wurde entfernt.`; : t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blockedCount });
return { return {
title: "Nicht alle Elemente entfernt", title: t('canvas.nodeDeleteBlockedPartialTitle'),
desc: `${why.desc} ${suffix}`, desc: `${why.desc} ${suffix}`,
}; };
}, },
}, },
ai: { ai: {
generating: { title: "Bild wird generiert…" }, generating: (t: ToastTranslations) => ({ title: t('ai.generating') }),
generated: { title: "Bild generiert" }, generated: (t: ToastTranslations, credits: number) => ({
generatedDesc: (credits: number) => `${credits} Credits verbraucht`, title: t('ai.generated'),
generationQueued: { title: "Generierung gestartet" }, desc: t('ai.generatedDesc', { credits }),
generationQueuedDesc: "Das Bild erscheint automatisch, sobald es fertig ist.", }),
generationFailed: { title: "Generierung fehlgeschlagen" }, generatedDesc: (t: ToastTranslations, credits: number) => t('ai.generatedDesc', { credits }),
creditsNotCharged: "Credits wurden nicht abgebucht", generationQueued: (t: ToastTranslations) => ({ title: t('ai.generationQueued') }),
insufficientCredits: (needed: number, available: number) => ({ generationQueuedDesc: (t: ToastTranslations) => t('ai.generationQueuedDesc'),
title: "Nicht genügend Credits", generationFailed: (t: ToastTranslations) => ({ title: t('ai.generationFailed') }),
desc: `${needed} Credits benötigt, ${available} verfügbar.`, 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: { export: {
frameExported: { title: "Frame exportiert" }, frameExported: (t: ToastTranslations) => ({ title: t('export.frameExported') }),
exportingFrames: { title: "Frames werden exportiert…" }, exportingFrames: (t: ToastTranslations) => ({ title: t('export.exportingFrames') }),
zipReady: { title: "ZIP bereit" }, zipReady: (t: ToastTranslations) => ({ title: t('export.zipReady') }),
exportFailed: { title: "Export fehlgeschlagen" }, exportFailed: (t: ToastTranslations) => ({ title: t('export.exportFailed') }),
frameEmpty: { frameEmpty: (t: ToastTranslations) => ({
title: "Export fehlgeschlagen", title: t('export.frameEmptyTitle'),
desc: "Frame hat keinen sichtbaren Inhalt.", desc: t('export.frameEmptyDesc'),
}, }),
noFramesOnCanvas: { noFramesOnCanvas: (t: ToastTranslations) => ({
title: "Export fehlgeschlagen", title: t('export.noFramesOnCanvasTitle'),
desc: "Keine Frames auf dem Canvas — zuerst einen Frame anlegen.", desc: t('export.noFramesOnCanvasDesc'),
}, }),
download: "Herunterladen", download: (t: ToastTranslations) => t('export.download'),
downloaded: "Heruntergeladen!", downloaded: (t: ToastTranslations) => t('export.downloaded'),
}, },
auth: { auth: {
welcomeBack: { title: "Willkommen zurück" }, welcomeBack: (t: ToastTranslations) => ({ title: t('auth.welcomeBack') }),
welcomeOnDashboard: { title: "Schön, dass du da bist" }, welcomeOnDashboard: (t: ToastTranslations) => ({ title: t('auth.welcomeOnDashboard') }),
checkEmail: (email: string) => ({ checkEmail: (t: ToastTranslations, email: string) => ({
title: "E-Mail prüfen", title: t('auth.checkEmailTitle'),
desc: `Bestätigungslink an ${email} gesendet.`, 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: { billing: {
subscriptionActivated: (credits: number) => ({ subscriptionActivated: (t: ToastTranslations, credits: number) => ({
title: "Abo aktiviert", title: t('billing.subscriptionActivatedTitle'),
desc: `${credits} Credits deinem Guthaben hinzugefügt.`, desc: t('billing.subscriptionActivatedDesc', { credits }),
}), }),
creditsAdded: (credits: number) => ({ creditsAdded: (t: ToastTranslations, credits: number) => ({
title: "Credits hinzugefügt", title: t('billing.creditsAddedTitle'),
desc: `+${credits} Credits`, desc: t('billing.creditsAddedDesc', { credits }),
}), }),
subscriptionCancelled: (periodEnd: string) => ({ subscriptionCancelled: (t: ToastTranslations, periodEnd: string) => ({
title: "Abo gekündigt", title: t('billing.subscriptionCancelledTitle'),
desc: `Deine Credits bleiben bis ${periodEnd} verfügbar.`, desc: t('billing.subscriptionCancelledDesc', { periodEnd }),
}), }),
paymentFailed: { paymentFailed: (t: ToastTranslations) => ({
title: "Zahlung fehlgeschlagen", title: t('billing.paymentFailedTitle'),
desc: "Bitte Zahlungsmethode aktualisieren.", desc: t('billing.paymentFailedDesc'),
},
dailyLimitReached: (limit: number) => ({
title: "Tageslimit erreicht",
desc: `Maximal ${limit} Generierungen pro Tag in deinem Tarif.`,
}), }),
lowCredits: (remaining: number) => ({ dailyLimitReached: (t: ToastTranslations, limit: number) => ({
title: "Credits fast aufgebraucht", title: t('billing.dailyLimitReachedTitle'),
desc: `Noch ${remaining} Credits übrig.`, desc: t('billing.dailyLimitReachedDesc', { limit }),
}), }),
topUp: "Aufladen", lowCredits: (t: ToastTranslations, remaining: number) => ({
upgrade: "Upgrade", title: t('billing.lowCreditsTitle'),
manage: "Verwalten", desc: t('billing.lowCreditsDesc', { remaining }),
redirectingToCheckout: { }),
title: "Weiterleitung…", topUp: (t: ToastTranslations) => t('billing.topUp'),
desc: "Du wirst zum sicheren Checkout weitergeleitet.", upgrade: (t: ToastTranslations) => t('billing.upgrade'),
}, manage: (t: ToastTranslations) => t('billing.manage'),
openingPortal: { redirectingToCheckout: (t: ToastTranslations) => ({
title: "Portal wird geöffnet…", title: t('billing.redirectingToCheckoutTitle'),
desc: "Du wirst zur Aboverwaltung weitergeleitet.", desc: t('billing.redirectingToCheckoutDesc'),
}, }),
testGrantFailed: { title: "Gutschrift fehlgeschlagen" }, openingPortal: (t: ToastTranslations) => ({
title: t('billing.openingPortalTitle'),
desc: t('billing.openingPortalDesc'),
}),
testGrantFailed: (t: ToastTranslations) => ({ title: t('billing.testGrantFailedTitle') }),
}, },
system: { system: {
reconnected: { title: "Verbindung wiederhergestellt" }, reconnected: (t: ToastTranslations) => ({ title: t('system.reconnected') }),
connectionLost: { connectionLost: (t: ToastTranslations) => ({
title: "Verbindung verloren", title: t('system.connectionLostTitle'),
desc: "Änderungen werden möglicherweise nicht gespeichert.", desc: t('system.connectionLostDesc'),
}, }),
copiedToClipboard: { title: "In Zwischenablage kopiert" }, copiedToClipboard: (t: ToastTranslations) => ({ title: t('system.copiedToClipboard') }),
}, },
dashboard: { dashboard: {
renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." }, renameEmpty: (t: ToastTranslations) => ({
renameSuccess: { title: "Arbeitsbereich umbenannt" }, title: t('dashboard.renameEmptyTitle'),
renameFailed: { title: "Umbenennen fehlgeschlagen" }, desc: t('dashboard.renameEmptyDesc'),
deleteSuccess: { title: "Arbeitsbereich gelöscht" }, }),
deleteFailed: { title: "Löschen fehlgeschlagen" }, 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; } 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 = { const DURATION = {
success: 4000, success: 4000,
@@ -8,70 +11,48 @@ const DURATION = {
info: 4000, info: 4000,
} as const; } as const;
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
export type ToastDurationOverrides = { export type ToastDurationOverrides = {
duration?: number; duration?: number;
}; };
export const toast = { export const toast = {
success( success(message: string, description?: string, opts?: ToastDurationOverrides) {
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.success(message, { return gooeyToast.success(message, {
description, description,
duration: opts?.duration ?? DURATION.success, duration: opts?.duration ?? DURATION.success,
}); });
}, },
error(message: string, description?: string, opts?: ToastDurationOverrides) {
error(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.error(message, { return gooeyToast.error(message, {
description, description,
duration: opts?.duration ?? DURATION.error, duration: opts?.duration ?? DURATION.error,
}); });
}, },
warning(message: string, description?: string, opts?: ToastDurationOverrides) {
warning(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.warning(message, { return gooeyToast.warning(message, {
description, description,
duration: opts?.duration ?? DURATION.warning, duration: opts?.duration ?? DURATION.warning,
}); });
}, },
info(message: string, description?: string, opts?: ToastDurationOverrides) {
info(
message: string,
description?: string,
opts?: ToastDurationOverrides,
) {
return gooeyToast.info(message, { return gooeyToast.info(message, {
description, description,
duration: opts?.duration ?? DURATION.info, duration: opts?.duration ?? DURATION.info,
}); });
}, },
promise<T>(promise: Promise<T>, data: GooeyPromiseData<T>) { promise<T>(promise: Promise<T>, data: GooeyPromiseData<T>) {
return gooeyToast.promise(promise, data); return gooeyToast.promise(promise, data);
}, },
action(message: string, opts: {
action( description?: string;
message: string, label: string;
opts: { onClick: () => void;
description?: string; successLabel?: string;
label: string; type?: "success" | "info" | "warning";
onClick: () => void; duration?: number;
successLabel?: string; }) {
type?: "success" | "info" | "warning";
duration?: number;
},
) {
const t = opts.type ?? "info"; const t = opts.type ?? "info";
return gooeyToast[t](message, { return gooeyToast[t](message, {
description: opts.description, description: opts.description,
@@ -83,18 +64,13 @@ export const toast = {
}, },
}); });
}, },
update(id: string | number, opts: {
update( title?: string;
id: string | number, description?: string;
opts: { type?: "default" | "success" | "error" | "warning" | "info";
title?: string; }) {
description?: string;
type?: "default" | "success" | "error" | "warning" | "info";
},
) {
gooeyToast.update(id, opts); gooeyToast.update(id, opts);
}, },
dismiss(id?: string | number) { dismiss(id?: string | number) {
gooeyToast.dismiss(id); gooeyToast.dismiss(id);
}, },
@@ -107,3 +83,277 @@ export const toastDuration = {
warning: DURATION.warning, warning: DURATION.warning,
info: DURATION.info, info: DURATION.info,
} as const; } 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[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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 { withSentryConfig } from "@sentry/nextjs";
import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
turbopack: { turbopack: {
root: __dirname, root: __dirname,
@@ -26,7 +29,7 @@ const nextConfig: NextConfig = {
}, },
}; };
export default withSentryConfig(nextConfig, { export default withSentryConfig(withNextIntl(nextConfig), {
// For all available options, see: // For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options // 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';