feat(i18n): replace remaining hardcoded strings with translation keys

Replace hardcoded English text with t() translation calls across
combos, providers, and ModelAvailabilityBadge components. Add
corresponding translation keys to locale files for full i18n coverage.
This commit is contained in:
diegosouzapw
2026-02-26 01:41:52 -03:00
parent 2126b0ee8d
commit 187aba0514
37 changed files with 1216 additions and 674 deletions

View File

@@ -866,7 +866,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move up"
title={t("moveUp")}
>
<span className="material-symbols-outlined text-[12px]">
arrow_upward
@@ -876,7 +876,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onClick={() => handleMoveDown(index)}
disabled={index === models.length - 1}
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move down"
title={t("moveDown")}
>
<span className="material-symbols-outlined text-[12px]">
arrow_downward
@@ -889,7 +889,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<button
onClick={() => handleRemoveModel(index)}
className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 transition-all"
title="Remove"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>

View File

@@ -2664,7 +2664,7 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder={isAnthropic ? "ac-prod" : "oc-prod"}
placeholder={isAnthropic ? t("anthropicPrefixPlaceholder") : t("openaiPrefixPlaceholder")}
hint={t("prefixHint")}
/>
{!isAnthropic && (
@@ -2679,7 +2679,9 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic })
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"}
placeholder={
isAnthropic ? t("anthropicBaseUrlPlaceholder") : t("openaiBaseUrlPlaceholder")
}
hint={t("compatibleBaseUrlHint", {
type: isAnthropic ? t("anthropic") : t("openai"),
})}

View File

@@ -9,22 +9,26 @@
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityBadge() {
const [data, setData] = useState(null);
const t = useTranslations("providers");
const tc = useTranslations("common");
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: t("available") },
cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
};
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [clearing, setClearing] = useState(null);
const ref = useRef(null);
const [clearing, setClearing] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
@@ -49,8 +53,8 @@ export default function ModelAvailabilityBadge() {
// Close popover on outside click
useEffect(() => {
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setExpanded(false);
}
};
@@ -58,7 +62,7 @@ export default function ModelAvailabilityBadge() {
return () => document.removeEventListener("mousedown", handleClick);
}, [expanded]);
const handleClearCooldown = async (provider, model) => {
const handleClearCooldown = async (provider: string, model: string) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
@@ -67,13 +71,13 @@ export default function ModelAvailabilityBadge() {
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
notify.success(t("cooldownCleared", { model }));
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
}
} catch {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
} finally {
setClearing(null);
}
@@ -83,12 +87,12 @@ export default function ModelAvailabilityBadge() {
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
const isHealthy = unavailableCount === 0;
// Group unhealthy models by provider
const byProvider = {};
models.forEach((m) => {
const byProvider: Record<string, any[]> = {};
models.forEach((m: any) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
@@ -105,12 +109,10 @@ export default function ModelAvailabilityBadge() {
: "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
}`}
>
<span className="material-symbols-outlined text-[14px]">
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{isHealthy ? "verified" : "warning"}
</span>
{isHealthy
? "All models operational"
: `${unavailableCount} model${unavailableCount !== 1 ? "s" : ""} with issues`}
{isHealthy ? t("allModelsOperational") : t("modelsWithIssues", { count: unavailableCount })}
</button>
{/* Expanded popover */}
@@ -121,25 +123,26 @@ export default function ModelAvailabilityBadge() {
<span
className="material-symbols-outlined text-[16px]"
style={{ color: isHealthy ? "#22c55e" : "#f59e0b" }}
aria-hidden="true"
>
{isHealthy ? "verified" : "warning"}
</span>
<span className="text-sm font-semibold text-text-main">Model Status</span>
<span className="text-sm font-semibold text-text-main">{t("modelStatus")}</span>
</div>
<button
onClick={fetchStatus}
className="p-1 rounded-lg hover:bg-surface text-text-muted hover:text-text-main transition-colors"
title="Refresh"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
refresh
</span>
</button>
</div>
<div className="px-4 py-3 max-h-60 overflow-y-auto">
{isHealthy ? (
<p className="text-sm text-text-muted text-center py-2">
All models are responding normally.
</p>
<p className="text-sm text-text-muted text-center py-2">{t("allModelsNormal")}</p>
) : (
<div className="flex flex-col gap-2.5">
{Object.entries(byProvider).map(([provider, provModels]) => (
@@ -148,8 +151,10 @@ export default function ModelAvailabilityBadge() {
{provider}
</p>
<div className="flex flex-col gap-1">
{(provModels as any).map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
{provModels.map((m) => {
const status =
STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] ||
STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
@@ -160,6 +165,7 @@ export default function ModelAvailabilityBadge() {
<span
className="material-symbols-outlined text-[14px] shrink-0"
style={{ color: status.color }}
aria-hidden="true"
>
{status.icon}
</span>
@@ -175,7 +181,7 @@ export default function ModelAvailabilityBadge() {
disabled={isClearing}
className="text-[10px] px-1.5! py-0.5! ml-2"
>
{isClearing ? "..." : "Clear"}
{isClearing ? t("clearing") : t("clearCooldown")}
</Button>
)}
</div>

View File

@@ -8,20 +8,24 @@
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityPanel() {
const [data, setData] = useState(null);
const t = useTranslations("providers");
const tc = useTranslations("common");
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: t("available") },
cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
};
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(null);
const [clearing, setClearing] = useState<string | null>(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
@@ -44,7 +48,7 @@ export default function ModelAvailabilityPanel() {
return () => clearInterval(interval);
}, [fetchStatus]);
const handleClearCooldown = async (provider, model) => {
const handleClearCooldown = async (provider: string, model: string) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
@@ -53,13 +57,13 @@ export default function ModelAvailabilityPanel() {
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
notify.success(t("cooldownCleared", { model }));
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
}
} catch {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
} finally {
setClearing(null);
}
@@ -69,8 +73,10 @@ export default function ModelAvailabilityPanel() {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">monitoring</span>
Loading model availability...
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
monitoring
</span>
{t("loadingAvailability")}
</div>
</Card>
);
@@ -78,18 +84,20 @@ export default function ModelAvailabilityPanel() {
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
if (models.length === 0 || unavailableCount === 0) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
verified
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<p className="text-sm text-text-muted">All models operational</p>
<h3 className="text-lg font-semibold text-text-main">{t("modelAvailability")}</h3>
<p className="text-sm text-text-muted">{t("allModelsOperational")}</p>
</div>
</div>
</Card>
@@ -97,8 +105,8 @@ export default function ModelAvailabilityPanel() {
}
// Group by provider
const byProvider = {};
models.forEach((m) => {
const byProvider: Record<string, any[]> = {};
models.forEach((m: any) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
@@ -110,17 +118,27 @@ export default function ModelAvailabilityPanel() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">warning</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
warning
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<h3 className="text-lg font-semibold text-text-main">{t("modelAvailability")}</h3>
<p className="text-sm text-text-muted">
{unavailableCount} model{unavailableCount !== 1 ? "s" : ""} with issues
{t("modelsWithIssues", { count: unavailableCount })}
</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchStatus} className="text-text-muted">
<span className="material-symbols-outlined text-[16px]">refresh</span>
<Button
size="sm"
variant="ghost"
onClick={fetchStatus}
className="text-text-muted"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
</Button>
</div>
@@ -129,8 +147,9 @@ export default function ModelAvailabilityPanel() {
<div key={provider} className="border border-border/30 rounded-lg p-3">
<p className="text-sm font-medium text-text-main mb-2 capitalize">{provider}</p>
<div className="flex flex-col gap-1.5">
{(provModels as any).map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
{provModels.map((m) => {
const status =
STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
@@ -141,6 +160,7 @@ export default function ModelAvailabilityPanel() {
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
aria-hidden="true"
>
{status.icon}
</span>
@@ -156,7 +176,7 @@ export default function ModelAvailabilityPanel() {
</span>
{m.cooldownUntil && (
<span className="text-xs text-text-muted">
until {new Date(m.cooldownUntil).toLocaleTimeString()}
{t("until", { time: new Date(m.cooldownUntil).toLocaleTimeString() })}
</span>
)}
</div>
@@ -168,7 +188,7 @@ export default function ModelAvailabilityPanel() {
disabled={isClearing}
className="text-xs"
>
{isClearing ? "Clearing..." : "Clear"}
{isClearing ? t("clearing") : t("clearCooldown")}
</Button>
)}
</div>

View File

@@ -94,7 +94,7 @@ export default function NewProviderPage() {
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Provider Selection */}
<Select
label="Provider"
label={t("providerLabel")}
options={providerOptions}
value={formData.provider}
onChange={(e) => handleChange("provider", e.target.value)}
@@ -141,7 +141,9 @@ export default function NewProviderPage() {
<span className="material-symbols-outlined">
{method.value === "api_key" ? "key" : "lock"}
</span>
<span className="font-medium">{method.label}</span>
<span className="font-medium">
{method.value === "api_key" ? t("apiKeyLabel") : t("oauth2Label")}
</span>
</button>
))}
</div>
@@ -150,7 +152,7 @@ export default function NewProviderPage() {
{/* API Key Input */}
{formData.authMethod === "api_key" && (
<Input
label="API Key"
label={t("apiKeyLabel")}
type="password"
placeholder={t("enterApiKey")}
value={formData.apiKey}
@@ -173,11 +175,11 @@ export default function NewProviderPage() {
{/* Display Name */}
<Input
label="Display Name"
placeholder="e.g., Production API, Dev Environment"
label={t("displayName")}
placeholder={t("displayNamePlaceholder")}
value={formData.displayName}
onChange={(e) => handleChange("displayName", e.target.value)}
hint="Optional. A friendly name to identify this configuration."
hint={t("displayNameHint")}
/>
{/* Active Toggle */}

View File

@@ -26,17 +26,19 @@ import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import { useTranslations } from "next-intl";
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
function getStatusDisplay(connected, error, errorCode) {
function getStatusDisplay(connected, error, errorCode, t) {
const parts = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
{t("connected", { count: connected })}
</Badge>
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
const errText = errorCode
? t("errorCount", { count: error, code: errorCode })
: t("errorCountNoCode", { count: error });
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
@@ -44,7 +46,7 @@ function getStatusDisplay(connected, error, errorCode) {
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
return <span className="text-text-muted">{t("noConnections")}</span>;
}
return parts;
}
@@ -90,13 +92,13 @@ function getConnectionErrorTag(connection) {
}
export default function ProvidersPage() {
const [connections, setConnections] = useState([]);
const [providerNodes, setProviderNodes] = useState([]);
const [connections, setConnections] = useState<any[]>([]);
const [providerNodes, setProviderNodes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const [testingMode, setTestingMode] = useState<string | null>(null);
const [testResults, setTestResults] = useState<any>(null);
const notify = useNotificationStore();
const t = useTranslations("providers");
const tc = useTranslations("common");
@@ -212,7 +214,7 @@ export default function ProvidersPage() {
.filter((node) => node.type === "openai-compatible")
.map((node) => ({
id: node.id,
name: node.name || "OpenAI Compatible",
name: node.name || t("openaiCompatibleName"),
color: "#10A37F",
textIcon: "OC",
apiType: node.apiType,
@@ -222,7 +224,7 @@ export default function ProvidersPage() {
.filter((node) => node.type === "anthropic-compatible")
.map((node) => ({
id: node.id,
name: node.name || "Anthropic Compatible",
name: node.name || t("anthropicCompatibleName"),
color: "#D97757",
textIcon: "AC",
}));
@@ -243,7 +245,7 @@ export default function ProvidersPage() {
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
{t("oauthProviders")}{" "}
<span className="size-2.5 rounded-full bg-blue-500" title="OAuth" />
<span className="size-2.5 rounded-full bg-blue-500" title={t("oauthLabel")} />
</h2>
<div className="flex items-center gap-2">
<ModelAvailabilityBadge />
@@ -284,7 +286,7 @@ export default function ProvidersPage() {
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
{t("freeProviders")}{" "}
<span className="size-2.5 rounded-full bg-green-500" title="Free" />
<span className="size-2.5 rounded-full bg-green-500" title={tc("free")} />
</h2>
<button
onClick={() => handleBatchTest("free")}
@@ -322,7 +324,7 @@ export default function ProvidersPage() {
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
{t("apiKeyProviders")}{" "}
<span className="size-2.5 rounded-full bg-amber-500" title="API Key" />
<span className="size-2.5 rounded-full bg-amber-500" title={t("apiKeyLabel")} />
</h2>
<button
onClick={() => handleBatchTest("apikey")}
@@ -360,7 +362,7 @@ export default function ProvidersPage() {
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
{t("compatibleProviders")}{" "}
<span className="size-2.5 rounded-full bg-orange-500" title="Compatible" />
<span className="size-2.5 rounded-full bg-orange-500" title={t("compatibleLabel")} />
</h2>
<div className="flex gap-2">
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
@@ -449,7 +451,7 @@ export default function ProvidersPage() {
<button
onClick={() => setTestResults(null)}
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Close test results"
aria-label={tc("close")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
@@ -465,6 +467,8 @@ export default function ProvidersPage() {
}
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgError, setImgError] = useState(false);
@@ -474,7 +478,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: tc("free"),
oauth: t("oauthLabel"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
};
return (
<Link href={`/dashboard/providers/${providerId}`} className="group">
@@ -509,7 +518,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.oauth} shrink-0`}
title={dotLabels[authType] || "OAuth"}
title={dotLabels[authType] || t("oauthLabel")}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
@@ -517,12 +526,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
{t("disabled")}
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, t)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
@@ -543,7 +552,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
)}
@@ -576,6 +585,8 @@ ProviderCard.propTypes = {
// API Key providers - use image with textIcon fallback (same as OAuth providers)
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
@@ -587,7 +598,12 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: tc("free"),
oauth: t("oauthLabel"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
};
// Determine icon path: OpenAI Compatible providers use specialized icons
const getIconPath = () => {
@@ -633,7 +649,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.apikey} shrink-0`}
title={dotLabels[authType] || "API Key"}
title={dotLabels[authType] || t("apiKeyLabel")}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
@@ -641,20 +657,20 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
{t("disabled")}
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, t)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
{provider.apiType === "responses" ? t("responses") : t("chat")}
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
{t("messages")}
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
@@ -677,7 +693,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
)}
@@ -710,6 +726,7 @@ ApiKeyProviderCard.propTypes = {
};
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
prefix: "",
@@ -719,11 +736,11 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const apiTypeOptions = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses API" },
{ value: "chat", label: t("chatCompletions") },
{ value: "responses", label: t("responsesApi") },
];
useEffect(() => {
@@ -790,38 +807,38 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
};
return (
<Modal isOpen={isOpen} title="Add OpenAI Compatible" onClose={onClose}>
<Modal isOpen={isOpen} title={t("addOpenAICompatible")} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="OpenAI Compatible (Prod)"
hint="Required. A friendly label for this node."
placeholder={t("compatibleProdPlaceholder", { type: t("openai") })}
hint={t("nameHint")}
/>
<Input
label="Prefix"
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="oc-prod"
hint="Required. Used as the provider prefix for model IDs."
placeholder={t("openaiPrefixPlaceholder")}
hint={t("prefixHint")}
/>
<Select
label="API Type"
label={t("apiTypeLabel")}
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.openai.com/v1"
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
placeholder={t("openaiBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("openai") })}
/>
<div className="flex gap-2">
<Input
label="API Key (for Check)"
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
@@ -833,13 +850,13 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
<div className="flex gap-2">
@@ -853,10 +870,10 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
submitting
}
>
{submitting ? "Creating..." : "Create"}
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -871,6 +888,7 @@ AddOpenAICompatibleModal.propTypes = {
};
function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
prefix: "",
@@ -879,7 +897,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
useEffect(() => {
// Reset validation when modal opens
@@ -943,32 +961,32 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
};
return (
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
<Modal isOpen={isOpen} title={t("addAnthropicCompatible")} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Anthropic Compatible (Prod)"
hint="Required. A friendly label for this node."
placeholder={t("compatibleProdPlaceholder", { type: t("anthropic") })}
hint={t("nameHint")}
/>
<Input
label="Prefix"
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="ac-prod"
hint="Required. Used as the provider prefix for model IDs."
placeholder={t("anthropicPrefixPlaceholder")}
hint={t("prefixHint")}
/>
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.anthropic.com/v1"
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
placeholder={t("anthropicBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("anthropic") })}
/>
<div className="flex gap-2">
<Input
label="API Key (for Check)"
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
@@ -980,13 +998,13 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
<div className="flex gap-2">
@@ -1000,10 +1018,10 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
submitting
}
>
{submitting ? "Creating..." : "Create"}
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -1020,6 +1038,9 @@ AddAnthropicCompatibleModal.propTypes = {
// ─── Provider Test Results View (mirrors combo TestResultsView) ──────────────
function ProviderTestResultsView({ results }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
if (results.error && !results.results) {
return (
<div className="text-center py-6">
@@ -1034,11 +1055,12 @@ function ProviderTestResultsView({ results }) {
const modeLabel =
{
oauth: "OAuth",
free: "Free",
apikey: "API Key",
provider: "Provider",
all: "All",
oauth: t("oauthLabel"),
free: tc("free"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
provider: t("providerLabel"),
all: tc("all"),
}[mode] || mode;
return (
@@ -1046,16 +1068,18 @@ function ProviderTestResultsView({ results }) {
{/* Summary header */}
{summary && (
<div className="flex items-center gap-3 text-xs mb-1">
<span className="text-text-muted">{modeLabel} Test</span>
<span className="text-text-muted">{t("modeTest", { mode: modeLabel })}</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400 font-medium">
{summary.passed} passed
{t("passedCount", { count: summary.passed })}
</span>
{summary.failed > 0 && (
<span className="px-2 py-0.5 rounded bg-red-500/15 text-red-400 font-medium">
{summary.failed} failed
{t("failedCount", { count: summary.failed })}
</span>
)}
<span className="text-text-muted ml-auto">{summary.total} tested</span>
<span className="text-text-muted ml-auto">
{t("testedCount", { count: summary.total })}
</span>
</div>
)}
@@ -1077,21 +1101,23 @@ function ProviderTestResultsView({ results }) {
<span className="text-text-muted ml-1.5">({r.provider})</span>
</div>
{r.latencyMs !== undefined && (
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
<span className="text-text-muted font-mono tabular-nums">
{t("millisecondsAbbr", { value: r.latencyMs })}
</span>
)}
<span
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
r.valid ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"
}`}
>
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
{r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")}
</span>
</div>
))}
{items.length === 0 && (
<div className="text-center py-4 text-text-muted text-sm">
No active connections found for this group.
{t("noActiveConnectionsInGroup")}
</div>
)}
</div>

