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:
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ChevronDown,
|
||||
Coins,
|
||||
@@ -29,12 +30,12 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { CreditOverview } from "@/components/dashboard/credit-overview";
|
||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
||||
import CanvasCard from "@/components/dashboard/canvas-card";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
|
||||
|
||||
@@ -51,6 +52,7 @@ function getInitials(nameOrEmail: string) {
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const welcomeToastSentRef = useRef(false);
|
||||
const { theme = "system", setTheme } = useTheme();
|
||||
@@ -82,11 +84,11 @@ export default function DashboardPage() {
|
||||
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
|
||||
welcomeToastSentRef.current = true;
|
||||
sessionStorage.setItem(key, "1");
|
||||
toast.success(msg.auth.welcomeOnDashboard.title);
|
||||
}, [session?.user]);
|
||||
toast.success(t('auth.welcomeOnDashboard'));
|
||||
}, [t, session?.user]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
toast.info(msg.auth.signedOut.title);
|
||||
toast.info(t('auth.signedOut'));
|
||||
await authClient.signOut();
|
||||
router.replace("/auth/sign-in");
|
||||
router.refresh();
|
||||
@@ -240,7 +242,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{canvases.map((canvas) => (
|
||||
{canvases.map((canvas: Doc<"canvases">) => (
|
||||
<CanvasCard
|
||||
key={canvas._id}
|
||||
canvas={canvas}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { InitUser } from "@/components/init-user";
|
||||
import { getAuthUser, getToken } from "@/lib/auth-server";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
|
||||
const manrope = Manrope({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
@@ -20,6 +21,8 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const initialToken = await getToken();
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
const user = await getAuthUser();
|
||||
if (user) {
|
||||
const id = user.userId ?? String(user._id);
|
||||
@@ -33,7 +36,7 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
lang={locale}
|
||||
suppressHydrationWarning
|
||||
className={cn("h-full", "antialiased", "font-sans", manrope.variable)}
|
||||
>
|
||||
@@ -56,7 +59,7 @@ export default async function RootLayout({
|
||||
></script>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers initialToken={initialToken}>
|
||||
<Providers initialToken={initialToken} locale={locale} messages={messages}>
|
||||
<InitUser />
|
||||
{children}
|
||||
</Providers>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations('toasts');
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -35,7 +36,7 @@ export default function Home() {
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.info(msg.auth.signedOut.title);
|
||||
toast.info(t('auth.signedOut'));
|
||||
void authClient.signOut().then(() => router.refresh());
|
||||
}}
|
||||
className="rounded-lg border border-border px-6 py-3 text-sm hover:bg-accent"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -9,7 +10,6 @@ import { api } from "@/convex/_generated/api";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { normalizeTier, TIER_MONTHLY_CREDITS } from "@/lib/polar-products";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const TIER_LABELS: Record<keyof typeof TIER_MONTHLY_CREDITS, string> = {
|
||||
free: "Free",
|
||||
@@ -19,6 +19,7 @@ const TIER_LABELS: Record<keyof typeof TIER_MONTHLY_CREDITS, string> = {
|
||||
};
|
||||
|
||||
export function ManageSubscription() {
|
||||
const t = useTranslations('toasts');
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const tier = normalizeTier(subscription?.tier);
|
||||
|
||||
@@ -45,8 +46,8 @@ export function ManageSubscription() {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
toast.info(
|
||||
msg.billing.openingPortal.title,
|
||||
msg.billing.openingPortal.desc,
|
||||
t('billing.openingPortalTitle'),
|
||||
t('billing.openingPortalDesc'),
|
||||
);
|
||||
void authClient.customer.portal();
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -13,18 +14,18 @@ import {
|
||||
TIER_MONTHLY_CREDITS,
|
||||
} from "@/lib/polar-products";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const TIER_ORDER = ["free", "starter", "pro", "max"] as const;
|
||||
|
||||
export function PricingCards() {
|
||||
const t = useTranslations('toasts');
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const currentTier = normalizeTier(subscription?.tier);
|
||||
|
||||
async function handleCheckout(polarProductId: string) {
|
||||
toast.info(
|
||||
msg.billing.redirectingToCheckout.title,
|
||||
msg.billing.redirectingToCheckout.desc,
|
||||
t('billing.redirectingToCheckoutTitle'),
|
||||
t('billing.redirectingToCheckoutDesc'),
|
||||
);
|
||||
await authClient.checkout({ products: [polarProductId] });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CreditCard, Zap } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,18 +10,18 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { TOPUP_PRODUCTS } from "@/lib/polar-products";
|
||||
import { calculateCustomTopup } from "@/lib/topup-calculator";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const CUSTOM_TOPUP_PRODUCT_ID = "POLAR_PRODUCT_ID_TOPUP_CUSTOM";
|
||||
|
||||
export function TopupPanel() {
|
||||
const t = useTranslations('toasts');
|
||||
const [customAmount, setCustomAmount] = useState(20);
|
||||
const { credits, bonusRate } = calculateCustomTopup(customAmount);
|
||||
|
||||
async function handleTopup(polarProductId: string) {
|
||||
toast.info(
|
||||
msg.billing.redirectingToCheckout.title,
|
||||
msg.billing.redirectingToCheckout.desc,
|
||||
t('billing.redirectingToCheckoutTitle'),
|
||||
t('billing.redirectingToCheckoutDesc'),
|
||||
);
|
||||
await authClient.checkout({ products: [polarProductId] });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Monitor,
|
||||
Moon,
|
||||
@@ -36,7 +37,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
|
||||
type CanvasAppMenuProps = {
|
||||
@@ -44,6 +44,7 @@ type CanvasAppMenuProps = {
|
||||
};
|
||||
|
||||
export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const canvas = useAuthQuery(api.canvases.get, { canvasId });
|
||||
const removeCanvas = useMutation(api.canvases.remove);
|
||||
@@ -65,8 +66,7 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
|
||||
const handleRename = async () => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
const { title, desc } = msg.dashboard.renameEmpty;
|
||||
toast.error(title, desc);
|
||||
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === canvas?.name) {
|
||||
@@ -76,10 +76,10 @@ export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
|
||||
setRenameSaving(true);
|
||||
try {
|
||||
await renameCanvas({ canvasId, name: trimmed });
|
||||
toast.success(msg.dashboard.renameSuccess.title);
|
||||
toast.success(t('dashboard.renameSuccess'));
|
||||
setRenameOpen(false);
|
||||
} catch {
|
||||
toast.error(msg.dashboard.renameFailed.title);
|
||||
toast.error(t('dashboard.renameFailed'));
|
||||
} finally {
|
||||
setRenameSaving(false);
|
||||
}
|
||||
|
||||
@@ -6,15 +6,19 @@ import {
|
||||
type Node as RFNode,
|
||||
type OnBeforeDelete,
|
||||
} from "@xyflow/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { computeBridgeCreatesForDeletedNodes } from "@/lib/canvas-utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||
import { type CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||
|
||||
import { getNodeDeleteBlockReason } from "./canvas-helpers";
|
||||
|
||||
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
||||
|
||||
type UseCanvasDeleteHandlersParams = {
|
||||
t: ToastTranslations;
|
||||
canvasId: Id<"canvases">;
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
@@ -32,6 +36,7 @@ type UseCanvasDeleteHandlersParams = {
|
||||
};
|
||||
|
||||
export function useCanvasDeleteHandlers({
|
||||
t,
|
||||
canvasId,
|
||||
nodes,
|
||||
edges,
|
||||
@@ -71,16 +76,20 @@ export function useCanvasDeleteHandlers({
|
||||
}
|
||||
|
||||
if (allowed.length === 0) {
|
||||
const { title, desc } = msg.canvas.nodeDeleteBlockedExplain(blockedReasons);
|
||||
const title = t('canvas.nodeDeleteBlockedTitle');
|
||||
const desc = t('canvas.nodeDeleteBlockedDesc');
|
||||
toast.warning(title, desc);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blocked.length > 0) {
|
||||
const { title, desc } = msg.canvas.nodeDeleteBlockedPartial(
|
||||
blocked.length,
|
||||
blockedReasons,
|
||||
);
|
||||
const title = t('canvas.nodeDeleteBlockedPartialTitle');
|
||||
const whyDesc = t('canvas.nodeDeleteBlockedDesc');
|
||||
const suffix =
|
||||
blocked.length === 1
|
||||
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
|
||||
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blocked.length });
|
||||
const desc = `${whyDesc} ${suffix}`;
|
||||
toast.warning(title, desc);
|
||||
return {
|
||||
nodes: allowed,
|
||||
@@ -140,10 +149,11 @@ export function useCanvasDeleteHandlers({
|
||||
}
|
||||
});
|
||||
|
||||
const { title } = msg.canvas.nodesRemoved(count);
|
||||
const title = t('canvas.nodesRemoved', { count });
|
||||
toast.info(title);
|
||||
},
|
||||
[
|
||||
t,
|
||||
canvasId,
|
||||
deletingNodeIds,
|
||||
edges,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
import {
|
||||
GENERATION_FAILURE_THRESHOLD,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./canvas-helpers";
|
||||
|
||||
export function useGenerationFailureWarnings(
|
||||
t: ReturnType<typeof useTranslations<'toasts'>>,
|
||||
convexNodes: Doc<"nodes">[] | undefined,
|
||||
): void {
|
||||
const recentGenerationFailureTimestampsRef = useRef<number[]>([]);
|
||||
@@ -60,11 +61,11 @@ export function useGenerationFailureWarnings(
|
||||
}
|
||||
|
||||
if (recentFailures.length >= GENERATION_FAILURE_THRESHOLD) {
|
||||
toast.warning(msg.ai.openrouterIssues.title, msg.ai.openrouterIssues.desc);
|
||||
toast.warning(t('ai.openrouterIssuesTitle'), t('ai.openrouterIssuesDesc'));
|
||||
recentGenerationFailureTimestampsRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
recentGenerationFailureTimestampsRef.current = recentFailures;
|
||||
}, [convexNodes]);
|
||||
}, [t, convexNodes]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DefaultEdgeOptions, Edge as RFEdge, Node as RFNode } from "@xyflow
|
||||
|
||||
import { readCanvasOps } from "@/lib/canvas-local-persistence";
|
||||
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||
import type { CanvasNodeDeleteBlockReason } from "@/lib/toast";
|
||||
|
||||
export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
|
||||
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
|
||||
|
||||
@@ -38,12 +38,11 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden overscroll-none">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
orientation="horizontal"
|
||||
className="h-full w-full min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="canvas-sidebar-panel"
|
||||
order={1}
|
||||
defaultSize={SIDEBAR_DEFAULT_SIZE}
|
||||
minSize={SIDEBAR_COLLAPSE_THRESHOLD}
|
||||
maxSize={SIDEBAR_MAX_SIZE}
|
||||
@@ -62,7 +61,6 @@ export function CanvasShell({ canvasId }: CanvasShellProps) {
|
||||
|
||||
<ResizablePanel
|
||||
id="canvas-main-panel"
|
||||
order={2}
|
||||
minSize={MAIN_PANEL_MIN_SIZE}
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LayoutDashboard, LogOut } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
function getInitials(nameOrEmail: string) {
|
||||
const normalized = nameOrEmail.trim();
|
||||
@@ -25,6 +25,7 @@ type CanvasUserMenuProps = {
|
||||
};
|
||||
|
||||
export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
@@ -32,7 +33,7 @@ export function CanvasUserMenu({ compact = false }: CanvasUserMenuProps) {
|
||||
const initials = getInitials(displayName);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
toast.info(msg.auth.signedOut.title);
|
||||
toast.info(t('auth.signedOut'));
|
||||
await authClient.signOut();
|
||||
router.replace("/auth/sign-in");
|
||||
router.refresh();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import {
|
||||
dropCanvasOpsByClientRequestIds,
|
||||
dropCanvasOpsByEdgeIds,
|
||||
@@ -153,6 +153,7 @@ function isLikelyTransientSyncError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
||||
@@ -1583,7 +1584,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useGenerationFailureWarnings(convexNodes);
|
||||
useGenerationFailureWarnings(t, convexNodes);
|
||||
|
||||
const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({
|
||||
scissorsMode,
|
||||
@@ -1596,6 +1597,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
});
|
||||
|
||||
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
|
||||
t,
|
||||
canvasId,
|
||||
nodes,
|
||||
edges,
|
||||
@@ -2456,7 +2458,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to upload dropped file:", err);
|
||||
toast.error(msg.canvas.uploadFailed.title, err instanceof Error ? err.message : undefined);
|
||||
toast.error(t('canvas.uploadFailed'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConvexConnectionState } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast, toastDuration } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected";
|
||||
|
||||
const RECONNECTED_HIDE_DELAY_MS = 1800;
|
||||
|
||||
export default function ConnectionBanner() {
|
||||
const t = useTranslations('toasts');
|
||||
const connectionState = useConvexConnectionState();
|
||||
const previousConnectedRef = useRef(connectionState.isWebSocketConnected);
|
||||
const disconnectToastIdRef = useRef<string | number | undefined>(undefined);
|
||||
@@ -77,8 +78,8 @@ export default function ConnectionBanner() {
|
||||
if (shouldAlertDisconnect) {
|
||||
if (disconnectToastIdRef.current === undefined) {
|
||||
disconnectToastIdRef.current = toast.error(
|
||||
msg.system.connectionLost.title,
|
||||
msg.system.connectionLost.desc,
|
||||
t('system.connectionLostTitle'),
|
||||
t('system.connectionLostDesc'),
|
||||
{ duration: Number.POSITIVE_INFINITY },
|
||||
);
|
||||
}
|
||||
@@ -88,11 +89,12 @@ export default function ConnectionBanner() {
|
||||
if (connected && disconnectToastIdRef.current !== undefined) {
|
||||
toast.dismiss(disconnectToastIdRef.current);
|
||||
disconnectToastIdRef.current = undefined;
|
||||
toast.success(msg.system.reconnected.title, undefined, {
|
||||
toast.success(t('system.reconnected'), undefined, {
|
||||
duration: toastDuration.successShort,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
t,
|
||||
connectionState.connectionRetries,
|
||||
connectionState.hasEverConnected,
|
||||
connectionState.isWebSocketConnected,
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useMutation } from "convex/react";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Coins } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
free: "Free",
|
||||
@@ -28,6 +28,7 @@ const showTestCreditGrant =
|
||||
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
|
||||
|
||||
export function CreditDisplay() {
|
||||
const t = useTranslations('toasts');
|
||||
const balance = useAuthQuery(api.credits.getBalance);
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const grantTestCredits = useMutation(api.credits.grantTestCredits);
|
||||
@@ -92,15 +93,14 @@ export function CreditDisplay() {
|
||||
onClick={() => {
|
||||
void grantTestCredits({ amount: 2000 })
|
||||
.then((r) => {
|
||||
const { title, desc } = msg.billing.creditsAdded(2000);
|
||||
toast.success(
|
||||
title,
|
||||
`${desc} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
|
||||
t('billing.creditsAddedTitle'),
|
||||
`${t('billing.creditsAddedDesc', { credits: 2000 })} — Stand: ${r.newBalance.toLocaleString("de-DE")}`,
|
||||
);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
toast.error(
|
||||
msg.billing.testGrantFailed.title,
|
||||
t('billing.testGrantFailedTitle'),
|
||||
e instanceof Error ? e.message : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import JSZip from "jszip";
|
||||
import { Archive, Loader2 } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
interface ExportButtonProps {
|
||||
canvasName?: string;
|
||||
}
|
||||
|
||||
export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const { getNodes } = useReactFlow();
|
||||
const exportFrame = useAction(api.export.exportFrame);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
@@ -72,19 +73,19 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
|
||||
try {
|
||||
await toast.promise(runExport(), {
|
||||
loading: msg.export.exportingFrames.title,
|
||||
success: msg.export.zipReady.title,
|
||||
loading: t('export.exportingFrames'),
|
||||
success: t('export.zipReady'),
|
||||
error: (err) => {
|
||||
const m = err instanceof Error ? err.message : "";
|
||||
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.title;
|
||||
if (m.includes("No images found")) return msg.export.frameEmpty.title;
|
||||
return msg.export.exportFailed.title;
|
||||
if (m === NO_FRAMES) return t('export.noFramesOnCanvasTitle');
|
||||
if (m.includes("No images found")) return t('export.frameEmptyTitle');
|
||||
return t('export.exportFailed');
|
||||
},
|
||||
description: {
|
||||
error: (err) => {
|
||||
const m = err instanceof Error ? err.message : "";
|
||||
if (m === NO_FRAMES) return msg.export.noFramesOnCanvas.desc;
|
||||
if (m.includes("No images found")) return msg.export.frameEmpty.desc;
|
||||
if (m === NO_FRAMES) return t('export.noFramesOnCanvasDesc');
|
||||
if (m.includes("No images found")) return t('export.frameEmptyDesc');
|
||||
return m || undefined;
|
||||
},
|
||||
},
|
||||
@@ -92,17 +93,17 @@ export function ExportButton({ canvasName = "canvas" }: ExportButtonProps) {
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : "";
|
||||
if (m === NO_FRAMES) {
|
||||
setError(msg.export.noFramesOnCanvas.desc);
|
||||
setError(t('export.noFramesOnCanvasDesc'));
|
||||
} else if (m.includes("No images found")) {
|
||||
setError(msg.export.frameEmpty.desc);
|
||||
setError(t('export.frameEmptyDesc'));
|
||||
} else {
|
||||
setError(m || msg.export.exportFailed.title);
|
||||
setError(m || t('export.exportFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setProgress(null);
|
||||
}
|
||||
}, [canvasName, exportFrame, getNodes, isExporting]);
|
||||
}, [t, canvasName, exportFrame, getNodes, isExporting]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Handle, Position, useReactFlow, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
|
||||
import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
|
||||
import { classifyError, type ErrorType } from "@/lib/ai-errors";
|
||||
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import {
|
||||
Loader2,
|
||||
@@ -59,6 +59,7 @@ export default function AiImageNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<AiImageNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const nodeData = data as AiImageNodeData;
|
||||
const { getEdges, getNode } = useReactFlow();
|
||||
const { status: syncStatus } = useCanvasSync();
|
||||
@@ -135,17 +136,17 @@ export default function AiImageNode({
|
||||
aspectRatio: nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO,
|
||||
}),
|
||||
{
|
||||
loading: msg.ai.generating.title,
|
||||
success: msg.ai.generationQueued.title,
|
||||
error: msg.ai.generationFailed.title,
|
||||
loading: t('ai.generating'),
|
||||
success: t('ai.generationQueued'),
|
||||
error: t('ai.generationFailed'),
|
||||
description: {
|
||||
success: msg.ai.generationQueuedDesc,
|
||||
error: msg.ai.creditsNotCharged,
|
||||
success: t('ai.generationQueuedDesc'),
|
||||
error: t('ai.creditsNotCharged'),
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : msg.ai.generationFailed.title);
|
||||
setLocalError(err instanceof Error ? err.message : t('ai.generationFailed'));
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
@@ -154,16 +155,16 @@ export default function AiImageNode({
|
||||
const modelName =
|
||||
getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI";
|
||||
|
||||
const renderErrorIcon = (category: AiErrorCategory) => {
|
||||
switch (category) {
|
||||
case "insufficient_credits":
|
||||
const renderErrorIcon = (type: ErrorType) => {
|
||||
switch (type) {
|
||||
case "insufficientCredits":
|
||||
return <Coins className="h-8 w-8 text-amber-500" />;
|
||||
case "rate_limited":
|
||||
case "rateLimited":
|
||||
case "timeout":
|
||||
return <Clock3 className="h-8 w-8 text-amber-500" />;
|
||||
case "content_policy":
|
||||
case "contentPolicy":
|
||||
return <ShieldAlert className="h-8 w-8 text-destructive" />;
|
||||
case "network":
|
||||
case "networkError":
|
||||
return <WifiOff className="h-8 w-8 text-destructive" />;
|
||||
default:
|
||||
return <AlertCircle className="h-8 w-8 text-destructive" />;
|
||||
@@ -226,15 +227,10 @@ export default function AiImageNode({
|
||||
|
||||
{status === "error" && !isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-muted">
|
||||
{renderErrorIcon(classifiedError.category)}
|
||||
{renderErrorIcon(classifiedError.type)}
|
||||
<p className="px-4 text-center text-xs font-medium text-destructive">
|
||||
{classifiedError.message}
|
||||
{classifiedError.rawMessage}
|
||||
</p>
|
||||
{classifiedError.detail && (
|
||||
<p className="px-6 text-center text-[10px] text-muted-foreground">
|
||||
{classifiedError.detail}
|
||||
</p>
|
||||
)}
|
||||
{classifiedError.creditsNotCharged && (
|
||||
<p className="px-6 text-center text-[10px] text-muted-foreground">
|
||||
Credits not charged
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
|
||||
interface FrameNodeData {
|
||||
@@ -19,6 +19,7 @@ interface FrameNodeData {
|
||||
}
|
||||
|
||||
export default function FrameNode({ id, data, selected, width, height }: NodeProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const nodeData = data as FrameNodeData;
|
||||
const { queueNodeDataUpdate, status } = useCanvasSync();
|
||||
const exportFrame = useAction(api.export.exportFrame);
|
||||
@@ -54,23 +55,23 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
||||
try {
|
||||
const result = await exportFrame({ frameNodeId: id as Id<"nodes"> });
|
||||
const fileLabel = `${label.trim() || "frame"}.png`;
|
||||
toast.action(msg.export.frameExported.title, {
|
||||
toast.action(t('export.frameExported'), {
|
||||
description: fileLabel,
|
||||
label: msg.export.download,
|
||||
label: t('export.download'),
|
||||
onClick: () => {
|
||||
window.open(result.url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
successLabel: msg.export.downloaded,
|
||||
successLabel: t('export.downloaded'),
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
const m = error instanceof Error ? error.message : "";
|
||||
if (m.includes("No images found")) {
|
||||
toast.error(msg.export.frameEmpty.title, msg.export.frameEmpty.desc);
|
||||
setExportError(msg.export.frameEmpty.desc);
|
||||
toast.error(t('export.frameEmptyTitle'), t('export.frameEmptyDesc'));
|
||||
setExportError(t('export.frameEmptyDesc'));
|
||||
} else {
|
||||
toast.error(msg.export.exportFailed.title, m || undefined);
|
||||
setExportError(m || msg.export.exportFailed.title);
|
||||
toast.error(t('export.exportFailed'), m || undefined);
|
||||
setExportError(m || t('export.exportFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import { useMutation } from "convex/react";
|
||||
@@ -73,6 +73,7 @@ export default function ImageNode({
|
||||
width,
|
||||
height,
|
||||
}: NodeProps<ImageNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
|
||||
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -121,17 +122,17 @@ export default function ImageNode({
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||
const { title, desc } = msg.canvas.uploadFormatError(
|
||||
file.type || file.name.split(".").pop() || "—",
|
||||
toast.error(
|
||||
t('canvas.uploadFailed'),
|
||||
t('canvas.uploadFormatError', { format: file.type || file.name.split(".").pop() || "—" }),
|
||||
);
|
||||
toast.error(title, desc);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
const { title, desc } = msg.canvas.uploadSizeError(
|
||||
Math.round(MAX_IMAGE_BYTES / (1024 * 1024)),
|
||||
toast.error(
|
||||
t('canvas.uploadFailed'),
|
||||
t('canvas.uploadSizeError', { maxMb: Math.round(MAX_IMAGE_BYTES / (1024 * 1024)) }),
|
||||
);
|
||||
toast.error(title, desc);
|
||||
return;
|
||||
}
|
||||
if (status.isOffline) {
|
||||
@@ -188,11 +189,11 @@ export default function ImageNode({
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(msg.canvas.imageUploaded.title);
|
||||
toast.success(t('canvas.imageUploaded'));
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
toast.error(
|
||||
msg.canvas.uploadFailed.title,
|
||||
t('canvas.uploadFailed'),
|
||||
err instanceof Error ? err.message : undefined,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import { useAction } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
import { Sparkles, Loader2, Coins } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
import { classifyError } from "@/lib/ai-errors";
|
||||
|
||||
type PromptNodeData = {
|
||||
@@ -57,6 +57,7 @@ export default function PromptNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps<PromptNode>) {
|
||||
const t = useTranslations('toasts');
|
||||
const nodeData = data as PromptNodeData;
|
||||
const router = useRouter();
|
||||
const { getEdges, getNode } = useReactFlow();
|
||||
@@ -166,13 +167,9 @@ export default function PromptNode({
|
||||
}
|
||||
|
||||
if (availableCredits !== null && !hasEnoughCredits) {
|
||||
const { title, desc } = msg.ai.insufficientCredits(
|
||||
creditCost,
|
||||
availableCredits,
|
||||
);
|
||||
toast.action(title, {
|
||||
description: desc,
|
||||
label: msg.billing.topUp,
|
||||
toast.action(t('ai.insufficientCreditsTitle'), {
|
||||
description: t('ai.insufficientCreditsDesc', { needed: creditCost, available: availableCredits }),
|
||||
label: t('billing.topUp'),
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
type: "warning",
|
||||
});
|
||||
@@ -256,30 +253,30 @@ export default function PromptNode({
|
||||
aspectRatio,
|
||||
}),
|
||||
{
|
||||
loading: msg.ai.generating.title,
|
||||
success: msg.ai.generationQueued.title,
|
||||
error: msg.ai.generationFailed.title,
|
||||
loading: t('ai.generating'),
|
||||
success: t('ai.generationQueued'),
|
||||
error: t('ai.generationFailed'),
|
||||
description: {
|
||||
success: msg.ai.generationQueuedDesc,
|
||||
error: msg.ai.creditsNotCharged,
|
||||
success: t('ai.generationQueuedDesc'),
|
||||
error: t('ai.creditsNotCharged'),
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const classified = classifyError(err);
|
||||
|
||||
if (classified.category === "daily_cap") {
|
||||
if (classified.type === "dailyCap") {
|
||||
toast.error(
|
||||
msg.billing.dailyLimitReached(0).title,
|
||||
t('billing.dailyLimitReachedTitle'),
|
||||
"Morgen stehen wieder Generierungen zur Verfügung.",
|
||||
);
|
||||
} else if (classified.category === "concurrency") {
|
||||
} else if (classified.type === "concurrency") {
|
||||
toast.warning(
|
||||
msg.ai.concurrentLimitReached.title,
|
||||
msg.ai.concurrentLimitReached.desc,
|
||||
t('ai.concurrentLimitReachedTitle'),
|
||||
t('ai.concurrentLimitReachedDesc'),
|
||||
);
|
||||
} else {
|
||||
setError(classified.message || msg.ai.generationFailed.title);
|
||||
setError(classified.rawMessage || t('ai.generationFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowUpRight, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ interface CanvasCardProps {
|
||||
}
|
||||
|
||||
export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
const t = useTranslations('toasts');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(canvas.name);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -60,8 +61,7 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmedName = editName.trim();
|
||||
if (!trimmedName) {
|
||||
const { title, desc } = msg.dashboard.renameEmpty;
|
||||
toast.error(title, desc);
|
||||
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
|
||||
return;
|
||||
}
|
||||
if (trimmedName === canvas.name) {
|
||||
@@ -74,15 +74,15 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateCanvas({ canvasId: canvas._id, name: trimmedName });
|
||||
toast.success(msg.dashboard.renameSuccess.title);
|
||||
toast.success(t('dashboard.renameSuccess'));
|
||||
setIsEditing(false);
|
||||
} catch {
|
||||
toast.error(msg.dashboard.renameFailed.title);
|
||||
toast.error(t('dashboard.renameFailed'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
saveInFlightRef.current = false;
|
||||
}
|
||||
}, [editName, canvas.name, canvas._id, updateCanvas]);
|
||||
}, [t, editName, canvas.name, canvas._id, updateCanvas]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -115,14 +115,14 @@ export default function CanvasCard({ canvas, onNavigate }: CanvasCardProps) {
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await removeCanvas({ canvasId: canvas._id });
|
||||
toast.success(msg.dashboard.deleteSuccess.title);
|
||||
toast.success(t('dashboard.deleteSuccess'));
|
||||
setDeleteOpen(false);
|
||||
} catch {
|
||||
toast.error(msg.dashboard.deleteFailed.title);
|
||||
toast.error(t('dashboard.deleteFailed'));
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}, [canvas._id, removeCanvas]);
|
||||
}, [t, canvas._id, removeCanvas]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -10,10 +11,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { formatEurFromCents } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier-Config — monatliches Credit-Kontingent pro Tier (in Cent)
|
||||
@@ -42,7 +41,12 @@ const TIER_BADGE_STYLES: Record<string, string> = {
|
||||
const LOW_CREDITS_THRESHOLD = 20;
|
||||
|
||||
export function CreditOverview() {
|
||||
const t = useTranslations('toasts');
|
||||
const router = useRouter();
|
||||
const format = useFormatter();
|
||||
|
||||
const formatEurFromCents = (cents: number) =>
|
||||
format.number(cents / 100, { style: "currency", currency: "EUR" });
|
||||
const balance = useAuthQuery(api.credits.getBalance);
|
||||
const subscription = useAuthQuery(api.credits.getSubscription);
|
||||
const usageStats = useAuthQuery(api.credits.getUsageStats);
|
||||
@@ -56,14 +60,13 @@ export function CreditOverview() {
|
||||
if (typeof window !== "undefined" && sessionStorage.getItem(key)) return;
|
||||
sessionStorage.setItem(key, "1");
|
||||
|
||||
const { title, desc } = msg.billing.lowCredits(available);
|
||||
toast.action(title, {
|
||||
description: desc,
|
||||
label: msg.billing.topUp,
|
||||
toast.action(t('billing.lowCreditsTitle'), {
|
||||
description: t('billing.lowCreditsDesc', { remaining: available }),
|
||||
label: t('billing.topUp'),
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
type: "warning",
|
||||
});
|
||||
}, [balance, router]);
|
||||
}, [t, balance, router]);
|
||||
|
||||
// ── Loading State ──────────────────────────────────────────────────────
|
||||
if (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { useFormatter } from "next-intl";
|
||||
import { Activity, Coins } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { formatEurFromCents, cn } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/format-time";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -45,10 +45,28 @@ function truncatedDescription(text: string, maxLen = 40) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RecentTransactions() {
|
||||
const format = useFormatter();
|
||||
const transactions = useAuthQuery(api.credits.getRecentTransactions, {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const formatEurFromCents = (cents: number) =>
|
||||
format.number(cents / 100, { style: "currency", currency: "EUR" });
|
||||
|
||||
const formatRelativeTime = (timestamp: number) => {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "Gerade eben";
|
||||
if (minutes < 60) return `vor ${minutes} Min.`;
|
||||
if (hours < 24) return `vor ${hours} Std.`;
|
||||
if (days < 7) return days === 1 ? "vor 1 Tag" : `vor ${days} Tagen`;
|
||||
return format.dateTime(timestamp, { day: "numeric", month: "short" });
|
||||
};
|
||||
|
||||
// ── Loading State ──────────────────────────────────────────────────────
|
||||
if (transactions === undefined) {
|
||||
return (
|
||||
@@ -102,7 +120,7 @@ export function RecentTransactions() {
|
||||
Letzte Aktivität
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{transactions.map((t) => {
|
||||
{transactions.map((t: NonNullable<typeof transactions>[number]) => {
|
||||
const isCredit = t.amount > 0;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useMutation } from "convex/react";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
|
||||
/**
|
||||
* Initialisiert die Credit-Balance für neue User.
|
||||
@@ -14,6 +14,7 @@ import { msg } from "@/lib/toast-messages";
|
||||
* dass jeder eingeloggte User eine Balance + Free-Subscription hat.
|
||||
*/
|
||||
export function InitUser() {
|
||||
const t = useTranslations('toasts');
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const balance = useAuthQuery(api.credits.getBalance);
|
||||
@@ -34,12 +35,12 @@ export function InitUser() {
|
||||
|
||||
void initBalance()
|
||||
.then(() => {
|
||||
toast.success(msg.auth.initialSetup.title, msg.auth.initialSetup.desc);
|
||||
toast.success(t('auth.initialSetupTitle'), t('auth.initialSetupDesc'));
|
||||
})
|
||||
.catch(() => {
|
||||
initStartedRef.current = false;
|
||||
});
|
||||
}, [session?.user, balance, initBalance]);
|
||||
}, [t, session?.user, balance, initBalance]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
24
components/locale-switcher.tsx
Normal file
24
components/locale-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { ConvexReactClient } from "convex/react";
|
||||
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
||||
import { AuthUIProvider } from "@daveyplate/better-auth-ui";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import type { AbstractIntlMessages } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GooeyToaster } from "goey-toast";
|
||||
@@ -35,14 +37,19 @@ function SentryAuthUserSync() {
|
||||
export function Providers({
|
||||
children,
|
||||
initialToken,
|
||||
locale,
|
||||
messages,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialToken?: string | null;
|
||||
locale?: string;
|
||||
messages?: AbstractIntlMessages;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ConvexBetterAuthProvider
|
||||
client={convex}
|
||||
authClient={authClient}
|
||||
@@ -66,6 +73,7 @@ export function Providers({
|
||||
/>
|
||||
</AuthUIProvider>
|
||||
</ConvexBetterAuthProvider>
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -22,6 +22,7 @@ import type * as openrouter from "../openrouter.js";
|
||||
import type * as pexels from "../pexels.js";
|
||||
import type * as polar from "../polar.js";
|
||||
import type * as storage from "../storage.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
@@ -44,6 +45,7 @@ declare const fullApi: ApiFromModules<{
|
||||
pexels: typeof pexels;
|
||||
polar: typeof polar;
|
||||
storage: typeof storage;
|
||||
users: typeof users;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
31
convex/ai.ts
31
convex/ai.ts
@@ -1,4 +1,4 @@
|
||||
import { v } from "convex/values";
|
||||
import { v, ConvexError } from "convex/values";
|
||||
import { action, internalAction, internalMutation } from "./_generated/server";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import {
|
||||
@@ -18,6 +18,19 @@ type ErrorCategory =
|
||||
| "provider"
|
||||
| "unknown";
|
||||
|
||||
interface ErrorData {
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
if (error instanceof ConvexError) {
|
||||
const data = error.data as ErrorData;
|
||||
return data?.code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error ?? "Generation failed");
|
||||
@@ -34,20 +47,23 @@ function categorizeError(error: unknown): {
|
||||
category: ErrorCategory;
|
||||
retryable: boolean;
|
||||
} {
|
||||
const code = getErrorCode(error);
|
||||
const message = errorMessage(error);
|
||||
const lower = message.toLowerCase();
|
||||
const status = parseOpenRouterStatus(message);
|
||||
|
||||
if (
|
||||
lower.includes("insufficient credits") ||
|
||||
lower.includes("daily generation limit") ||
|
||||
lower.includes("concurrent job limit")
|
||||
code === "CREDITS_TEST_DISABLED" ||
|
||||
code === "CREDITS_INVALID_AMOUNT" ||
|
||||
code === "CREDITS_BALANCE_NOT_FOUND" ||
|
||||
code === "CREDITS_DAILY_CAP_REACHED" ||
|
||||
code === "CREDITS_CONCURRENCY_LIMIT"
|
||||
) {
|
||||
return { category: "credits", retryable: false };
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("modell lehnt ab") ||
|
||||
code === "OPENROUTER_MODEL_REFUSAL" ||
|
||||
lower.includes("content policy") ||
|
||||
lower.includes("policy") ||
|
||||
lower.includes("moderation") ||
|
||||
@@ -94,6 +110,11 @@ function categorizeError(error: unknown): {
|
||||
}
|
||||
|
||||
function formatTerminalStatusMessage(error: unknown): string {
|
||||
const code = getErrorCode(error);
|
||||
if (code) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const message = errorMessage(error).trim() || "Generation failed";
|
||||
const { category } = categorizeError(error);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { v, ConvexError } from "convex/values";
|
||||
import { optionalAuth, requireAuth } from "./helpers";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
@@ -275,10 +275,10 @@ export const grantTestCredits = mutation({
|
||||
},
|
||||
handler: async (ctx, { amount = 2000 }) => {
|
||||
if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") {
|
||||
throw new Error("Test-Gutschriften sind deaktiviert (ALLOW_TEST_CREDIT_GRANT).");
|
||||
throw new ConvexError({ code: "CREDITS_TEST_DISABLED" });
|
||||
}
|
||||
if (amount <= 0 || amount > 1_000_000) {
|
||||
throw new Error("Ungültiger Betrag.");
|
||||
throw new ConvexError({ code: "CREDITS_INVALID_AMOUNT" });
|
||||
}
|
||||
const user = await requireAuth(ctx);
|
||||
const balance = await ctx.db
|
||||
@@ -287,7 +287,7 @@ export const grantTestCredits = mutation({
|
||||
.unique();
|
||||
|
||||
if (!balance) {
|
||||
throw new Error("Keine Credit-Balance — zuerst einloggen / initBalance.");
|
||||
throw new ConvexError({ code: "CREDITS_BALANCE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const next = balance.balance + amount;
|
||||
@@ -362,16 +362,18 @@ export const reserve = mutation({
|
||||
.unique();
|
||||
|
||||
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
|
||||
throw new Error(
|
||||
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_DAILY_CAP_REACHED",
|
||||
data: { limit: config.dailyGenerationCap, tier },
|
||||
});
|
||||
}
|
||||
|
||||
// Concurrency Limit prüfen
|
||||
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
|
||||
throw new Error(
|
||||
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_CONCURRENCY_LIMIT",
|
||||
data: { limit: config.concurrencyLimit },
|
||||
});
|
||||
}
|
||||
|
||||
// Credits reservieren
|
||||
@@ -487,7 +489,14 @@ export const commit = mutation({
|
||||
actualCost: v.number(),
|
||||
openRouterCost: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { transactionId, actualCost, openRouterCost }) => {
|
||||
handler: async (
|
||||
ctx,
|
||||
{ transactionId, actualCost, openRouterCost }
|
||||
): Promise<
|
||||
{ status: "already_committed" } |
|
||||
{ status: "already_released" } |
|
||||
{ status: "committed" }
|
||||
> => {
|
||||
const user = await requireAuth(ctx);
|
||||
const transaction = await ctx.db.get(transactionId);
|
||||
if (!transaction || transaction.userId !== user.userId) {
|
||||
@@ -571,7 +580,14 @@ export const release = mutation({
|
||||
args: {
|
||||
transactionId: v.id("creditTransactions"),
|
||||
},
|
||||
handler: async (ctx, { transactionId }) => {
|
||||
handler: async (
|
||||
ctx,
|
||||
{ transactionId }
|
||||
): Promise<
|
||||
{ status: "already_released" } |
|
||||
{ status: "already_committed" } |
|
||||
{ status: "released" }
|
||||
> => {
|
||||
const user = await requireAuth(ctx);
|
||||
const transaction = await ctx.db.get(transactionId);
|
||||
if (!transaction || transaction.userId !== user.userId) {
|
||||
@@ -761,16 +777,18 @@ export const checkAbuseLimits = internalMutation({
|
||||
|
||||
const dailyCount = usage?.generationCount ?? 0;
|
||||
if (dailyCount >= config.dailyGenerationCap) {
|
||||
throw new Error(
|
||||
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_DAILY_CAP_REACHED",
|
||||
data: { limit: config.dailyGenerationCap, tier },
|
||||
});
|
||||
}
|
||||
|
||||
const currentConcurrency = usage?.concurrentJobs ?? 0;
|
||||
if (currentConcurrency >= config.concurrencyLimit) {
|
||||
throw new Error(
|
||||
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_CONCURRENCY_LIMIT",
|
||||
data: { limit: config.concurrencyLimit },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export const exportFrame = action({
|
||||
});
|
||||
|
||||
// Find image/ai-image nodes visually within the frame
|
||||
const imageNodes = allNodes.filter((node) => {
|
||||
const imageNodes = allNodes.filter((node: (typeof allNodes)[number]) => {
|
||||
if (node.type !== "image" && node.type !== "ai-image") return false;
|
||||
const data = node.data as { storageId?: string };
|
||||
if (!data.storageId) return false;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
|
||||
export interface OpenRouterModel {
|
||||
@@ -144,7 +146,7 @@ export async function generateImageViaOpenRouter(
|
||||
|
||||
const message = data?.choices?.[0]?.message as Record<string, unknown> | undefined;
|
||||
if (!message) {
|
||||
throw new Error("OpenRouter: choices[0].message fehlt");
|
||||
throw new ConvexError({ code: "OPENROUTER_MISSING_MESSAGE" });
|
||||
}
|
||||
|
||||
let rawImage: string | undefined;
|
||||
@@ -186,7 +188,10 @@ export async function generateImageViaOpenRouter(
|
||||
) {
|
||||
const r =
|
||||
typeof refusal === "string" ? refusal : JSON.stringify(refusal);
|
||||
throw new Error(`OpenRouter: Modell lehnt ab — ${r.slice(0, 500)}`);
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_MODEL_REFUSAL",
|
||||
data: { reason: r.slice(0, 500) },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -205,19 +210,23 @@ export async function generateImageViaOpenRouter(
|
||||
: Array.isArray(content)
|
||||
? JSON.stringify(content).slice(0, 400)
|
||||
: "";
|
||||
throw new Error(
|
||||
`OpenRouter: kein Bild in der Antwort. Keys: ${Object.keys(message).join(", ")}. ` +
|
||||
(reasoning ? `reasoning: ${reasoning}` : `content: ${contentPreview}`),
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_NO_IMAGE_IN_RESPONSE",
|
||||
data: {
|
||||
keys: Object.keys(message).join(", "),
|
||||
reasoningOrContent: reasoning || contentPreview,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let dataUri = rawImage;
|
||||
if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) {
|
||||
const imgRes = await fetch(rawImage);
|
||||
if (!imgRes.ok) {
|
||||
throw new Error(
|
||||
`OpenRouter: Bild-URL konnte nicht geladen werden (${imgRes.status})`,
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_IMAGE_URL_LOAD_FAILED",
|
||||
data: { status: imgRes.status },
|
||||
});
|
||||
}
|
||||
const mimeTypeFromRes =
|
||||
imgRes.headers.get("content-type") ?? "image/png";
|
||||
@@ -237,12 +246,12 @@ export async function generateImageViaOpenRouter(
|
||||
}
|
||||
|
||||
if (!dataUri.startsWith("data:")) {
|
||||
throw new Error("OpenRouter: Bild konnte nicht als data-URI erstellt werden");
|
||||
throw new ConvexError({ code: "OPENROUTER_DATA_URI_CREATION_FAILED" });
|
||||
}
|
||||
|
||||
const comma = dataUri.indexOf(",");
|
||||
if (comma === -1) {
|
||||
throw new Error("OpenRouter: data-URI ohne Base64-Teil");
|
||||
throw new ConvexError({ code: "OPENROUTER_DATA_URI_MISSING_BASE64" });
|
||||
}
|
||||
const meta = dataUri.slice(0, comma);
|
||||
const base64Data = dataUri.slice(comma + 1);
|
||||
|
||||
@@ -310,4 +310,16 @@ export default defineSchema({
|
||||
concurrentJobs: v.number(), // Aktuell laufende Jobs
|
||||
})
|
||||
.index("by_user_date", ["userId", "date"]),
|
||||
|
||||
// ==========================================================================
|
||||
// User Settings
|
||||
// ==========================================================================
|
||||
|
||||
userSettings: defineTable({
|
||||
userId: v.string(), // Better Auth User ID
|
||||
locale: v.optional(v.union(v.literal('de'), v.literal('en'))),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_user", ["userId"]),
|
||||
});
|
||||
|
||||
47
convex/users.ts
Normal file
47
convex/users.ts
Normal 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
18
i18n/request.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
176
lib/ai-errors.ts
176
lib/ai-errors.ts
@@ -1,23 +1,22 @@
|
||||
export type AiErrorCategory =
|
||||
| "insufficient_credits"
|
||||
| "rate_limited"
|
||||
| "content_policy"
|
||||
export type ErrorType =
|
||||
| "timeout"
|
||||
| "network"
|
||||
| "server"
|
||||
| "invalid_request"
|
||||
| "daily_cap"
|
||||
| "concurrency"
|
||||
| "unknown";
|
||||
| "insufficientCredits"
|
||||
| "networkError"
|
||||
| "rateLimited"
|
||||
| "modelUnavailable"
|
||||
| "generic"
|
||||
| "contentPolicy"
|
||||
| "invalidRequest"
|
||||
| "dailyCap"
|
||||
| "concurrency";
|
||||
|
||||
export interface AiError {
|
||||
category: AiErrorCategory;
|
||||
message: string;
|
||||
detail?: string;
|
||||
type: ErrorType;
|
||||
retryable: boolean;
|
||||
creditsNotCharged: boolean;
|
||||
showTopUp: boolean;
|
||||
retryCount?: number;
|
||||
rawMessage?: string;
|
||||
}
|
||||
|
||||
type RawErrorObject = {
|
||||
@@ -27,45 +26,50 @@ type RawErrorObject = {
|
||||
retryCount?: unknown;
|
||||
};
|
||||
|
||||
const CATEGORY_ALIASES: Record<string, AiErrorCategory> = {
|
||||
insufficient_credits: "insufficient_credits",
|
||||
insufficientcredits: "insufficient_credits",
|
||||
not_enough_credits: "insufficient_credits",
|
||||
notenoughcredits: "insufficient_credits",
|
||||
credits: "insufficient_credits",
|
||||
payment_required: "insufficient_credits",
|
||||
paymentrequired: "insufficient_credits",
|
||||
rate_limit: "rate_limited",
|
||||
ratelimit: "rate_limited",
|
||||
rate_limited: "rate_limited",
|
||||
ratelimited: "rate_limited",
|
||||
too_many_requests: "rate_limited",
|
||||
toomanyrequests: "rate_limited",
|
||||
content_policy: "content_policy",
|
||||
contentpolicy: "content_policy",
|
||||
safety: "content_policy",
|
||||
const TYPE_ALIASES: Record<string, ErrorType> = {
|
||||
insufficient_credits: "insufficientCredits",
|
||||
insufficientcredits: "insufficientCredits",
|
||||
not_enough_credits: "insufficientCredits",
|
||||
notenoughcredits: "insufficientCredits",
|
||||
credits: "insufficientCredits",
|
||||
payment_required: "insufficientCredits",
|
||||
paymentrequired: "insufficientCredits",
|
||||
rate_limit: "rateLimited",
|
||||
ratelimit: "rateLimited",
|
||||
rate_limited: "rateLimited",
|
||||
ratelimited: "rateLimited",
|
||||
too_many_requests: "rateLimited",
|
||||
toomanyrequests: "rateLimited",
|
||||
content_policy: "contentPolicy",
|
||||
contentpolicy: "contentPolicy",
|
||||
safety: "contentPolicy",
|
||||
timeout: "timeout",
|
||||
timed_out: "timeout",
|
||||
timedout: "timeout",
|
||||
network: "network",
|
||||
connection: "network",
|
||||
server: "server",
|
||||
invalid_request: "invalid_request",
|
||||
invalidrequest: "invalid_request",
|
||||
bad_request: "invalid_request",
|
||||
badrequest: "invalid_request",
|
||||
daily_cap: "daily_cap",
|
||||
dailycap: "daily_cap",
|
||||
daily_limit: "daily_cap",
|
||||
dailylimit: "daily_cap",
|
||||
network: "networkError",
|
||||
connection: "networkError",
|
||||
networkerror: "networkError",
|
||||
server: "modelUnavailable",
|
||||
model_unavailable: "modelUnavailable",
|
||||
modelunavailable: "modelUnavailable",
|
||||
invalid_request: "invalidRequest",
|
||||
invalidrequest: "invalidRequest",
|
||||
bad_request: "invalidRequest",
|
||||
badrequest: "invalidRequest",
|
||||
unknown_model: "invalidRequest",
|
||||
daily_cap: "dailyCap",
|
||||
dailycap: "dailyCap",
|
||||
daily_limit: "dailyCap",
|
||||
dailylimit: "dailyCap",
|
||||
concurrency: "concurrency",
|
||||
concurrent: "concurrency",
|
||||
unknown: "generic",
|
||||
};
|
||||
|
||||
function normalizeCategory(value: string | undefined): AiErrorCategory | undefined {
|
||||
function normalizeType(value: string | undefined): ErrorType | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.toLowerCase().replace(/[^a-z]/g, "");
|
||||
return CATEGORY_ALIASES[normalized];
|
||||
return TYPE_ALIASES[normalized];
|
||||
}
|
||||
|
||||
function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): number | undefined {
|
||||
@@ -86,15 +90,15 @@ function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): numb
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cleanPrefixMessage(text: string): { category?: AiErrorCategory; message: string } {
|
||||
function cleanPrefixMessage(text: string): { type?: ErrorType; message: string } {
|
||||
const trimmed = text.trim();
|
||||
|
||||
const bracketPrefix = trimmed.match(/^\[([a-zA-Z_\- ]+)\]\s*[:\-]?\s*(.+)$/);
|
||||
if (bracketPrefix?.[1] && bracketPrefix[2]) {
|
||||
const category = normalizeCategory(bracketPrefix[1]);
|
||||
if (category) {
|
||||
const type = normalizeType(bracketPrefix[1]);
|
||||
if (type) {
|
||||
return {
|
||||
category,
|
||||
type,
|
||||
message: bracketPrefix[2].trim(),
|
||||
};
|
||||
}
|
||||
@@ -102,10 +106,10 @@ function cleanPrefixMessage(text: string): { category?: AiErrorCategory; message
|
||||
|
||||
const plainPrefix = trimmed.match(/^([a-zA-Z_\- ]{3,40})\s*[:|\-]\s*(.+)$/);
|
||||
if (plainPrefix?.[1] && plainPrefix[2]) {
|
||||
const category = normalizeCategory(plainPrefix[1]);
|
||||
if (category) {
|
||||
const type = normalizeType(plainPrefix[1]);
|
||||
if (type) {
|
||||
return {
|
||||
category,
|
||||
type,
|
||||
message: plainPrefix[2].trim(),
|
||||
};
|
||||
}
|
||||
@@ -129,17 +133,17 @@ function splitMessageAndDetail(message: string): { message: string; detail?: str
|
||||
return { message };
|
||||
}
|
||||
|
||||
function inferCategoryFromText(text: string): AiErrorCategory {
|
||||
function inferTypeFromText(text: string): ErrorType {
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
const openRouterStatus = lower.match(/openrouter api error\s*(\d{3})/i);
|
||||
if (openRouterStatus?.[1]) {
|
||||
const status = Number.parseInt(openRouterStatus[1], 10);
|
||||
if (status === 402) return "insufficient_credits";
|
||||
if (status === 402) return "insufficientCredits";
|
||||
if (status === 408 || status === 504) return "timeout";
|
||||
if (status === 429) return "rate_limited";
|
||||
if (status >= 500) return "server";
|
||||
if (status >= 400) return "invalid_request";
|
||||
if (status === 429) return "rateLimited";
|
||||
if (status >= 500) return "modelUnavailable";
|
||||
if (status >= 400) return "invalidRequest";
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -149,7 +153,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
|
||||
lower.includes("guthaben") ||
|
||||
lower.includes("nicht genug credits")
|
||||
) {
|
||||
return "insufficient_credits";
|
||||
return "insufficientCredits";
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -158,7 +162,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
|
||||
lower.includes("ratelimit") ||
|
||||
lower.includes("429")
|
||||
) {
|
||||
return "rate_limited";
|
||||
return "rateLimited";
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -166,7 +170,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
|
||||
lower.includes("tageslimit erreicht") ||
|
||||
lower.includes("daily generation limit")
|
||||
) {
|
||||
return "daily_cap";
|
||||
return "dailyCap";
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -191,7 +195,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
|
||||
lower.includes("fetch failed") ||
|
||||
lower.includes("econn")
|
||||
) {
|
||||
return "network";
|
||||
return "networkError";
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -200,7 +204,7 @@ function inferCategoryFromText(text: string): AiErrorCategory {
|
||||
lower.includes("refusal") ||
|
||||
lower.includes("modell lehnt ab")
|
||||
) {
|
||||
return "content_policy";
|
||||
return "contentPolicy";
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -209,85 +213,75 @@ function inferCategoryFromText(text: string): AiErrorCategory {
|
||||
lower.includes("unknown model") ||
|
||||
lower.includes("missing")
|
||||
) {
|
||||
return "invalid_request";
|
||||
return "invalidRequest";
|
||||
}
|
||||
|
||||
if (lower.includes("server") || lower.includes("5xx")) {
|
||||
return "server";
|
||||
return "modelUnavailable";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
function defaultsForCategory(category: AiErrorCategory): Omit<AiError, "category" | "detail" | "retryCount"> {
|
||||
switch (category) {
|
||||
case "insufficient_credits":
|
||||
function defaultsForType(type: ErrorType): Omit<AiError, "type" | "retryCount" | "rawMessage"> {
|
||||
switch (type) {
|
||||
case "insufficientCredits":
|
||||
return {
|
||||
message: "Not enough credits for this generation",
|
||||
retryable: false,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: true,
|
||||
};
|
||||
case "rate_limited":
|
||||
case "rateLimited":
|
||||
return {
|
||||
message: "The model is busy right now",
|
||||
retryable: true,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "content_policy":
|
||||
case "contentPolicy":
|
||||
return {
|
||||
message: "The request was blocked by model safety rules",
|
||||
retryable: false,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "timeout":
|
||||
return {
|
||||
message: "The generation timed out",
|
||||
retryable: true,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "network":
|
||||
case "networkError":
|
||||
return {
|
||||
message: "Network issue while contacting the model",
|
||||
retryable: true,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "server":
|
||||
case "modelUnavailable":
|
||||
return {
|
||||
message: "The AI service returned a server error",
|
||||
retryable: true,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "invalid_request":
|
||||
case "invalidRequest":
|
||||
return {
|
||||
message: "The request could not be processed",
|
||||
retryable: false,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "daily_cap":
|
||||
case "dailyCap":
|
||||
return {
|
||||
message: "Tageslimit erreicht",
|
||||
retryable: false,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "concurrency":
|
||||
return {
|
||||
message: "Generierung bereits aktiv",
|
||||
retryable: true,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
};
|
||||
case "unknown":
|
||||
case "generic":
|
||||
default:
|
||||
return {
|
||||
message: "Generation failed",
|
||||
retryable: true,
|
||||
creditsNotCharged: true,
|
||||
showTopUp: false,
|
||||
@@ -313,22 +307,20 @@ export function classifyError(rawError: unknown): AiError {
|
||||
const rawDetail = typeof rawObj?.detail === "string" ? rawObj.detail.trim() : undefined;
|
||||
|
||||
const prefixed = cleanPrefixMessage(rawMessage);
|
||||
const explicitCategory =
|
||||
normalizeCategory(typeof rawObj?.category === "string" ? rawObj.category : undefined) ??
|
||||
prefixed.category;
|
||||
const category = explicitCategory ?? inferCategoryFromText(prefixed.message);
|
||||
const explicitType =
|
||||
normalizeType(typeof rawObj?.category === "string" ? rawObj.category : undefined) ??
|
||||
prefixed.type;
|
||||
const type = explicitType ?? inferTypeFromText(prefixed.message);
|
||||
|
||||
const defaults = defaultsForCategory(category);
|
||||
const defaults = defaultsForType(type);
|
||||
const split = splitMessageAndDetail(prefixed.message);
|
||||
const message = split.message || defaults.message;
|
||||
|
||||
return {
|
||||
category,
|
||||
message,
|
||||
detail: split.detail ?? rawDetail,
|
||||
type,
|
||||
retryable: defaults.retryable,
|
||||
creditsNotCharged: defaults.creditsNotCharged,
|
||||
showTopUp: defaults.showTopUp,
|
||||
retryCount: extractRetryCount(rawMessage, rawObj),
|
||||
rawMessage: split.message || rawMessage || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -1,194 +1,207 @@
|
||||
// Zentrales Dictionary für alle Toast-Strings.
|
||||
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
|
||||
'use client';
|
||||
|
||||
/** Grund, warum ein Node-Löschen noch blockiert ist. */
|
||||
export type CanvasNodeDeleteBlockReason = "optimistic";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast, type ToastDurationOverrides } from './toast';
|
||||
import type { CanvasNodeDeleteBlockReason } from './toast';
|
||||
|
||||
const DURATION = {
|
||||
success: 4000,
|
||||
successShort: 2000,
|
||||
error: 6000,
|
||||
warning: 5000,
|
||||
info: 4000,
|
||||
} as const;
|
||||
|
||||
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
||||
|
||||
function canvasNodeDeleteWhy(
|
||||
t: ToastTranslations,
|
||||
reasons: Set<CanvasNodeDeleteBlockReason>,
|
||||
): { title: string; desc: string } {
|
||||
if (reasons.size === 0) {
|
||||
return {
|
||||
title: "Löschen momentan nicht möglich",
|
||||
desc: "Bitte kurz warten und erneut versuchen.",
|
||||
title: t('canvas.nodeDeleteBlockedTitle'),
|
||||
desc: t('canvas.nodeDeleteBlockedDesc'),
|
||||
};
|
||||
}
|
||||
if (reasons.size === 1) {
|
||||
const only = [...reasons][0]!;
|
||||
if (only === "optimistic") {
|
||||
if (only === 'optimistic') {
|
||||
return {
|
||||
title: "Element wird noch angelegt",
|
||||
desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.",
|
||||
title: t('canvas.nodeDeleteOptimisticTitle'),
|
||||
desc: t('canvas.nodeDeleteOptimisticDesc'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Löschen momentan nicht möglich",
|
||||
desc: "Bitte kurz warten und erneut versuchen.",
|
||||
title: t('canvas.nodeDeleteBlockedTitle'),
|
||||
desc: t('canvas.nodeDeleteBlockedDesc'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Löschen momentan nicht möglich",
|
||||
desc: "Mindestens ein Element wird noch angelegt. Bitte kurz warten und erneut versuchen.",
|
||||
title: t('canvas.nodeDeleteBlockedTitle'),
|
||||
desc: t('canvas.nodeDeleteBlockedMultiDesc'),
|
||||
};
|
||||
}
|
||||
|
||||
export const msg = {
|
||||
canvas: {
|
||||
imageUploaded: { title: "Bild hochgeladen" },
|
||||
uploadFailed: { title: "Upload fehlgeschlagen" },
|
||||
uploadFormatError: (format: string) => ({
|
||||
title: "Upload fehlgeschlagen",
|
||||
desc: `Format „${format}“ wird nicht unterstützt. Erlaubt: PNG, JPG, WebP.`,
|
||||
imageUploaded: (t: ToastTranslations) => ({
|
||||
title: t('canvas.imageUploaded'),
|
||||
}),
|
||||
uploadSizeError: (maxMb: number) => ({
|
||||
title: "Upload fehlgeschlagen",
|
||||
desc: `Maximale Dateigröße: ${maxMb} MB.`,
|
||||
uploadFailed: (t: ToastTranslations) => ({
|
||||
title: t('canvas.uploadFailed'),
|
||||
}),
|
||||
nodeRemoved: { title: "Element entfernt" },
|
||||
nodesRemoved: (count: number) => ({
|
||||
title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`,
|
||||
uploadFormatError: (t: ToastTranslations, format: string) => ({
|
||||
title: t('canvas.uploadFailed'),
|
||||
desc: t('canvas.uploadFormatError', { format }),
|
||||
}),
|
||||
/** Warum gerade kein (vollständiges) Löschen möglich ist — aus den gesammelten Gründen der blockierten Nodes. */
|
||||
nodeDeleteBlockedExplain: canvasNodeDeleteWhy,
|
||||
nodeDeleteBlockedPartial: (
|
||||
blockedCount: number,
|
||||
reasons: Set<CanvasNodeDeleteBlockReason>,
|
||||
) => {
|
||||
const why = canvasNodeDeleteWhy(reasons);
|
||||
uploadSizeError: (t: ToastTranslations, maxMb: number) => ({
|
||||
title: t('canvas.uploadFailed'),
|
||||
desc: t('canvas.uploadSizeError', { maxMb }),
|
||||
}),
|
||||
nodeRemoved: (t: ToastTranslations) => ({
|
||||
title: t('canvas.nodeRemoved'),
|
||||
}),
|
||||
nodesRemoved: (t: ToastTranslations, count: number) => ({
|
||||
title: t('canvas.nodesRemoved', { count }),
|
||||
}),
|
||||
nodeDeleteBlockedExplain: (t: ToastTranslations, reasons: Set<CanvasNodeDeleteBlockReason>) => canvasNodeDeleteWhy(t, reasons),
|
||||
nodeDeleteBlockedPartial: (t: ToastTranslations, blockedCount: number, reasons: Set<CanvasNodeDeleteBlockReason>) => {
|
||||
const why = canvasNodeDeleteWhy(t, reasons);
|
||||
const suffix =
|
||||
blockedCount === 1
|
||||
? "Ein Element wurde deshalb nicht gelöscht; die übrige Auswahl wurde entfernt."
|
||||
: `${blockedCount} Elemente wurden deshalb nicht gelöscht; die übrige Auswahl wurde entfernt.`;
|
||||
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
|
||||
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blockedCount });
|
||||
return {
|
||||
title: "Nicht alle Elemente entfernt",
|
||||
title: t('canvas.nodeDeleteBlockedPartialTitle'),
|
||||
desc: `${why.desc} ${suffix}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
ai: {
|
||||
generating: { title: "Bild wird generiert…" },
|
||||
generated: { title: "Bild generiert" },
|
||||
generatedDesc: (credits: number) => `${credits} Credits verbraucht`,
|
||||
generationQueued: { title: "Generierung gestartet" },
|
||||
generationQueuedDesc: "Das Bild erscheint automatisch, sobald es fertig ist.",
|
||||
generationFailed: { title: "Generierung fehlgeschlagen" },
|
||||
creditsNotCharged: "Credits wurden nicht abgebucht",
|
||||
insufficientCredits: (needed: number, available: number) => ({
|
||||
title: "Nicht genügend Credits",
|
||||
desc: `${needed} Credits benötigt, ${available} verfügbar.`,
|
||||
generating: (t: ToastTranslations) => ({ title: t('ai.generating') }),
|
||||
generated: (t: ToastTranslations, credits: number) => ({
|
||||
title: t('ai.generated'),
|
||||
desc: t('ai.generatedDesc', { credits }),
|
||||
}),
|
||||
generatedDesc: (t: ToastTranslations, credits: number) => t('ai.generatedDesc', { credits }),
|
||||
generationQueued: (t: ToastTranslations) => ({ title: t('ai.generationQueued') }),
|
||||
generationQueuedDesc: (t: ToastTranslations) => t('ai.generationQueuedDesc'),
|
||||
generationFailed: (t: ToastTranslations) => ({ title: t('ai.generationFailed') }),
|
||||
creditsNotCharged: (t: ToastTranslations) => t('ai.creditsNotCharged'),
|
||||
insufficientCredits: (t: ToastTranslations, needed: number, available: number) => ({
|
||||
title: t('ai.insufficientCreditsTitle'),
|
||||
desc: t('ai.insufficientCreditsDesc', { needed, available }),
|
||||
}),
|
||||
modelUnavailable: (t: ToastTranslations) => ({
|
||||
title: t('ai.modelUnavailableTitle'),
|
||||
desc: t('ai.modelUnavailableDesc'),
|
||||
}),
|
||||
contentPolicy: (t: ToastTranslations) => ({
|
||||
title: t('ai.contentPolicyTitle'),
|
||||
desc: t('ai.contentPolicyDesc'),
|
||||
}),
|
||||
timeout: (t: ToastTranslations) => ({
|
||||
title: t('ai.timeoutTitle'),
|
||||
desc: t('ai.timeoutDesc'),
|
||||
}),
|
||||
openrouterIssues: (t: ToastTranslations) => ({
|
||||
title: t('ai.openrouterIssuesTitle'),
|
||||
desc: t('ai.openrouterIssuesDesc'),
|
||||
}),
|
||||
concurrentLimitReached: (t: ToastTranslations) => ({
|
||||
title: t('ai.concurrentLimitReachedTitle'),
|
||||
desc: t('ai.concurrentLimitReachedDesc'),
|
||||
}),
|
||||
modelUnavailable: {
|
||||
title: "Modell vorübergehend nicht verfügbar",
|
||||
desc: "Versuche ein anderes Modell oder probiere es später erneut.",
|
||||
},
|
||||
contentPolicy: {
|
||||
title: "Anfrage durch Inhaltsrichtlinie blockiert",
|
||||
desc: "Versuche, den Prompt umzuformulieren.",
|
||||
},
|
||||
timeout: {
|
||||
title: "Generierung abgelaufen",
|
||||
desc: "Credits wurden nicht abgebucht.",
|
||||
},
|
||||
openrouterIssues: {
|
||||
title: "OpenRouter möglicherweise gestört",
|
||||
desc: "Mehrere Generierungen fehlgeschlagen.",
|
||||
},
|
||||
concurrentLimitReached: {
|
||||
title: "Generierung bereits aktiv",
|
||||
desc: "Bitte warte, bis die laufende Generierung abgeschlossen ist.",
|
||||
},
|
||||
},
|
||||
|
||||
export: {
|
||||
frameExported: { title: "Frame exportiert" },
|
||||
exportingFrames: { title: "Frames werden exportiert…" },
|
||||
zipReady: { title: "ZIP bereit" },
|
||||
exportFailed: { title: "Export fehlgeschlagen" },
|
||||
frameEmpty: {
|
||||
title: "Export fehlgeschlagen",
|
||||
desc: "Frame hat keinen sichtbaren Inhalt.",
|
||||
frameExported: (t: ToastTranslations) => ({ title: t('export.frameExported') }),
|
||||
exportingFrames: (t: ToastTranslations) => ({ title: t('export.exportingFrames') }),
|
||||
zipReady: (t: ToastTranslations) => ({ title: t('export.zipReady') }),
|
||||
exportFailed: (t: ToastTranslations) => ({ title: t('export.exportFailed') }),
|
||||
frameEmpty: (t: ToastTranslations) => ({
|
||||
title: t('export.frameEmptyTitle'),
|
||||
desc: t('export.frameEmptyDesc'),
|
||||
}),
|
||||
noFramesOnCanvas: (t: ToastTranslations) => ({
|
||||
title: t('export.noFramesOnCanvasTitle'),
|
||||
desc: t('export.noFramesOnCanvasDesc'),
|
||||
}),
|
||||
download: (t: ToastTranslations) => t('export.download'),
|
||||
downloaded: (t: ToastTranslations) => t('export.downloaded'),
|
||||
},
|
||||
noFramesOnCanvas: {
|
||||
title: "Export fehlgeschlagen",
|
||||
desc: "Keine Frames auf dem Canvas — zuerst einen Frame anlegen.",
|
||||
},
|
||||
download: "Herunterladen",
|
||||
downloaded: "Heruntergeladen!",
|
||||
},
|
||||
|
||||
auth: {
|
||||
welcomeBack: { title: "Willkommen zurück" },
|
||||
welcomeOnDashboard: { title: "Schön, dass du da bist" },
|
||||
checkEmail: (email: string) => ({
|
||||
title: "E-Mail prüfen",
|
||||
desc: `Bestätigungslink an ${email} gesendet.`,
|
||||
welcomeBack: (t: ToastTranslations) => ({ title: t('auth.welcomeBack') }),
|
||||
welcomeOnDashboard: (t: ToastTranslations) => ({ title: t('auth.welcomeOnDashboard') }),
|
||||
checkEmail: (t: ToastTranslations, email: string) => ({
|
||||
title: t('auth.checkEmailTitle'),
|
||||
desc: t('auth.checkEmailDesc', { email }),
|
||||
}),
|
||||
sessionExpired: (t: ToastTranslations) => ({
|
||||
title: t('auth.sessionExpiredTitle'),
|
||||
desc: t('auth.sessionExpiredDesc'),
|
||||
}),
|
||||
signedOut: (t: ToastTranslations) => ({ title: t('auth.signedOut') }),
|
||||
signIn: (t: ToastTranslations) => t('auth.signIn'),
|
||||
initialSetup: (t: ToastTranslations) => ({
|
||||
title: t('auth.initialSetupTitle'),
|
||||
desc: t('auth.initialSetupDesc'),
|
||||
}),
|
||||
sessionExpired: {
|
||||
title: "Sitzung abgelaufen",
|
||||
desc: "Bitte erneut anmelden.",
|
||||
},
|
||||
signedOut: { title: "Abgemeldet" },
|
||||
signIn: "Anmelden",
|
||||
initialSetup: {
|
||||
title: "Startguthaben aktiv",
|
||||
desc: "Du kannst loslegen.",
|
||||
},
|
||||
},
|
||||
|
||||
billing: {
|
||||
subscriptionActivated: (credits: number) => ({
|
||||
title: "Abo aktiviert",
|
||||
desc: `${credits} Credits deinem Guthaben hinzugefügt.`,
|
||||
subscriptionActivated: (t: ToastTranslations, credits: number) => ({
|
||||
title: t('billing.subscriptionActivatedTitle'),
|
||||
desc: t('billing.subscriptionActivatedDesc', { credits }),
|
||||
}),
|
||||
creditsAdded: (credits: number) => ({
|
||||
title: "Credits hinzugefügt",
|
||||
desc: `+${credits} Credits`,
|
||||
creditsAdded: (t: ToastTranslations, credits: number) => ({
|
||||
title: t('billing.creditsAddedTitle'),
|
||||
desc: t('billing.creditsAddedDesc', { credits }),
|
||||
}),
|
||||
subscriptionCancelled: (periodEnd: string) => ({
|
||||
title: "Abo gekündigt",
|
||||
desc: `Deine Credits bleiben bis ${periodEnd} verfügbar.`,
|
||||
subscriptionCancelled: (t: ToastTranslations, periodEnd: string) => ({
|
||||
title: t('billing.subscriptionCancelledTitle'),
|
||||
desc: t('billing.subscriptionCancelledDesc', { periodEnd }),
|
||||
}),
|
||||
paymentFailed: {
|
||||
title: "Zahlung fehlgeschlagen",
|
||||
desc: "Bitte Zahlungsmethode aktualisieren.",
|
||||
paymentFailed: (t: ToastTranslations) => ({
|
||||
title: t('billing.paymentFailedTitle'),
|
||||
desc: t('billing.paymentFailedDesc'),
|
||||
}),
|
||||
dailyLimitReached: (t: ToastTranslations, limit: number) => ({
|
||||
title: t('billing.dailyLimitReachedTitle'),
|
||||
desc: t('billing.dailyLimitReachedDesc', { limit }),
|
||||
}),
|
||||
lowCredits: (t: ToastTranslations, remaining: number) => ({
|
||||
title: t('billing.lowCreditsTitle'),
|
||||
desc: t('billing.lowCreditsDesc', { remaining }),
|
||||
}),
|
||||
topUp: (t: ToastTranslations) => t('billing.topUp'),
|
||||
upgrade: (t: ToastTranslations) => t('billing.upgrade'),
|
||||
manage: (t: ToastTranslations) => t('billing.manage'),
|
||||
redirectingToCheckout: (t: ToastTranslations) => ({
|
||||
title: t('billing.redirectingToCheckoutTitle'),
|
||||
desc: t('billing.redirectingToCheckoutDesc'),
|
||||
}),
|
||||
openingPortal: (t: ToastTranslations) => ({
|
||||
title: t('billing.openingPortalTitle'),
|
||||
desc: t('billing.openingPortalDesc'),
|
||||
}),
|
||||
testGrantFailed: (t: ToastTranslations) => ({ title: t('billing.testGrantFailedTitle') }),
|
||||
},
|
||||
dailyLimitReached: (limit: number) => ({
|
||||
title: "Tageslimit erreicht",
|
||||
desc: `Maximal ${limit} Generierungen pro Tag in deinem Tarif.`,
|
||||
}),
|
||||
lowCredits: (remaining: number) => ({
|
||||
title: "Credits fast aufgebraucht",
|
||||
desc: `Noch ${remaining} Credits übrig.`,
|
||||
}),
|
||||
topUp: "Aufladen",
|
||||
upgrade: "Upgrade",
|
||||
manage: "Verwalten",
|
||||
redirectingToCheckout: {
|
||||
title: "Weiterleitung…",
|
||||
desc: "Du wirst zum sicheren Checkout weitergeleitet.",
|
||||
},
|
||||
openingPortal: {
|
||||
title: "Portal wird geöffnet…",
|
||||
desc: "Du wirst zur Aboverwaltung weitergeleitet.",
|
||||
},
|
||||
testGrantFailed: { title: "Gutschrift fehlgeschlagen" },
|
||||
},
|
||||
|
||||
system: {
|
||||
reconnected: { title: "Verbindung wiederhergestellt" },
|
||||
connectionLost: {
|
||||
title: "Verbindung verloren",
|
||||
desc: "Änderungen werden möglicherweise nicht gespeichert.",
|
||||
reconnected: (t: ToastTranslations) => ({ title: t('system.reconnected') }),
|
||||
connectionLost: (t: ToastTranslations) => ({
|
||||
title: t('system.connectionLostTitle'),
|
||||
desc: t('system.connectionLostDesc'),
|
||||
}),
|
||||
copiedToClipboard: (t: ToastTranslations) => ({ title: t('system.copiedToClipboard') }),
|
||||
},
|
||||
copiedToClipboard: { title: "In Zwischenablage kopiert" },
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." },
|
||||
renameSuccess: { title: "Arbeitsbereich umbenannt" },
|
||||
renameFailed: { title: "Umbenennen fehlgeschlagen" },
|
||||
deleteSuccess: { title: "Arbeitsbereich gelöscht" },
|
||||
deleteFailed: { title: "Löschen fehlgeschlagen" },
|
||||
renameEmpty: (t: ToastTranslations) => ({
|
||||
title: t('dashboard.renameEmptyTitle'),
|
||||
desc: t('dashboard.renameEmptyDesc'),
|
||||
}),
|
||||
renameSuccess: (t: ToastTranslations) => ({ title: t('dashboard.renameSuccess') }),
|
||||
renameFailed: (t: ToastTranslations) => ({ title: t('dashboard.renameFailed') }),
|
||||
deleteSuccess: (t: ToastTranslations) => ({ title: t('dashboard.deleteSuccess') }),
|
||||
deleteFailed: (t: ToastTranslations) => ({ title: t('dashboard.deleteFailed') }),
|
||||
},
|
||||
} as const;
|
||||
|
||||
326
lib/toast.ts
326
lib/toast.ts
@@ -1,4 +1,7 @@
|
||||
import { gooeyToast, type GooeyPromiseData } from "goey-toast";
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { gooeyToast, type GooeyPromiseData } from 'goey-toast';
|
||||
|
||||
const DURATION = {
|
||||
success: 4000,
|
||||
@@ -8,70 +11,48 @@ const DURATION = {
|
||||
info: 4000,
|
||||
} as const;
|
||||
|
||||
type ToastTranslations = ReturnType<typeof useTranslations<'toasts'>>;
|
||||
|
||||
export type ToastDurationOverrides = {
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const toast = {
|
||||
success(
|
||||
message: string,
|
||||
description?: string,
|
||||
opts?: ToastDurationOverrides,
|
||||
) {
|
||||
success(message: string, description?: string, opts?: ToastDurationOverrides) {
|
||||
return gooeyToast.success(message, {
|
||||
description,
|
||||
duration: opts?.duration ?? DURATION.success,
|
||||
});
|
||||
},
|
||||
|
||||
error(
|
||||
message: string,
|
||||
description?: string,
|
||||
opts?: ToastDurationOverrides,
|
||||
) {
|
||||
error(message: string, description?: string, opts?: ToastDurationOverrides) {
|
||||
return gooeyToast.error(message, {
|
||||
description,
|
||||
duration: opts?.duration ?? DURATION.error,
|
||||
});
|
||||
},
|
||||
|
||||
warning(
|
||||
message: string,
|
||||
description?: string,
|
||||
opts?: ToastDurationOverrides,
|
||||
) {
|
||||
warning(message: string, description?: string, opts?: ToastDurationOverrides) {
|
||||
return gooeyToast.warning(message, {
|
||||
description,
|
||||
duration: opts?.duration ?? DURATION.warning,
|
||||
});
|
||||
},
|
||||
|
||||
info(
|
||||
message: string,
|
||||
description?: string,
|
||||
opts?: ToastDurationOverrides,
|
||||
) {
|
||||
info(message: string, description?: string, opts?: ToastDurationOverrides) {
|
||||
return gooeyToast.info(message, {
|
||||
description,
|
||||
duration: opts?.duration ?? DURATION.info,
|
||||
});
|
||||
},
|
||||
|
||||
promise<T>(promise: Promise<T>, data: GooeyPromiseData<T>) {
|
||||
return gooeyToast.promise(promise, data);
|
||||
},
|
||||
|
||||
action(
|
||||
message: string,
|
||||
opts: {
|
||||
action(message: string, opts: {
|
||||
description?: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
successLabel?: string;
|
||||
type?: "success" | "info" | "warning";
|
||||
duration?: number;
|
||||
},
|
||||
) {
|
||||
}) {
|
||||
const t = opts.type ?? "info";
|
||||
return gooeyToast[t](message, {
|
||||
description: opts.description,
|
||||
@@ -83,18 +64,13 @@ export const toast = {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
update(
|
||||
id: string | number,
|
||||
opts: {
|
||||
update(id: string | number, opts: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: "default" | "success" | "error" | "warning" | "info";
|
||||
},
|
||||
) {
|
||||
}) {
|
||||
gooeyToast.update(id, opts);
|
||||
},
|
||||
|
||||
dismiss(id?: string | number) {
|
||||
gooeyToast.dismiss(id);
|
||||
},
|
||||
@@ -107,3 +83,277 @@ export const toastDuration = {
|
||||
warning: DURATION.warning,
|
||||
info: DURATION.info,
|
||||
} as const;
|
||||
|
||||
export type CanvasNodeDeleteBlockReason = 'optimistic';
|
||||
|
||||
export function showImageUploadedToast(t: ToastTranslations) {
|
||||
toast.success(t('canvas.imageUploaded'));
|
||||
}
|
||||
|
||||
export function showUploadFailedToast(t: ToastTranslations, reason?: string) {
|
||||
if (reason) {
|
||||
toast.error(t('canvas.uploadFailed'), reason);
|
||||
} else {
|
||||
toast.error(t('canvas.uploadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
export function showUploadFormatError(t: ToastTranslations, format: string) {
|
||||
toast.error(t('canvas.uploadFailed'), t('canvas.uploadFormatError', { format }));
|
||||
}
|
||||
|
||||
export function showUploadSizeError(t: ToastTranslations, maxMb: number) {
|
||||
toast.error(t('canvas.uploadFailed'), t('canvas.uploadSizeError', { maxMb }));
|
||||
}
|
||||
|
||||
export function showNodeRemovedToast(t: ToastTranslations) {
|
||||
toast.success(t('canvas.nodeRemoved'));
|
||||
}
|
||||
|
||||
export function showNodesRemovedToast(t: ToastTranslations, count: number) {
|
||||
const title = t('canvas.nodesRemoved', { count });
|
||||
toast.success(title);
|
||||
}
|
||||
|
||||
export function canvasNodeDeleteWhy(
|
||||
t: ToastTranslations,
|
||||
reasons: Set<CanvasNodeDeleteBlockReason>,
|
||||
) {
|
||||
if (reasons.size === 0) {
|
||||
return {
|
||||
title: t('canvas.nodeDeleteBlockedTitle'),
|
||||
desc: t('canvas.nodeDeleteBlockedDesc'),
|
||||
};
|
||||
}
|
||||
if (reasons.size === 1) {
|
||||
const only = [...reasons][0]!;
|
||||
if (only === 'optimistic') {
|
||||
return {
|
||||
title: t('canvas.nodeDeleteOptimisticTitle'),
|
||||
desc: t('canvas.nodeDeleteOptimisticDesc'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t('canvas.nodeDeleteBlockedTitle'),
|
||||
desc: t('canvas.nodeDeleteBlockedDesc'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t('canvas.nodeDeleteBlockedTitle'),
|
||||
desc: t('canvas.nodeDeleteBlockedMultiDesc'),
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasNodeDeleteBlockedPartial(
|
||||
t: ToastTranslations,
|
||||
blockedCount: number,
|
||||
reasons: Set<CanvasNodeDeleteBlockReason>,
|
||||
) {
|
||||
const why = canvasNodeDeleteWhy(t, reasons);
|
||||
const suffix =
|
||||
blockedCount === 1
|
||||
? t('canvas.nodeDeleteBlockedPartialSuffixOne')
|
||||
: t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blockedCount });
|
||||
return {
|
||||
title: t('canvas.nodeDeleteBlockedPartialTitle'),
|
||||
desc: `${why.desc} ${suffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function showGeneratingToast(t: ToastTranslations) {
|
||||
gooeyToast.info(t('ai.generating'), { duration: Infinity });
|
||||
}
|
||||
|
||||
export function showGeneratedToast(
|
||||
t: ToastTranslations,
|
||||
credits: number,
|
||||
) {
|
||||
toast.success(t('ai.generated'), t('ai.generatedDesc', { credits }));
|
||||
}
|
||||
|
||||
export function showGenerationQueuedToast(t: ToastTranslations) {
|
||||
toast.success(t('ai.generationQueued'), t('ai.generationQueuedDesc'));
|
||||
}
|
||||
|
||||
export function showGenerationFailedToast(t: ToastTranslations) {
|
||||
toast.error(t('ai.generationFailed'));
|
||||
}
|
||||
|
||||
export function showCreditsNotChargedToast(t: ToastTranslations) {
|
||||
toast.warning(t('ai.creditsNotCharged'));
|
||||
}
|
||||
|
||||
export function showInsufficientCreditsToast(
|
||||
t: ToastTranslations,
|
||||
needed: number,
|
||||
available: number,
|
||||
) {
|
||||
toast.error(t('ai.insufficientCreditsTitle'), t('ai.insufficientCreditsDesc', { needed, available }));
|
||||
}
|
||||
|
||||
export function showModelUnavailableToast(t: ToastTranslations) {
|
||||
toast.error(t('ai.modelUnavailableTitle'), t('ai.modelUnavailableDesc'));
|
||||
}
|
||||
|
||||
export function showContentPolicyBlockedToast(t: ToastTranslations) {
|
||||
toast.error(t('ai.contentPolicyTitle'), t('ai.contentPolicyDesc'));
|
||||
}
|
||||
|
||||
export function showTimeoutToast(t: ToastTranslations) {
|
||||
toast.error(t('ai.timeoutTitle'), t('ai.timeoutDesc'));
|
||||
}
|
||||
|
||||
export function showOpenrouterIssuesToast(t: ToastTranslations) {
|
||||
toast.error(t('ai.openrouterIssuesTitle'), t('ai.openrouterIssuesDesc'));
|
||||
}
|
||||
|
||||
export function showConcurrentLimitReachedToast(t: ToastTranslations) {
|
||||
toast.error(t('ai.concurrentLimitReachedTitle'), t('ai.concurrentLimitReachedDesc'));
|
||||
}
|
||||
|
||||
export function showFrameExportedToast(t: ToastTranslations) {
|
||||
toast.success(t('export.frameExported'));
|
||||
}
|
||||
|
||||
export function showExportingFramesToast(t: ToastTranslations) {
|
||||
gooeyToast.info(t('export.exportingFrames'), { duration: Infinity });
|
||||
}
|
||||
|
||||
export function showZipReadyToast(t: ToastTranslations) {
|
||||
toast.success(t('export.zipReady'));
|
||||
}
|
||||
|
||||
export function showExportFailedToast(t: ToastTranslations) {
|
||||
toast.error(t('export.exportFailed'));
|
||||
}
|
||||
|
||||
export function showFrameEmptyToast(t: ToastTranslations) {
|
||||
toast.error(t('export.frameEmptyTitle'), t('export.frameEmptyDesc'));
|
||||
}
|
||||
|
||||
export function showNoFramesOnCanvasToast(t: ToastTranslations) {
|
||||
toast.error(t('export.noFramesOnCanvasTitle'), t('export.noFramesOnCanvasDesc'));
|
||||
}
|
||||
|
||||
export function showDownloadToast(t: ToastTranslations) {
|
||||
toast.success(t('export.downloaded'), t('export.download'));
|
||||
}
|
||||
|
||||
export function showWelcomeBackToast(t: ToastTranslations) {
|
||||
toast.success(t('auth.welcomeBack'));
|
||||
}
|
||||
|
||||
export function showWelcomeOnDashboardToast(t: ToastTranslations) {
|
||||
toast.success(t('auth.welcomeOnDashboard'));
|
||||
}
|
||||
|
||||
export function showCheckEmailToast(t: ToastTranslations, email: string) {
|
||||
toast.success(t('auth.checkEmailTitle'), t('auth.checkEmailDesc', { email }));
|
||||
}
|
||||
|
||||
export function showSessionExpiredToast(t: ToastTranslations) {
|
||||
toast.error(t('auth.sessionExpiredTitle'), t('auth.sessionExpiredDesc'));
|
||||
}
|
||||
|
||||
export function showSignedOutToast(t: ToastTranslations) {
|
||||
toast.success(t('auth.signedOut'));
|
||||
}
|
||||
|
||||
export function showSignInToast(t: ToastTranslations) {
|
||||
toast.success(t('auth.signIn'));
|
||||
}
|
||||
|
||||
export function showInitialSetupToast(t: ToastTranslations) {
|
||||
toast.success(t('auth.initialSetupTitle'), t('auth.initialSetupDesc'));
|
||||
}
|
||||
|
||||
export function showSubscriptionActivatedToast(
|
||||
t: ToastTranslations,
|
||||
credits: number,
|
||||
) {
|
||||
toast.success(t('billing.subscriptionActivatedTitle'), t('billing.subscriptionActivatedDesc', { credits }));
|
||||
}
|
||||
|
||||
export function showCreditsAddedToast(t: ToastTranslations, credits: number) {
|
||||
toast.success(t('billing.creditsAddedTitle'), t('billing.creditsAddedDesc', { credits }));
|
||||
}
|
||||
|
||||
export function showSubscriptionCancelledToast(
|
||||
t: ToastTranslations,
|
||||
periodEnd: string,
|
||||
) {
|
||||
gooeyToast.info(t('billing.subscriptionCancelledTitle'), { description: t('billing.subscriptionCancelledDesc', { periodEnd }) });
|
||||
}
|
||||
|
||||
export function showPaymentFailedToast(t: ToastTranslations) {
|
||||
toast.error(t('billing.paymentFailedTitle'), t('billing.paymentFailedDesc'));
|
||||
}
|
||||
|
||||
export function showDailyLimitReachedToast(t: ToastTranslations, limit: number) {
|
||||
toast.error(t('billing.dailyLimitReachedTitle'), t('billing.dailyLimitReachedDesc', { limit }));
|
||||
}
|
||||
|
||||
export function showLowCreditsToast(t: ToastTranslations, remaining: number) {
|
||||
toast.warning(t('billing.lowCreditsTitle'), t('billing.lowCreditsDesc', { remaining }));
|
||||
}
|
||||
|
||||
export function showTopUpToast(t: ToastTranslations) {
|
||||
toast.success(t('billing.topUp'));
|
||||
}
|
||||
|
||||
export function showUpgradeToast(t: ToastTranslations) {
|
||||
toast.success(t('billing.upgrade'));
|
||||
}
|
||||
|
||||
export function showManageToast(t: ToastTranslations) {
|
||||
toast.success(t('billing.manage'));
|
||||
}
|
||||
|
||||
export function showRedirectingToCheckoutToast(t: ToastTranslations) {
|
||||
gooeyToast.info(t('billing.redirectingToCheckoutTitle'), { description: t('billing.redirectingToCheckoutDesc') });
|
||||
}
|
||||
|
||||
export function showOpeningPortalToast(t: ToastTranslations) {
|
||||
gooeyToast.info(t('billing.openingPortalTitle'), { description: t('billing.openingPortalDesc') });
|
||||
}
|
||||
|
||||
export function showTestGrantFailedToast(t: ToastTranslations) {
|
||||
toast.error(t('billing.testGrantFailedTitle'));
|
||||
}
|
||||
|
||||
export function showReconnectedToast(t: ToastTranslations) {
|
||||
toast.success(t('system.reconnected'));
|
||||
}
|
||||
|
||||
export function showConnectionLostToast(t: ToastTranslations) {
|
||||
toast.error(t('system.connectionLostTitle'), t('system.connectionLostDesc'));
|
||||
}
|
||||
|
||||
export function showCopiedToClipboardToast(t: ToastTranslations) {
|
||||
toast.success(t('system.copiedToClipboard'));
|
||||
}
|
||||
|
||||
export function showRenameEmptyToast(t: ToastTranslations) {
|
||||
toast.error(t('dashboard.renameEmptyTitle'), t('dashboard.renameEmptyDesc'));
|
||||
}
|
||||
|
||||
export function showRenameSuccessToast(t: ToastTranslations) {
|
||||
toast.success(t('dashboard.renameSuccess'));
|
||||
}
|
||||
|
||||
export function showRenameFailedToast(t: ToastTranslations) {
|
||||
toast.error(t('dashboard.renameFailed'));
|
||||
}
|
||||
|
||||
export function showDeleteSuccessToast(t: ToastTranslations) {
|
||||
toast.success(t('dashboard.deleteSuccess'));
|
||||
}
|
||||
|
||||
export function showDeleteFailedToast(t: ToastTranslations) {
|
||||
toast.error(t('dashboard.deleteFailed'));
|
||||
}
|
||||
|
||||
export function getToastTranslations() {
|
||||
const t = useTranslations('toasts');
|
||||
return t as ToastTranslations;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,3 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/** Credits / Preise: Werte sind Euro-Cent (siehe PRD, Manifest). */
|
||||
export function formatEurFromCents(cents: number) {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(cents / 100)
|
||||
}
|
||||
|
||||
220
messages/de.json
Normal file
220
messages/de.json
Normal 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
220
messages/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
@@ -26,7 +29,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
export default withSentryConfig(withNextIntl(nextConfig), {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
|
||||
7
routing.ts
Normal file
7
routing.ts
Normal 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
1
src/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { routing } from '../../routing';
|
||||
Reference in New Issue
Block a user