Implement internationalization support across components

- Integrated `next-intl` for toast messages and locale handling in various components, including `Providers`, `CanvasUserMenu`, and `CreditOverview`.
- Replaced hardcoded strings with translation keys to enhance localization capabilities.
- Updated `RootLayout` to dynamically set the language attribute based on the user's locale.
- Ensured consistent user feedback through localized toast messages in actions such as sign-out, canvas operations, and billing notifications.
This commit is contained in:
2026-04-01 18:16:52 +02:00
parent 6ce1d4a82e
commit 79d9092d43
44 changed files with 1385 additions and 507 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

47
convex/users.ts Normal file
View File

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