View File

@@ -11,6 +11,11 @@ export default function AppearanceTab() {
const t = useTranslations("settings");
const [settings, setSettings] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const themeOptionLabels: Record<string, string> = {
light: t("themeLight"),
dark: t("themeDark"),
system: t("themeSystem"),
};
useEffect(() => {
fetch("/api/settings")
@@ -64,7 +69,7 @@ export default function AppearanceTab() {
<div className="pt-4 border-t border-border">
<div
role="tablist"
aria-label="Theme selection"
aria-label={t("themeSelectionAria")}
className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5"
>
{["light", "dark", "system"].map((option) => (
@@ -83,7 +88,7 @@ export default function AppearanceTab() {
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
{option === "light" ? "light_mode" : option === "dark" ? "dark_mode" : "contrast"}
</span>
<span className="capitalize">{option}</span>
<span>{themeOptionLabels[option] || option}</span>
</button>
))}
</div>

View File

@@ -21,6 +21,20 @@ export default function ComboDefaultsTab() {
const [saving, setSaving] = useState(false);
const t = useTranslations("settings");
const tc = useTranslations("common");
const strategyOptions = [
{ value: "priority", label: t("priority"), icon: "sort" },
{ value: "weighted", label: t("weighted"), icon: "percent" },
{ value: "round-robin", label: t("roundRobin"), icon: "autorenew" },
{ value: "random", label: t("random"), icon: "shuffle" },
{ value: "least-used", label: t("leastUsed"), icon: "low_priority" },
{ value: "cost-optimized", label: t("costOpt"), icon: "savings" },
];
const numericSettings = [
{ key: "maxRetries", label: t("maxRetriesLabel"), min: 0, max: 5 },
{ key: "retryDelayMs", label: t("retryDelayLabel"), min: 500, max: 10000, step: 500 },
{ key: "timeoutMs", label: t("timeoutLabel"), min: 5000, max: 300000, step: 5000 },
{ key: "maxComboDepth", label: t("maxNestingDepth"), min: 1, max: 10 },
];
useEffect(() => {
fetch("/api/settings/combo-defaults")
@@ -82,17 +96,10 @@ export default function ComboDefaultsTab() {
</div>
<div
role="tablist"
aria-label="Combo strategy"
aria-label={t("comboStrategyAria")}
className="grid grid-cols-3 gap-1 p-0.5 rounded-md bg-black/5 dark:bg-white/5"
>
{[
{ value: "priority", label: "Priority", icon: "sort" },
{ value: "weighted", label: "Weighted", icon: "percent" },
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
{ value: "random", label: "Random", icon: "shuffle" },
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
].map((s) => (
{strategyOptions.map((s) => (
<button
key={s.value}
role="tab"
@@ -114,12 +121,7 @@ export default function ComboDefaultsTab() {
{/* Numeric settings */}
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
{[
{ key: "maxRetries", label: "Max Retries", min: 0, max: 5 },
{ key: "retryDelayMs", label: "Retry Delay (ms)", min: 500, max: 10000, step: 500 },
{ key: "timeoutMs", label: "Timeout (ms)", min: 5000, max: 300000, step: 5000 },
{ key: "maxComboDepth", label: "Max Nesting Depth", min: 1, max: 10 },
].map(({ key, label, min, max, step }) => (
{numericSettings.map(({ key, label, min, max, step }) => (
<Input
key={key}
label={label}
@@ -140,7 +142,7 @@ export default function ComboDefaultsTab() {
{comboDefaults.strategy === "round-robin" && (
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
<Input
label="Concurrency / Model"
label={t("concurrencyPerModel")}
type="number"
min={1}
max={20}
@@ -155,7 +157,7 @@ export default function ComboDefaultsTab() {
className="text-sm"
/>
<Input
label="Queue Timeout (ms)"
label={t("queueTimeout")}
type="number"
min={1000}
max={120000}
@@ -227,7 +229,7 @@ export default function ComboDefaultsTab() {
}))
}
className="text-xs w-16"
aria-label={`${provider} max retries`}
aria-label={t("providerMaxRetriesAria", { provider })}
/>
<span className="text-[10px] text-text-muted">{t("retries")}</span>
<Input
@@ -246,13 +248,13 @@ export default function ComboDefaultsTab() {
}))
}
className="text-xs w-24"
aria-label={`${provider} timeout ms`}
aria-label={t("providerTimeoutAria", { provider })}
/>
<span className="text-[10px] text-text-muted">{t("ms")}</span>
<button
onClick={() => removeProviderOverride(provider)}
className="ml-auto text-red-400 hover:text-red-500 transition-colors"
aria-label={`Remove ${provider} override`}
aria-label={t("removeProviderOverrideAria", { provider })}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
close
@@ -264,12 +266,12 @@ export default function ComboDefaultsTab() {
<div className="flex items-center gap-2 mt-2">
<Input
type="text"
placeholder="e.g. google, openai..."
placeholder={t("newProviderNamePlaceholder")}
value={newOverrideProvider}
onChange={(e) => setNewOverrideProvider(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addProviderOverride()}
className="text-xs flex-1"
aria-label="New provider name"
aria-label={t("newProviderNameAria")}
/>
<Button
variant="outline"

View File

@@ -5,13 +5,6 @@ import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
const ALL_COLUMNS = [
{ key: "timestamp", label: "Time" },
{ key: "action", label: "Action" },
{ key: "actor", label: "Actor" },
{ key: "details", label: "Details" },
];
export default function ComplianceTab() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
@@ -25,6 +18,12 @@ export default function ComplianceTab() {
});
const notify = useNotificationStore();
const t = useTranslations("settings");
const allColumns = [
{ key: "timestamp", label: t("time") },
{ key: "action", label: t("action") },
{ key: "actor", label: t("actor") },
{ key: "details", label: t("details") },
];
useEffect(() => {
fetch("/api/compliance/audit-log?limit=100")
@@ -56,38 +55,41 @@ export default function ComplianceTab() {
return true;
});
const columns = ALL_COLUMNS.filter((c) => visibleCols[c.key]);
const columns = allColumns.filter((c) => visibleCols[c.key]);
const handleToggleCol = useCallback((key) => {
setVisibleCols((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const renderCell = useCallback((row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || "system"}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
}, []);
const renderCell = useCallback(
(row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || t("systemActor")}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
},
[t]
);
return (
<Card className="p-6">
@@ -96,7 +98,7 @@ export default function ComplianceTab() {
<span className="material-symbols-outlined text-[20px]">policy</span>
{t("auditLog")}
</h3>
<ColumnToggle columns={ALL_COLUMNS} visible={visibleCols} onToggle={handleToggleCol} />
<ColumnToggle columns={allColumns} visible={visibleCols} onToggle={handleToggleCol} />
</div>
<FilterBar
@@ -104,8 +106,8 @@ export default function ComplianceTab() {
onSearchChange={setSearch}
placeholder={t("searchAuditLogs")}
filters={[
{ key: "action", label: "Action", options: actionOptions },
{ key: "actor", label: "Actor", options: actorOptions },
{ key: "action", label: t("action"), options: actionOptions },
{ key: "actor", label: t("actor"), options: actorOptions },
]}
activeFilters={filters}
onFilterChange={(key, val) => setFilters((prev) => ({ ...prev, [key]: val }))}

View File

@@ -146,13 +146,13 @@ export default function FallbackChainsEditor() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label={t("modelName")}
placeholder="claude-sonnet-4-20250514"
placeholder={t("modelNamePlaceholder")}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
/>
<Input
label={t("providersCommaSeparated")}
placeholder="anthropic, openai, gemini"
placeholder={t("providersCommaSeparatedPlaceholder")}
value={newProviders}
onChange={(e) => setNewProviders(e.target.value)}
/>
@@ -204,7 +204,7 @@ export default function FallbackChainsEditor() {
<button
onClick={() => handleDelete(model)}
className="text-text-muted hover:text-red-400 transition-colors ml-2"
title="Delete chain"
title={t("deleteChain")}
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>

View File

@@ -1,14 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { Card, Button, Input } from "@/shared/components";
import { useTranslations } from "next-intl";
const MODES = [
{ value: "disabled", label: "Disabled", icon: "block" },
{ value: "blacklist", label: "Blacklist", icon: "do_not_disturb" },
{ value: "whitelist", label: "Whitelist", icon: "verified_user" },
{ value: "whitelist-priority", label: "WL Priority", icon: "priority_high" },
{ value: "disabled", labelKey: "ipModeDisabled", icon: "block" },
{ value: "blacklist", labelKey: "ipModeBlacklist", icon: "do_not_disturb" },
{ value: "whitelist", labelKey: "ipModeWhitelist", icon: "verified_user" },
{ value: "whitelist-priority", labelKey: "ipModeWhitelistPriority", icon: "priority_high" },
];
export default function IPFilterSection() {
@@ -49,8 +49,6 @@ export default function IPFilterSection() {
} catch {}
};
const toggleEnabled = () => updateConfig({ enabled: !config.enabled });
const setMode = (mode) => {
if (mode === "disabled") {
updateConfig({ enabled: false });
@@ -112,7 +110,7 @@ export default function IPFilterSection() {
<span
className={`text-xs font-medium ${activeMode === m.value ? "text-red-400" : "text-text-muted"}`}
>
{m.label}
{t(m.labelKey)}
</span>
</button>
))}
@@ -125,7 +123,7 @@ export default function IPFilterSection() {
<div className="flex-1">
<Input
label={t("addIpAddress")}
placeholder="192.168.1.0/24 or 10.0.*.*"
placeholder={t("ipAddressPlaceholder")}
value={newIP}
onChange={(e) => setNewIP(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addIP()}
@@ -159,7 +157,7 @@ export default function IPFilterSection() {
{config.blacklist.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Blocked ({config.blacklist.length})
{t("blocked", { count: config.blacklist.length })}
</p>
<div className="flex flex-wrap gap-1.5">
{config.blacklist.map((ip) => (
@@ -185,7 +183,7 @@ export default function IPFilterSection() {
{config.whitelist.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Allowed ({config.whitelist.length})
{t("allowed", { count: config.whitelist.length })}
</p>
<div className="flex flex-wrap gap-1.5">
{config.whitelist.map((ip) => (
@@ -211,7 +209,7 @@ export default function IPFilterSection() {
{config.tempBans.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Temporary Bans ({config.tempBans.length})
{t("temporaryBans", { count: config.tempBans.length })}
</p>
<div className="flex flex-col gap-1.5">
{config.tempBans.map((ban) => (
@@ -226,7 +224,7 @@ export default function IPFilterSection() {
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted tabular-nums">
{Math.ceil(ban.remainingMs / 60000)}m left
{t("minLeft", { min: Math.ceil(ban.remainingMs / 60000) })}
</span>
<button
onClick={() => removeBan(ban.ip)}

View File

@@ -79,7 +79,7 @@ export default function ProxyTab() {
isOpen={proxyModalOpen}
onClose={() => setProxyModalOpen(false)}
level="global"
levelLabel="Global"
levelLabel={t("globalLabel")}
onSaved={loadGlobalProxy}
/>
</>

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
// ─── State colors and labels ──────────────────────────────────────────────
const STATE_STYLES = {
@@ -11,31 +11,37 @@ const STATE_STYLES = {
bg: "bg-emerald-500/15",
text: "text-emerald-400",
border: "border-emerald-500/30",
label: "CLOSED",
icon: "check_circle",
},
OPEN: {
bg: "bg-red-500/15",
text: "text-red-400",
border: "border-red-500/30",
label: "OPEN",
icon: "error",
},
HALF_OPEN: {
bg: "bg-amber-500/15",
text: "text-amber-400",
border: "border-amber-500/30",
label: "HALF-OPEN",
icon: "warning",
},
};
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
"half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
open: { icon: "error", color: "#ef4444", label: "Open" },
closed: { icon: "check_circle", color: "#22c55e" },
"half-open": { icon: "pending", color: "#f59e0b" },
open: { icon: "error", color: "#ef4444" },
};
function getBreakerStateLabel(state, t) {
const normalized = String(state || "closed")
.toLowerCase()
.replaceAll("_", "-");
if (normalized === "open") return t("breakerStateOpen");
if (normalized === "half-open") return t("breakerStateHalfOpen");
return t("breakerStateClosed");
}
function formatMs(ms) {
if (!ms || ms <= 0) return "—";
if (ms < 1000) return `${ms}ms`;
@@ -43,6 +49,10 @@ function formatMs(ms) {
return `${(ms / 60000).toFixed(1)}m`;
}
function getErrorMessage(err, fallback) {
return err instanceof Error && err.message ? err.message : fallback;
}
// ─── Provider Profiles Card ──────────────────────────────────────────────
function ProviderProfilesCard({ profiles, onSave, saving }) {
const [editMode, setEditMode] = useState(false);
@@ -54,12 +64,17 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
setDraft(profiles);
}, [profiles]);
const formatMsRaw = (value) => (value == null ? "—" : `${value}${t("ms")}`);
const fields = [
{ key: "transientCooldown", label: "Transient Cooldown", suffix: "ms" },
{ key: "rateLimitCooldown", label: "Rate Limit Cooldown", suffix: "ms" },
{ key: "maxBackoffLevel", label: "Max Backoff Level", suffix: "" },
{ key: "circuitBreakerThreshold", label: "CB Threshold", suffix: " fails" },
{ key: "circuitBreakerReset", label: "CB Reset Time", suffix: "ms" },
{ key: "transientCooldown", label: t("transientCooldown"), format: formatMsRaw },
{ key: "rateLimitCooldown", label: t("rateLimitCooldown"), format: formatMsRaw },
{ key: "maxBackoffLevel", label: t("maxBackoffLevel") },
{
key: "circuitBreakerThreshold",
label: t("cbThreshold"),
format: (value) => (value == null ? "—" : t("failures", { count: value })),
},
{ key: "circuitBreakerReset", label: t("cbResetTime"), format: formatMsRaw },
];
const handleSave = () => {
@@ -111,7 +126,7 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
{type === "oauth" ? t("oauthProviders") : t("apiKeyProviders")}
</h3>
<div className="space-y-2">
{fields.map(({ key, label, suffix }) => (
{fields.map(({ key, label, format }) => (
<div key={key} className="flex items-center justify-between">
<span className="text-xs text-text-muted">{label}</span>
{editMode ? (
@@ -129,8 +144,9 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
/>
) : (
<span className="text-sm font-mono">
{profiles?.[type]?.[key] ?? "—"}
{suffix && profiles?.[type]?.[key] != null ? suffix : ""}
{format
? format(profiles?.[type]?.[key])
: (profiles?.[type]?.[key] ?? "—")}
</span>
)}
</div>
@@ -203,9 +219,9 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
</h3>
<div className="grid grid-cols-3 gap-4">
{[
{ key: "requestsPerMinute", label: "RPM", suffix: "" },
{ key: "minTimeBetweenRequests", label: "Min Gap", suffix: "ms", format: formatMs },
{ key: "concurrentRequests", label: "Max Concurrent", suffix: "" },
{ key: "requestsPerMinute", label: t("rpm") },
{ key: "minTimeBetweenRequests", label: t("minGap"), format: formatMs },
{ key: "concurrentRequests", label: t("maxConcurrent") },
].map(({ key, label, format }) => (
<div key={key}>
{editMode ? (
@@ -241,9 +257,21 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
>
<span className="text-sm font-medium">{rl.provider || rl.key}</span>
<div className="flex items-center gap-3 text-xs text-text-muted">
{rl.reservoir != null && <span>Reservoir: {rl.reservoir}</span>}
{rl.running != null && <span>Running: {rl.running}</span>}
{rl.queued != null && <span>Queued: {rl.queued}</span>}
{rl.reservoir != null && (
<span>
{t("reservoir")}: {rl.reservoir}
</span>
)}
{rl.running != null && (
<span>
{t("running")}: {rl.running}
</span>
)}
{rl.queued != null && (
<span>
{t("queued")}: {rl.queued}
</span>
)}
</div>
</div>
))}
@@ -275,8 +303,8 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
? t("tripped", { count: activeBreakers.length })
: t("healthy", { count: totalBreakers })}
</span>
{activeBreakers.length > 0 && (
<Button
@@ -315,13 +343,13 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
{t("failures", { count: b.failureCount })}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
{getBreakerStateLabel(b.state, t)}
</span>
</div>
</div>
@@ -340,6 +368,7 @@ function PoliciesCard() {
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const locale = useLocale();
const t = useTranslations("settings");
const fetchPolicies = useCallback(async () => {
@@ -371,7 +400,7 @@ function PoliciesCard() {
body: JSON.stringify({ action: "unlock", identifier }),
});
if (res.ok) {
notify.success(`Unlocked: ${identifier}`);
notify.success(t("unlockedIdentifier", { identifier }));
await fetchPolicies();
} else {
notify.error(t("failedUnlock"));
@@ -448,7 +477,7 @@ function PoliciesCard() {
{status.icon}
</span>
<span className="text-sm text-text-main font-medium">
{cb.name || cb.provider || "Unknown"}
{cb.name || cb.provider || t("unknown")}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
@@ -457,11 +486,11 @@ function PoliciesCard() {
color: status.color,
}}
>
{status.label}
{getBreakerStateLabel(cb.state, t)}
</span>
{cb.failures > 0 && (
<span className="text-xs text-text-muted">
{cb.failures} failures
{t("failures", { count: cb.failures })}
</span>
)}
</div>
@@ -491,7 +520,9 @@ function PoliciesCard() {
<span className="font-mono text-sm text-text-main">{identifier}</span>
{typeof id === "object" && id.lockedAt && (
<span className="text-xs text-text-muted">
since {new Date(id.lockedAt).toLocaleString()}
{t("sinceDate", {
date: new Date(id.lockedAt).toLocaleString(locale),
})}
</span>
)}
</div>
@@ -529,16 +560,16 @@ export default function ResilienceTab() {
try {
setLoading(true);
const res = await fetch("/api/resilience");
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
if (!res.ok) throw new Error(t("failedLoadWithStatus", { status: res.status }));
const json = await res.json();
setData(json);
setError(null);
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("failedLoadResilience")));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
loadData();
@@ -551,10 +582,10 @@ export default function ResilienceTab() {
try {
setLoading(true);
const res = await fetch("/api/resilience/reset", { method: "POST" });
if (!res.ok) throw new Error("Reset failed");
if (!res.ok) throw new Error(t("resetFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("resetFailed")));
} finally {
setLoading(false);
}
@@ -568,10 +599,10 @@ export default function ResilienceTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profiles }),
});
if (!res.ok) throw new Error("Save failed");
if (!res.ok) throw new Error(t("saveFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("saveFailed")));
} finally {
setSaving(false);
}
@@ -585,10 +616,10 @@ export default function ResilienceTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaults }),
});
if (!res.ok) throw new Error("Save failed");
if (!res.ok) throw new Error(t("saveFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("saveFailed")));
} finally {
setSaving(false);
}

View File

@@ -1,30 +1,30 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Input, Toggle, Button } from "@/shared/components";
import { Card, Input, Button } from "@/shared/components";
import FallbackChainsEditor from "./FallbackChainsEditor";
import { useTranslations } from "next-intl";
const STRATEGIES = [
{
value: "fill-first",
label: "Fill First",
desc: "Use accounts in priority order",
labelKey: "fillFirst",
descKey: "fillFirstDesc",
icon: "vertical_align_top",
},
{ value: "round-robin", label: "Round Robin", desc: "Cycle through all accounts", icon: "loop" },
{ value: "p2c", label: "P2C", desc: "Pick 2 random, use the healthier one", icon: "balance" },
{ value: "random", label: "Random", desc: "Random account each request", icon: "shuffle" },
{ value: "round-robin", labelKey: "roundRobin", descKey: "roundRobinDesc", icon: "loop" },
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "balance" },
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
{
value: "least-used",
label: "Least Used",
desc: "Pick least recently used account",
labelKey: "leastUsed",
descKey: "leastUsedDesc",
icon: "low_priority",
},
{
value: "cost-optimized",
label: "Cost Opt",
desc: "Prefer cheapest available account",
labelKey: "costOpt",
descKey: "costOptDesc",
icon: "savings",
},
];
@@ -36,6 +36,14 @@ export default function RoutingTab() {
const [newPattern, setNewPattern] = useState("");
const [newTarget, setNewTarget] = useState("");
const t = useTranslations("settings");
const strategyHintKeyByValue: Record<string, string> = {
"fill-first": "fillFirstDesc",
"round-robin": "roundRobinDesc",
p2c: "p2cDesc",
random: "randomDesc",
"least-used": "leastUsedDesc",
"cost-optimized": "costOptDesc",
};
useEffect(() => {
fetch("/api/settings")
@@ -114,9 +122,9 @@ export default function RoutingTab() {
<p
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
>
{s.label}
{t(s.labelKey)}
</p>
<p className="text-xs text-text-muted mt-0.5">{s.desc}</p>
<p className="text-xs text-text-muted mt-0.5">{t(s.descKey)}</p>
</div>
</button>
))}
@@ -141,18 +149,7 @@ export default function RoutingTab() {
)}
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
{settings.fallbackStrategy === "round-robin" &&
`Distributing requests across accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.`}
{settings.fallbackStrategy === "fill-first" &&
"Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "p2c" &&
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
{settings.fallbackStrategy === "random" &&
"Randomly selects an available account for each request."}
{settings.fallbackStrategy === "least-used" &&
"Picks the account that was used least recently."}
{settings.fallbackStrategy === "cost-optimized" &&
"Prefers accounts with the lowest cost (priority-based, extensible with actual cost data)."}
{t(strategyHintKeyByValue[settings.fallbackStrategy] || "fillFirstDesc")}
</p>
</Card>
@@ -199,7 +196,7 @@ export default function RoutingTab() {
<div className="flex-1">
<Input
label={t("pattern")}
placeholder="claude-sonnet-*"
placeholder={t("aliasPatternPlaceholder")}
value={newPattern}
onChange={(e) => setNewPattern(e.target.value)}
/>
@@ -207,7 +204,7 @@ export default function RoutingTab() {
<div className="flex-1">
<Input
label={t("targetModel")}
placeholder="claude-sonnet-4-20250514"
placeholder={t("aliasTargetPlaceholder")}
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
/>

View File

@@ -188,14 +188,7 @@ export default function SecurityTab() {
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{t("requireAuthModels")}</p>
<p className="text-sm text-text-muted">
When ON, the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
endpoint returns 404 for unauthenticated requests. Prevents model discovery by
unauthorized users.
</p>
<p className="text-sm text-text-muted">{t("requireAuthModelsDesc")}</p>
</div>
<Toggle
checked={settings.requireAuthForModels === true}
@@ -208,13 +201,7 @@ export default function SecurityTab() {
<div className="pt-4 border-t border-border/50">
<div className="mb-3">
<p className="font-medium">{t("blockedProviders")}</p>
<p className="text-sm text-text-muted">
Hide specific providers from the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
response. Blocked providers will not appear in model listings.
</p>
<p className="text-sm text-text-muted">{t("blockedProvidersDesc")}</p>
</div>
<div className="flex flex-wrap gap-2">
{Object.values(AI_PROVIDERS).map((provider: any) => {
@@ -229,7 +216,11 @@ export default function SecurityTab() {
? "bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
title={isBlocked ? `Unblock ${provider.name}` : `Block ${provider.name}`}
title={
isBlocked
? t("unblockProviderTitle", { provider: provider.name })
: t("blockProviderTitle", { provider: provider.name })
}
>
<span
className="material-symbols-outlined text-[14px]"
@@ -250,8 +241,7 @@ export default function SecurityTab() {
{blockedProviders.length > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">warning</span>
{blockedProviders.length} provider{blockedProviders.length !== 1 ? "s" : ""} blocked
from /models
{t("providersBlocked", { count: blockedProviders.length })}
</p>
)}
</div>

View File

@@ -32,7 +32,7 @@ export default function SessionInfoCard() {
const loginTime = sessionStorage.getItem("omniroute_login_time");
const now = Date.now();
let sessionAge = "Unknown";
let sessionAge = t("unknown");
if (loginTime) {
const elapsed = now - parseInt(loginTime, 10);
const hours = Math.floor(elapsed / 3600000);
@@ -61,7 +61,7 @@ export default function SessionInfoCard() {
loginTime: loginTime ? new Date(parseInt(loginTime, 10)).toLocaleString() : null,
sessionAge,
ipAddress: "—", // Server-side only
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || "Unknown",
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || t("unknown"),
});
setLoading(false);
}
@@ -109,7 +109,7 @@ export default function SessionInfoCard() {
<h3 className="text-lg font-semibold">{t("session")}</h3>
</div>
<div className="flex flex-col gap-3" role="list" aria-label="Session details">
<div className="flex flex-col gap-3" role="list" aria-label={t("sessionDetailsAria")}>
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">{t("status")}</span>
<span className="flex items-center gap-1.5">

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, Badge } from "@/shared/components";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
export default function SystemStorageTab() {
const [backups, setBackups] = useState([]);
@@ -19,6 +19,7 @@ export default function SystemStorageTab() {
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const locale = useLocale();
const t = useTranslations("settings");
const tc = useTranslations("common");
const [storageHealth, setStorageHealth] = useState({
@@ -61,20 +62,23 @@ export default function SystemStorageTab() {
const data = await res.json();
if (res.ok) {
if (data.filename) {
setManualBackupStatus({ type: "success", message: `Backup created: ${data.filename}` });
setManualBackupStatus({
type: "success",
message: t("backupCreated", { file: data.filename }),
});
} else {
setManualBackupStatus({
type: "info",
message: data.message || "No changes since last backup",
message: data.message || t("noChangesSinceBackup"),
});
}
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setManualBackupStatus({ type: "error", message: data.error || "Backup failed" });
setManualBackupStatus({ type: "error", message: data.error || t("backupFailed") });
}
} catch {
setManualBackupStatus({ type: "error", message: "An error occurred" });
setManualBackupStatus({ type: "error", message: t("errorOccurred") });
} finally {
setManualBackupLoading(false);
}
@@ -93,15 +97,20 @@ export default function SystemStorageTab() {
if (res.ok) {
setRestoreStatus({
type: "success",
message: `Restored! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`,
message: t("restoreSuccess", {
connections: data.connectionCount,
nodes: data.nodeCount,
combos: data.comboCount,
apiKeys: data.apiKeyCount,
}),
});
await loadBackups();
await loadStorageHealth();
} else {
setRestoreStatus({ type: "error", message: data.error || "Restore failed" });
setRestoreStatus({ type: "error", message: data.error || t("restoreFailed") });
}
} catch {
setRestoreStatus({ type: "error", message: "An error occurred during restore" });
setRestoreStatus({ type: "error", message: t("errorDuringRestore") });
} finally {
setRestoringId(null);
setConfirmRestoreId(null);
@@ -118,7 +127,7 @@ export default function SystemStorageTab() {
const res = await fetch("/api/db-backups/export");
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Export failed");
throw new Error(data.error || t("exportFailed"));
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -136,7 +145,10 @@ export default function SystemStorageTab() {
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
setImportStatus({ type: "error", message: `Export failed: ${(err as Error).message}` });
setImportStatus({
type: "error",
message: t("exportFailedWithError", { error: (err as Error).message }),
});
} finally {
setExportLoading(false);
}
@@ -177,15 +189,20 @@ export default function SystemStorageTab() {
if (res.ok) {
setImportStatus({
type: "success",
message: `Database imported! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`,
message: t("importSuccess", {
connections: data.connectionCount,
nodes: data.nodeCount,
combos: data.comboCount,
apiKeys: data.apiKeyCount,
}),
});
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setImportStatus({ type: "error", message: data.error || "Import failed" });
setImportStatus({ type: "error", message: data.error || t("importFailed") });
}
} catch {
setImportStatus({ type: "error", message: "An error occurred during import" });
setImportStatus({ type: "error", message: t("errorDuringImport") });
} finally {
setImportLoading(false);
setPendingImportFile(null);
@@ -210,12 +227,18 @@ export default function SystemStorageTab() {
const then = new Date(isoString);
const diffMs = (now as any) - (then as any);
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1) return t("justNow");
if (diffMin < 60) return t("minutesAgo", { count: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
if (diffHr < 24) return t("hoursAgo", { count: diffHr });
const diffDays = Math.floor(diffHr / 24);
return `${diffDays}d ago`;
return t("daysAgo", { count: diffDays });
};
const formatBackupReason = (reason) => {
if (reason === "manual") return t("backupReasonManual");
if (reason === "pre-restore") return t("backupReasonPreRestore");
return reason;
};
return (
@@ -268,7 +291,7 @@ export default function SystemStorageTab() {
setExportLoading(true);
try {
const res = await fetch("/api/db-backups/exportAll");
if (!res.ok) throw new Error("Export failed");
if (!res.ok) throw new Error(t("exportFailed"));
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") || "";
const filenameMatch = cd.match(/filename="?([^"]+)"?/);
@@ -284,7 +307,7 @@ export default function SystemStorageTab() {
} catch (err) {
setImportStatus({
type: "error",
message: `Full export failed: ${(err as Error).message}`,
message: t("fullExportFailedWithError", { error: (err as Error).message }),
});
} finally {
setExportLoading(false);
@@ -325,9 +348,7 @@ export default function SystemStorageTab() {
<div className="flex-1">
<p className="text-sm font-medium text-amber-500 mb-1">{t("confirmDbImport")}</p>
<p className="text-xs text-text-muted mb-2">
This will replace <strong>all current data</strong> with the content from{" "}
<span className="font-mono">{pendingImportFile.name}</span>. A backup will be
created automatically before the import.
{t("confirmDbImportDesc", { file: pendingImportFile.name })}
</p>
<div className="flex items-center gap-2">
<Button
@@ -374,7 +395,7 @@ export default function SystemStorageTab() {
<p className="text-sm font-medium">{t("lastBackup")}</p>
<p className="text-xs text-text-muted">
{storageHealth.lastBackupAt
? `${new Date(storageHealth.lastBackupAt).toLocaleString("pt-BR")} (${formatRelativeTime(storageHealth.lastBackupAt)})`
? `${new Date(storageHealth.lastBackupAt).toLocaleString(locale)} (${formatRelativeTime(storageHealth.lastBackupAt)})`
: t("noBackupYet")}
</p>
</div>
@@ -485,7 +506,7 @@ export default function SystemStorageTab() {
<>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-muted">
{backups.length} backup(s) available
{t("backupsAvailable", { count: backups.length })}
</span>
<button
onClick={loadBackups}
@@ -511,7 +532,7 @@ export default function SystemStorageTab() {
description
</span>
<span className="text-sm font-medium truncate">
{new Date(backup.createdAt).toLocaleString("pt-BR")}
{new Date(backup.createdAt).toLocaleString(locale)}
</span>
<Badge
variant={
@@ -523,11 +544,11 @@ export default function SystemStorageTab() {
}
size="sm"
>
{backup.reason}
{formatBackupReason(backup.reason)}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-text-muted ml-6">
<span>{backup.connectionCount} connection(s)</span>
<span>{t("connectionsCount", { count: backup.connectionCount })}</span>
<span></span>
<span>{formatBytes(backup.size)}</span>
</div>

View File

@@ -1,41 +1,41 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Select } from "@/shared/components";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
const MODES = [
{
value: "passthrough",
label: "Passthrough",
desc: "No changes — client controls thinking budget",
labelKey: "passthrough",
descKey: "passthroughDesc",
icon: "arrow_forward",
},
{
value: "auto",
label: "Auto",
desc: "Strip all thinking config — let provider decide",
labelKey: "auto",
descKey: "autoDesc",
icon: "auto_awesome",
},
{
value: "custom",
label: "Custom",
desc: "Set a fixed token budget for all requests",
labelKey: "custom",
descKey: "customDesc",
icon: "tune",
},
{
value: "adaptive",
label: "Adaptive",
desc: "Scale budget based on request complexity",
labelKey: "adaptive",
descKey: "adaptiveDesc",
icon: "trending_up",
},
];
const EFFORTS = [
{ value: "none", label: "None (0 tokens)" },
{ value: "low", label: "Low (1K tokens)" },
{ value: "medium", label: "Medium (10K tokens)" },
{ value: "high", label: "High (128K tokens)" },
{ value: "none", labelKey: "effortNone" },
{ value: "low", labelKey: "effortLow" },
{ value: "medium", labelKey: "effortMedium" },
{ value: "high", labelKey: "effortHigh" },
];
export default function ThinkingBudgetTab() {
@@ -126,9 +126,9 @@ export default function ThinkingBudgetTab() {
<p
className={`text-sm font-medium ${config.mode === m.value ? "text-violet-400" : ""}`}
>
{m.label}
{t(m.labelKey)}
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{m.desc}</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{t(m.descKey)}</p>
</div>
</button>
))}
@@ -179,7 +179,7 @@ export default function ThinkingBudgetTab() {
: "border-border/50 text-text-muted hover:border-border"
}`}
>
{e.value.charAt(0).toUpperCase() + e.value.slice(1)}
{t(e.labelKey)}
</button>
))}
</div>

View File

@@ -39,7 +39,7 @@ export default function SettingsPage() {
{/* Tab navigation */}
<div
role="tablist"
aria-label="Settings sections"
aria-label={t("settingsSectionsAria")}
className="inline-flex items-center p-1 rounded-lg bg-black/5 dark:bg-white/5 self-start"
>
{tabs.map((tab) => (

View File

@@ -101,19 +101,19 @@ export default function PricingSettingsPage() {
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>
<strong>Input:</strong> {t("inputTokenDesc")}
<strong>{t("input")}:</strong> {t("inputTokenDesc")}
</li>
<li>
<strong>Output:</strong> {t("outputTokenDesc")}
<strong>{t("output")}:</strong> {t("outputTokenDesc")}
</li>
<li>
<strong>Cached:</strong> {t("cachedTokenDesc")}
<strong>{t("cached")}:</strong> {t("cachedTokenDesc")}
</li>
<li>
<strong>Reasoning:</strong> {t("reasoningTokenDesc")}
<strong>{t("reasoning")}:</strong> {t("reasoningTokenDesc")}
</li>
<li>
<strong>Cache Creation:</strong> {t("cacheCreationTokenDesc")}
<strong>{t("cacheCreation")}:</strong> {t("cacheCreationTokenDesc")}
</li>
</ul>
<p>{t("customPricingNote")}</p>
@@ -142,13 +142,13 @@ export default function PricingSettingsPage() {
<div key={provider} className="text-sm">
<span className="font-semibold">{provider.toUpperCase()}:</span>{" "}
<span className="text-text-muted">
{Object.keys(currentPricing[provider]).length} models
{Object.keys(currentPricing[provider]).length} {t("models")}
</span>
</div>
))}
{Object.keys(currentPricing).length > 5 && (
<div className="text-sm text-text-muted">
+ {Object.keys(currentPricing).length - 5} more providers
+ {t("moreProviders", { count: Object.keys(currentPricing).length - 5 })}
</div>
)}
</div>

View File

@@ -18,6 +18,8 @@ export default function LiveMonitorMode() {
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const intervalRef = useRef(null);
const notAvailable = t("notAvailableSymbol");
const formatLatency = (value) => t("millisecondsShort", { value });
const fetchHistory = async () => {
try {
@@ -55,7 +57,10 @@ export default function LiveMonitorMode() {
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
<span
className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0"
aria-hidden="true"
>
info
</span>
<div>
@@ -79,7 +84,12 @@ export default function LiveMonitorMode() {
/>
<StatCard icon="check_circle" label={t("successful")} value={successCount} color="green" />
<StatCard icon="error" label={t("errors")} value={errorCount} color="red" />
<StatCard icon="speed" label={t("avgLatency")} value={`${avgLatency}ms`} color="purple" />
<StatCard
icon="speed"
label={t("avgLatency")}
value={formatLatency(avgLatency)}
color="purple"
/>
</div>
{/* Controls */}
@@ -88,6 +98,7 @@ export default function LiveMonitorMode() {
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[18px] ${autoRefresh ? "text-green-500 animate-pulse" : "text-text-muted"}`}
aria-hidden="true"
>
{autoRefresh ? "radio_button_checked" : "radio_button_unchecked"}
</span>
@@ -102,7 +113,9 @@ export default function LiveMonitorMode() {
onClick={fetchHistory}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
{tc("refresh")}
</button>
</div>
@@ -115,12 +128,17 @@ export default function LiveMonitorMode() {
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
<span className="material-symbols-outlined animate-spin mr-2" aria-hidden="true">
progress_activity
</span>
{tc("loading")}
</div>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
<span
className="material-symbols-outlined text-[48px] mb-3 opacity-30"
aria-hidden="true"
>
monitoring
</span>
<p className="text-sm font-medium mb-1">{t("noTranslations")}</p>
@@ -172,7 +190,9 @@ export default function LiveMonitorMode() {
className="border-b border-border/50 hover:bg-bg-subtle/50 transition-colors"
>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : "—"}
{event.timestamp
? new Date(event.timestamp).toLocaleTimeString()
: notAvailable}
</td>
<td className="py-2 pr-4">
<Badge variant="default" size="sm">
@@ -180,7 +200,10 @@ export default function LiveMonitorMode() {
</Badge>
</td>
<td className="py-2 pr-4 text-text-muted">
<span className="material-symbols-outlined text-[14px]">
<span
className="material-symbols-outlined text-[14px]"
aria-hidden="true"
>
arrow_forward
</span>
</td>
@@ -190,7 +213,7 @@ export default function LiveMonitorMode() {
</Badge>
</td>
<td className="py-2 pr-4 text-xs font-mono text-text-muted">
{event.model || "—"}
{event.model || notAvailable}
</td>
<td className="py-2 pr-4">
{event.status === "success" ? (
@@ -204,7 +227,7 @@ export default function LiveMonitorMode() {
)}
</td>
<td className="py-2 text-right text-xs text-text-muted">
{event.latency ? `${event.latency}ms` : "—"}
{event.latency ? formatLatency(event.latency) : notAvailable}
</td>
</tr>
);
@@ -224,7 +247,12 @@ function StatCard({ icon, label, value, color }) {
<Card>
<div className="p-4 flex items-center gap-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-lg bg-${color}-500/10`}>
<span className={`material-symbols-outlined text-[22px] text-${color}-500`}>{icon}</span>
<span
className={`material-symbols-outlined text-[22px] text-${color}-500`}
aria-hidden="true"
>
{icon}
</span>
</div>
<div>
<p className="text-lg font-bold text-text-main">{value}</p>

View File

@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
/**
* BudgetTab — Batch C
@@ -14,7 +14,7 @@ import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
function ProgressBar({ value, max, warningAt = 0.8 }) {
function ProgressBar({ value, max, warningAt = 0.8, formatCurrency }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const ratio = max > 0 ? value / max : 0;
const color = ratio >= 1 ? "#ef4444" : ratio >= warningAt ? "#f59e0b" : "#22c55e";
@@ -22,8 +22,8 @@ function ProgressBar({ value, max, warningAt = 0.8 }) {
return (
<div className="w-full">
<div className="flex justify-between text-xs mb-1">
<span className="text-text-muted">${value.toFixed(2)}</span>
<span className="text-text-muted">${max.toFixed(2)}</span>
<span className="text-text-muted">{formatCurrency(value)}</span>
<span className="text-text-muted">{formatCurrency(max)}</span>
</div>
<div className="w-full h-2 rounded-full bg-surface/50 overflow-hidden">
<div
@@ -37,6 +37,7 @@ function ProgressBar({ value, max, warningAt = 0.8 }) {
export default function BudgetTab() {
const t = useTranslations("usage");
const locale = useLocale();
const [keys, setKeys] = useState([]);
const [selectedKey, setSelectedKey] = useState(null);
const [budget, setBudget] = useState(null);
@@ -48,6 +49,13 @@ export default function BudgetTab() {
warningThreshold: "80",
});
const notify = useNotificationStore();
const formatCurrency = (value) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(value || 0));
// Load API keys
useEffect(() => {
@@ -115,7 +123,9 @@ export default function BudgetTab() {
if (loading) {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
account_balance_wallet
</span>
{t("loadingBudgetData")}
</div>
);
@@ -143,7 +153,9 @@ export default function BudgetTab() {
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
account_balance_wallet
</span>
</div>
<h3 className="text-lg font-semibold">{t("budgetManagement")}</h3>
</div>
@@ -167,16 +179,26 @@ export default function BudgetTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">{t("todaysSpend")}</p>
<p className="text-2xl font-bold text-text-main">${dailyCost.toFixed(2)}</p>
<p className="text-2xl font-bold text-text-main">{formatCurrency(dailyCost)}</p>
{dailyLimit > 0 && (
<ProgressBar value={dailyCost} max={dailyLimit} warningAt={warnPct} />
<ProgressBar
value={dailyCost}
max={dailyLimit}
warningAt={warnPct}
formatCurrency={formatCurrency}
/>
)}
</div>
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">{t("thisMonth")}</p>
<p className="text-2xl font-bold text-text-main">${monthlyCost.toFixed(2)}</p>
<p className="text-2xl font-bold text-text-main">{formatCurrency(monthlyCost)}</p>
{monthlyLimit > 0 && (
<ProgressBar value={monthlyCost} max={monthlyLimit} warningAt={warnPct} />
<ProgressBar
value={monthlyCost}
max={monthlyLimit}
warningAt={warnPct}
formatCurrency={formatCurrency}
/>
)}
</div>
</div>
@@ -225,13 +247,14 @@ export default function BudgetTab() {
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[20px]"
aria-hidden="true"
style={{ color: budget.budgetCheck.allowed ? "#22c55e" : "#ef4444" }}
>
{budget.budgetCheck.allowed ? "check_circle" : "block"}
</span>
<span className="text-sm">
{budget.budgetCheck.allowed
? t("budgetOk", { remaining: (budget.budgetCheck.remaining || 0).toFixed(2) })
? t("budgetOk", { remaining: formatCurrency(budget.budgetCheck.remaining || 0) })
: t("budgetExceeded")}
</span>
</div>

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
import QuotaProgressBar from "./QuotaProgressBar";
@@ -26,6 +27,7 @@ export default function ProviderLimitCard({
}) {
const [refreshing, setRefreshing] = useState(false);
const [imgError, setImgError] = useState(false);
const t = useTranslations("usage");
const handleRefresh = async () => {
if (!onRefresh || refreshing) return;
@@ -70,7 +72,7 @@ export default function ProviderLimitCard({
) : (
<Image
src={`/providers/${provider}.png`}
alt={provider || "Provider"}
alt={provider || t("providerLimits")}
width={40}
height={40}
className="object-contain rounded-lg"
@@ -83,7 +85,10 @@ export default function ProviderLimitCard({
<div>
<h3 className="font-semibold text-text-primary">{name || provider}</h3>
{plan && (
<Badge variant={(planVariants as any)[plan?.toLowerCase()] || "default"} size={"xs" as any}>
<Badge
variant={(planVariants as any)[plan?.toLowerCase()] || "default"}
size={"xs" as any}
>
{plan}
</Badge>
)}
@@ -95,7 +100,7 @@ export default function ProviderLimitCard({
onClick={handleRefresh}
disabled={refreshing || loading}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Refresh quota"
title={t("refreshQuota")}
>
<span
className={`material-symbols-outlined text-[20px] text-text-muted ${
@@ -171,7 +176,7 @@ export default function ProviderLimitCard({
{!loading && !error && !message && quotas?.length === 0 && (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[48px] opacity-20">data_usage</span>
<p className="text-sm mt-2">No quota data available</p>
<p className="text-sm mt-2">{t("noQuotaDataAvailable")}</p>
</div>
)}
</Card>

View File

@@ -1,11 +1,12 @@
"use client";
import { formatResetTime, calculatePercentage } from "./utils";
import { useLocale, useTranslations } from "next-intl";
/**
* Format reset time display (Today, 12:00 PM)
*/
function formatResetTimeDisplay(resetTime) {
function formatResetTimeDisplay(resetTime, locale, t) {
if (!resetTime) return null;
try {
@@ -17,20 +18,19 @@ function formatResetTimeDisplay(resetTime) {
let dayStr = "";
if (date >= today && date < tomorrow) {
dayStr = "Today";
dayStr = t("today");
} else if (date >= tomorrow && date < new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000)) {
dayStr = "Tomorrow";
dayStr = t("tomorrow");
} else {
dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
dayStr = date.toLocaleDateString(locale, { month: "short", day: "numeric" });
}
const timeStr = date.toLocaleTimeString("en-US", {
const timeStr = date.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
return `${dayStr}, ${timeStr}`;
return t("dayTimeFormat", { day: dayStr, time: timeStr });
} catch {
return null;
}
@@ -71,6 +71,9 @@ function getColorClasses(remainingPercentage) {
* Quota Table Component - Table-based display for quota data
*/
export default function QuotaTable({ quotas = [] }) {
const t = useTranslations("usage");
const locale = useLocale();
if (!quotas || quotas.length === 0) {
return null;
}
@@ -92,7 +95,7 @@ export default function QuotaTable({ quotas = [] }) {
const colors = getColorClasses(remaining);
const countdown = formatResetTime(quota.resetAt);
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
const resetDisplay = formatResetTimeDisplay(quota.resetAt, locale, t);
return (
<tr
@@ -137,17 +140,19 @@ export default function QuotaTable({ quotas = [] }) {
{/* Reset Time */}
<td className="py-2 px-3">
{countdown !== "-" || resetDisplay ? (
{countdown !== t("notAvailableSymbol") || resetDisplay ? (
<div className="space-y-0.5">
{countdown !== "-" && (
<div className="text-sm text-text-primary font-medium">in {countdown}</div>
{countdown !== t("notAvailableSymbol") && (
<div className="text-sm text-text-primary font-medium">
{t("inDuration", { duration: countdown })}
</div>
)}
{resetDisplay && (
<div className="text-xs text-text-muted">{resetDisplay}</div>
)}
</div>
) : (
<div className="text-sm text-text-muted italic">N/A</div>
<div className="text-sm text-text-muted italic">{t("notApplicable")}</div>
)}
</td>
</tr>

View File

@@ -56,7 +56,10 @@ export default function RateLimitStatus() {
{data.lockouts.length === 0 ? (
<div className="text-center py-6 text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 block opacity-40">
<span
className="material-symbols-outlined text-[32px] mb-2 block opacity-40"
aria-hidden="true"
>
lock_open
</span>
<p className="text-sm">{t("noLockouts")}</p>
@@ -70,7 +73,10 @@ export default function RateLimitStatus() {
bg-orange-500/5 border border-orange-500/15"
>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[16px] text-orange-400">
<span
className="material-symbols-outlined text-[16px] text-orange-400"
aria-hidden="true"
>
lock
</span>
<div>

View File

@@ -54,7 +54,10 @@ export default function SessionsTab() {
{data.sessions.length === 0 ? (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[40px] mb-2 block opacity-40">
<span
className="material-symbols-outlined text-[40px] mb-2 block opacity-40"
aria-hidden="true"
>
fingerprint
</span>
<p className="text-sm">{t("noSessions")}</p>

View File

@@ -27,7 +27,9 @@ export default function ForgotPasswordPage() {
<Card className="mb-4">
<div className="flex items-start gap-4 p-2">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0 mt-0.5">
<span className="material-symbols-outlined text-[20px]">terminal</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
terminal
</span>
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold mb-1">{t("methodCliTitle")}</h2>
@@ -44,7 +46,9 @@ export default function ForgotPasswordPage() {
<Card className="mb-6">
<div className="flex items-start gap-4 p-2">
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 text-amber-500 shrink-0 mt-0.5">
<span className="material-symbols-outlined text-[20px]">database</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
database
</span>
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold mb-1">{t("methodManualTitle")}</h2>
@@ -77,7 +81,9 @@ export default function ForgotPasswordPage() {
href="/login"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
arrow_back
</span>
{t("backToLogin")}
</Link>
</div>

View File

@@ -1,10 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
const FEATURES = [
{
icon: "link",
title: "Unified Endpoint",
desc: "Access all providers via a single standard API URL.",
titleKey: "featureUnifiedEndpointTitle",
descKey: "featureUnifiedEndpointDesc",
colors: {
border: "hover:border-blue-500/50",
bg: "hover:bg-blue-500/5",
@@ -15,8 +16,8 @@ const FEATURES = [
},
{
icon: "bolt",
title: "Easy Setup",
desc: "Get up and running in minutes with npx command.",
titleKey: "featureEasySetupTitle",
descKey: "featureEasySetupDesc",
colors: {
border: "hover:border-orange-500/50",
bg: "hover:bg-orange-500/5",
@@ -27,8 +28,8 @@ const FEATURES = [
},
{
icon: "shield_with_heart",
title: "Model Fallback",
desc: "Automatically switch providers on failure or high latency.",
titleKey: "featureModelFallbackTitle",
descKey: "featureModelFallbackDesc",
colors: {
border: "hover:border-rose-500/50",
bg: "hover:bg-rose-500/5",
@@ -39,8 +40,8 @@ const FEATURES = [
},
{
icon: "monitoring",
title: "Usage Tracking",
desc: "Detailed analytics and cost monitoring across all models.",
titleKey: "featureUsageTrackingTitle",
descKey: "featureUsageTrackingDesc",
colors: {
border: "hover:border-purple-500/50",
bg: "hover:bg-purple-500/5",
@@ -51,8 +52,8 @@ const FEATURES = [
},
{
icon: "key",
title: "OAuth & API Keys",
desc: "Securely manage credentials in one vault.",
titleKey: "featureOAuthApiKeysTitle",
descKey: "featureOAuthApiKeysDesc",
colors: {
border: "hover:border-amber-500/50",
bg: "hover:bg-amber-500/5",
@@ -63,8 +64,8 @@ const FEATURES = [
},
{
icon: "cloud_sync",
title: "Cloud Sync",
desc: "Sync your configurations across devices instantly.",
titleKey: "featureCloudSyncTitle",
descKey: "featureCloudSyncDesc",
colors: {
border: "hover:border-sky-500/50",
bg: "hover:bg-sky-500/5",
@@ -75,8 +76,8 @@ const FEATURES = [
},
{
icon: "terminal",
title: "CLI Support",
desc: "Works with Claude Code, Codex, Cline, Cursor, and more.",
titleKey: "featureCliSupportTitle",
descKey: "featureCliSupportDesc",
colors: {
border: "hover:border-emerald-500/50",
bg: "hover:bg-emerald-500/5",
@@ -87,8 +88,8 @@ const FEATURES = [
},
{
icon: "dashboard",
title: "Dashboard",
desc: "Visual dashboard for real-time traffic analysis.",
titleKey: "featureDashboardTitle",
descKey: "featureDashboardDesc",
colors: {
border: "hover:border-fuchsia-500/50",
bg: "hover:bg-fuchsia-500/5",
@@ -100,33 +101,35 @@ const FEATURES = [
];
export default function Features() {
const t = useTranslations("landing");
return (
<section className="py-24 px-6" id="features">
<div className="max-w-7xl mx-auto">
<div className="mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">Powerful Features</h2>
<p className="text-gray-400 max-w-xl text-lg">
Everything you need to manage your AI infrastructure in one place, built for scale.
</p>
<h2 className="text-3xl md:text-4xl font-bold mb-4">{t("powerfulFeatures")}</h2>
<p className="text-gray-400 max-w-xl text-lg">{t("featuresSubtitle")}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{FEATURES.map((feature) => (
<div
key={feature.title}
key={feature.titleKey}
className={`p-6 rounded-xl bg-[#23180f] border border-[#3a2f27] ${feature.colors.border} ${feature.colors.bg} transition-all duration-300 group`}
>
<div
className={`w-10 h-10 rounded-lg ${feature.colors.iconBg} flex items-center justify-center mb-4 ${feature.colors.iconText} group-hover:scale-110 transition-transform duration-300`}
>
<span className="material-symbols-outlined">{feature.icon}</span>
<span className="material-symbols-outlined" aria-hidden="true">
{feature.icon}
</span>
</div>
<h3
className={`text-lg font-bold mb-2 ${feature.colors.titleHover} transition-colors`}
>
{feature.title}
{t(feature.titleKey)}
</h3>
<p className="text-sm text-gray-400 leading-relaxed">{feature.desc}</p>
<p className="text-sm text-gray-400 leading-relaxed">{t(feature.descKey)}</p>
</div>
))}
</div>

View File

@@ -1,149 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
const CLI_TOOLS = [
{ id: "claude", name: "Claude Code", image: "/providers/claude.png" },
{ id: "codex", name: "OpenAI Codex", image: "/providers/codex.png" },
{ id: "cline", name: "Cline", image: "/providers/cline.png" },
{ id: "cursor", name: "Cursor", image: "/providers/cursor.png" },
];
const PROVIDERS = [
{ id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" },
{ id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" },
{ id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" },
{ id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" },
];
import { useTranslations } from "next-intl";
export default function FlowAnimation() {
const t = useTranslations("landing");
const [activeFlow, setActiveFlow] = useState(0);
const cliTools = [
{ id: "claude", name: t("flowToolClaudeCode"), image: "/providers/claude.png" },
{ id: "codex", name: t("flowToolOpenAICodex"), image: "/providers/codex.png" },
{ id: "cline", name: t("flowToolCline"), image: "/providers/cline.png" },
{ id: "cursor", name: t("flowToolCursor"), image: "/providers/cursor.png" },
];
const providers = [
{
id: "openai",
name: t("flowProviderOpenAI"),
color: "bg-emerald-500",
textColor: "text-white",
},
{
id: "anthropic",
name: t("flowProviderAnthropic"),
color: "bg-orange-400",
textColor: "text-white",
},
{
id: "gemini",
name: t("flowProviderGemini"),
color: "bg-blue-500",
textColor: "text-white",
},
{
id: "github",
name: t("flowProviderGithubCopilot"),
color: "bg-gray-700",
textColor: "text-white",
},
];
useEffect(() => {
const interval = setInterval(() => {
setActiveFlow((prev) => (prev + 1) % PROVIDERS.length);
setActiveFlow((prev) => (prev + 1) % providers.length);
}, 2000);
return () => clearInterval(interval);
}, []);
}, [providers.length]);
return (
<div className="mt-16 w-full max-w-4xl relative h-[360px] hidden md:flex items-center justify-center animate-[float_6s_ease-in-out_infinite]">
{/* OmniRoute Hub - Center */}
<div className="relative z-20 w-32 h-32 rounded-full bg-[#111520] border-2 border-[#E54D5E] shadow-[0_0_40px_rgba(229,77,94,0.3)] flex flex-col items-center justify-center gap-1 group cursor-pointer hover:scale-105 transition-transform duration-500">
<span className="material-symbols-outlined text-4xl text-[#E54D5E]">hub</span>
<span className="text-xs font-bold text-white tracking-widest uppercase">OmniRoute</span>
<div className="absolute inset-0 rounded-full border border-[#E54D5E]/30 animate-ping opacity-20"></div>
</div>
<div className="mt-16 w-full max-w-4xl">
<div className="relative h-[360px] hidden md:flex items-center justify-center animate-[float_6s_ease-in-out_infinite]">
{/* OmniRoute Hub - Center */}
<div className="relative z-20 w-32 h-32 rounded-full bg-[#111520] border-2 border-[#E54D5E] shadow-[0_0_40px_rgba(229,77,94,0.3)] flex flex-col items-center justify-center gap-1 group cursor-pointer hover:scale-105 transition-transform duration-500">
<span className="material-symbols-outlined text-4xl text-[#E54D5E]" aria-hidden="true">
hub
</span>
<span className="text-xs font-bold text-white tracking-widest uppercase">
{t("brandName")}
</span>
<div className="absolute inset-0 rounded-full border border-[#E54D5E]/30 animate-ping opacity-20"></div>
</div>
{/* CLI Tools - Left side */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col gap-7">
{CLI_TOOLS.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
>
<div className="w-16 h-16 rounded-2xl bg-[#111520] border border-[#2D333B] flex items-center justify-center overflow-hidden p-2 hover:border-[#E54D5E]/50 transition-all hover:scale-105">
<Image
src={tool.image}
alt={tool.name}
width={48}
height={48}
className="object-contain rounded-xl max-w-[48px] max-h-[48px]"
sizes="48px"
/>
{/* CLI Tools - Left side */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col gap-7">
{cliTools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
>
<div className="w-16 h-16 rounded-2xl bg-[#111520] border border-[#2D333B] flex items-center justify-center overflow-hidden p-2 hover:border-[#E54D5E]/50 transition-all hover:scale-105">
<Image
src={tool.image}
alt={tool.name}
width={48}
height={48}
className="object-contain rounded-xl max-w-[48px] max-h-[48px]"
sizes="48px"
/>
</div>
</div>
</div>
))}
</div>
))}
</div>
{/* SVG Lines from CLI to OmniRoute */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 50 C 250 70, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 140 C 250 140, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 210 C 250 210, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 300 C 250 280, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
</svg>
{/* SVG Lines from CLI to OmniRoute */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none stroke-yellow-700"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 50 C 250 70, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 140 C 250 140, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 210 C 250 210, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
<path
className="animate-[dash_2s_linear_infinite]"
d="M 60 300 C 250 280, 250 180, 360 180"
fill="none"
strokeDasharray="5,5"
strokeWidth="2"
></path>
</svg>
{/* SVG Lines from OmniRoute to Providers */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 440 180 C 550 180, 550 50, 740 50"
fill="none"
stroke={activeFlow === 0 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 0 ? "3" : "2"}
className={activeFlow === 0 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 130, 740 130"
fill="none"
stroke={activeFlow === 1 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 1 ? "3" : "2"}
className={activeFlow === 1 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 230, 740 230"
fill="none"
stroke={activeFlow === 2 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 2 ? "3" : "2"}
className={activeFlow === 2 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 310, 740 310"
fill="none"
stroke={activeFlow === 3 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 3 ? "3" : "2"}
className={activeFlow === 3 ? "animate-pulse" : ""}
></path>
</svg>
{/* SVG Lines from OmniRoute to Providers */}
<svg
className="absolute inset-0 w-full h-full z-10 pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 440 180 C 550 180, 550 50, 740 50"
fill="none"
stroke={activeFlow === 0 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 0 ? "3" : "2"}
className={activeFlow === 0 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 130, 740 130"
fill="none"
stroke={activeFlow === 1 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 1 ? "3" : "2"}
className={activeFlow === 1 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 230, 740 230"
fill="none"
stroke={activeFlow === 2 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 2 ? "3" : "2"}
className={activeFlow === 2 ? "animate-pulse" : ""}
></path>
<path
d="M 440 180 C 550 180, 550 310, 740 310"
fill="none"
stroke={activeFlow === 3 ? "#E54D5E" : "rgb(75, 85, 99)"}
strokeWidth={activeFlow === 3 ? "3" : "2"}
className={activeFlow === 3 ? "animate-pulse" : ""}
></path>
</svg>
{/* AI Providers - Right side */}
<div className="absolute right-0 top-0 bottom-0 flex flex-col justify-between py-6">
{PROVIDERS.map((provider, idx) => (
<div
key={provider.id}
className={`px-4 py-2 rounded-lg ${provider.color} ${provider.textColor} flex items-center justify-center font-bold text-xs shadow-lg hover:scale-110 transition-all cursor-help min-w-[140px] ${
activeFlow === idx ? "ring-4 ring-[#E54D5E]/50 scale-110" : ""
}`}
title={provider.name}
>
{provider.name}
</div>
))}
{/* AI Providers - Right side */}
<div className="absolute right-0 top-0 bottom-0 flex flex-col justify-between py-6">
{providers.map((provider, idx) => (
<div
key={provider.id}
className={`px-4 py-2 rounded-lg ${provider.color} ${provider.textColor} flex items-center justify-center font-bold text-xs shadow-lg hover:scale-110 transition-all cursor-help min-w-[140px] ${
activeFlow === idx ? "ring-4 ring-[#E54D5E]/50 scale-110" : ""
}`}
title={provider.name}
>
{provider.name}
</div>
))}
</div>
</div>
{/* Mobile fallback */}
<div className="md:hidden mt-8 w-full p-4 rounded-lg bg-[#111520] border border-[#2D333B]">
<p className="text-sm text-center text-gray-400">Interactive diagram visible on desktop</p>
<p className="text-sm text-center text-gray-400">{t("interactiveDiagram")}</p>
</div>
</div>
);

View File

@@ -1,7 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
import OmniRouteLogo from "@/shared/components/OmniRouteLogo";
export default function Footer() {
const t = useTranslations("landing");
const year = new Date().getFullYear();
return (
<footer className="border-t border-[#2D333B] bg-[#080A0F] pt-16 pb-8 px-6">
<div className="max-w-7xl mx-auto">
@@ -12,12 +16,9 @@ export default function Footer() {
<div className="size-6 rounded bg-[#E54D5E] flex items-center justify-center text-white">
<OmniRouteLogo size={16} className="text-white" />
</div>
<h3 className="text-white text-lg font-bold">OmniRoute</h3>
<h3 className="text-white text-lg font-bold">{t("brandName")}</h3>
</div>
<p className="text-gray-500 text-sm max-w-xs mb-6">
The unified endpoint for AI generation. Connect, route, and manage your AI providers
with ease.
</p>
<p className="text-gray-500 text-sm max-w-xs mb-6">{t("footerTagline")}</p>
<div className="flex gap-4">
<a
className="text-gray-400 hover:text-white transition-colors"
@@ -25,25 +26,27 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
<span className="material-symbols-outlined">code</span>
<span className="material-symbols-outlined" aria-hidden="true">
code
</span>
</a>
</div>
</div>
{/* Product */}
<div className="flex flex-col gap-4">
<h4 className="font-bold text-white">Product</h4>
<h4 className="font-bold text-white">{t("product")}</h4>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="#features"
>
Features
{t("featuresLink")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="/dashboard"
>
Dashboard
{t("dashboardLink")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
@@ -51,18 +54,18 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
Changelog
{t("changelog")}
</a>
</div>
{/* Resources */}
<div className="flex flex-col gap-4">
<h4 className="font-bold text-white">Resources</h4>
<h4 className="font-bold text-white">{t("resources")}</h4>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="/docs"
>
Documentation
{t("documentation")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
@@ -70,7 +73,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
GitHub
{t("github")}
</a>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
@@ -78,27 +81,27 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
NPM
{t("npm")}
</a>
</div>
{/* Legal */}
<div className="flex flex-col gap-4">
<h4 className="font-bold text-white">Legal</h4>
<h4 className="font-bold text-white">{t("legal")}</h4>
<a
className="text-gray-400 hover:text-[#E54D5E] text-sm transition-colors"
href="https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>
MIT License
{t("mitLicense")}
</a>
</div>
</div>
{/* Bottom */}
<div className="border-t border-[#2D333B] pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-gray-600 text-sm">© 2025 OmniRoute. All rights reserved.</p>
<p className="text-gray-600 text-sm">{t("copyright", { year })}</p>
<div className="flex gap-6">
<a
className="text-gray-600 hover:text-white text-sm transition-colors"
@@ -106,7 +109,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
GitHub
{t("github")}
</a>
<a
className="text-gray-600 hover:text-white text-sm transition-colors"
@@ -114,7 +117,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
NPM
{t("npm")}
</a>
</div>
</div>

View File

@@ -1,10 +1,16 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
export default function GetStarted() {
const t = useTranslations("landing");
const [copied, setCopied] = useState(false);
const handleCopy = (text) => {
const endpoint = "http://localhost:20128";
const dashboardUrl = `${endpoint}/dashboard`;
const command = "npx omniroute";
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@@ -16,11 +22,8 @@ export default function GetStarted() {
<div className="flex flex-col lg:flex-row gap-16 items-start">
{/* Left: Steps */}
<div className="flex-1">
<h2 className="text-3xl md:text-4xl font-bold mb-6">Get Started in 30 Seconds</h2>
<p className="text-gray-400 text-lg mb-8">
Install OmniRoute, configure your providers via web dashboard, and start routing AI
requests.
</p>
<h2 className="text-3xl md:text-4xl font-bold mb-6">{t("getStartedIn30Seconds")}</h2>
<p className="text-gray-400 text-lg mb-8">{t("getStartedDescription")}</p>
<div className="flex flex-col gap-6">
<div className="flex gap-4">
@@ -28,10 +31,8 @@ export default function GetStarted() {
1
</div>
<div>
<h4 className="font-bold text-lg">Install OmniRoute</h4>
<p className="text-sm text-gray-500 mt-1">
Run npx command to start the server instantly
</p>
<h4 className="font-bold text-lg">{t("installOmniRoute")}</h4>
<p className="text-sm text-gray-500 mt-1">{t("installStepDescription")}</p>
</div>
</div>
@@ -40,10 +41,8 @@ export default function GetStarted() {
2
</div>
<div>
<h4 className="font-bold text-lg">Open Dashboard</h4>
<p className="text-sm text-gray-500 mt-1">
Configure providers and API keys via web interface
</p>
<h4 className="font-bold text-lg">{t("openDashboard")}</h4>
<p className="text-sm text-gray-500 mt-1">{t("openDashboardStepDescription")}</p>
</div>
</div>
@@ -52,9 +51,9 @@ export default function GetStarted() {
3
</div>
<div>
<h4 className="font-bold text-lg">Route Requests</h4>
<h4 className="font-bold text-lg">{t("routeRequests")}</h4>
<p className="text-sm text-gray-500 mt-1">
Point your CLI tools to http://localhost:20128
{t("routeRequestsStepDescription", { endpoint })}
</p>
</div>
</div>
@@ -69,44 +68,46 @@ export default function GetStarted() {
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<div className="ml-2 text-xs text-gray-500 font-mono">terminal</div>
<div className="ml-2 text-xs text-gray-500 font-mono">{t("terminal")}</div>
</div>
{/* Terminal content */}
<div className="p-6 font-mono text-sm leading-relaxed overflow-x-auto">
<div
className="flex items-center gap-2 mb-4 group cursor-pointer"
onClick={() => handleCopy("npx omniroute")}
onClick={() => handleCopy(command)}
>
<span className="text-green-400">$</span>
<span className="text-white">npx omniroute</span>
<span className="text-white">{command}</span>
<span className="ml-auto text-gray-500 text-xs opacity-0 group-hover:opacity-100">
{copied ? "✓ Copied" : "Copy"}
{copied ? t("copied") : t("copy")}
</span>
</div>
<div className="text-gray-400 mb-6">
<span className="text-[#E54D5E]">&gt;</span> Starting OmniRoute...
<span className="text-[#E54D5E]">&gt;</span> {t("startingOmniRoute")}
<br />
<span className="text-[#E54D5E]">&gt;</span> Server running on{" "}
<span className="text-blue-400">http://localhost:20128</span>
<span className="text-[#E54D5E]">&gt;</span> {t("serverRunningOnLabel")}{" "}
<span className="text-blue-400">{endpoint}</span>
<br />
<span className="text-[#E54D5E]">&gt;</span> Dashboard:{" "}
<span className="text-blue-400">http://localhost:20128/dashboard</span>
<span className="text-[#E54D5E]">&gt;</span> {t("dashboardLabel")}:{" "}
<span className="text-blue-400">{dashboardUrl}</span>
<br />
<span className="text-green-400">&gt;</span> Ready to route!
<span className="text-green-400">&gt;</span> {t("readyToRoute")}
</div>
<div className="text-xs text-gray-500 mb-2 border-t border-gray-700 pt-4">
📝 Configure providers in dashboard or use environment variables
{t("configureProvidersNote")}
</div>
<div className="text-gray-400 text-xs">
<span className="text-purple-400">Data Location:</span>
<span className="text-purple-400">{t("dataLocation")}</span>
<br />
<span className="text-gray-500"> macOS/Linux:</span> ~/.omniroute/db.json
<span className="text-gray-500">{t("dataLocationMacLinux")}</span>{" "}
~/.omniroute/db.json
<br />
<span className="text-gray-500"> Windows:</span> %APPDATA%/omniroute/db.json
<span className="text-gray-500">{t("dataLocationWindows")}</span>{" "}
%APPDATA%/omniroute/db.json
</div>
</div>
</div>

View File

@@ -1,6 +1,11 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
export default function HeroSection() {
const t = useTranslations("landing");
const router = useRouter();
return (
<section className="relative pt-32 pb-20 px-6 min-h-[90vh] flex flex-col items-center justify-center overflow-hidden">
{/* Glow effect */}
@@ -10,26 +15,30 @@ export default function HeroSection() {
{/* Version badge */}
<div className="inline-flex items-center gap-2 rounded-full border border-[#2D333B] bg-[#111520]/50 px-3 py-1 text-xs font-medium text-[#E54D5E]">
<span className="flex h-2 w-2 rounded-full bg-[#E54D5E] animate-pulse"></span>
v1.0 is now live
{t("versionLive")}
</div>
{/* Main heading */}
<h1 className="text-5xl md:text-7xl font-black leading-[1.1] tracking-tight">
One Endpoint for <br />
<span className="text-[#E54D5E]">All AI Providers</span>
{t("oneEndpoint")} <br />
<span className="text-[#E54D5E]">{t("allProviders")}</span>
</h1>
{/* Description */}
<p className="text-lg md:text-xl text-gray-400 max-w-2xl mx-auto font-light">
AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly
with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.
{t("heroDescription")}
</p>
{/* CTA Buttons */}
<div className="flex flex-wrap items-center justify-center gap-4 w-full">
<button className="h-12 px-8 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-base font-bold transition-all shadow-[0_0_15px_rgba(229,77,94,0.4)] flex items-center gap-2">
<span className="material-symbols-outlined">rocket_launch</span>
Get Started
<button
onClick={() => router.push("/dashboard")}
className="h-12 px-8 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-base font-bold transition-all shadow-[0_0_15px_rgba(229,77,94,0.4)] flex items-center gap-2"
>
<span className="material-symbols-outlined" aria-hidden="true">
rocket_launch
</span>
{t("getStarted")}
</button>
<a
href="https://github.com/diegosouzapw/OmniRoute"
@@ -37,8 +46,10 @@ export default function HeroSection() {
rel="noopener noreferrer"
className="h-12 px-8 rounded-lg border border-[#2D333B] bg-[#111520] hover:bg-[#2D333B] text-white text-base font-bold transition-all flex items-center gap-2"
>
<span className="material-symbols-outlined">code</span>
View on GitHub
<span className="material-symbols-outlined" aria-hidden="true">
code
</span>
{t("viewOnGithub")}
</a>
</div>
</div>

View File

@@ -1,15 +1,15 @@
"use client";
import { useTranslations } from "next-intl";
export default function HowItWorks() {
const t = useTranslations("landing");
return (
<section className="py-24 border-y border-[#2D333B] bg-[#111520]/30" id="how-it-works">
<div className="max-w-7xl mx-auto px-6">
<div className="mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">How OmniRoute Works</h2>
<p className="text-gray-400 max-w-xl text-lg">
Data flows seamlessly from your application through our intelligent routing layer to the
best provider for the job.
</p>
<h2 className="text-3xl md:text-4xl font-bold mb-4">{t("howItWorks")}</h2>
<p className="text-gray-400 max-w-xl text-lg">{t("howItWorksDescription")}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 relative">
@@ -19,30 +19,29 @@ export default function HowItWorks() {
{/* Step 1: CLI & SDKs */}
<div className="flex flex-col gap-6 relative group">
<div className="w-24 h-24 rounded-2xl bg-[#0B0E14] border border-[#2D333B] flex items-center justify-center shadow-xl group-hover:border-gray-500 transition-colors z-10 mx-auto md:mx-0">
<span className="material-symbols-outlined text-4xl text-gray-300">terminal</span>
<span className="material-symbols-outlined text-4xl text-gray-300" aria-hidden="true">
terminal
</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2">1. CLI &amp; SDKs</h3>
<p className="text-sm text-gray-400">
Your requests start from your favorite tools or our unified SDK. Just change the
base URL.
</p>
<h3 className="text-xl font-bold mb-2">{t("howItWorksStep1Title")}</h3>
<p className="text-sm text-gray-400">{t("howItWorksStep1Description")}</p>
</div>
</div>
{/* Step 2: OmniRoute Hub */}
<div className="flex flex-col gap-6 relative group md:items-center md:text-center">
<div className="w-24 h-24 rounded-2xl bg-[#0B0E14] border-2 border-[#E54D5E] flex items-center justify-center shadow-[0_0_30px_rgba(229,77,94,0.2)] z-10 mx-auto">
<span className="material-symbols-outlined text-4xl text-[#E54D5E] animate-pulse">
<span
className="material-symbols-outlined text-4xl text-[#E54D5E] animate-pulse"
aria-hidden="true"
>
hub
</span>
</div>
<div>
<h3 className="text-xl font-bold mb-2 text-[#E54D5E]">2. OmniRoute Hub</h3>
<p className="text-sm text-gray-400">
Our engine analyzes the prompt, checks provider health, and routes for lowest
latency or cost.
</p>
<h3 className="text-xl font-bold mb-2 text-[#E54D5E]">{t("howItWorksStep2Title")}</h3>
<p className="text-sm text-gray-400">{t("howItWorksStep2Description")}</p>
</div>
</div>
@@ -57,10 +56,8 @@ export default function HowItWorks() {
</div>
</div>
<div>
<h3 className="text-xl font-bold mb-2">3. AI Providers</h3>
<p className="text-sm text-gray-400">
The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.
</p>
<h3 className="text-xl font-bold mb-2">{t("howItWorksStep3Title")}</h3>
<p className="text-sm text-gray-400">{t("howItWorksStep3Description")}</p>
</div>
</div>
</div>

View File

@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import OmniRouteLogo from "@/shared/components/OmniRouteLogo";
export default function Navigation() {
const t = useTranslations("landing");
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const router = useRouter();
@@ -15,12 +17,12 @@ export default function Navigation() {
type="button"
className="flex items-center gap-3 cursor-pointer bg-transparent border-none p-0"
onClick={() => router.push("/")}
aria-label="Navigate to home"
aria-label={t("navigateHome")}
>
<div className="size-8 rounded bg-linear-to-br from-[#E54D5E] to-[#C93D4E] flex items-center justify-center text-white">
<OmniRouteLogo size={20} className="text-white" />
</div>
<h2 className="text-white text-xl font-bold tracking-tight">OmniRoute</h2>
<h2 className="text-white text-xl font-bold tracking-tight">{t("brandName")}</h2>
</button>
{/* Desktop menu */}
@@ -29,19 +31,19 @@ export default function Navigation() {
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="#features"
>
Features
{t("featuresLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="#how-it-works"
>
How it Works
{t("howItWorks")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="/docs"
>
Docs
{t("docsLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors flex items-center gap-1"
@@ -49,7 +51,10 @@ export default function Navigation() {
target="_blank"
rel="noopener noreferrer"
>
GitHub <span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t("github")}
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
open_in_new
</span>
</a>
</div>
@@ -59,13 +64,16 @@ export default function Navigation() {
onClick={() => router.push("/dashboard")}
className="hidden sm:flex h-9 items-center justify-center rounded-lg px-4 bg-[#E54D5E] hover:bg-[#C93D4E] transition-all text-white text-sm font-bold shadow-[0_0_15px_rgba(229,77,94,0.4)] hover:shadow-[0_0_20px_rgba(229,77,94,0.6)]"
>
Get Started
{t("getStarted")}
</button>
<button
className="md:hidden text-white"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={t("toggleMenu")}
>
<span className="material-symbols-outlined">{mobileMenuOpen ? "close" : "menu"}</span>
<span className="material-symbols-outlined" aria-hidden="true">
{mobileMenuOpen ? "close" : "menu"}
</span>
</button>
</div>
</div>
@@ -79,20 +87,20 @@ export default function Navigation() {
href="#features"
onClick={() => setMobileMenuOpen(false)}
>
Features
{t("featuresLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="#how-it-works"
onClick={() => setMobileMenuOpen(false)}
>
How it Works
{t("howItWorks")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
href="/docs"
>
Docs
{t("docsLink")}
</a>
<a
className="text-gray-300 hover:text-white text-sm font-medium transition-colors"
@@ -100,13 +108,13 @@ export default function Navigation() {
target="_blank"
rel="noopener noreferrer"
>
GitHub
{t("github")}
</a>
<button
onClick={() => router.push("/dashboard")}
className="h-9 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-sm font-bold"
>
Get Started
{t("getStarted")}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Navigation from "./components/Navigation";
import HeroSection from "./components/HeroSection";
import FlowAnimation from "./components/FlowAnimation";
@@ -9,6 +10,7 @@ import GetStarted from "./components/GetStarted";
import Footer from "./components/Footer";
export default function LandingPage() {
const t = useTranslations("landing");
const router = useRouter();
return (
<div className="relative text-white font-sans overflow-x-hidden antialiased selection:bg-[#E54D5E] selection:text-white">
@@ -64,25 +66,20 @@ export default function LandingPage() {
<section className="py-32 px-6 relative overflow-hidden">
<div className="absolute inset-0 bg-linear-to-t from-[#E54D5E]/5 to-transparent pointer-events-none"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<h2 className="text-4xl md:text-5xl font-black mb-6">
Ready to Simplify Your AI Infrastructure?
</h2>
<p className="text-xl text-gray-400 mb-10 max-w-2xl mx-auto">
Join developers who are streamlining their AI integrations with OmniRoute. Open
source and free to start.
</p>
<h2 className="text-4xl md:text-5xl font-black mb-6">{t("ctaTitle")}</h2>
<p className="text-xl text-gray-400 mb-10 max-w-2xl mx-auto">{t("ctaDescription")}</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={() => router.push("/dashboard")}
className="w-full sm:w-auto h-14 px-10 rounded-lg bg-[#E54D5E] hover:bg-[#C93D4E] text-white text-lg font-bold transition-all shadow-[0_0_20px_rgba(229,77,94,0.5)]"
>
Start Free
{t("startFree")}
</button>
<button
onClick={() => router.push("/docs")}
className="w-full sm:w-auto h-14 px-10 rounded-lg border border-[#2D333B] hover:bg-[#111520] text-white text-lg font-bold transition-all"
>
Read Documentation
{t("readDocumentation")}
</button>
</div>
</div>

View File

@@ -321,6 +321,9 @@
"concurrencyPerModel": "Concurrency / Model",
"queueTimeout": "Queue Timeout (ms)",
"advancedHint": "Leave empty to use global defaults. These override per-provider settings.",
"moveUp": "Move up",
"moveDown": "Move down",
"removeModel": "Remove",
"saving": "Saving...",
"weighted": "Weighted",
"leastUsed": "Least-Used",
@@ -524,11 +527,18 @@
"newAccount": "New Account",
"deleteConfirm": "Are you sure you want to delete this provider?",
"testing": "Testing...",
"testConnection": "Test Connection",
"testSuccess": "Connection successful",
"testFailed": "Connection failed",
"available": "Available",
"cooldown": "Cooldown",
"unavailable": "Unavailable",
"unknown": "Unknown",
"oauthLabel": "OAuth",
"compatibleLabel": "Compatible",
"chat": "Chat",
"responses": "Responses",
"messages": "Messages",
"oauthProviders": "OAuth Providers",
"freeProviders": "Free Providers",
"apiKeyProviders": "API Key Providers",
@@ -553,14 +563,17 @@
"addNewProvider": "Add New Provider",
"backToProviders": "Back to Providers",
"configureNewProvider": "Configure a new AI provider to use with your applications.",
"providerLabel": "Provider",
"selectProvider": "Select a provider",
"selectedProvider": "Selected provider",
"authMethod": "Authentication Method",
"apiKeyLabel": "API Key",
"apiKeyRequired": "API Key is required",
"selectProviderRequired": "Please select a provider",
"enterApiKey": "Enter your API key",
"apiKeySecure": "Your API key will be encrypted and stored securely.",
"oauth2Connect": "Connect with OAuth2",
"oauth2Label": "OAuth2",
"oauth2Desc": "Connect your account using OAuth2 authentication.",
"displayName": "Display Name",
"displayNamePlaceholder": "e.g., Production API, Dev Environment",
@@ -580,7 +593,16 @@
"loadingAvailability": "Loading model availability...",
"clearCooldown": "Clear",
"clearing": "Clearing...",
"until": "Until {time}",
"providerTestFailed": "Provider test failed",
"modeTest": "{mode} Test",
"passedCount": "{count} passed",
"failedCount": "{count} failed",
"testedCount": "{count} tested",
"millisecondsAbbr": "{value}ms",
"okShort": "OK",
"errorShort": "ERROR",
"noActiveConnectionsInGroup": "No active connections found for this group.",
"allTestsPassed": "All {total} tests passed",
"testSummary": "{passed}/{total} passed, {failed} failed",
"nameLabel": "Name",
@@ -590,6 +612,10 @@
"prefixHint": "Required. Unique prefix for model names.",
"nameHint": "Required. A friendly label for this node.",
"baseUrlHint": "Required. \u2003Provider API base URL.",
"anthropicPrefixPlaceholder": "ac-prod",
"openaiPrefixPlaceholder": "oc-prod",
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
"validateConnection": "Validate Connection",
"validating": "Validating...",
"connectionValid": "Connection is valid!",
@@ -723,6 +749,7 @@
"check": "Check",
"valid": "Valid",
"invalid": "Invalid",
"creating": "Creating...",
"validationChecksAnthropicCompatible": "Validation checks {provider} by verifying the API key.",
"validationChecksOpenAiCompatible": "Validation checks {provider} via /models on your base URL.",
"priorityLabel": "Priority",
@@ -808,7 +835,12 @@
"ai": "AI",
"advanced": "Advanced",
"localMode": "Local Mode — All data stored on your machine",
"settingsSectionsAria": "Settings sections",
"switchThemes": "Switch between light and dark themes",
"themeSelectionAria": "Theme selection",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"hideHealthLogs": "Hide Health Check Logs",
"hideHealthLogsDesc": "When ON, suppress [HealthCheck] messages in server console",
"promptCache": "Prompt Cache",
@@ -821,6 +853,7 @@
"globalProxy": "Global Proxy",
"globalProxyDesc": "Configure a global outbound proxy for all API calls. Individual providers, combos, and keys can override this.",
"noGlobalProxy": "No global proxy configured",
"globalLabel": "Global",
"configure": "Configure",
"globalSystemPrompt": "Global System Prompt",
"systemPromptDesc": "Injected into all requests at proxy level",
@@ -838,6 +871,10 @@
"customDesc": "Set a fixed token budget for all requests",
"adaptive": "Adaptive",
"adaptiveDesc": "Scale budget based on request complexity",
"effortNone": "None (0 tokens)",
"effortLow": "Low (1K tokens)",
"effortMedium": "Medium (10K tokens)",
"effortHigh": "High (128K tokens)",
"tokenBudget": "Token Budget",
"tokens": "tokens",
"baseEffortLevel": "Base Effort Level",
@@ -862,6 +899,8 @@
"blockedProviders": "Blocked Providers",
"blockedProvidersDesc": "Hide specific providers from the /v1/models response. Blocked providers will not appear in model listings.",
"providersBlocked": "{count} provider(s) blocked from /models",
"blockProviderTitle": "Block {provider}",
"unblockProviderTitle": "Unblock {provider}",
"routingStrategy": "Routing Strategy",
"fillFirst": "Fill First",
"fillFirstDesc": "Use accounts in priority order",
@@ -879,10 +918,13 @@
"stickyLimitDesc": "Calls per account before switching",
"modelAliases": "Model Aliases",
"modelAliasesDesc": "Wildcard patterns to remap model names • Use * and ?",
"aliasPatternPlaceholder": "claude-sonnet-*",
"aliasTargetPlaceholder": "claude-sonnet-4-20250514",
"pattern": "Pattern",
"targetModel": "Target Model",
"add": "+ Add",
"session": "Session",
"sessionDetailsAria": "Session details",
"status": "Status",
"authenticated": "Authenticated",
"guest": "Guest",
@@ -892,9 +934,16 @@
"clearLocalData": "Clear Local Data",
"logout": "Logout",
"clearLocalDataConfirm": "Clear all local data? This will reset your preferences.",
"unknown": "Unknown",
"systemActor": "system",
"ipAccessControl": "IP Access Control",
"ipAccessControlDesc": "Block or allow specific IP addresses",
"ipModeDisabled": "Disabled",
"ipModeBlacklist": "Blacklist",
"ipModeWhitelist": "Whitelist",
"ipModeWhitelistPriority": "WL Priority",
"addIpAddress": "Add IP Address",
"ipAddressPlaceholder": "192.168.1.0/24 or 10.0.*.*",
"block": "+ Block",
"allow": "+ Allow",
"blocked": "Blocked ({count})",
@@ -913,7 +962,9 @@
"fallbackChainsDesc": "Define provider fallback order per model",
"addChain": "+ Add Chain",
"modelName": "Model Name",
"modelNamePlaceholder": "claude-sonnet-4-20250514",
"providersCommaSeparated": "Providers (comma-separated, in priority order)",
"providersCommaSeparatedPlaceholder": "anthropic, openai, gemini",
"createChain": "Create Chain",
"noFallbackChains": "No Fallback Chains",
"noFallbackChainsDesc": "Create a chain to define provider fallback order for a model.",
@@ -923,20 +974,30 @@
"chainDeleted": "Chain deleted for {model}",
"failedCreateChain": "Failed to create chain",
"failedDeleteChain": "Failed to delete chain",
"deleteChain": "Delete chain",
"fillModelAndProviders": "Please fill model name and providers",
"addAtLeastOneProvider": "Add at least one provider",
"comboDefaultsTitle": "Combo Defaults",
"globalComboConfig": "Global combo configuration",
"defaultStrategy": "Default Strategy",
"defaultStrategyDesc": "Applied to new combos without explicit strategy",
"comboStrategyAria": "Combo strategy",
"priority": "Priority",
"weighted": "Weighted",
"maxRetriesLabel": "Max Retries",
"retryDelayLabel": "Retry Delay (ms)",
"timeoutLabel": "Timeout (ms)",
"healthCheck": "Health Check",
"healthCheckDesc": "Pre-check provider availability",
"trackMetrics": "Track Metrics",
"trackMetricsDesc": "Record per-combo request metrics",
"providerOverrides": "Provider Overrides",
"providerOverridesDesc": "Override timeout and retries per provider. Provider settings override global defaults.",
"providerMaxRetriesAria": "{provider} max retries",
"providerTimeoutAria": "{provider} timeout ms",
"removeProviderOverrideAria": "Remove {provider} override",
"newProviderNamePlaceholder": "e.g. google, openai...",
"newProviderNameAria": "New provider name",
"retries": "retries",
"ms": "ms",
"saveComboDefaults": "Save Combo Defaults",
@@ -964,6 +1025,9 @@
"running": "Running",
"queued": "Queued",
"circuitBreakers": "Circuit Breakers",
"breakerStateClosed": "Closed",
"breakerStateOpen": "Open",
"breakerStateHalfOpen": "Half-Open",
"tripped": "{count} tripped",
"healthy": "{count} healthy",
"resetAll": "Reset All",
@@ -973,9 +1037,15 @@
"allOperational": "All systems operational — no lockouts or tripped breakers",
"loadingPolicies": "Loading policies...",
"lockedIdentifiers": "Locked Identifiers",
"unlockedIdentifier": "Unlocked: {identifier}",
"sinceDate": "since {date}",
"forceUnlock": "Force Unlock",
"unlocking": "Unlocking...",
"failedUnlock": "Failed to unlock",
"failedLoadWithStatus": "Failed to load: {status}",
"failedLoadResilience": "Failed to load resilience status",
"saveFailed": "Save failed",
"resetFailed": "Reset failed",
"loadingResilience": "Loading resilience status...",
"retry": "Retry",
"systemStorage": "System & Storage",
@@ -1003,6 +1073,19 @@
"no": "No",
"restore": "Restore",
"invalidFileType": "Invalid file type. Only .sqlite files are accepted.",
"exportFailed": "Export failed",
"exportFailedWithError": "Export failed: {error}",
"fullExportFailedWithError": "Full export failed: {error}",
"backupCreated": "Backup created: {file}",
"restoreSuccess": "Restored! {connections} connections, {nodes} nodes, {combos} combos, {apiKeys} API keys.",
"importSuccess": "Database imported! {connections} connections, {nodes} nodes, {combos} combos, {apiKeys} API keys.",
"justNow": "just now",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago",
"backupReasonManual": "manual",
"backupReasonPreRestore": "pre-restore",
"connectionsCount": "{count, plural, one {# connection} other {# connections}}",
"noChangesSinceBackup": "No changes since last backup",
"backupFailed": "Backup failed",
"restoreFailed": "Restore failed",
@@ -1025,6 +1108,7 @@
"saving": "Saving...",
"model": "Model",
"models": "models",
"moreProviders": "{count} more providers",
"withPricing": "with pricing configured",
"policiesCircuitBreakers": "Policies & Circuit Breakers",
"activeIssuesDetected": "Active issues detected",
@@ -1080,6 +1164,8 @@
"successful": "Successful",
"errors": "Errors",
"avgLatency": "Avg Latency",
"millisecondsShort": "{value}ms",
"notAvailableSymbol": "—",
"liveAutoRefreshing": "Live — Auto-refreshing",
"paused": "Paused",
"eventsAppearHint": "Translation events appear here as requests flow through OmniRoute. Use any of these methods to generate events:",
@@ -1179,7 +1265,7 @@
"monthlyLimitPlaceholder": "e.g. 50.00",
"warningThresholdPlaceholder": "80",
"saveLimits": "Save Limits",
"budgetOk": "Budget OK — ${remaining} remaining",
"budgetOk": "Budget OK — {remaining} remaining",
"budgetExceeded": "Budget exceeded — requests may be blocked",
"totalRequests": "Total requests",
"noDataYet": "No data yet",
@@ -1279,6 +1365,11 @@
"lastUsed": "Last Used",
"actions": "Actions",
"refreshQuota": "Refresh quota",
"today": "Today",
"tomorrow": "Tomorrow",
"dayTimeFormat": "{day}, {time}",
"inDuration": "in {duration}",
"notApplicable": "N/A",
"rawPlanWithValue": "Raw plan: {plan}",
"noPlanFromProvider": "No plan from provider",
"noQuotaData": "No quota data",
@@ -1390,19 +1481,86 @@
"forgotPassword": "Forgot password?"
},
"landing": {
"brandName": "OmniRoute",
"navigateHome": "Navigate to home",
"toggleMenu": "Toggle menu",
"featuresLink": "Features",
"docsLink": "Docs",
"github": "GitHub",
"versionLive": "v1.0 is now live",
"oneEndpoint": "One Endpoint for",
"allProviders": "All AI Providers",
"oneEndpoint": "One Endpoint",
"powerfulFeatures": "Powerful Features",
"howItWorks": "How OmniRoute Works",
"installOmniRoute": "Install OmniRoute",
"openDashboard": "Open Dashboard",
"routeRequests": "Route Requests",
"dataLocation": "Data Location:",
"heroDescription": "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.",
"getStarted": "Get Started",
"viewOnGithub": "View on GitHub",
"powerfulFeatures": "Powerful Features",
"featuresSubtitle": "Everything you need to manage your AI infrastructure in one place, built for scale.",
"featureUnifiedEndpointTitle": "Unified Endpoint",
"featureUnifiedEndpointDesc": "Access all providers via a single standard API URL.",
"featureEasySetupTitle": "Easy Setup",
"featureEasySetupDesc": "Get up and running in minutes with npx command.",
"featureModelFallbackTitle": "Model Fallback",
"featureModelFallbackDesc": "Automatically switch providers on failure or high latency.",
"featureUsageTrackingTitle": "Usage Tracking",
"featureUsageTrackingDesc": "Detailed analytics and cost monitoring across all models.",
"featureOAuthApiKeysTitle": "OAuth & API Keys",
"featureOAuthApiKeysDesc": "Securely manage credentials in one vault.",
"featureCloudSyncTitle": "Cloud Sync",
"featureCloudSyncDesc": "Sync your configurations across devices instantly.",
"featureCliSupportTitle": "CLI Support",
"featureCliSupportDesc": "Works with Claude Code, Codex, Cline, Cursor, and more.",
"featureDashboardTitle": "Dashboard",
"featureDashboardDesc": "Visual dashboard for real-time traffic analysis.",
"howItWorks": "How OmniRoute Works",
"howItWorksDescription": "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.",
"howItWorksStep1Title": "1. CLI & SDKs",
"howItWorksStep1Description": "Your requests start from your favorite tools or our unified SDK. Just change the base URL.",
"howItWorksStep2Title": "2. OmniRoute Hub",
"howItWorksStep2Description": "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.",
"howItWorksStep3Title": "3. AI Providers",
"howItWorksStep3Description": "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.",
"getStartedIn30Seconds": "Get Started in 30 Seconds",
"getStartedDescription": "Install OmniRoute, configure your providers via web dashboard, and start routing AI requests.",
"installOmniRoute": "Install OmniRoute",
"installStepDescription": "Run npx command to start the server instantly",
"openDashboard": "Open Dashboard",
"openDashboardStepDescription": "Configure providers and API keys via web interface",
"routeRequests": "Route Requests",
"routeRequestsStepDescription": "Point your CLI tools to {endpoint}",
"terminal": "terminal",
"copy": "Copy",
"copied": "✓ Copied",
"startingOmniRoute": "Starting OmniRoute...",
"serverRunningOnLabel": "Server running on",
"dashboardLabel": "Dashboard",
"readyToRoute": "Ready to route! ✓",
"configureProvidersNote": "📝 Configure providers in dashboard or use environment variables",
"dataLocation": "Data Location:",
"dataLocationMacLinux": " macOS/Linux:",
"dataLocationWindows": " Windows:",
"product": "Product",
"dashboardLink": "Dashboard",
"changelog": "Changelog",
"resources": "Resources",
"documentation": "Documentation",
"npm": "NPM",
"legal": "Legal",
"interactiveDiagram": "Interactive diagram visible on desktop"
"mitLicense": "MIT License",
"footerTagline": "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.",
"copyright": "© {year} OmniRoute. All rights reserved.",
"flowToolClaudeCode": "Claude Code",
"flowToolOpenAICodex": "OpenAI Codex",
"flowToolCline": "Cline",
"flowToolCursor": "Cursor",
"flowProviderOpenAI": "OpenAI",
"flowProviderAnthropic": "Anthropic",
"flowProviderGemini": "Gemini",
"flowProviderGithubCopilot": "GitHub Copilot",
"interactiveDiagram": "Interactive diagram visible on desktop",
"ctaTitle": "Ready to Simplify Your AI Infrastructure?",
"ctaDescription": "Join developers who are streamlining their AI integrations with OmniRoute. Open source and free to start.",
"startFree": "Start Free",
"readDocumentation": "Read Documentation"
},
"docs": {
"title": "Documentation",

View File

@@ -321,6 +321,9 @@
"concurrencyPerModel": "Concorrência / Modelo",
"queueTimeout": "Timeout da Fila (ms)",
"advancedHint": "Deixe vazio para usar padrões globais. Estes substituem configurações por provedor.",
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo",
"removeModel": "Remover",
"saving": "Salvando...",
"weighted": "Ponderado",
"leastUsed": "Menos Usado",
@@ -524,11 +527,18 @@
"newAccount": "Nova Conta",
"deleteConfirm": "Tem certeza que deseja excluir este provedor?",
"testing": "Testando...",
"testConnection": "Testar conexão",
"testSuccess": "Conexão bem-sucedida",
"testFailed": "Falha na conexão",
"available": "Disponível",
"cooldown": "Cooldown",
"unavailable": "Indisponível",
"unknown": "Desconhecido",
"oauthLabel": "OAuth",
"compatibleLabel": "Compatível",
"chat": "Chat",
"responses": "Responses",
"messages": "Mensagens",
"oauthProviders": "Provedores OAuth",
"freeProviders": "Provedores Gratuitos",
"apiKeyProviders": "Provedores por Chave de API",
@@ -553,14 +563,17 @@
"addNewProvider": "Adicionar Novo Provedor",
"backToProviders": "Voltar para Provedores",
"configureNewProvider": "Configure um novo provedor de IA para usar com suas aplicações.",
"providerLabel": "Provedor",
"selectProvider": "Selecione um provedor",
"selectedProvider": "Provedor selecionado",
"authMethod": "Método de Autenticação",
"apiKeyLabel": "Chave de API",
"apiKeyRequired": "Chave de API é obrigatória",
"selectProviderRequired": "Selecione um provedor",
"enterApiKey": "Digite sua chave de API",
"apiKeySecure": "Sua chave de API será criptografada e armazenada com segurança.",
"oauth2Connect": "Conectar com OAuth2",
"oauth2Label": "OAuth2",
"oauth2Desc": "Conecte sua conta usando autenticação OAuth2.",
"displayName": "Nome de Exibição",
"displayNamePlaceholder": "ex.: API de Produção, Ambiente Dev",
@@ -580,7 +593,16 @@
"loadingAvailability": "Carregando disponibilidade dos modelos...",
"clearCooldown": "Limpar",
"clearing": "Limpando...",
"until": "Até {time}",
"providerTestFailed": "Teste de provedor falhou",
"modeTest": "Teste {mode}",
"passedCount": "{count} passaram",
"failedCount": "{count} falharam",
"testedCount": "{count} testados",
"millisecondsAbbr": "{value}ms",
"okShort": "OK",
"errorShort": "ERRO",
"noActiveConnectionsInGroup": "Nenhuma conexão ativa encontrada para este grupo.",
"allTestsPassed": "Todos os {total} testes passaram",
"testSummary": "{passed}/{total} passaram, {failed} falharam",
"nameLabel": "Nome",
@@ -590,6 +612,10 @@
"prefixHint": "Obrigatório. Prefixo único para nomes de modelos.",
"nameHint": "Obrigatório. Um rótulo amigável para este nó.",
"baseUrlHint": "Obrigatório. URL base da API do provedor.",
"anthropicPrefixPlaceholder": "ac-prod",
"openaiPrefixPlaceholder": "oc-prod",
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
"validateConnection": "Validar Conexão",
"validating": "Validando...",
"connectionValid": "Conexão válida!",
@@ -723,6 +749,7 @@
"check": "Verificar",
"valid": "Válido",
"invalid": "Inválido",
"creating": "Criando...",
"validationChecksAnthropicCompatible": "A validação verifica {provider} conferindo a chave de API.",
"validationChecksOpenAiCompatible": "A validação verifica {provider} via /models na sua URL base.",
"priorityLabel": "Prioridade",
@@ -808,7 +835,12 @@
"ai": "IA",
"advanced": "Avançado",
"localMode": "Modo Local — Todos os dados armazenados na sua máquina",
"settingsSectionsAria": "Seções de configurações",
"switchThemes": "Alternar entre temas claro e escuro",
"themeSelectionAria": "Seleção de tema",
"themeLight": "Claro",
"themeDark": "Escuro",
"themeSystem": "Sistema",
"hideHealthLogs": "Ocultar Logs de Health Check",
"hideHealthLogsDesc": "Quando ATIVADO, suprime mensagens [HealthCheck] no console do servidor",
"promptCache": "Cache de Prompt",
@@ -821,6 +853,7 @@
"globalProxy": "Proxy Global",
"globalProxyDesc": "Configure um proxy de saída global para todas as chamadas de API. Provedores individuais, combos e chaves podem sobrescrever.",
"noGlobalProxy": "Nenhum proxy global configurado",
"globalLabel": "Global",
"configure": "Configurar",
"globalSystemPrompt": "Prompt de Sistema Global",
"systemPromptDesc": "Injetado em todas as requisições no nível do proxy",
@@ -838,6 +871,10 @@
"customDesc": "Define um orçamento fixo de tokens para todas as requisições",
"adaptive": "Adaptativo",
"adaptiveDesc": "Escala orçamento baseado na complexidade da requisição",
"effortNone": "Nenhum (0 tokens)",
"effortLow": "Baixo (1K tokens)",
"effortMedium": "Médio (10K tokens)",
"effortHigh": "Alto (128K tokens)",
"tokenBudget": "Orçamento de Tokens",
"tokens": "tokens",
"baseEffortLevel": "Nível de Esforço Base",
@@ -862,6 +899,8 @@
"blockedProviders": "Provedores Bloqueados",
"blockedProvidersDesc": "Ocultar provedores específicos da resposta /v1/models. Provedores bloqueados não aparecerão nas listagens de modelos.",
"providersBlocked": "{count} provedor(es) bloqueado(s) do /models",
"blockProviderTitle": "Bloquear {provider}",
"unblockProviderTitle": "Desbloquear {provider}",
"routingStrategy": "Estratégia de Roteamento",
"fillFirst": "Preencher Primeiro",
"fillFirstDesc": "Usar contas em ordem de prioridade",
@@ -879,10 +918,13 @@
"stickyLimitDesc": "Chamadas por conta antes de trocar",
"modelAliases": "Aliases de Modelo",
"modelAliasesDesc": "Padrões coringa para remapear nomes de modelos • Use * e ?",
"aliasPatternPlaceholder": "claude-sonnet-*",
"aliasTargetPlaceholder": "claude-sonnet-4-20250514",
"pattern": "Padrão",
"targetModel": "Modelo Alvo",
"add": "+ Adicionar",
"session": "Sessão",
"sessionDetailsAria": "Detalhes da sessão",
"status": "Status",
"authenticated": "Autenticado",
"guest": "Visitante",
@@ -892,9 +934,16 @@
"clearLocalData": "Limpar Dados Locais",
"logout": "Sair",
"clearLocalDataConfirm": "Limpar todos os dados locais? Isso redefinirá suas preferências.",
"unknown": "Desconhecido",
"systemActor": "sistema",
"ipAccessControl": "Controle de Acesso por IP",
"ipAccessControlDesc": "Bloquear ou permitir endereços IP específicos",
"ipModeDisabled": "Desativado",
"ipModeBlacklist": "Lista de Bloqueio",
"ipModeWhitelist": "Lista de Permissão",
"ipModeWhitelistPriority": "Permissão Prioritária",
"addIpAddress": "Adicionar Endereço IP",
"ipAddressPlaceholder": "192.168.1.0/24 ou 10.0.*.*",
"block": "+ Bloquear",
"allow": "+ Permitir",
"blocked": "Bloqueados ({count})",
@@ -913,7 +962,9 @@
"fallbackChainsDesc": "Definir ordem de fallback de provedores por modelo",
"addChain": "+ Adicionar Cadeia",
"modelName": "Nome do Modelo",
"modelNamePlaceholder": "claude-sonnet-4-20250514",
"providersCommaSeparated": "Provedores (separados por vírgula, em ordem de prioridade)",
"providersCommaSeparatedPlaceholder": "anthropic, openai, gemini",
"createChain": "Criar Cadeia",
"noFallbackChains": "Sem Cadeias de Fallback",
"noFallbackChainsDesc": "Crie uma cadeia para definir a ordem de fallback de provedores para um modelo.",
@@ -923,20 +974,30 @@
"chainDeleted": "Cadeia excluída para {model}",
"failedCreateChain": "Falha ao criar cadeia",
"failedDeleteChain": "Falha ao excluir cadeia",
"deleteChain": "Excluir cadeia",
"fillModelAndProviders": "Preencha o nome do modelo e os provedores",
"addAtLeastOneProvider": "Adicione pelo menos um provedor",
"comboDefaultsTitle": "Padrões de Combo",
"globalComboConfig": "Configuração global de combos",
"defaultStrategy": "Estratégia Padrão",
"defaultStrategyDesc": "Aplicada a novos combos sem estratégia explícita",
"comboStrategyAria": "Estratégia de combo",
"priority": "Prioridade",
"weighted": "Ponderado",
"maxRetriesLabel": "Máx. Tentativas",
"retryDelayLabel": "Atraso entre Tentativas (ms)",
"timeoutLabel": "Timeout (ms)",
"healthCheck": "Verificação de Saúde",
"healthCheckDesc": "Verificar disponibilidade do provedor antes",
"trackMetrics": "Rastrear Métricas",
"trackMetricsDesc": "Registrar métricas de requisição por combo",
"providerOverrides": "Sobrescritas por Provedor",
"providerOverridesDesc": "Substituir timeout e tentativas por provedor. Configurações do provedor substituem os padrões globais.",
"providerMaxRetriesAria": "{provider} tentativas máximas",
"providerTimeoutAria": "timeout de {provider} em ms",
"removeProviderOverrideAria": "Remover sobrescrita de {provider}",
"newProviderNamePlaceholder": "ex.: google, openai...",
"newProviderNameAria": "Nome do novo provedor",
"retries": "tentativas",
"ms": "ms",
"saveComboDefaults": "Salvar Padrões de Combo",
@@ -964,6 +1025,9 @@
"running": "Em Execução",
"queued": "Na Fila",
"circuitBreakers": "Disjuntores",
"breakerStateClosed": "Fechado",
"breakerStateOpen": "Aberto",
"breakerStateHalfOpen": "Semiaberto",
"tripped": "{count} aberto(s)",
"healthy": "{count} saudável(is)",
"resetAll": "Resetar Todos",
@@ -973,9 +1037,15 @@
"allOperational": "Todos os sistemas operacionais — sem bloqueios ou disjuntores ativados",
"loadingPolicies": "Carregando políticas...",
"lockedIdentifiers": "Identificadores Bloqueados",
"unlockedIdentifier": "Desbloqueado: {identifier}",
"sinceDate": "desde {date}",
"forceUnlock": "Forçar Desbloqueio",
"unlocking": "Desbloqueando...",
"failedUnlock": "Falha ao desbloquear",
"failedLoadWithStatus": "Falha ao carregar: {status}",
"failedLoadResilience": "Falha ao carregar status de resiliência",
"saveFailed": "Falha ao salvar",
"resetFailed": "Falha ao resetar",
"loadingResilience": "Carregando status de resiliência...",
"retry": "Tentar Novamente",
"systemStorage": "Sistema e Armazenamento",
@@ -1003,6 +1073,19 @@
"no": "Não",
"restore": "Restaurar",
"invalidFileType": "Tipo de arquivo inválido. Apenas arquivos .sqlite são aceitos.",
"exportFailed": "Falha na exportação",
"exportFailedWithError": "Falha na exportação: {error}",
"fullExportFailedWithError": "Falha na exportação completa: {error}",
"backupCreated": "Backup criado: {file}",
"restoreSuccess": "Restaurado! {connections} conexões, {nodes} nós, {combos} combos, {apiKeys} chaves de API.",
"importSuccess": "Banco importado! {connections} conexões, {nodes} nós, {combos} combos, {apiKeys} chaves de API.",
"justNow": "agora mesmo",
"minutesAgo": "{count}m atrás",
"hoursAgo": "{count}h atrás",
"daysAgo": "{count}d atrás",
"backupReasonManual": "manual",
"backupReasonPreRestore": "pré-restauração",
"connectionsCount": "{count, plural, one {# conexão} other {# conexões}}",
"noChangesSinceBackup": "Sem alterações desde o último backup",
"backupFailed": "Falha no backup",
"restoreFailed": "Falha na restauração",
@@ -1025,6 +1108,7 @@
"saving": "Salvando...",
"model": "Modelo",
"models": "modelos",
"moreProviders": "{count} provedores adicionais",
"withPricing": "com preço configurado",
"policiesCircuitBreakers": "Políticas e Disjuntores",
"activeIssuesDetected": "Problemas ativos detectados",
@@ -1080,6 +1164,8 @@
"successful": "Sucesso",
"errors": "Erros",
"avgLatency": "Latência Média",
"millisecondsShort": "{value}ms",
"notAvailableSymbol": "—",
"liveAutoRefreshing": "Ao vivo — atualizando automaticamente",
"paused": "Pausado",
"eventsAppearHint": "Os eventos de tradução aparecem aqui conforme as requisições passam pelo OmniRoute. Use qualquer um destes métodos para gerar eventos:",
@@ -1179,7 +1265,7 @@
"monthlyLimitPlaceholder": "ex.: 50.00",
"warningThresholdPlaceholder": "80",
"saveLimits": "Salvar limites",
"budgetOk": "Orçamento OK — ${remaining} restantes",
"budgetOk": "Orçamento OK — {remaining} restantes",
"budgetExceeded": "Orçamento excedido — requisições podem ser bloqueadas",
"totalRequests": "Total de requisições",
"noDataYet": "Sem dados ainda",
@@ -1279,6 +1365,11 @@
"lastUsed": "Último uso",
"actions": "Ações",
"refreshQuota": "Atualizar cota",
"today": "Hoje",
"tomorrow": "Amanhã",
"dayTimeFormat": "{day}, {time}",
"inDuration": "em {duration}",
"notApplicable": "N/D",
"rawPlanWithValue": "Plano bruto: {plan}",
"noPlanFromProvider": "Sem plano do provedor",
"noQuotaData": "Sem dados de cota",
@@ -1390,19 +1481,86 @@
"forgotPassword": "Esqueceu a senha?"
},
"landing": {
"brandName": "OmniRoute",
"navigateHome": "Navegar para a página inicial",
"toggleMenu": "Alternar menu",
"featuresLink": "Recursos",
"docsLink": "Docs",
"github": "GitHub",
"versionLive": "v1.0 já está no ar",
"oneEndpoint": "Um Endpoint para",
"allProviders": "Todos os Provedores de IA",
"oneEndpoint": "Um Endpoint",
"powerfulFeatures": "Recursos Poderosos",
"howItWorks": "Como o OmniRoute Funciona",
"installOmniRoute": "Instalar o OmniRoute",
"openDashboard": "Abrir Painel",
"routeRequests": "Rotear Requisições",
"dataLocation": "Local dos Dados:",
"heroDescription": "Proxy de endpoint de IA com painel web - uma versão em JavaScript do CLIProxyAPI. Funciona perfeitamente com Claude Code, OpenAI Codex, Cline, RooCode e outras ferramentas CLI.",
"getStarted": "Começar",
"viewOnGithub": "Ver no GitHub",
"powerfulFeatures": "Recursos Poderosos",
"featuresSubtitle": "Tudo que você precisa para gerenciar sua infraestrutura de IA em um só lugar, preparado para escala.",
"featureUnifiedEndpointTitle": "Endpoint Unificado",
"featureUnifiedEndpointDesc": "Acesse todos os provedores por uma única URL de API padrão.",
"featureEasySetupTitle": "Configuração Fácil",
"featureEasySetupDesc": "Fique pronto em minutos com o comando npx.",
"featureModelFallbackTitle": "Fallback de Modelo",
"featureModelFallbackDesc": "Alterne automaticamente entre provedores em caso de falha ou alta latência.",
"featureUsageTrackingTitle": "Rastreamento de Uso",
"featureUsageTrackingDesc": "Análises detalhadas e monitoramento de custos em todos os modelos.",
"featureOAuthApiKeysTitle": "OAuth e Chaves de API",
"featureOAuthApiKeysDesc": "Gerencie credenciais com segurança em um único cofre.",
"featureCloudSyncTitle": "Sincronização em Nuvem",
"featureCloudSyncDesc": "Sincronize suas configurações entre dispositivos instantaneamente.",
"featureCliSupportTitle": "Suporte a CLI",
"featureCliSupportDesc": "Funciona com Claude Code, Codex, Cline, Cursor e mais.",
"featureDashboardTitle": "Painel",
"featureDashboardDesc": "Painel visual para análise de tráfego em tempo real.",
"howItWorks": "Como o OmniRoute Funciona",
"howItWorksDescription": "Os dados fluem de forma contínua da sua aplicação pela nossa camada de roteamento inteligente até o melhor provedor para cada tarefa.",
"howItWorksStep1Title": "1. CLI e SDKs",
"howItWorksStep1Description": "Suas requisições começam nas suas ferramentas favoritas ou no nosso SDK unificado. Basta trocar a URL base.",
"howItWorksStep2Title": "2. Hub OmniRoute",
"howItWorksStep2Description": "Nosso mecanismo analisa o prompt, verifica a saúde dos provedores e roteia para menor latência ou custo.",
"howItWorksStep3Title": "3. Provedores de IA",
"howItWorksStep3Description": "A requisição é atendida por OpenAI, Anthropic, Gemini ou outros provedores instantaneamente.",
"getStartedIn30Seconds": "Comece em 30 segundos",
"getStartedDescription": "Instale o OmniRoute, configure seus provedores pelo painel web e comece a rotear requisições de IA.",
"installOmniRoute": "Instalar o OmniRoute",
"installStepDescription": "Execute o comando npx para iniciar o servidor instantaneamente",
"openDashboard": "Abrir Painel",
"openDashboardStepDescription": "Configure provedores e chaves de API pela interface web",
"routeRequests": "Rotear Requisições",
"routeRequestsStepDescription": "Aponte suas ferramentas CLI para {endpoint}",
"terminal": "terminal",
"copy": "Copiar",
"copied": "✓ Copiado",
"startingOmniRoute": "Iniciando OmniRoute...",
"serverRunningOnLabel": "Servidor em execução em",
"dashboardLabel": "Painel",
"readyToRoute": "Pronto para rotear! ✓",
"configureProvidersNote": "📝 Configure provedores no painel ou use variáveis de ambiente",
"dataLocation": "Local dos Dados:",
"dataLocationMacLinux": " macOS/Linux:",
"dataLocationWindows": " Windows:",
"product": "Produto",
"dashboardLink": "Painel",
"changelog": "Changelog",
"resources": "Recursos",
"documentation": "Documentação",
"npm": "NPM",
"legal": "Legal",
"interactiveDiagram": "Diagrama interativo visível no desktop"
"mitLicense": "Licença MIT",
"footerTagline": "O endpoint unificado para geração de IA. Conecte, roteie e gerencie seus provedores de IA com facilidade.",
"copyright": "© {year} OmniRoute. Todos os direitos reservados.",
"flowToolClaudeCode": "Claude Code",
"flowToolOpenAICodex": "OpenAI Codex",
"flowToolCline": "Cline",
"flowToolCursor": "Cursor",
"flowProviderOpenAI": "OpenAI",
"flowProviderAnthropic": "Anthropic",
"flowProviderGemini": "Gemini",
"flowProviderGithubCopilot": "GitHub Copilot",
"interactiveDiagram": "Diagrama interativo visível no desktop",
"ctaTitle": "Pronto para simplificar sua infraestrutura de IA?",
"ctaDescription": "Junte-se a desenvolvedores que estão simplificando suas integrações de IA com o OmniRoute. Open source e grátis para começar.",
"startFree": "Começar grátis",
"readDocumentation": "Ler documentação"
},
"docs": {
"title": "Documentação",