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