add multi language for the website

This commit is contained in:
Puechberty Arthur
2026-04-20 21:32:49 +02:00
parent e0f4e1b8fe
commit 2206c82379
26 changed files with 1409 additions and 136 deletions
@@ -1,4 +1,4 @@
import { DashboardClient } from "../../components/dashboard-client";
import { DashboardClient } from "../../../components/dashboard-client";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
import { hasLocale, NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import LanguageSwitcher from "../../components/LanguageSwitcher";
import { routing, type AppLocale } from "../../i18n/routing";
import enMessages from "../../messages/en.json";
import frMessages from "../../messages/fr.json";
import "../globals.css";
const headingFont = Space_Grotesk({
subsets: ["latin"],
variable: "--font-heading",
weight: ["500", "700"],
});
const monoFont = IBM_Plex_Mono({
subsets: ["latin"],
variable: "--font-mono",
weight: ["400", "500"],
});
const metadataByLocale: Record<AppLocale, { title: string; description: string }> = {
en: enMessages.metadata,
fr: frMessages.metadata,
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale: candidateLocale } = await params;
const locale = hasLocale(routing.locales, candidateLocale)
? candidateLocale
: routing.defaultLocale;
const metadata = metadataByLocale[locale];
const languages = routing.locales.reduce<Record<string, string>>(
(accumulator, currentLocale) => {
accumulator[currentLocale] = `/${currentLocale}`;
return accumulator;
},
{},
);
return {
title: metadata.title,
description: metadata.description,
alternates: {
canonical: `/${locale}`,
languages,
},
};
}
export default async function LocaleLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ locale: string }>;
}>) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body className={`${headingFont.variable} ${monoFont.variable}`}>
<NextIntlClientProvider locale={locale} messages={messages}>
<div className="locale-switcher-shell">
<LanguageSwitcher />
</div>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { getT } from "../../../i18n/server";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
export default async function LoginPage() {
const t = await getT();
return (
<main className="page-shell">
<section className="panel panel-login reveal-up">
<p className="eyebrow">{t("login.eyebrow")}</p>
<h1>{t("login.title")}</h1>
<p>{t("login.description")}</p>
<a className="button-primary" href={`${apiBaseUrl}/auth/discord/login`}>
{t("login.cta")}
</a>
</section>
</main>
);
}
@@ -1,7 +1,7 @@
import Hero from "../components/Hero";
import Features from "../components/Features";
import HowItWorks from "../components/HowItWorks";
import Footer from "../components/Footer";
import Features from "../../components/Features";
import Footer from "../../components/Footer";
import Hero from "../../components/Hero";
import HowItWorks from "../../components/HowItWorks";
export default function HomePage() {
return (
+48
View File
@@ -37,6 +37,43 @@ body {
linear-gradient(140deg, var(--sand-50), #f8f7f4 55%, #fff3e3);
}
.locale-switcher-shell {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 80;
}
.locale-switcher {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(23, 20, 16, 0.74);
padding: 0.2rem 0.55rem;
backdrop-filter: blur(8px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.2);
}
.locale-switcher select {
border: 0;
background: transparent;
color: #fff;
font-size: 0.84rem;
font-weight: 700;
font-family: var(--font-heading), sans-serif;
cursor: pointer;
}
.locale-switcher select:focus {
outline: none;
}
.locale-switcher select:disabled {
opacity: 0.7;
cursor: progress;
}
.page-shell {
min-height: 100vh;
display: grid;
@@ -283,3 +320,14 @@ input:focus {
padding: 2rem;
}
}
@media (max-width: 640px) {
.locale-switcher-shell {
top: 0.7rem;
right: 0.7rem;
}
.locale-switcher select {
font-size: 0.78rem;
}
}
-33
View File
@@ -1,33 +0,0 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
import "./globals.css";
const headingFont = Space_Grotesk({
subsets: ["latin"],
variable: "--font-heading",
weight: ["500", "700"],
});
const monoFont = IBM_Plex_Mono({
subsets: ["latin"],
variable: "--font-mono",
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Discord Bot SaaS",
description: "Multi-tenant Discord bot management platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body className={`${headingFont.variable} ${monoFont.variable}`}>{children}</body>
</html>
);
}
-19
View File
@@ -1,19 +0,0 @@
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
export default function LoginPage() {
return (
<main className="page-shell">
<section className="panel panel-login reveal-up">
<p className="eyebrow">Discord Bot SaaS Platform</p>
<h1>Connecte ton compte Discord</h1>
<p>
Authentifie-toi via OAuth2 pour accéder à ton espace multi-tenant et piloter les bots de ton
organisation.
</p>
<a className="button-primary" href={`${apiBaseUrl}/auth/discord/login`}>
Continuer avec Discord
</a>
</section>
</main>
);
}
+42 -10
View File
@@ -1,11 +1,13 @@
import FeatureCard from "./FeatureCard";
import { getT } from "../i18n/server";
export default async function Features() {
const t = await getT();
export default function Features() {
const items = [
{
title: "Multi-bot",
description:
"Gérez plusieurs bots indépendants depuis un tableau de bord unique — configurations et logs centralisés.",
title: t("features.items.autoCommands.title"),
description: t("features.items.autoCommands.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-white">
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -14,8 +16,8 @@ export default function Features() {
),
},
{
title: "Gestion simple",
description: "Démarrez, arrêtez, et configurez en quelques clics — interface claire et rapide.",
title: t("features.items.componentsV2.title"),
description: t("features.items.componentsV2.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -24,8 +26,38 @@ export default function Features() {
),
},
{
title: "Scalable",
description: "Scale automatique des instances pour absorber la charge et garder la disponibilité.",
title: t("features.items.autoHelp.title"),
description: t("features.items.autoHelp.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
title: t("features.items.multiLanguage.title"),
description: t("features.items.multiLanguage.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 11h0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
title: t("features.items.dynamicVariables.title"),
description: t("features.items.dynamicVariables.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 8h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
title: t("features.items.hosting.title"),
description: t("features.items.hosting.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -37,8 +69,8 @@ export default function Features() {
return (
<section>
<h2 className="text-2xl font-bold text-white">Fonctionnalités principales</h2>
<p className="mt-2 text-gray-400 max-w-2xl">Toutes les fonctionnalités essentielles pour piloter vos bots en production.</p>
<h2 className="text-2xl font-bold text-white">{t("features.title")}</h2>
<p className="mt-2 text-gray-400 max-w-2xl">{t("features.subtitle")}</p>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
{items.map((it, idx) => (
+8 -2
View File
@@ -1,8 +1,14 @@
export default function Footer() {
import { getT } from "../i18n/server";
export default async function Footer() {
const t = await getT();
return (
<footer className="mt-20 border-t border-white/6 bg-gradient-to-t from-black/0 to-black/30">
<div className="container mx-auto px-6 py-8 flex items-center justify-between">
<div className="text-sm text-gray-400">© {new Date().getFullYear()} Shadow</div>
<div className="text-sm text-gray-400">
Copyright {new Date().getFullYear()} Shadow - {t("footer.tagline")}
</div>
<div className="text-sm text-gray-400">
<a href="https://github.com/" target="_blank" rel="noreferrer" className="hover:text-white transition">GitHub</a>
</div>
+23 -16
View File
@@ -1,11 +1,15 @@
import Link from "next/link";
import { Link } from "../i18n/navigation";
import { getT } from "../i18n/server";
import Logo from "./Logo";
export default function Hero() {
export default async function Hero() {
const t = await getT();
const bots = [
{ name: "Moderation", status: "online" },
{ name: "Welcome", status: "stopped" },
{ name: "Analytics", status: "online" },
{ name: t("hero.bots.moderation"), status: "online" as const },
{ name: t("hero.bots.welcome"), status: "stopped" as const },
{ name: t("hero.bots.analytics"), status: "online" as const },
];
return (
@@ -13,28 +17,29 @@ export default function Hero() {
<header className="flex items-center justify-between">
<Logo />
<nav className="flex items-center gap-4">
<Link href="/login" className="text-sm text-gray-400 hover:text-white transition">Se connecter</Link>
<Link href="/login" className="text-sm text-gray-400 hover:text-white transition">
{t("hero.navLogin")}
</Link>
</nav>
</header>
<div className="mt-12 lg:flex lg:items-center lg:justify-between">
<div className="lg:w-7/12">
<h1 className="text-4xl sm:text-5xl font-extrabold leading-tight tracking-tight text-white">
Shadow Gérez vos bots Discord simplement
{t("hero.title")}
</h1>
<p className="mt-4 text-lg text-gray-300 max-w-2xl">
Centralisez, déployez et contrôlez tous vos bots depuis un tableau de bord moderne. Démarrez,
stoppez et scalez en temps réel, en toute simplicité.
{t("hero.description")}
</p>
<div className="mt-8 flex items-center gap-4">
<Link href="/dashboard" className="inline-flex items-center gap-3 px-5 py-3 bg-white text-black rounded-md shadow hover:scale-[1.02] transition transform">
Accéder au dashboard
<Link href="/login" className="inline-flex items-center gap-3 px-5 py-3 bg-white text-black rounded-md shadow hover:scale-[1.02] transition transform">
{t("hero.ctaStart")}
</Link>
<Link href="/login" className="inline-flex items-center gap-2 px-4 py-3 border border-gray-700 text-gray-200 rounded-md hover:bg-gray-800 transition">
Se connecter
{t("hero.ctaLogin")}
</Link>
</div>
</div>
@@ -46,20 +51,22 @@ export default function Hero() {
<div key={i} className="flex items-center justify-between bg-gray-900/40 p-3 rounded-lg">
<div>
<div className="text-sm font-medium text-white">{b.name}</div>
<div className="text-xs text-gray-400">Instances: 1 · CPU 35%</div>
<div className="text-xs text-gray-400">{t("hero.card.instanceMetrics")}</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-1 text-xs rounded-full ${b.status === "online" ? "bg-green-500/20 text-green-300" : "bg-gray-700 text-gray-300"}`}>
{b.status === "online" ? "Online" : "Stopped"}
{b.status === "online" ? t("hero.status.online") : t("hero.status.stopped")}
</span>
<button className="px-3 py-1 bg-white/10 text-sm rounded-md hover:bg-white/20 transition">Manage</button>
<button className="px-3 py-1 bg-white/10 text-sm rounded-md hover:bg-white/20 transition">
{t("hero.card.manage")}
</button>
</div>
</div>
))}
</div>
<div className="mt-4 text-xs text-gray-400">Aperçu en temps réel du statut de vos bots</div>
<div className="mt-4 text-xs text-gray-400">{t("hero.card.preview")}</div>
</div>
</div>
</div>
+13 -9
View File
@@ -1,23 +1,27 @@
export default function HowItWorks() {
import { getT } from "../i18n/server";
export default async function HowItWorks() {
const t = await getT();
const steps = [
{
title: "Ajouter un bot",
desc: "Connectez votre application Discord via OAuth et enregistrez-la dans Shadow.",
title: t("howItWorks.steps.addBot.title"),
desc: t("howItWorks.steps.addBot.description"),
},
{
title: "Gérer depuis le dashboard",
desc: "Accédez aux commandes, aux logs et aux paramètres depuis une interface centralisée.",
title: t("howItWorks.steps.dashboard.title"),
desc: t("howItWorks.steps.dashboard.description"),
},
{
title: "Contrôler en temps réel",
desc: "Démarrez, arrêtez et scalez vos bots instantanément selon la demande.",
title: t("howItWorks.steps.realtime.title"),
desc: t("howItWorks.steps.realtime.description"),
},
];
return (
<section>
<h2 className="text-2xl font-bold text-white">Comment ça marche</h2>
<p className="mt-2 text-gray-400 max-w-2xl">Trois étapes simples pour prendre le contrôle de vos bots.</p>
<h2 className="text-2xl font-bold text-white">{t("howItWorks.title")}</h2>
<p className="mt-2 text-gray-400 max-w-2xl">{t("howItWorks.subtitle")}</p>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
{steps.map((s, i) => (
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useLocale } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useT } from "../i18n/client";
import { usePathname, useRouter } from "../i18n/navigation";
import { routing, type AppLocale } from "../i18n/routing";
export default function LanguageSwitcher() {
const t = useT();
const locale = useLocale() as AppLocale;
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const localeLabels: Record<AppLocale, string> = {
en: t("localeSwitcher.locales.en"),
fr: t("localeSwitcher.locales.fr"),
};
const handleLocaleChange = (nextLocale: AppLocale) => {
const query = Object.fromEntries(searchParams.entries());
startTransition(() => {
router.replace({ pathname, query }, { locale: nextLocale });
});
};
return (
<label className="locale-switcher">
<span className="sr-only">{t("localeSwitcher.label")}</span>
<select
aria-label={t("localeSwitcher.label")}
disabled={isPending}
onChange={(event) => handleLocaleChange(event.target.value as AppLocale)}
value={locale}
>
{routing.locales.map((availableLocale) => (
<option key={availableLocale} value={availableLocale}>
{localeLabels[availableLocale]}
</option>
))}
</select>
</label>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import Link from "next/link";
import { Link } from "../i18n/navigation";
export default function Logo({ className = "" }: { className?: string }) {
return (
+48 -37
View File
@@ -1,6 +1,7 @@
"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useT } from "../i18n/client";
type User = {
id: string;
@@ -27,15 +28,25 @@ type DashboardClientProps = {
apiBaseUrl: string;
};
const statusLabel: Record<BotStatus, string> = {
stopped: "Arrete",
starting: "Demarrage",
running: "En ligne",
stopping: "Arret",
error: "Erreur",
};
type BotAction = "start" | "stop" | "restart";
export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
const t = useT();
const statusLabel: Record<BotStatus, string> = {
stopped: t("dashboard.status.stopped"),
starting: t("dashboard.status.starting"),
running: t("dashboard.status.running"),
stopping: t("dashboard.status.stopping"),
error: t("dashboard.status.error"),
};
const actionLabel: Record<BotAction, string> = {
start: t("dashboard.actions.start"),
stop: t("dashboard.actions.stop"),
restart: t("dashboard.actions.restart"),
};
const [user, setUser] = useState<User | null>(null);
const [bots, setBots] = useState<Bot[]>([]);
const [token, setToken] = useState("");
@@ -61,7 +72,7 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
}
if (!meResponse.ok) {
throw new Error("Impossible de recuperer la session utilisateur");
throw new Error(t("dashboard.errors.fetchSession"));
}
const meJson = await meResponse.json();
@@ -72,18 +83,18 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
});
if (!botsResponse.ok) {
throw new Error("Impossible de recuperer la liste des bots");
throw new Error(t("dashboard.errors.fetchBots"));
}
const botsJson = await botsResponse.json();
setBots((botsJson.bots ?? []) as Bot[]);
} catch (cause) {
const message = cause instanceof Error ? cause.message : "Erreur inattendue";
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
setError(message);
} finally {
setLoading(false);
}
}, [apiBaseUrl]);
}, [apiBaseUrl, t]);
useEffect(() => {
void refreshData();
@@ -119,21 +130,21 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
if (!response.ok) {
const errorJson = await response.json().catch(() => ({}));
throw new Error(errorJson.error ?? "Ajout du bot impossible");
throw new Error(errorJson.error ?? t("dashboard.errors.addBot"));
}
setToken("");
setDisplayName("");
await refreshData();
} catch (cause) {
const message = cause instanceof Error ? cause.message : "Erreur inattendue";
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
setError(message);
} finally {
setSubmitting(false);
}
};
const triggerAction = async (botId: string, action: "start" | "stop" | "restart") => {
const triggerAction = async (botId: string, action: BotAction) => {
setError(null);
try {
@@ -144,28 +155,28 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
if (!response.ok) {
const errorJson = await response.json().catch(() => ({}));
throw new Error(errorJson.error ?? `Action ${action} impossible`);
throw new Error(errorJson.error ?? t("dashboard.errors.actionFailed", { action: actionLabel[action] }));
}
await refreshData();
} catch (cause) {
const message = cause instanceof Error ? cause.message : "Erreur inattendue";
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
setError(message);
}
};
if (loading) {
return <p className="muted">Chargement du dashboard...</p>;
return <p className="muted">{t("dashboard.loading")}</p>;
}
if (!user) {
return (
<div className="stack">
<p className="eyebrow">Session requise</p>
<h1>Connexion necessaire</h1>
<p>Ton dashboard est protege. Connecte-toi via Discord pour administrer tes bots.</p>
<p className="eyebrow">{t("dashboard.sessionRequired")}</p>
<h1>{t("dashboard.loginRequired")}</h1>
<p>{t("dashboard.loginDescription")}</p>
<a className="button-primary" href={`${apiBaseUrl}/auth/discord/login`}>
Se connecter
{t("dashboard.loginCta")}
</a>
</div>
);
@@ -175,12 +186,12 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
<div className="stack">
<header className="dashboard-header">
<div>
<p className="eyebrow">Tenant {user.tenantId}</p>
<p className="eyebrow">{t("dashboard.tenant", { tenantId: user.tenantId })}</p>
<h1>{user.username}</h1>
<p className="muted">Role: {user.role}</p>
<p className="muted">{t("dashboard.role", { role: user.role })}</p>
</div>
<button className="button-ghost" onClick={handleLogout} type="button">
Se deconnecter
{t("dashboard.logout")}
</button>
</header>
@@ -188,11 +199,11 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
<section className="card-grid">
<article className="card add-bot-card">
<h2>Ajouter un bot</h2>
<p>Le token est verifie cote API puis chiffre avant stockage en base.</p>
<h2>{t("dashboard.addBot.title")}</h2>
<p>{t("dashboard.addBot.description")}</p>
<form className="stack-tight" onSubmit={handleAddBot}>
<label>
Token bot Discord
{t("dashboard.addBot.tokenLabel")}
<input
autoComplete="off"
name="token"
@@ -204,7 +215,7 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
/>
</label>
<label>
Nom d'affichage (optionnel)
{t("dashboard.addBot.displayNameLabel")}
<input
name="displayName"
onChange={(event) => setDisplayName(event.target.value)}
@@ -214,40 +225,40 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
/>
</label>
<button className="button-primary" disabled={submitting} type="submit">
{submitting ? "Validation en cours..." : "Ajouter le bot"}
{submitting ? t("dashboard.addBot.submitPending") : t("dashboard.addBot.submit")}
</button>
</form>
</article>
<article className="card bots-card">
<div className="row-between">
<h2>Mes bots</h2>
<h2>{t("dashboard.bots.title")}</h2>
<button className="button-ghost" onClick={() => void refreshData()} type="button">
Rafraichir
{t("dashboard.bots.refresh")}
</button>
</div>
{bots.length === 0 ? (
<p className="muted">Aucun bot pour ce tenant.</p>
<p className="muted">{t("dashboard.bots.empty")}</p>
) : (
<ul className="bot-list">
{bots.map((bot) => (
<li className="bot-item" key={bot.id}>
<div className="bot-main">
<p className="bot-name">{bot.displayName}</p>
<p className="muted mono">Discord ID: {bot.discordBotId}</p>
<p className="muted mono">{t("dashboard.bots.discordId", { discordBotId: bot.discordBotId })}</p>
<p className={`status status-${bot.status}`}>{statusLabel[bot.status]}</p>
{bot.lastError ? <p className="error-inline">Derniere erreur: {bot.lastError}</p> : null}
{bot.lastError ? <p className="error-inline">{t("dashboard.bots.lastError", { message: bot.lastError })}</p> : null}
</div>
<div className="actions">
<button onClick={() => void triggerAction(bot.id, "start")} type="button">
Start
{actionLabel.start}
</button>
<button onClick={() => void triggerAction(bot.id, "stop")} type="button">
Stop
{actionLabel.stop}
</button>
<button onClick={() => void triggerAction(bot.id, "restart")} type="button">
Restart
{actionLabel.restart}
</button>
</div>
</li>
+5
View File
@@ -0,0 +1,5 @@
import { useTranslations } from "next-intl";
export function useT() {
return useTranslations();
}
+6
View File
@@ -0,0 +1,6 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
+12
View File
@@ -0,0 +1,12 @@
import "next-intl";
import enMessages from "../messages/en.json";
import { routing } from "./routing";
declare module "next-intl" {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof enMessages;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { hasLocale } from "next-intl";
import { getRequestConfig } from "next-intl/server";
import { routing, type AppLocale } from "./routing";
type Messages = Record<string, unknown>;
const messageLoaders: Record<AppLocale, () => Promise<Messages>> = {
en: async () => (await import("../messages/en.json")).default as Messages,
fr: async () => (await import("../messages/fr.json")).default as Messages,
};
const isObject = (value: unknown): value is Record<string, unknown> => {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
};
const mergeMessages = (base: Messages, override: Messages): Messages => {
const merged: Messages = { ...base };
for (const [key, value] of Object.entries(override)) {
const current = merged[key];
if (isObject(current) && isObject(value)) {
merged[key] = mergeMessages(current, value);
continue;
}
merged[key] = value;
}
return merged;
};
export default getRequestConfig(async ({ requestLocale }) => {
const requestedLocale = await requestLocale;
const locale = hasLocale(routing.locales, requestedLocale)
? requestedLocale
: routing.defaultLocale;
const defaultMessages = await messageLoaders[routing.defaultLocale]();
const localeMessages = await messageLoaders[locale]();
return {
locale,
messages:
locale === routing.defaultLocale
? defaultMessages
: mergeMessages(defaultMessages, localeMessages),
};
});
+9
View File
@@ -0,0 +1,9 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "fr"],
defaultLocale: "en",
localePrefix: "always",
});
export type AppLocale = (typeof routing.locales)[number];
+5
View File
@@ -0,0 +1,5 @@
import { getTranslations } from "next-intl/server";
export async function getT() {
return getTranslations();
}
+135
View File
@@ -0,0 +1,135 @@
{
"metadata": {
"title": "Shadow - Discord bot hosting (EUR 2/month per bot)",
"description": "Multi-tenant SaaS platform to host and manage Discord bots with auto commands, components v2, multi-language support and dynamic variables."
},
"localeSwitcher": {
"label": "Language",
"locales": {
"en": "English",
"fr": "Francais"
}
},
"hero": {
"navLogin": "Sign in",
"title": "Shadow - Host your Discord bots (from EUR 2/month per bot)",
"description": "Host and manage your bots with auto commands (prefix and slash), components v2, automatic help, multi-language support and dynamic variables.",
"ctaStart": "Get started - EUR 2/month",
"ctaLogin": "Sign in",
"bots": {
"moderation": "Moderation",
"welcome": "Welcome",
"analytics": "Analytics"
},
"status": {
"online": "Online",
"stopped": "Stopped"
},
"card": {
"instanceMetrics": "Instances: 1 | CPU 35%",
"manage": "Manage",
"preview": "Real-time preview of your bot status"
}
},
"features": {
"title": "Main features",
"subtitle": "Everything you need to run your bots in production.",
"items": {
"autoCommands": {
"title": "Automatic commands",
"description": "Prefix and slash commands are generated and synced automatically for each bot."
},
"componentsV2": {
"title": "Components v2",
"description": "Full support for components v2 and reliable rich interactions."
},
"autoHelp": {
"title": "Automatic help",
"description": "Help and command documentation generated automatically."
},
"multiLanguage": {
"title": "Multi-language",
"description": "Translate messages and command labels, then enable multiple locales quickly."
},
"dynamicVariables": {
"title": "Dynamic variables",
"description": "Dynamic templates with variables like bot_latency, guild_count and more."
},
"hosting": {
"title": "Hosting and scalability",
"description": "Simple plans at EUR 2/month per bot with monitoring and scalability included."
}
}
},
"howItWorks": {
"title": "How it works",
"subtitle": "Three simple steps to take control of your bots.",
"steps": {
"addBot": {
"title": "Add a bot",
"description": "Connect your Discord application with OAuth and register it in Shadow."
},
"dashboard": {
"title": "Manage from dashboard",
"description": "Access commands, logs and settings from one centralized interface."
},
"realtime": {
"title": "Control in real time",
"description": "Start, stop and scale your bots instantly based on demand."
}
}
},
"footer": {
"tagline": "Discord bot hosting - plans from EUR 2/month per bot"
},
"login": {
"eyebrow": "Shadow - Discord bot hosting",
"title": "Connect your Discord account",
"description": "Authenticate with OAuth2 to access your space and host your bots.",
"cta": "Continue with Discord"
},
"dashboard": {
"loading": "Loading dashboard...",
"sessionRequired": "Session required",
"loginRequired": "Sign in required",
"loginDescription": "Your dashboard is protected. Sign in with Discord to manage your bots.",
"loginCta": "Sign in",
"tenant": "Tenant {tenantId}",
"role": "Role: {role}",
"logout": "Sign out",
"addBot": {
"title": "Add a bot",
"description": "The token is validated by the API and encrypted before it is stored.",
"tokenLabel": "Discord bot token",
"displayNameLabel": "Display name (optional)",
"submit": "Add bot",
"submitPending": "Validating..."
},
"bots": {
"title": "My bots",
"refresh": "Refresh",
"empty": "No bot for this tenant.",
"discordId": "Discord ID: {discordBotId}",
"lastError": "Last error: {message}"
},
"status": {
"stopped": "Stopped",
"starting": "Starting",
"running": "Online",
"stopping": "Stopping",
"error": "Error"
},
"actions": {
"start": "Start",
"stop": "Stop",
"restart": "Restart"
},
"errors": {
"fetchSession": "Unable to fetch user session",
"fetchBots": "Unable to fetch bot list",
"unexpected": "Unexpected error",
"addBot": "Unable to add bot",
"actionFailed": "Action failed: {action}"
}
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"metadata": {
"title": "Shadow - Hebergement de bots Discord (a partir de 2 EUR/mois par bot)",
"description": "Plateforme SaaS multi-tenant pour heberger et gerer vos bots Discord avec commandes automatiques, components v2, multi-langue et variables dynamiques."
},
"localeSwitcher": {
"label": "Langue",
"locales": {
"en": "English",
"fr": "Francais"
}
},
"hero": {
"navLogin": "Se connecter",
"title": "Shadow - Hebergez vos bots Discord (a partir de 2 EUR/mois par bot)",
"description": "Hebergez et gerez vos bots avec commandes automatiques (prefix et slash), components v2, aide automatique, support multi-langue et variables dynamiques.",
"ctaStart": "Commencer - 2 EUR/mois",
"ctaLogin": "Se connecter",
"bots": {
"moderation": "Moderation",
"welcome": "Bienvenue",
"analytics": "Analytics"
},
"status": {
"online": "En ligne",
"stopped": "Arrete"
},
"card": {
"instanceMetrics": "Instances: 1 | CPU 35%",
"manage": "Gerer",
"preview": "Apercu en temps reel du statut de vos bots"
}
},
"features": {
"title": "Fonctionnalites principales",
"subtitle": "Tout ce quil faut pour piloter vos bots en production.",
"items": {
"autoCommands": {
"title": "Commandes automatiques",
"description": "Les commandes prefix et slash sont generees et synchronisees automatiquement pour chaque bot."
},
"componentsV2": {
"title": "Components v2",
"description": "Support complet des components v2 et des interactions riches fiables."
},
"autoHelp": {
"title": "Aide automatique",
"description": "Aide et documentation des commandes generees automatiquement."
},
"multiLanguage": {
"title": "Multi-langue",
"description": "Traduisez les messages et les labels de commandes puis activez plusieurs langues facilement."
},
"dynamicVariables": {
"title": "Variables dynamiques",
"description": "Templates dynamiques avec des variables comme bot_latency, guild_count et plus."
},
"hosting": {
"title": "Hebergement et scalabilite",
"description": "Plans simples a 2 EUR/mois par bot avec monitoring et scalabilite inclus."
}
}
},
"howItWorks": {
"title": "Comment ca marche",
"subtitle": "Trois etapes simples pour prendre le controle de vos bots.",
"steps": {
"addBot": {
"title": "Ajouter un bot",
"description": "Connectez votre application Discord via OAuth et enregistrez-la dans Shadow."
},
"dashboard": {
"title": "Gerer depuis le dashboard",
"description": "Accedez aux commandes, aux logs et aux parametres depuis une interface centralisee."
},
"realtime": {
"title": "Controler en temps reel",
"description": "Demarrez, arretez et scalez vos bots instantanement selon la demande."
}
}
},
"footer": {
"tagline": "Hebergement de bots Discord - plans des 2 EUR/mois par bot"
},
"login": {
"eyebrow": "Shadow - Hebergement de bots Discord",
"title": "Connectez votre compte Discord",
"description": "Authentifiez-vous via OAuth2 pour acceder a votre espace et heberger vos bots.",
"cta": "Continuer avec Discord"
},
"dashboard": {
"loading": "Chargement du dashboard...",
"sessionRequired": "Session requise",
"loginRequired": "Connexion necessaire",
"loginDescription": "Votre dashboard est protege. Connectez-vous via Discord pour administrer vos bots.",
"loginCta": "Se connecter",
"tenant": "Tenant {tenantId}",
"role": "Role: {role}",
"logout": "Se deconnecter",
"addBot": {
"title": "Ajouter un bot",
"description": "Le token est verifie cote API puis chiffre avant stockage.",
"tokenLabel": "Token bot Discord",
"displayNameLabel": "Nom daffichage (optionnel)",
"submit": "Ajouter le bot",
"submitPending": "Validation en cours..."
},
"bots": {
"title": "Mes bots",
"refresh": "Rafraichir",
"empty": "Aucun bot pour ce tenant.",
"discordId": "Discord ID: {discordBotId}",
"lastError": "Derniere erreur: {message}"
},
"status": {
"stopped": "Arrete",
"starting": "Demarrage",
"running": "En ligne",
"stopping": "Arret",
"error": "Erreur"
},
"actions": {
"start": "Demarrer",
"stop": "Arreter",
"restart": "Redemarrer"
},
"errors": {
"fetchSession": "Impossible de recuperer la session utilisateur",
"fetchBots": "Impossible de recuperer la liste des bots",
"unexpected": "Erreur inattendue",
"addBot": "Ajout du bot impossible",
"actionFailed": "Action impossible: {action}"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
+5 -1
View File
@@ -1,7 +1,11 @@
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
};
export default nextConfig;
export default withNextIntl(nextConfig);
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"next": "15.1.2",
"next-intl": "^4.9.1",
"react": "19.0.0",
"react-dom": "19.0.0"
},