mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
add multi language for the website
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { Link } from "../i18n/navigation";
|
||||
|
||||
export default function Logo({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function useT() {
|
||||
return useTranslations();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
|
||||
import { routing } from "./routing";
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing);
|
||||
Vendored
+12
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
@@ -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];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function getT() {
|
||||
return getTranslations();
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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|.*\\..*).*)"],
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.2",
|
||||
"next-intl": "^4.9.1",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user