diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 9803046..4289c24 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { useTheme } from "next-themes"; import { useMutation } from "convex/react"; +import { useTranslations } from "next-intl"; import { ChevronDown, Coins, @@ -29,12 +30,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { api } from "@/convex/_generated/api"; +import type { Doc } from "@/convex/_generated/dataModel"; import { authClient } from "@/lib/auth-client"; import { CreditOverview } from "@/components/dashboard/credit-overview"; import { RecentTransactions } from "@/components/dashboard/recent-transactions"; import CanvasCard from "@/components/dashboard/canvas-card"; import { toast } from "@/lib/toast"; -import { msg } from "@/lib/toast-messages"; import { useAuthQuery } from "@/hooks/use-auth-query"; @@ -51,6 +52,7 @@ function getInitials(nameOrEmail: string) { } export default function DashboardPage() { + const t = useTranslations('toasts'); const router = useRouter(); const welcomeToastSentRef = useRef(false); const { theme = "system", setTheme } = useTheme(); @@ -82,11 +84,11 @@ export default function DashboardPage() { if (typeof window !== "undefined" && sessionStorage.getItem(key)) return; welcomeToastSentRef.current = true; sessionStorage.setItem(key, "1"); - toast.success(msg.auth.welcomeOnDashboard.title); - }, [session?.user]); + toast.success(t('auth.welcomeOnDashboard')); + }, [t, session?.user]); const handleSignOut = async () => { - toast.info(msg.auth.signedOut.title); + toast.info(t('auth.signedOut')); await authClient.signOut(); router.replace("/auth/sign-in"); router.refresh(); @@ -240,7 +242,7 @@ export default function DashboardPage() { ) : (
- {canvases.map((canvas) => ( + {canvases.map((canvas: Doc<"canvases">) => ( ) { const initialToken = await getToken(); + const locale = await getLocale(); + const messages = await getMessages(); const user = await getAuthUser(); if (user) { const id = user.userId ?? String(user._id); @@ -33,7 +36,7 @@ export default async function RootLayout({ return ( @@ -56,7 +59,7 @@ export default async function RootLayout({ > - + {children} diff --git a/app/page.tsx b/app/page.tsx index a681aa4..0294ba9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,10 +3,11 @@ import { authClient } from "@/lib/auth-client"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { toast } from "@/lib/toast"; -import { msg } from "@/lib/toast-messages"; export default function Home() { + const t = useTranslations('toasts'); const { data: session, isPending } = authClient.useSession(); const router = useRouter(); @@ -35,7 +36,7 @@ export default function Home() { + ); +} diff --git a/components/providers.tsx b/components/providers.tsx index b92dca2..b812763 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -6,6 +6,8 @@ import { ConvexReactClient } from "convex/react"; import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"; import { AuthUIProvider } from "@daveyplate/better-auth-ui"; import { ThemeProvider } from "next-themes"; +import { NextIntlClientProvider } from "next-intl"; +import type { AbstractIntlMessages } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { GooeyToaster } from "goey-toast"; @@ -35,37 +37,43 @@ function SentryAuthUserSync() { export function Providers({ children, initialToken, + locale, + messages, }: { children: ReactNode; initialToken?: string | null; + locale?: string; + messages?: AbstractIntlMessages; }) { const router = useRouter(); return ( - - - + router.refresh()} - Link={Link} + initialToken={initialToken} > - {children} - - - + + router.refresh()} + Link={Link} + > + {children} + + + + ); } diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index a9e86c8..248ed5e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -22,6 +22,7 @@ import type * as openrouter from "../openrouter.js"; import type * as pexels from "../pexels.js"; import type * as polar from "../polar.js"; import type * as storage from "../storage.js"; +import type * as users from "../users.js"; import type { ApiFromModules, @@ -44,6 +45,7 @@ declare const fullApi: ApiFromModules<{ pexels: typeof pexels; polar: typeof polar; storage: typeof storage; + users: typeof users; }>; /** diff --git a/convex/ai.ts b/convex/ai.ts index aa65238..8a4b44c 100644 --- a/convex/ai.ts +++ b/convex/ai.ts @@ -1,4 +1,4 @@ -import { v } from "convex/values"; +import { v, ConvexError } from "convex/values"; import { action, internalAction, internalMutation } from "./_generated/server"; import { api, internal } from "./_generated/api"; import { @@ -18,6 +18,19 @@ type ErrorCategory = | "provider" | "unknown"; +interface ErrorData { + code?: string; + [key: string]: unknown; +} + +function getErrorCode(error: unknown): string | undefined { + if (error instanceof ConvexError) { + const data = error.data as ErrorData; + return data?.code; + } + return undefined; +} + function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error ?? "Generation failed"); @@ -34,20 +47,23 @@ function categorizeError(error: unknown): { category: ErrorCategory; retryable: boolean; } { + const code = getErrorCode(error); const message = errorMessage(error); const lower = message.toLowerCase(); const status = parseOpenRouterStatus(message); if ( - lower.includes("insufficient credits") || - lower.includes("daily generation limit") || - lower.includes("concurrent job limit") + code === "CREDITS_TEST_DISABLED" || + code === "CREDITS_INVALID_AMOUNT" || + code === "CREDITS_BALANCE_NOT_FOUND" || + code === "CREDITS_DAILY_CAP_REACHED" || + code === "CREDITS_CONCURRENCY_LIMIT" ) { return { category: "credits", retryable: false }; } if ( - lower.includes("modell lehnt ab") || + code === "OPENROUTER_MODEL_REFUSAL" || lower.includes("content policy") || lower.includes("policy") || lower.includes("moderation") || @@ -94,6 +110,11 @@ function categorizeError(error: unknown): { } function formatTerminalStatusMessage(error: unknown): string { + const code = getErrorCode(error); + if (code) { + return code; + } + const message = errorMessage(error).trim() || "Generation failed"; const { category } = categorizeError(error); diff --git a/convex/credits.ts b/convex/credits.ts index ebc7d71..d2d29ac 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -1,5 +1,5 @@ import { query, mutation, internalMutation } from "./_generated/server"; -import { v } from "convex/values"; +import { v, ConvexError } from "convex/values"; import { optionalAuth, requireAuth } from "./helpers"; import { internal } from "./_generated/api"; @@ -275,10 +275,10 @@ export const grantTestCredits = mutation({ }, handler: async (ctx, { amount = 2000 }) => { if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") { - throw new Error("Test-Gutschriften sind deaktiviert (ALLOW_TEST_CREDIT_GRANT)."); + throw new ConvexError({ code: "CREDITS_TEST_DISABLED" }); } if (amount <= 0 || amount > 1_000_000) { - throw new Error("Ungültiger Betrag."); + throw new ConvexError({ code: "CREDITS_INVALID_AMOUNT" }); } const user = await requireAuth(ctx); const balance = await ctx.db @@ -287,7 +287,7 @@ export const grantTestCredits = mutation({ .unique(); if (!balance) { - throw new Error("Keine Credit-Balance — zuerst einloggen / initBalance."); + throw new ConvexError({ code: "CREDITS_BALANCE_NOT_FOUND" }); } const next = balance.balance + amount; @@ -362,16 +362,18 @@ export const reserve = mutation({ .unique(); if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) { - throw new Error( - `daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)` - ); + throw new ConvexError({ + code: "CREDITS_DAILY_CAP_REACHED", + data: { limit: config.dailyGenerationCap, tier }, + }); } // Concurrency Limit prüfen if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) { - throw new Error( - `concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten` - ); + throw new ConvexError({ + code: "CREDITS_CONCURRENCY_LIMIT", + data: { limit: config.concurrencyLimit }, + }); } // Credits reservieren @@ -487,7 +489,14 @@ export const commit = mutation({ actualCost: v.number(), openRouterCost: v.optional(v.number()), }, - handler: async (ctx, { transactionId, actualCost, openRouterCost }) => { + handler: async ( + ctx, + { transactionId, actualCost, openRouterCost } + ): Promise< + { status: "already_committed" } | + { status: "already_released" } | + { status: "committed" } + > => { const user = await requireAuth(ctx); const transaction = await ctx.db.get(transactionId); if (!transaction || transaction.userId !== user.userId) { @@ -571,7 +580,14 @@ export const release = mutation({ args: { transactionId: v.id("creditTransactions"), }, - handler: async (ctx, { transactionId }) => { + handler: async ( + ctx, + { transactionId } + ): Promise< + { status: "already_released" } | + { status: "already_committed" } | + { status: "released" } + > => { const user = await requireAuth(ctx); const transaction = await ctx.db.get(transactionId); if (!transaction || transaction.userId !== user.userId) { @@ -761,16 +777,18 @@ export const checkAbuseLimits = internalMutation({ const dailyCount = usage?.generationCount ?? 0; if (dailyCount >= config.dailyGenerationCap) { - throw new Error( - `daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)` - ); + throw new ConvexError({ + code: "CREDITS_DAILY_CAP_REACHED", + data: { limit: config.dailyGenerationCap, tier }, + }); } const currentConcurrency = usage?.concurrentJobs ?? 0; if (currentConcurrency >= config.concurrencyLimit) { - throw new Error( - `concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten` - ); + throw new ConvexError({ + code: "CREDITS_CONCURRENCY_LIMIT", + data: { limit: config.concurrencyLimit }, + }); } }, }); diff --git a/convex/export.ts b/convex/export.ts index f4c9f7b..6f231e1 100644 --- a/convex/export.ts +++ b/convex/export.ts @@ -44,7 +44,7 @@ export const exportFrame = action({ }); // Find image/ai-image nodes visually within the frame - const imageNodes = allNodes.filter((node) => { + const imageNodes = allNodes.filter((node: (typeof allNodes)[number]) => { if (node.type !== "image" && node.type !== "ai-image") return false; const data = node.data as { storageId?: string }; if (!data.storageId) return false; diff --git a/convex/openrouter.ts b/convex/openrouter.ts index 889381d..601046e 100644 --- a/convex/openrouter.ts +++ b/convex/openrouter.ts @@ -1,3 +1,5 @@ +import { ConvexError } from "convex/values"; + export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; export interface OpenRouterModel { @@ -144,7 +146,7 @@ export async function generateImageViaOpenRouter( const message = data?.choices?.[0]?.message as Record | undefined; if (!message) { - throw new Error("OpenRouter: choices[0].message fehlt"); + throw new ConvexError({ code: "OPENROUTER_MISSING_MESSAGE" }); } let rawImage: string | undefined; @@ -186,7 +188,10 @@ export async function generateImageViaOpenRouter( ) { const r = typeof refusal === "string" ? refusal : JSON.stringify(refusal); - throw new Error(`OpenRouter: Modell lehnt ab — ${r.slice(0, 500)}`); + throw new ConvexError({ + code: "OPENROUTER_MODEL_REFUSAL", + data: { reason: r.slice(0, 500) }, + }); } if ( @@ -205,19 +210,23 @@ export async function generateImageViaOpenRouter( : Array.isArray(content) ? JSON.stringify(content).slice(0, 400) : ""; - throw new Error( - `OpenRouter: kein Bild in der Antwort. Keys: ${Object.keys(message).join(", ")}. ` + - (reasoning ? `reasoning: ${reasoning}` : `content: ${contentPreview}`), - ); + throw new ConvexError({ + code: "OPENROUTER_NO_IMAGE_IN_RESPONSE", + data: { + keys: Object.keys(message).join(", "), + reasoningOrContent: reasoning || contentPreview, + }, + }); } let dataUri = rawImage; if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) { const imgRes = await fetch(rawImage); if (!imgRes.ok) { - throw new Error( - `OpenRouter: Bild-URL konnte nicht geladen werden (${imgRes.status})`, - ); + throw new ConvexError({ + code: "OPENROUTER_IMAGE_URL_LOAD_FAILED", + data: { status: imgRes.status }, + }); } const mimeTypeFromRes = imgRes.headers.get("content-type") ?? "image/png"; @@ -237,12 +246,12 @@ export async function generateImageViaOpenRouter( } if (!dataUri.startsWith("data:")) { - throw new Error("OpenRouter: Bild konnte nicht als data-URI erstellt werden"); + throw new ConvexError({ code: "OPENROUTER_DATA_URI_CREATION_FAILED" }); } const comma = dataUri.indexOf(","); if (comma === -1) { - throw new Error("OpenRouter: data-URI ohne Base64-Teil"); + throw new ConvexError({ code: "OPENROUTER_DATA_URI_MISSING_BASE64" }); } const meta = dataUri.slice(0, comma); const base64Data = dataUri.slice(comma + 1); diff --git a/convex/schema.ts b/convex/schema.ts index 1360215..7af1ff2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -310,4 +310,16 @@ export default defineSchema({ concurrentJobs: v.number(), // Aktuell laufende Jobs }) .index("by_user_date", ["userId", "date"]), + + // ========================================================================== + // User Settings + // ========================================================================== + + userSettings: defineTable({ + userId: v.string(), // Better Auth User ID + locale: v.optional(v.union(v.literal('de'), v.literal('en'))), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_user", ["userId"]), }); diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 0000000..5569d96 --- /dev/null +++ b/convex/users.ts @@ -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; + }, +}); diff --git a/i18n/request.ts b/i18n/request.ts new file mode 100644 index 0000000..70864e4 --- /dev/null +++ b/i18n/request.ts @@ -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, + }; +}); diff --git a/lib/ai-errors.ts b/lib/ai-errors.ts index 6dea288..2132391 100644 --- a/lib/ai-errors.ts +++ b/lib/ai-errors.ts @@ -1,23 +1,22 @@ -export type AiErrorCategory = - | "insufficient_credits" - | "rate_limited" - | "content_policy" +export type ErrorType = | "timeout" - | "network" - | "server" - | "invalid_request" - | "daily_cap" - | "concurrency" - | "unknown"; + | "insufficientCredits" + | "networkError" + | "rateLimited" + | "modelUnavailable" + | "generic" + | "contentPolicy" + | "invalidRequest" + | "dailyCap" + | "concurrency"; export interface AiError { - category: AiErrorCategory; - message: string; - detail?: string; + type: ErrorType; retryable: boolean; creditsNotCharged: boolean; showTopUp: boolean; retryCount?: number; + rawMessage?: string; } type RawErrorObject = { @@ -27,45 +26,50 @@ type RawErrorObject = { retryCount?: unknown; }; -const CATEGORY_ALIASES: Record = { - insufficient_credits: "insufficient_credits", - insufficientcredits: "insufficient_credits", - not_enough_credits: "insufficient_credits", - notenoughcredits: "insufficient_credits", - credits: "insufficient_credits", - payment_required: "insufficient_credits", - paymentrequired: "insufficient_credits", - rate_limit: "rate_limited", - ratelimit: "rate_limited", - rate_limited: "rate_limited", - ratelimited: "rate_limited", - too_many_requests: "rate_limited", - toomanyrequests: "rate_limited", - content_policy: "content_policy", - contentpolicy: "content_policy", - safety: "content_policy", +const TYPE_ALIASES: Record = { + insufficient_credits: "insufficientCredits", + insufficientcredits: "insufficientCredits", + not_enough_credits: "insufficientCredits", + notenoughcredits: "insufficientCredits", + credits: "insufficientCredits", + payment_required: "insufficientCredits", + paymentrequired: "insufficientCredits", + rate_limit: "rateLimited", + ratelimit: "rateLimited", + rate_limited: "rateLimited", + ratelimited: "rateLimited", + too_many_requests: "rateLimited", + toomanyrequests: "rateLimited", + content_policy: "contentPolicy", + contentpolicy: "contentPolicy", + safety: "contentPolicy", timeout: "timeout", timed_out: "timeout", timedout: "timeout", - network: "network", - connection: "network", - server: "server", - invalid_request: "invalid_request", - invalidrequest: "invalid_request", - bad_request: "invalid_request", - badrequest: "invalid_request", - daily_cap: "daily_cap", - dailycap: "daily_cap", - daily_limit: "daily_cap", - dailylimit: "daily_cap", + network: "networkError", + connection: "networkError", + networkerror: "networkError", + server: "modelUnavailable", + model_unavailable: "modelUnavailable", + modelunavailable: "modelUnavailable", + invalid_request: "invalidRequest", + invalidrequest: "invalidRequest", + bad_request: "invalidRequest", + badrequest: "invalidRequest", + unknown_model: "invalidRequest", + daily_cap: "dailyCap", + dailycap: "dailyCap", + daily_limit: "dailyCap", + dailylimit: "dailyCap", concurrency: "concurrency", concurrent: "concurrency", + unknown: "generic", }; -function normalizeCategory(value: string | undefined): AiErrorCategory | undefined { +function normalizeType(value: string | undefined): ErrorType | undefined { if (!value) return undefined; const normalized = value.toLowerCase().replace(/[^a-z]/g, ""); - return CATEGORY_ALIASES[normalized]; + return TYPE_ALIASES[normalized]; } function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): number | undefined { @@ -86,15 +90,15 @@ function extractRetryCount(rawText: string, rawObj: RawErrorObject | null): numb return undefined; } -function cleanPrefixMessage(text: string): { category?: AiErrorCategory; message: string } { +function cleanPrefixMessage(text: string): { type?: ErrorType; message: string } { const trimmed = text.trim(); const bracketPrefix = trimmed.match(/^\[([a-zA-Z_\- ]+)\]\s*[:\-]?\s*(.+)$/); if (bracketPrefix?.[1] && bracketPrefix[2]) { - const category = normalizeCategory(bracketPrefix[1]); - if (category) { + const type = normalizeType(bracketPrefix[1]); + if (type) { return { - category, + type, message: bracketPrefix[2].trim(), }; } @@ -102,10 +106,10 @@ function cleanPrefixMessage(text: string): { category?: AiErrorCategory; message const plainPrefix = trimmed.match(/^([a-zA-Z_\- ]{3,40})\s*[:|\-]\s*(.+)$/); if (plainPrefix?.[1] && plainPrefix[2]) { - const category = normalizeCategory(plainPrefix[1]); - if (category) { + const type = normalizeType(plainPrefix[1]); + if (type) { return { - category, + type, message: plainPrefix[2].trim(), }; } @@ -129,17 +133,17 @@ function splitMessageAndDetail(message: string): { message: string; detail?: str return { message }; } -function inferCategoryFromText(text: string): AiErrorCategory { +function inferTypeFromText(text: string): ErrorType { const lower = text.toLowerCase(); const openRouterStatus = lower.match(/openrouter api error\s*(\d{3})/i); if (openRouterStatus?.[1]) { const status = Number.parseInt(openRouterStatus[1], 10); - if (status === 402) return "insufficient_credits"; + if (status === 402) return "insufficientCredits"; if (status === 408 || status === 504) return "timeout"; - if (status === 429) return "rate_limited"; - if (status >= 500) return "server"; - if (status >= 400) return "invalid_request"; + if (status === 429) return "rateLimited"; + if (status >= 500) return "modelUnavailable"; + if (status >= 400) return "invalidRequest"; } if ( @@ -149,7 +153,7 @@ function inferCategoryFromText(text: string): AiErrorCategory { lower.includes("guthaben") || lower.includes("nicht genug credits") ) { - return "insufficient_credits"; + return "insufficientCredits"; } if ( @@ -158,7 +162,7 @@ function inferCategoryFromText(text: string): AiErrorCategory { lower.includes("ratelimit") || lower.includes("429") ) { - return "rate_limited"; + return "rateLimited"; } if ( @@ -166,7 +170,7 @@ function inferCategoryFromText(text: string): AiErrorCategory { lower.includes("tageslimit erreicht") || lower.includes("daily generation limit") ) { - return "daily_cap"; + return "dailyCap"; } if ( @@ -191,7 +195,7 @@ function inferCategoryFromText(text: string): AiErrorCategory { lower.includes("fetch failed") || lower.includes("econn") ) { - return "network"; + return "networkError"; } if ( @@ -200,7 +204,7 @@ function inferCategoryFromText(text: string): AiErrorCategory { lower.includes("refusal") || lower.includes("modell lehnt ab") ) { - return "content_policy"; + return "contentPolicy"; } if ( @@ -209,85 +213,75 @@ function inferCategoryFromText(text: string): AiErrorCategory { lower.includes("unknown model") || lower.includes("missing") ) { - return "invalid_request"; + return "invalidRequest"; } if (lower.includes("server") || lower.includes("5xx")) { - return "server"; + return "modelUnavailable"; } - return "unknown"; + return "generic"; } -function defaultsForCategory(category: AiErrorCategory): Omit { - switch (category) { - case "insufficient_credits": +function defaultsForType(type: ErrorType): Omit { + switch (type) { + case "insufficientCredits": return { - message: "Not enough credits for this generation", retryable: false, creditsNotCharged: true, showTopUp: true, }; - case "rate_limited": + case "rateLimited": return { - message: "The model is busy right now", retryable: true, creditsNotCharged: true, showTopUp: false, }; - case "content_policy": + case "contentPolicy": return { - message: "The request was blocked by model safety rules", retryable: false, creditsNotCharged: true, showTopUp: false, }; case "timeout": return { - message: "The generation timed out", retryable: true, creditsNotCharged: true, showTopUp: false, }; - case "network": + case "networkError": return { - message: "Network issue while contacting the model", retryable: true, creditsNotCharged: true, showTopUp: false, }; - case "server": + case "modelUnavailable": return { - message: "The AI service returned a server error", retryable: true, creditsNotCharged: true, showTopUp: false, }; - case "invalid_request": + case "invalidRequest": return { - message: "The request could not be processed", retryable: false, creditsNotCharged: true, showTopUp: false, }; - case "daily_cap": + case "dailyCap": return { - message: "Tageslimit erreicht", retryable: false, creditsNotCharged: true, showTopUp: false, }; case "concurrency": return { - message: "Generierung bereits aktiv", retryable: true, creditsNotCharged: true, showTopUp: false, }; - case "unknown": + case "generic": default: return { - message: "Generation failed", retryable: true, creditsNotCharged: true, showTopUp: false, @@ -313,22 +307,20 @@ export function classifyError(rawError: unknown): AiError { const rawDetail = typeof rawObj?.detail === "string" ? rawObj.detail.trim() : undefined; const prefixed = cleanPrefixMessage(rawMessage); - const explicitCategory = - normalizeCategory(typeof rawObj?.category === "string" ? rawObj.category : undefined) ?? - prefixed.category; - const category = explicitCategory ?? inferCategoryFromText(prefixed.message); + const explicitType = + normalizeType(typeof rawObj?.category === "string" ? rawObj.category : undefined) ?? + prefixed.type; + const type = explicitType ?? inferTypeFromText(prefixed.message); - const defaults = defaultsForCategory(category); + const defaults = defaultsForType(type); const split = splitMessageAndDetail(prefixed.message); - const message = split.message || defaults.message; return { - category, - message, - detail: split.detail ?? rawDetail, + type, retryable: defaults.retryable, creditsNotCharged: defaults.creditsNotCharged, showTopUp: defaults.showTopUp, retryCount: extractRetryCount(rawMessage, rawObj), + rawMessage: split.message || rawMessage || undefined, }; } diff --git a/lib/format-time.ts b/lib/format-time.ts deleted file mode 100644 index b842062..0000000 --- a/lib/format-time.ts +++ /dev/null @@ -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", - }); -} diff --git a/lib/toast-messages.ts b/lib/toast-messages.ts index ade39e4..4c5f34e 100644 --- a/lib/toast-messages.ts +++ b/lib/toast-messages.ts @@ -1,194 +1,207 @@ -// Zentrales Dictionary für alle Toast-Strings. -// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen. +'use client'; -/** Grund, warum ein Node-Löschen noch blockiert ist. */ -export type CanvasNodeDeleteBlockReason = "optimistic"; +import { useTranslations } from 'next-intl'; +import { toast, type ToastDurationOverrides } from './toast'; +import type { CanvasNodeDeleteBlockReason } from './toast'; + +const DURATION = { + success: 4000, + successShort: 2000, + error: 6000, + warning: 5000, + info: 4000, +} as const; + +type ToastTranslations = ReturnType>; function canvasNodeDeleteWhy( + t: ToastTranslations, reasons: Set, ): { title: string; desc: string } { if (reasons.size === 0) { return { - title: "Löschen momentan nicht möglich", - desc: "Bitte kurz warten und erneut versuchen.", + title: t('canvas.nodeDeleteBlockedTitle'), + desc: t('canvas.nodeDeleteBlockedDesc'), }; } if (reasons.size === 1) { const only = [...reasons][0]!; - if (only === "optimistic") { + if (only === 'optimistic') { return { - title: "Element wird noch angelegt", - desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.", + title: t('canvas.nodeDeleteOptimisticTitle'), + desc: t('canvas.nodeDeleteOptimisticDesc'), }; } return { - title: "Löschen momentan nicht möglich", - desc: "Bitte kurz warten und erneut versuchen.", + title: t('canvas.nodeDeleteBlockedTitle'), + desc: t('canvas.nodeDeleteBlockedDesc'), }; } return { - title: "Löschen momentan nicht möglich", - desc: "Mindestens ein Element wird noch angelegt. Bitte kurz warten und erneut versuchen.", + title: t('canvas.nodeDeleteBlockedTitle'), + desc: t('canvas.nodeDeleteBlockedMultiDesc'), }; } export const msg = { canvas: { - imageUploaded: { title: "Bild hochgeladen" }, - uploadFailed: { title: "Upload fehlgeschlagen" }, - uploadFormatError: (format: string) => ({ - title: "Upload fehlgeschlagen", - desc: `Format „${format}“ wird nicht unterstützt. Erlaubt: PNG, JPG, WebP.`, + imageUploaded: (t: ToastTranslations) => ({ + title: t('canvas.imageUploaded'), }), - uploadSizeError: (maxMb: number) => ({ - title: "Upload fehlgeschlagen", - desc: `Maximale Dateigröße: ${maxMb} MB.`, + uploadFailed: (t: ToastTranslations) => ({ + title: t('canvas.uploadFailed'), }), - nodeRemoved: { title: "Element entfernt" }, - nodesRemoved: (count: number) => ({ - title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`, + uploadFormatError: (t: ToastTranslations, format: string) => ({ + title: t('canvas.uploadFailed'), + desc: t('canvas.uploadFormatError', { format }), }), - /** Warum gerade kein (vollständiges) Löschen möglich ist — aus den gesammelten Gründen der blockierten Nodes. */ - nodeDeleteBlockedExplain: canvasNodeDeleteWhy, - nodeDeleteBlockedPartial: ( - blockedCount: number, - reasons: Set, - ) => { - const why = canvasNodeDeleteWhy(reasons); + uploadSizeError: (t: ToastTranslations, maxMb: number) => ({ + title: t('canvas.uploadFailed'), + desc: t('canvas.uploadSizeError', { maxMb }), + }), + nodeRemoved: (t: ToastTranslations) => ({ + title: t('canvas.nodeRemoved'), + }), + nodesRemoved: (t: ToastTranslations, count: number) => ({ + title: t('canvas.nodesRemoved', { count }), + }), + nodeDeleteBlockedExplain: (t: ToastTranslations, reasons: Set) => canvasNodeDeleteWhy(t, reasons), + nodeDeleteBlockedPartial: (t: ToastTranslations, blockedCount: number, reasons: Set) => { + const why = canvasNodeDeleteWhy(t, reasons); const suffix = blockedCount === 1 - ? "Ein Element wurde deshalb nicht gelöscht; die übrige Auswahl wurde entfernt." - : `${blockedCount} Elemente wurden deshalb nicht gelöscht; die übrige Auswahl wurde entfernt.`; + ? t('canvas.nodeDeleteBlockedPartialSuffixOne') + : t('canvas.nodeDeleteBlockedPartialSuffixOther', { count: blockedCount }); return { - title: "Nicht alle Elemente entfernt", + title: t('canvas.nodeDeleteBlockedPartialTitle'), desc: `${why.desc} ${suffix}`, }; }, }, - ai: { - generating: { title: "Bild wird generiert…" }, - generated: { title: "Bild generiert" }, - generatedDesc: (credits: number) => `${credits} Credits verbraucht`, - generationQueued: { title: "Generierung gestartet" }, - generationQueuedDesc: "Das Bild erscheint automatisch, sobald es fertig ist.", - generationFailed: { title: "Generierung fehlgeschlagen" }, - creditsNotCharged: "Credits wurden nicht abgebucht", - insufficientCredits: (needed: number, available: number) => ({ - title: "Nicht genügend Credits", - desc: `${needed} Credits benötigt, ${available} verfügbar.`, + generating: (t: ToastTranslations) => ({ title: t('ai.generating') }), + generated: (t: ToastTranslations, credits: number) => ({ + title: t('ai.generated'), + desc: t('ai.generatedDesc', { credits }), + }), + generatedDesc: (t: ToastTranslations, credits: number) => t('ai.generatedDesc', { credits }), + generationQueued: (t: ToastTranslations) => ({ title: t('ai.generationQueued') }), + generationQueuedDesc: (t: ToastTranslations) => t('ai.generationQueuedDesc'), + generationFailed: (t: ToastTranslations) => ({ title: t('ai.generationFailed') }), + creditsNotCharged: (t: ToastTranslations) => t('ai.creditsNotCharged'), + insufficientCredits: (t: ToastTranslations, needed: number, available: number) => ({ + title: t('ai.insufficientCreditsTitle'), + desc: t('ai.insufficientCreditsDesc', { needed, available }), + }), + modelUnavailable: (t: ToastTranslations) => ({ + title: t('ai.modelUnavailableTitle'), + desc: t('ai.modelUnavailableDesc'), + }), + contentPolicy: (t: ToastTranslations) => ({ + title: t('ai.contentPolicyTitle'), + desc: t('ai.contentPolicyDesc'), + }), + timeout: (t: ToastTranslations) => ({ + title: t('ai.timeoutTitle'), + desc: t('ai.timeoutDesc'), + }), + openrouterIssues: (t: ToastTranslations) => ({ + title: t('ai.openrouterIssuesTitle'), + desc: t('ai.openrouterIssuesDesc'), + }), + concurrentLimitReached: (t: ToastTranslations) => ({ + title: t('ai.concurrentLimitReachedTitle'), + desc: t('ai.concurrentLimitReachedDesc'), }), - modelUnavailable: { - title: "Modell vorübergehend nicht verfügbar", - desc: "Versuche ein anderes Modell oder probiere es später erneut.", - }, - contentPolicy: { - title: "Anfrage durch Inhaltsrichtlinie blockiert", - desc: "Versuche, den Prompt umzuformulieren.", - }, - timeout: { - title: "Generierung abgelaufen", - desc: "Credits wurden nicht abgebucht.", - }, - openrouterIssues: { - title: "OpenRouter möglicherweise gestört", - desc: "Mehrere Generierungen fehlgeschlagen.", - }, - concurrentLimitReached: { - title: "Generierung bereits aktiv", - desc: "Bitte warte, bis die laufende Generierung abgeschlossen ist.", - }, }, - export: { - frameExported: { title: "Frame exportiert" }, - exportingFrames: { title: "Frames werden exportiert…" }, - zipReady: { title: "ZIP bereit" }, - exportFailed: { title: "Export fehlgeschlagen" }, - frameEmpty: { - title: "Export fehlgeschlagen", - desc: "Frame hat keinen sichtbaren Inhalt.", - }, - noFramesOnCanvas: { - title: "Export fehlgeschlagen", - desc: "Keine Frames auf dem Canvas — zuerst einen Frame anlegen.", - }, - download: "Herunterladen", - downloaded: "Heruntergeladen!", + frameExported: (t: ToastTranslations) => ({ title: t('export.frameExported') }), + exportingFrames: (t: ToastTranslations) => ({ title: t('export.exportingFrames') }), + zipReady: (t: ToastTranslations) => ({ title: t('export.zipReady') }), + exportFailed: (t: ToastTranslations) => ({ title: t('export.exportFailed') }), + frameEmpty: (t: ToastTranslations) => ({ + title: t('export.frameEmptyTitle'), + desc: t('export.frameEmptyDesc'), + }), + noFramesOnCanvas: (t: ToastTranslations) => ({ + title: t('export.noFramesOnCanvasTitle'), + desc: t('export.noFramesOnCanvasDesc'), + }), + download: (t: ToastTranslations) => t('export.download'), + downloaded: (t: ToastTranslations) => t('export.downloaded'), }, - auth: { - welcomeBack: { title: "Willkommen zurück" }, - welcomeOnDashboard: { title: "Schön, dass du da bist" }, - checkEmail: (email: string) => ({ - title: "E-Mail prüfen", - desc: `Bestätigungslink an ${email} gesendet.`, + welcomeBack: (t: ToastTranslations) => ({ title: t('auth.welcomeBack') }), + welcomeOnDashboard: (t: ToastTranslations) => ({ title: t('auth.welcomeOnDashboard') }), + checkEmail: (t: ToastTranslations, email: string) => ({ + title: t('auth.checkEmailTitle'), + desc: t('auth.checkEmailDesc', { email }), + }), + sessionExpired: (t: ToastTranslations) => ({ + title: t('auth.sessionExpiredTitle'), + desc: t('auth.sessionExpiredDesc'), + }), + signedOut: (t: ToastTranslations) => ({ title: t('auth.signedOut') }), + signIn: (t: ToastTranslations) => t('auth.signIn'), + initialSetup: (t: ToastTranslations) => ({ + title: t('auth.initialSetupTitle'), + desc: t('auth.initialSetupDesc'), }), - sessionExpired: { - title: "Sitzung abgelaufen", - desc: "Bitte erneut anmelden.", - }, - signedOut: { title: "Abgemeldet" }, - signIn: "Anmelden", - initialSetup: { - title: "Startguthaben aktiv", - desc: "Du kannst loslegen.", - }, }, - billing: { - subscriptionActivated: (credits: number) => ({ - title: "Abo aktiviert", - desc: `${credits} Credits deinem Guthaben hinzugefügt.`, + subscriptionActivated: (t: ToastTranslations, credits: number) => ({ + title: t('billing.subscriptionActivatedTitle'), + desc: t('billing.subscriptionActivatedDesc', { credits }), }), - creditsAdded: (credits: number) => ({ - title: "Credits hinzugefügt", - desc: `+${credits} Credits`, + creditsAdded: (t: ToastTranslations, credits: number) => ({ + title: t('billing.creditsAddedTitle'), + desc: t('billing.creditsAddedDesc', { credits }), }), - subscriptionCancelled: (periodEnd: string) => ({ - title: "Abo gekündigt", - desc: `Deine Credits bleiben bis ${periodEnd} verfügbar.`, + subscriptionCancelled: (t: ToastTranslations, periodEnd: string) => ({ + title: t('billing.subscriptionCancelledTitle'), + desc: t('billing.subscriptionCancelledDesc', { periodEnd }), }), - paymentFailed: { - title: "Zahlung fehlgeschlagen", - desc: "Bitte Zahlungsmethode aktualisieren.", - }, - dailyLimitReached: (limit: number) => ({ - title: "Tageslimit erreicht", - desc: `Maximal ${limit} Generierungen pro Tag in deinem Tarif.`, + paymentFailed: (t: ToastTranslations) => ({ + title: t('billing.paymentFailedTitle'), + desc: t('billing.paymentFailedDesc'), }), - lowCredits: (remaining: number) => ({ - title: "Credits fast aufgebraucht", - desc: `Noch ${remaining} Credits übrig.`, + dailyLimitReached: (t: ToastTranslations, limit: number) => ({ + title: t('billing.dailyLimitReachedTitle'), + desc: t('billing.dailyLimitReachedDesc', { limit }), }), - topUp: "Aufladen", - upgrade: "Upgrade", - manage: "Verwalten", - redirectingToCheckout: { - title: "Weiterleitung…", - desc: "Du wirst zum sicheren Checkout weitergeleitet.", - }, - openingPortal: { - title: "Portal wird geöffnet…", - desc: "Du wirst zur Aboverwaltung weitergeleitet.", - }, - testGrantFailed: { title: "Gutschrift fehlgeschlagen" }, + lowCredits: (t: ToastTranslations, remaining: number) => ({ + title: t('billing.lowCreditsTitle'), + desc: t('billing.lowCreditsDesc', { remaining }), + }), + topUp: (t: ToastTranslations) => t('billing.topUp'), + upgrade: (t: ToastTranslations) => t('billing.upgrade'), + manage: (t: ToastTranslations) => t('billing.manage'), + redirectingToCheckout: (t: ToastTranslations) => ({ + title: t('billing.redirectingToCheckoutTitle'), + desc: t('billing.redirectingToCheckoutDesc'), + }), + openingPortal: (t: ToastTranslations) => ({ + title: t('billing.openingPortalTitle'), + desc: t('billing.openingPortalDesc'), + }), + testGrantFailed: (t: ToastTranslations) => ({ title: t('billing.testGrantFailedTitle') }), }, - system: { - reconnected: { title: "Verbindung wiederhergestellt" }, - connectionLost: { - title: "Verbindung verloren", - desc: "Änderungen werden möglicherweise nicht gespeichert.", - }, - copiedToClipboard: { title: "In Zwischenablage kopiert" }, + reconnected: (t: ToastTranslations) => ({ title: t('system.reconnected') }), + connectionLost: (t: ToastTranslations) => ({ + title: t('system.connectionLostTitle'), + desc: t('system.connectionLostDesc'), + }), + copiedToClipboard: (t: ToastTranslations) => ({ title: t('system.copiedToClipboard') }), }, - dashboard: { - renameEmpty: { title: "Name ungültig", desc: "Name darf nicht leer sein." }, - renameSuccess: { title: "Arbeitsbereich umbenannt" }, - renameFailed: { title: "Umbenennen fehlgeschlagen" }, - deleteSuccess: { title: "Arbeitsbereich gelöscht" }, - deleteFailed: { title: "Löschen fehlgeschlagen" }, + renameEmpty: (t: ToastTranslations) => ({ + title: t('dashboard.renameEmptyTitle'), + desc: t('dashboard.renameEmptyDesc'), + }), + renameSuccess: (t: ToastTranslations) => ({ title: t('dashboard.renameSuccess') }), + renameFailed: (t: ToastTranslations) => ({ title: t('dashboard.renameFailed') }), + deleteSuccess: (t: ToastTranslations) => ({ title: t('dashboard.deleteSuccess') }), + deleteFailed: (t: ToastTranslations) => ({ title: t('dashboard.deleteFailed') }), }, } as const; diff --git a/lib/toast.ts b/lib/toast.ts index 7e42cd7..3e0e462 100644 --- a/lib/toast.ts +++ b/lib/toast.ts @@ -1,4 +1,7 @@ -import { gooeyToast, type GooeyPromiseData } from "goey-toast"; +'use client'; + +import { useTranslations } from 'next-intl'; +import { gooeyToast, type GooeyPromiseData } from 'goey-toast'; const DURATION = { success: 4000, @@ -8,70 +11,48 @@ const DURATION = { info: 4000, } as const; +type ToastTranslations = ReturnType>; + export type ToastDurationOverrides = { duration?: number; }; export const toast = { - success( - message: string, - description?: string, - opts?: ToastDurationOverrides, - ) { + success(message: string, description?: string, opts?: ToastDurationOverrides) { return gooeyToast.success(message, { description, duration: opts?.duration ?? DURATION.success, }); }, - - error( - message: string, - description?: string, - opts?: ToastDurationOverrides, - ) { + error(message: string, description?: string, opts?: ToastDurationOverrides) { return gooeyToast.error(message, { description, duration: opts?.duration ?? DURATION.error, }); }, - - warning( - message: string, - description?: string, - opts?: ToastDurationOverrides, - ) { + warning(message: string, description?: string, opts?: ToastDurationOverrides) { return gooeyToast.warning(message, { description, duration: opts?.duration ?? DURATION.warning, }); }, - - info( - message: string, - description?: string, - opts?: ToastDurationOverrides, - ) { + info(message: string, description?: string, opts?: ToastDurationOverrides) { return gooeyToast.info(message, { description, duration: opts?.duration ?? DURATION.info, }); }, - promise(promise: Promise, data: GooeyPromiseData) { return gooeyToast.promise(promise, data); }, - - action( - message: string, - opts: { - description?: string; - label: string; - onClick: () => void; - successLabel?: string; - type?: "success" | "info" | "warning"; - duration?: number; - }, - ) { + action(message: string, opts: { + description?: string; + label: string; + onClick: () => void; + successLabel?: string; + type?: "success" | "info" | "warning"; + duration?: number; + }) { const t = opts.type ?? "info"; return gooeyToast[t](message, { description: opts.description, @@ -83,18 +64,13 @@ export const toast = { }, }); }, - - update( - id: string | number, - opts: { - title?: string; - description?: string; - type?: "default" | "success" | "error" | "warning" | "info"; - }, - ) { + update(id: string | number, opts: { + title?: string; + description?: string; + type?: "default" | "success" | "error" | "warning" | "info"; + }) { gooeyToast.update(id, opts); }, - dismiss(id?: string | number) { gooeyToast.dismiss(id); }, @@ -107,3 +83,277 @@ export const toastDuration = { warning: DURATION.warning, info: DURATION.info, } as const; + +export type CanvasNodeDeleteBlockReason = 'optimistic'; + +export function showImageUploadedToast(t: ToastTranslations) { + toast.success(t('canvas.imageUploaded')); +} + +export function showUploadFailedToast(t: ToastTranslations, reason?: string) { + if (reason) { + toast.error(t('canvas.uploadFailed'), reason); + } else { + toast.error(t('canvas.uploadFailed')); + } +} + +export function showUploadFormatError(t: ToastTranslations, format: string) { + toast.error(t('canvas.uploadFailed'), t('canvas.uploadFormatError', { format })); +} + +export function showUploadSizeError(t: ToastTranslations, maxMb: number) { + toast.error(t('canvas.uploadFailed'), t('canvas.uploadSizeError', { maxMb })); +} + +export function showNodeRemovedToast(t: ToastTranslations) { + toast.success(t('canvas.nodeRemoved')); +} + +export function showNodesRemovedToast(t: ToastTranslations, count: number) { + const title = t('canvas.nodesRemoved', { count }); + toast.success(title); +} + +export function canvasNodeDeleteWhy( + t: ToastTranslations, + reasons: Set, +) { + 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, +) { + 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; +} diff --git a/lib/utils.ts b/lib/utils.ts index 4a79689..bd0c391 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,11 +4,3 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } - -/** Credits / Preise: Werte sind Euro-Cent (siehe PRD, Manifest). */ -export function formatEurFromCents(cents: number) { - return new Intl.NumberFormat("de-DE", { - style: "currency", - currency: "EUR", - }).format(cents / 100) -} diff --git a/messages/de.json b/messages/de.json new file mode 100644 index 0000000..116b893 --- /dev/null +++ b/messages/de.json @@ -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" + } + } +} diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..b79e06a --- /dev/null +++ b/messages/en.json @@ -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" + } + } +} diff --git a/next.config.ts b/next.config.ts index 39053e6..eca5e0b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import { withSentryConfig } from "@sentry/nextjs"; +import createNextIntlPlugin from "next-intl/plugin"; import type { NextConfig } from "next"; +const withNextIntl = createNextIntlPlugin(); + const nextConfig: NextConfig = { turbopack: { root: __dirname, @@ -26,7 +29,7 @@ const nextConfig: NextConfig = { }, }; -export default withSentryConfig(nextConfig, { +export default withSentryConfig(withNextIntl(nextConfig), { // For all available options, see: // https://www.npmjs.com/package/@sentry/webpack-plugin#options diff --git a/routing.ts b/routing.ts new file mode 100644 index 0000000..8aef005 --- /dev/null +++ b/routing.ts @@ -0,0 +1,7 @@ +import { defineRouting } from 'next-intl/routing'; + +export const routing = defineRouting({ + locales: ['de', 'en'], + defaultLocale: 'de', + localePrefix: 'never', +}); diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..4d5a045 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1 @@ +export { routing } from '../../routing';