-
Status
-
Job ID
-
Finished
+
{t("status")}
+
{t("jobId")}
+
{t("jobDetail.finished")}
{[...children]
.sort((a, b) => {
@@ -258,7 +260,7 @@ export default function JobDetail({ params }: { params: Promise<{ id: string }>
{/* Events */}
- Events ({events.length})
+ {t("jobDetail.eventsTitle")} {t("jobDetail.eventsCount", { count: events.length })}
diff --git a/apps/web/app/jobs/page.tsx b/apps/web/app/[locale]/jobs/page.tsx
similarity index 76%
rename from apps/web/app/jobs/page.tsx
rename to apps/web/app/[locale]/jobs/page.tsx
index 08f6655..7eff46a 100644
--- a/apps/web/app/jobs/page.tsx
+++ b/apps/web/app/[locale]/jobs/page.tsx
@@ -6,6 +6,8 @@ import { listJobs, Job } from "@/lib/api";
import { StatusBadge } from "@/components/StatusBadge";
import { SkeletonTableRow } from "@/components/Skeleton";
import { useToast } from "@/components/Toast";
+import { useTranslations } from "next-intl";
+
const STATUS_OPTIONS = ["", "queued", "running", "succeeded", "failed", "cancelled"];
function formatDate(iso?: string | null) {
@@ -45,6 +47,7 @@ function InlineProgress({
}
export default function JobsPage() {
+ const t = useTranslations("jobs");
const toast = useToast();
const [jobs, setJobs] = useState
([]);
const [loading, setLoading] = useState(true);
@@ -91,11 +94,11 @@ export default function JobsPage() {
return (
<>
-
Jobs
+ {t("title")}
{activeCount > 0 && (
- {activeCount} active
+ {t("activeJobs", { count: activeCount })}
)}
@@ -106,7 +109,7 @@ export default function JobsPage() {
className="rounded-md border bg-white px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{STATUS_OPTIONS.map((s) => (
-
+
))}
@@ -117,7 +120,7 @@ export default function JobsPage() {
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
- Auto-refresh
+ {t("autoRefresh")}
{autoRefresh && }
@@ -125,7 +128,7 @@ export default function JobsPage() {
onClick={() => refresh(statusFilter)}
className="rounded-md border bg-white px-3 py-1.5 text-sm hover:bg-gray-50"
>
- Refresh
+ {t("refresh")}
@@ -136,12 +139,37 @@ export default function JobsPage() {
)}
-
+ {/* Mobile card list */}
+
+ {loading && Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+ {!loading && jobs.length === 0 && (
+
{t("noJobsFound")}
+ )}
+ {!loading && jobs.map((j) => (
+
+
+
+ {formatDate(j.created_at)}
+
+
{j.operation}
+
+
{j.id}
+
+ ))}
+
+
+ {/* Desktop table */}
+
-
Status
-
Operation
-
Job ID
-
Created
+
{t("status")}
+
{t("operation")}
+
{t("jobId")}
+
{t("created")}
{loading && Array.from({ length: 5 }).map((_, i) => (
@@ -166,7 +194,7 @@ export default function JobsPage() {
{!loading && jobs.length === 0 && (
- No jobs found.
+ {t("noJobsFound")}
)}
diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx
new file mode 100644
index 0000000..0b99bd9
--- /dev/null
+++ b/apps/web/app/[locale]/layout.tsx
@@ -0,0 +1,54 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import "../globals.css";
+import { ResetDbButton } from "@/components/ResetDbButton";
+import { NavLinks } from "@/components/NavLinks";
+import { SupportButton } from "@/components/SupportButton";
+import { ToastProvider } from "@/components/Toast";
+import { UsagePolicyModal } from "@/components/UsagePolicyModal";
+import { NextIntlClientProvider } from "next-intl";
+import { getMessages } from "next-intl/server";
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+
+const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
+const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
+
+export const metadata: Metadata = {
+ title: "PROTEA",
+ description: "Protein data platform — job queue and pipeline management",
+};
+
+export default async function LocaleLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ const messages = await getMessages();
+ return (
+
+
+
+
+
+
+ PROTEA
+ |
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/maintenance/page.tsx b/apps/web/app/[locale]/maintenance/page.tsx
similarity index 72%
rename from apps/web/app/maintenance/page.tsx
rename to apps/web/app/[locale]/maintenance/page.tsx
index b7547f5..d573395 100644
--- a/apps/web/app/maintenance/page.tsx
+++ b/apps/web/app/[locale]/maintenance/page.tsx
@@ -10,6 +10,7 @@ import {
type VacuumEmbeddingsPreview,
} from "@/lib/api";
import { useToast } from "@/components/Toast";
+import { useTranslations } from "next-intl";
function StatRow({ label, value, highlight }: { label: string; value: number | null; highlight?: boolean }) {
return (
@@ -33,6 +34,11 @@ function VacuumCard({
onVacuum,
loading,
vacuuming,
+ labelClean,
+ labelToClean,
+ labelRefresh,
+ labelVacuum,
+ labelCleaning,
}: {
title: string;
description: string;
@@ -44,6 +50,11 @@ function VacuumCard({
onVacuum: () => void;
loading: boolean;
vacuuming: boolean;
+ labelClean: string;
+ labelToClean: string;
+ labelRefresh: string;
+ labelVacuum: string;
+ labelCleaning: string;
}) {
const hasOrphans = orphanValue !== null && orphanValue > 0;
const pct = totalValue ? Math.round(((orphanValue ?? 0) / totalValue) * 100) : 0;
@@ -61,7 +72,7 @@ function VacuumCard({
hasOrphans ? "bg-amber-100 text-amber-700" : "bg-green-100 text-green-700"
}`}
>
- {hasOrphans ? `${orphanValue.toLocaleString()} to clean` : "Clean"}
+ {hasOrphans ? labelToClean : labelClean}
)}
@@ -89,14 +100,14 @@ function VacuumCard({
disabled={loading}
className="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
- {loading ? "Loading…" : "Refresh"}
+ {loading ? "Loading…" : labelRefresh}