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:
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -22,6 +22,7 @@ import type * as openrouter from "../openrouter.js";
|
||||
import type * as pexels from "../pexels.js";
|
||||
import type * as polar from "../polar.js";
|
||||
import type * as storage from "../storage.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
@@ -44,6 +45,7 @@ declare const fullApi: ApiFromModules<{
|
||||
pexels: typeof pexels;
|
||||
polar: typeof polar;
|
||||
storage: typeof storage;
|
||||
users: typeof users;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
31
convex/ai.ts
31
convex/ai.ts
@@ -1,4 +1,4 @@
|
||||
import { v } from "convex/values";
|
||||
import { v, ConvexError } from "convex/values";
|
||||
import { action, internalAction, internalMutation } from "./_generated/server";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import {
|
||||
@@ -18,6 +18,19 @@ type ErrorCategory =
|
||||
| "provider"
|
||||
| "unknown";
|
||||
|
||||
interface ErrorData {
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
if (error instanceof ConvexError) {
|
||||
const data = error.data as ErrorData;
|
||||
return data?.code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error ?? "Generation failed");
|
||||
@@ -34,20 +47,23 @@ function categorizeError(error: unknown): {
|
||||
category: ErrorCategory;
|
||||
retryable: boolean;
|
||||
} {
|
||||
const code = getErrorCode(error);
|
||||
const message = errorMessage(error);
|
||||
const lower = message.toLowerCase();
|
||||
const status = parseOpenRouterStatus(message);
|
||||
|
||||
if (
|
||||
lower.includes("insufficient credits") ||
|
||||
lower.includes("daily generation limit") ||
|
||||
lower.includes("concurrent job limit")
|
||||
code === "CREDITS_TEST_DISABLED" ||
|
||||
code === "CREDITS_INVALID_AMOUNT" ||
|
||||
code === "CREDITS_BALANCE_NOT_FOUND" ||
|
||||
code === "CREDITS_DAILY_CAP_REACHED" ||
|
||||
code === "CREDITS_CONCURRENCY_LIMIT"
|
||||
) {
|
||||
return { category: "credits", retryable: false };
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("modell lehnt ab") ||
|
||||
code === "OPENROUTER_MODEL_REFUSAL" ||
|
||||
lower.includes("content policy") ||
|
||||
lower.includes("policy") ||
|
||||
lower.includes("moderation") ||
|
||||
@@ -94,6 +110,11 @@ function categorizeError(error: unknown): {
|
||||
}
|
||||
|
||||
function formatTerminalStatusMessage(error: unknown): string {
|
||||
const code = getErrorCode(error);
|
||||
if (code) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const message = errorMessage(error).trim() || "Generation failed";
|
||||
const { category } = categorizeError(error);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { v, ConvexError } from "convex/values";
|
||||
import { optionalAuth, requireAuth } from "./helpers";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
@@ -275,10 +275,10 @@ export const grantTestCredits = mutation({
|
||||
},
|
||||
handler: async (ctx, { amount = 2000 }) => {
|
||||
if (process.env.ALLOW_TEST_CREDIT_GRANT !== "true") {
|
||||
throw new Error("Test-Gutschriften sind deaktiviert (ALLOW_TEST_CREDIT_GRANT).");
|
||||
throw new ConvexError({ code: "CREDITS_TEST_DISABLED" });
|
||||
}
|
||||
if (amount <= 0 || amount > 1_000_000) {
|
||||
throw new Error("Ungültiger Betrag.");
|
||||
throw new ConvexError({ code: "CREDITS_INVALID_AMOUNT" });
|
||||
}
|
||||
const user = await requireAuth(ctx);
|
||||
const balance = await ctx.db
|
||||
@@ -287,7 +287,7 @@ export const grantTestCredits = mutation({
|
||||
.unique();
|
||||
|
||||
if (!balance) {
|
||||
throw new Error("Keine Credit-Balance — zuerst einloggen / initBalance.");
|
||||
throw new ConvexError({ code: "CREDITS_BALANCE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const next = balance.balance + amount;
|
||||
@@ -362,16 +362,18 @@ export const reserve = mutation({
|
||||
.unique();
|
||||
|
||||
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
|
||||
throw new Error(
|
||||
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_DAILY_CAP_REACHED",
|
||||
data: { limit: config.dailyGenerationCap, tier },
|
||||
});
|
||||
}
|
||||
|
||||
// Concurrency Limit prüfen
|
||||
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
|
||||
throw new Error(
|
||||
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_CONCURRENCY_LIMIT",
|
||||
data: { limit: config.concurrencyLimit },
|
||||
});
|
||||
}
|
||||
|
||||
// Credits reservieren
|
||||
@@ -487,7 +489,14 @@ export const commit = mutation({
|
||||
actualCost: v.number(),
|
||||
openRouterCost: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { transactionId, actualCost, openRouterCost }) => {
|
||||
handler: async (
|
||||
ctx,
|
||||
{ transactionId, actualCost, openRouterCost }
|
||||
): Promise<
|
||||
{ status: "already_committed" } |
|
||||
{ status: "already_released" } |
|
||||
{ status: "committed" }
|
||||
> => {
|
||||
const user = await requireAuth(ctx);
|
||||
const transaction = await ctx.db.get(transactionId);
|
||||
if (!transaction || transaction.userId !== user.userId) {
|
||||
@@ -571,7 +580,14 @@ export const release = mutation({
|
||||
args: {
|
||||
transactionId: v.id("creditTransactions"),
|
||||
},
|
||||
handler: async (ctx, { transactionId }) => {
|
||||
handler: async (
|
||||
ctx,
|
||||
{ transactionId }
|
||||
): Promise<
|
||||
{ status: "already_released" } |
|
||||
{ status: "already_committed" } |
|
||||
{ status: "released" }
|
||||
> => {
|
||||
const user = await requireAuth(ctx);
|
||||
const transaction = await ctx.db.get(transactionId);
|
||||
if (!transaction || transaction.userId !== user.userId) {
|
||||
@@ -761,16 +777,18 @@ export const checkAbuseLimits = internalMutation({
|
||||
|
||||
const dailyCount = usage?.generationCount ?? 0;
|
||||
if (dailyCount >= config.dailyGenerationCap) {
|
||||
throw new Error(
|
||||
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_DAILY_CAP_REACHED",
|
||||
data: { limit: config.dailyGenerationCap, tier },
|
||||
});
|
||||
}
|
||||
|
||||
const currentConcurrency = usage?.concurrentJobs ?? 0;
|
||||
if (currentConcurrency >= config.concurrencyLimit) {
|
||||
throw new Error(
|
||||
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "CREDITS_CONCURRENCY_LIMIT",
|
||||
data: { limit: config.concurrencyLimit },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export const exportFrame = action({
|
||||
});
|
||||
|
||||
// Find image/ai-image nodes visually within the frame
|
||||
const imageNodes = allNodes.filter((node) => {
|
||||
const imageNodes = allNodes.filter((node: (typeof allNodes)[number]) => {
|
||||
if (node.type !== "image" && node.type !== "ai-image") return false;
|
||||
const data = node.data as { storageId?: string };
|
||||
if (!data.storageId) return false;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
|
||||
export interface OpenRouterModel {
|
||||
@@ -144,7 +146,7 @@ export async function generateImageViaOpenRouter(
|
||||
|
||||
const message = data?.choices?.[0]?.message as Record<string, unknown> | undefined;
|
||||
if (!message) {
|
||||
throw new Error("OpenRouter: choices[0].message fehlt");
|
||||
throw new ConvexError({ code: "OPENROUTER_MISSING_MESSAGE" });
|
||||
}
|
||||
|
||||
let rawImage: string | undefined;
|
||||
@@ -186,7 +188,10 @@ export async function generateImageViaOpenRouter(
|
||||
) {
|
||||
const r =
|
||||
typeof refusal === "string" ? refusal : JSON.stringify(refusal);
|
||||
throw new Error(`OpenRouter: Modell lehnt ab — ${r.slice(0, 500)}`);
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_MODEL_REFUSAL",
|
||||
data: { reason: r.slice(0, 500) },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -205,19 +210,23 @@ export async function generateImageViaOpenRouter(
|
||||
: Array.isArray(content)
|
||||
? JSON.stringify(content).slice(0, 400)
|
||||
: "";
|
||||
throw new Error(
|
||||
`OpenRouter: kein Bild in der Antwort. Keys: ${Object.keys(message).join(", ")}. ` +
|
||||
(reasoning ? `reasoning: ${reasoning}` : `content: ${contentPreview}`),
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_NO_IMAGE_IN_RESPONSE",
|
||||
data: {
|
||||
keys: Object.keys(message).join(", "),
|
||||
reasoningOrContent: reasoning || contentPreview,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let dataUri = rawImage;
|
||||
if (rawImage.startsWith("http://") || rawImage.startsWith("https://")) {
|
||||
const imgRes = await fetch(rawImage);
|
||||
if (!imgRes.ok) {
|
||||
throw new Error(
|
||||
`OpenRouter: Bild-URL konnte nicht geladen werden (${imgRes.status})`,
|
||||
);
|
||||
throw new ConvexError({
|
||||
code: "OPENROUTER_IMAGE_URL_LOAD_FAILED",
|
||||
data: { status: imgRes.status },
|
||||
});
|
||||
}
|
||||
const mimeTypeFromRes =
|
||||
imgRes.headers.get("content-type") ?? "image/png";
|
||||
@@ -237,12 +246,12 @@ export async function generateImageViaOpenRouter(
|
||||
}
|
||||
|
||||
if (!dataUri.startsWith("data:")) {
|
||||
throw new Error("OpenRouter: Bild konnte nicht als data-URI erstellt werden");
|
||||
throw new ConvexError({ code: "OPENROUTER_DATA_URI_CREATION_FAILED" });
|
||||
}
|
||||
|
||||
const comma = dataUri.indexOf(",");
|
||||
if (comma === -1) {
|
||||
throw new Error("OpenRouter: data-URI ohne Base64-Teil");
|
||||
throw new ConvexError({ code: "OPENROUTER_DATA_URI_MISSING_BASE64" });
|
||||
}
|
||||
const meta = dataUri.slice(0, comma);
|
||||
const base64Data = dataUri.slice(comma + 1);
|
||||
|
||||
@@ -310,4 +310,16 @@ export default defineSchema({
|
||||
concurrentJobs: v.number(), // Aktuell laufende Jobs
|
||||
})
|
||||
.index("by_user_date", ["userId", "date"]),
|
||||
|
||||
// ==========================================================================
|
||||
// User Settings
|
||||
// ==========================================================================
|
||||
|
||||
userSettings: defineTable({
|
||||
userId: v.string(), // Better Auth User ID
|
||||
locale: v.optional(v.union(v.literal('de'), v.literal('en'))),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_user", ["userId"]),
|
||||
});
|
||||
|
||||
47
convex/users.ts
Normal file
47
convex/users.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./helpers";
|
||||
|
||||
export const setLocale = mutation({
|
||||
args: {
|
||||
locale: v.union(v.literal("de"), v.literal("en")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireAuth(ctx);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("userSettings")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.unique();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
locale: args.locale,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("userSettings", {
|
||||
userId,
|
||||
locale: args.locale,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const getLocale = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { userId } = await requireAuth(ctx);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("userSettings")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.unique();
|
||||
|
||||
return existing?.locale ?? null;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user