diff --git a/app/(auth)/sign-in/collaborator/page.tsx b/app/(auth)/sign-in/collaborator/page.tsx
index 753467032..fbb722f52 100644
--- a/app/(auth)/sign-in/collaborator/page.tsx
+++ b/app/(auth)/sign-in/collaborator/page.tsx
@@ -1,19 +1,21 @@
import { InviteSignIn } from "@/components/invite-sign-in";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
+import { getTranslations } from "next-intl/server";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
+ const t = await getTranslations("InviteSignIn");
const { token } = await searchParams;
if (!token?.trim()) {
return (
- Invite unavailable
- This invitation link is invalid.
+ {t("unavailableTitle")}
+ {t("unavailableLinkDesc")}
);
diff --git a/app/(main)/loading.tsx b/app/(main)/loading.tsx
index f392b2e33..7512fd3b0 100644
--- a/app/(main)/loading.tsx
+++ b/app/(main)/loading.tsx
@@ -1,5 +1,12 @@
import { Loader } from "@/components/loader";
+import { getTranslations } from "next-intl/server";
-export default function Loading() {
- return Loading;
+export default async function Loading() {
+ const t = await getTranslations("Loading");
+
+ return (
+
+ {t("text")}
+
+ );
}
diff --git a/app/(main)/main-root-layout.tsx b/app/(main)/main-root-layout.tsx
index 537dbb76e..802a172fc 100644
--- a/app/(main)/main-root-layout.tsx
+++ b/app/(main)/main-root-layout.tsx
@@ -1,6 +1,7 @@
import { User } from "@/components/user";
import { AdminButton } from "@/components/admin-button";
import { About } from "@/components/about";
+import { LanguageSwitcher } from "@/components/language-switcher";
export function MainRootLayout({
children,
@@ -13,6 +14,7 @@ export function MainRootLayout({
diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx
index 71a8da0d7..d3cfcfc41 100644
--- a/app/(main)/page.tsx
+++ b/app/(main)/page.tsx
@@ -7,6 +7,7 @@ import { RepoSelect } from "@/components/repo/repo-select";
import { RepoTemplates } from "@/components/repo/repo-templates";
import { RepoLatest } from "@/components/repo/repo-latest";
import { DocumentTitle } from "@/components/document-title";
+import { useTranslations } from "next-intl";
import { hasGithubIdentity } from "@/lib/authz-shared";
import {
Empty,
@@ -22,6 +23,7 @@ export default function Page() {
const [defaultAccount, setDefaultAccount] = useState
(null);
const [hasRecentVisits, setHasRecentVisits] = useState(false);
const { user } = useUser();
+ const t = useTranslations("HomePage");
const isGithubUser = hasGithubIdentity(user);
useEffect(() => {
@@ -33,21 +35,21 @@ export default function Page() {
return (
-
+
{user.accounts.length > 0 ? (
{hasRecentVisits && (
- Recently visited
+ {t("recent")}
)}
- Open a project
+ {t("open")}
setDefaultAccount(account)}
@@ -56,7 +58,7 @@ export default function Page() {
{isGithubUser && (
- Create from a template
+ {t("template")}
@@ -65,10 +67,9 @@ export default function Page() {
) : isGithubUser ? (
- Install the GitHub App
+ {t("installTitle")}
- Install the GitHub App on at least one account before you can
- open or create projects.
+ {t("installDesc")}
@@ -85,18 +86,16 @@ export default function Page() {
GitHub
- Install GitHub App
+ {t("installCta")}
) : (
- No repositories yet
+ {t("noneTitle")}
- You need an invitation to a repository before you can
- collaborate. Ask a repository owner or organization admin to
- invite you.
+ {t("noneDesc")}
diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx
index 217047cd3..d62ddb9b5 100644
--- a/app/(main)/settings/page.tsx
+++ b/app/(main)/settings/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { headers } from "next/headers";
+import { getTranslations } from "next-intl/server";
import { and, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/db";
@@ -21,6 +22,7 @@ import { ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";
export default async function Page() {
+ const t = await getTranslations("SettingsPage");
const session = await auth.api.getSession({
headers: await headers(),
});
@@ -39,7 +41,7 @@ export default async function Page() {
return (
-
+
- Go home
+ {t("back")}
- Settings
+ {t("title")}
@@ -65,9 +67,11 @@ export default async function Page() {
- Authentication
+
+ {t("authTitle")}
+
- Your sign-in methods and linked identity providers.
+ {t("authDesc")}
@@ -84,10 +88,10 @@ export default async function Page() {
- Installations
+ {t("instTitle")}
- Manage the accounts the Github application is installed on.
+ {t("instDesc")}
diff --git a/app/error.tsx b/app/error.tsx
index 15a01fd71..cb8574136 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { GithubAuthExpired } from "@/components/github-auth-expired";
import { isGithubAuthError } from "@/lib/github-auth";
+import { useTranslations } from "next-intl";
import {
Empty,
EmptyContent,
@@ -20,6 +21,8 @@ export default function Error({
error: Error & { digest?: string };
reset: () => void;
}) {
+ const t = useTranslations("ErrorPage");
+
useEffect(() => {
console.error(error);
}, [error]);
@@ -31,7 +34,7 @@ export default function Error({
return (
- Something went wrong
+ {t("title")}
{error.message}
@@ -39,13 +42,13 @@ export default function Error({
className={buttonVariants({ variant: "default" })}
href="/"
>
- Go home
+ {t("goHome")}
diff --git a/app/layout.tsx b/app/layout.tsx
index a8906bba1..a43a8a373 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,5 +1,7 @@
import { Toaster } from "@/components/ui/sonner"
import { Providers } from "@/components/providers";
+import { NextIntlClientProvider } from "next-intl";
+import { getLocale } from "next-intl/server";
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { getBaseUrl } from "@/lib/base-url";
@@ -57,8 +59,10 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
+ const locale = await getLocale();
+
return (
-
+
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 4cac55f1c..c71842617 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,17 +1,20 @@
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
+import { getTranslations } from "next-intl/server";
+
+export default async function NotFound() {
+ const t = await getTranslations("NotFoundPage");
-export default function NotFound() {
return (
- Page not found
- The page or resource you requested could not be found.
+ {t("title")}
+ {t("desc")}
- Go home
+ {t("goHome")}
diff --git a/components/about.tsx b/components/about.tsx
index 0bd32756d..927102ba6 100644
--- a/components/about.tsx
+++ b/components/about.tsx
@@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { ArrowUpRight } from "lucide-react";
+import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -31,6 +32,7 @@ const version =
const UPDATE_DOCS_URL = "https://pagescms.org/docs";
export function About() {
+ const t = useTranslations("AboutDialog");
const [open, setOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState(null);
@@ -84,11 +86,11 @@ export function About() {
- About Pages CMS
+ {t("title")}
- About Pages CMS
+ {t("title")}
@@ -106,15 +108,14 @@ export function About() {
Pages CMS
-
- Open source CMS for static sites. Edit directly on GitHub with a
- clean interface.
+
+ {t("desc")}
{version}
@@ -129,7 +130,7 @@ export function About() {
variant="secondary"
className="bg-primary/10 font-medium text-primary"
>
- Update to {latestVersion}
+ {t("updateTo")} {latestVersion}
@@ -138,7 +139,7 @@ export function About() {
}
/>
pagescms.org
@@ -146,7 +147,7 @@ export function About() {
}
/>
pagescms.org/docs
@@ -154,7 +155,7 @@ export function About() {
}
/>
pagescms/pagescms
diff --git a/components/admin-button.tsx b/components/admin-button.tsx
index 507e75122..6fe5177a5 100644
--- a/components/admin-button.tsx
+++ b/components/admin-button.tsx
@@ -2,17 +2,19 @@
import Link from "next/link";
import { Settings } from "lucide-react";
+import { useTranslations } from "next-intl";
import { useUser } from "@/contexts/user-context";
import { Button } from "@/components/ui/button";
export function AdminButton() {
const { user } = useUser();
+ const t = useTranslations("AdminButton");
if (!user?.isAdmin) return null;
return (
diff --git a/components/github-auth-expired.tsx b/components/github-auth-expired.tsx
index 86433d1c4..16607e592 100644
--- a/components/github-auth-expired.tsx
+++ b/components/github-auth-expired.tsx
@@ -6,8 +6,10 @@ import { getSafeRedirect } from "@/lib/auth-redirect";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader } from "lucide-react";
+import { useTranslations } from "next-intl";
const GithubAuthExpired = () => {
+ const t = useTranslations("GithubAuthExpired");
const [loading, setLoading] = useState(false);
const handleSignInAgain = async () => {
@@ -28,11 +30,11 @@ const GithubAuthExpired = () => {
return (
- GitHub session expired
- Your GitHub session has expired. You'll need to sign in again.
+ {t("title")}
+ {t("desc")}
diff --git a/components/invite-sign-in.tsx b/components/invite-sign-in.tsx
index 1c84e81eb..1a5b42eb7 100644
--- a/components/invite-sign-in.tsx
+++ b/components/invite-sign-in.tsx
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Loader } from "lucide-react";
import { toast } from "sonner";
+import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth-client";
import { Button, buttonVariants } from "@/components/ui/button";
import { OtpVerificationForm } from "@/components/otp-verification-form";
@@ -28,6 +29,7 @@ type InviteState =
};
export function InviteSignIn({ token }: { token: string }) {
+ const t = useTranslations("InviteSignIn");
const [state, setState] = useState({ status: "loading" });
const [otp, setOtp] = useState("");
const [pending, setPending] = useState(null);
@@ -81,7 +83,7 @@ export function InviteSignIn({ token }: { token: string }) {
toast.error(result.error.message);
}
} catch {
- toast.error("Unable to send sign-in code.");
+ toast.error(t("unableToSendCode"));
} finally {
setPending(null);
}
@@ -90,7 +92,7 @@ export function InviteSignIn({ token }: { token: string }) {
async function verifyOtp() {
if (state.status !== "otp_required") return;
if (otp.length !== 6) {
- toast.error("Enter the 6-digit code.");
+ toast.error(t("enterCode"));
return;
}
@@ -121,10 +123,10 @@ export function InviteSignIn({ token }: { token: string }) {
return;
}
- toast.error("Unable to claim this invitation.");
+ toast.error(t("unableToClaim"));
setPending(null);
} catch {
- toast.error("Unable to verify code.");
+ toast.error(t("unableToVerify"));
setPending(null);
}
}
@@ -143,12 +145,12 @@ export function InviteSignIn({ token }: { token: string }) {
return (
- Invite unavailable
- This invitation is no longer available.
+ {t("unavailableTitle")}
+ {t("unavailableDesc")}
- Sign in
+ {t("signIn")}
@@ -159,8 +161,8 @@ export function InviteSignIn({ token }: { token: string }) {
return (
- Wrong account
- This invitation was sent to another account.
+ {t("wrongAccountTitle")}
+ {t("wrongAccountDesc")}
- Go home
+ {t("goHome")}
diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx
new file mode 100644
index 000000000..796a26b0b
--- /dev/null
+++ b/components/language-switcher.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { Globe } from "lucide-react";
+import { useLocale, useTranslations } from "next-intl";
+import {
+ LANGUAGE_COOKIE_KEY,
+ isLanguage,
+ SUPPORTED_LANGUAGES,
+} from "@/i18n/constants";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+const languageLabelKey = {
+ en: "en",
+ de: "de",
+ es: "es",
+ fr: "fr",
+} as const;
+
+export function LanguageSwitcher() {
+ const router = useRouter();
+ const locale = useLocale();
+ const t = useTranslations("LanguageSwitcher");
+ const language = isLanguage(locale) ? locale : "en";
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/components/otp-verification-form.tsx b/components/otp-verification-form.tsx
index 1abdd30db..d7a783776 100644
--- a/components/otp-verification-form.tsx
+++ b/components/otp-verification-form.tsx
@@ -17,7 +17,11 @@ type OtpVerificationFormProps = {
pending: boolean;
resendDisabled?: boolean;
resendPending?: boolean;
+ title?: string;
+ description?: string;
submitLabel?: string;
+ resendLabel?: string;
+ signInAnotherWayLabel?: string;
onChange: (value: string) => void;
onResend: () => void;
onSignInAnotherWay?: () => void;
@@ -31,20 +35,26 @@ export function OtpVerificationForm({
pending,
resendDisabled,
resendPending,
+ title = "Verify your login",
+ description,
submitLabel = "Verify code",
+ resendLabel = "Resend code",
+ signInAnotherWayLabel = "Sign in another way",
onChange,
onResend,
onSignInAnotherWay,
onSubmit,
}: OtpVerificationFormProps) {
+ const resolvedDescription = description || `Enter the 6-digit code sent to ${emailLabel}.`;
+
return (
diff --git a/components/repo/repo-sidebar.tsx b/components/repo/repo-sidebar.tsx
index 734eae9af..7de874cac 100644
--- a/components/repo/repo-sidebar.tsx
+++ b/components/repo/repo-sidebar.tsx
@@ -10,6 +10,7 @@ import {
} from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
import { useConfig } from "@/contexts/config-context";
import { useRepo } from "@/contexts/repo-context";
import { useUser } from "@/contexts/user-context";
@@ -89,6 +90,7 @@ type NavigationNode = {
};
function RepoSwitcher() {
+ const t = useTranslations("RepoSidebar");
const router = useRouter();
const { owner, repo, branches = [] } = useRepo();
const { config } = useConfig();
@@ -195,13 +197,13 @@ function RepoSwitcher() {
target="_blank"
rel="noreferrer"
>
- View on GitHub
+ {t("viewGithub")}
- Branches
+ {t("branches")}
- Manage branches
+ {t("manageBranches")}
{recentRepos.length > 0 && (
<>
- Recently visited
+ {t("recent")}
{recentRepos.map((visit) => (
- All projects
+ {t("allProjects")}
- Manage branches
+ {t("manageBranches")}
@@ -260,6 +262,7 @@ function RepoSwitcher() {
}
export function RepoSidebar() {
+ const t = useTranslations("RepoSidebar");
const pathname = usePathname();
const { user } = useUser();
const { config } = useConfig();
@@ -314,9 +317,9 @@ export function RepoSidebar() {
return media.map((item: any) => ({
type: "media",
name: item.name || "default",
- label: item.label || item.name || "Media",
+ label: item.label || item.name || t("media"),
}));
- }, [config]);
+ }, [config, t]);
const adminItems = useMemo
(() => {
if (!config) return [];
@@ -329,7 +332,7 @@ export function RepoSidebar() {
if (canManageRepo && isCacheEnabled(configObject)) {
items.push({
key: "admin-cache",
- label: "Cache",
+ label: t("cache"),
href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/cache`,
icon: ,
});
@@ -338,14 +341,14 @@ export function RepoSidebar() {
if (canManageRepo) {
items.push({
key: "admin-actions",
- label: "Actions",
+ label: t("actions"),
href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/actions`,
icon: ,
});
items.push({
key: "admin-collaborators",
- label: "Collaborators",
+ label: t("collaborators"),
href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collaborators`,
icon: ,
});
@@ -354,14 +357,14 @@ export function RepoSidebar() {
if (canManageRepo && isConfigEnabled(configObject)) {
items.push({
key: "admin-configuration",
- label: "Configuration",
+ label: t("configuration"),
href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/configuration`,
icon: ,
});
}
return items;
- }, [config, user]);
+ }, [config, t, user]);
const rootActions = useMemo(
() => getRootActions(config?.object),
[config?.object],
@@ -526,26 +529,26 @@ export function RepoSidebar() {
);
}
- const renderNavigationGroup = (label: string, nodes: NavigationNode[]) => {
+ const renderNavigationGroup = (key: string, label: string, nodes: NavigationNode[]) => {
if (nodes.length === 0) return null;
return (
-
+
{label}
- {nodes.map((node) => renderNavigationNode(node, `${label}-${node.name}`))}
+ {nodes.map((node) => renderNavigationNode(node, `${key}-${node.name}`))}
);
};
- const renderFlatGroup = (label: string, items: NavItem[]) => {
+ const renderFlatGroup = (key: string, label: string, items: NavItem[]) => {
if (items.length === 0) return null;
return (
-
+
{label}
@@ -570,12 +573,12 @@ export function RepoSidebar() {
};
const groups = [
- renderNavigationGroup("Content", contentNavigation),
- renderNavigationGroup("Media", mediaNavigation),
+ renderNavigationGroup("content", t("content"), contentNavigation),
+ renderNavigationGroup("media", t("media"), mediaNavigation),
rootActions.length > 0 && config
? (
-
- Actions
+
+ {t("actions")}
)
: null,
- renderFlatGroup("Admin", adminItems),
+ renderFlatGroup("admin", t("admin"), adminItems),
].filter(Boolean);
return (
diff --git a/components/settings/identities.tsx b/components/settings/identities.tsx
index 3a8e88c95..488f5aeb8 100644
--- a/components/settings/identities.tsx
+++ b/components/settings/identities.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
import { signIn } from "@/lib/auth-client";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -35,6 +36,7 @@ export function Identities({
githubManageUrl,
}: IdentitiesProps) {
const router = useRouter();
+ const t = useTranslations("Identities");
const [pendingAction, setPendingAction] = useState<
"connect" | "disconnect" | null
>(null);
@@ -65,17 +67,17 @@ export function Identities({
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.status) {
const message =
- payload?.message || "Failed to disconnect GitHub account.";
+ payload?.message || t("disconnectFailed");
throw new Error(message);
}
- toast.success("GitHub account disconnected.");
+ toast.success(t("disconnected"));
router.refresh();
} catch (error) {
const message =
error instanceof Error
? error.message
- : "Failed to disconnect GitHub account.";
+ : t("disconnectFailed");
toast.error(message);
} finally {
setPendingAction(null);
@@ -87,7 +89,7 @@ export function Identities({
- Email
+ {t("email")}
{email}
@@ -99,11 +101,11 @@ export function Identities({
)}
>
- GitHub
+ {t("github")}
{githubConnected && (
- {githubUsername ? `@${githubUsername}` : "Connected"}
+ {githubUsername ? `@${githubUsername}` : t("connected")}
)}
{!githubConnected ? (
@@ -114,7 +116,7 @@ export function Identities({
onClick={handleConnectGithub}
disabled={pendingAction !== null}
>
- Connect
+ {t("connect")}
{pendingAction === "connect" && (
)}
@@ -133,7 +135,7 @@ export function Identities({
) : (
)}
- GitHub actions
+ {t("actions")}
@@ -141,7 +143,7 @@ export function Identities({
<>
- Manage on GitHub
+ {t("manage")}
@@ -153,7 +155,7 @@ export function Identities({
onClick={handleDisconnectGithub}
disabled={pendingAction !== null}
>
- Disconnect
+ {t("disconnect")}
diff --git a/components/settings/installations.tsx b/components/settings/installations.tsx
index c0d36624c..bb06bdf67 100644
--- a/components/settings/installations.tsx
+++ b/components/settings/installations.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslations } from "next-intl";
import { useUser } from "@/contexts/user-context";
import { Button } from "@/components/ui/button";
import { getGithubInstallationUrl } from "@/lib/github-app";
@@ -13,12 +14,13 @@ import { ArrowUpRight, Ban, EllipsisVertical } from "lucide-react";
const Installations = () => {
const { user } = useUser();
+ const t = useTranslations("Installations");
if (!user || !user.accounts) {
return (
- No account with the Github application installed.
+ {t("empty")}
);
}
@@ -42,7 +44,7 @@ const Installations = () => {
@@ -52,7 +54,7 @@ const Installations = () => {
target="_blank"
rel="noreferrer"
>
- Manage GitHub App
+ {t("manage")}
diff --git a/components/settings/profile.tsx b/components/settings/profile.tsx
index ec6279ce9..55cc0f7f5 100644
--- a/components/settings/profile.tsx
+++ b/components/settings/profile.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getInitialsFromName } from "@/lib/utils/avatar";
import {
@@ -30,6 +31,7 @@ type ProfileProps = {
export function Profile({ name, email, githubUsername }: ProfileProps) {
const router = useRouter();
+ const t = useTranslations("ProfileCard");
const [displayName, setDisplayName] = useState(name?.trim() || "");
const [isSaving, setIsSaving] = useState(false);
@@ -50,13 +52,16 @@ export function Profile({ name, email, githubUsername }: ProfileProps) {
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.status) {
- throw new Error(payload?.message || "Failed to update profile.");
+ throw new Error(payload?.message || t("updateFailed"));
}
- toast.success("Profile updated.");
+ toast.success(t("updated"));
router.refresh();
} catch (error) {
- const message = error instanceof Error ? error.message : "Failed to update profile.";
+ const message =
+ error instanceof Error
+ ? error.message
+ : t("updateFailed");
toast.error(message);
} finally {
setIsSaving(false);
@@ -66,8 +71,8 @@ export function Profile({ name, email, githubUsername }: ProfileProps) {
return (
- Profile
- Manage the information displayed to other users.
+ {t("title")}
+ {t("desc")}