harmonisation du style dans tout le site

This commit is contained in:
Puechberty Arthur
2026-04-20 22:00:52 +02:00
parent 2206c82379
commit f101af4872
24 changed files with 950 additions and 497 deletions
+4 -3
View File
@@ -1,4 +1,5 @@
import { DashboardClient } from "../../../components/dashboard-client";
import PageWrapper from "../../../components/ui/PageWrapper";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
@@ -6,10 +7,10 @@ export const dynamic = "force-dynamic";
export default function DashboardPage() {
return (
<main className="page-shell">
<section className="panel panel-dashboard reveal-up">
<PageWrapper contentClassName="space-y-8" currentPath="dashboard">
<section className="reveal-up">
<DashboardClient apiBaseUrl={apiBaseUrl} />
</section>
</main>
</PageWrapper>
);
}
+1 -5
View File
@@ -4,7 +4,6 @@ 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";
@@ -77,11 +76,8 @@ export default async function LocaleLayout({
return (
<html lang={locale}>
<body className={`${headingFont.variable} ${monoFont.variable}`}>
<body className={`${headingFont.variable} ${monoFont.variable} antialiased`}>
<NextIntlClientProvider locale={locale} messages={messages}>
<div className="locale-switcher-shell">
<LanguageSwitcher />
</div>
{children}
</NextIntlClientProvider>
</body>
+29 -9
View File
@@ -1,4 +1,8 @@
import { getT } from "../../../i18n/server";
import { Badge } from "../../../components/ui/Badge";
import { buttonClassName } from "../../../components/ui/Button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../components/ui/Card";
import PageWrapper from "../../../components/ui/PageWrapper";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
@@ -6,15 +10,31 @@ 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>
<PageWrapper contentClassName="space-y-8" currentPath="login">
<section className="mx-auto w-full max-w-xl reveal-up">
<Card>
<CardHeader className="space-y-3">
<Badge className="w-fit" variant="accent">
{t("login.eyebrow")}
</Badge>
<CardTitle>{t("login.title")}</CardTitle>
<CardDescription>{t("login.description")}</CardDescription>
</CardHeader>
<CardContent>
<a
className={buttonClassName({
fullWidth: true,
size: "lg",
variant: "primary",
})}
href={`${apiBaseUrl}/auth/discord/login`}
>
{t("login.cta")}
</a>
</CardContent>
</Card>
</section>
</main>
</PageWrapper>
);
}
+11 -13
View File
@@ -2,20 +2,18 @@ import Features from "../../components/Features";
import Footer from "../../components/Footer";
import Hero from "../../components/Hero";
import HowItWorks from "../../components/HowItWorks";
import PageWrapper from "../../components/ui/PageWrapper";
export default function HomePage() {
export default async function HomePage() {
return (
<main className="min-h-screen bg-gradient-to-b from-gray-900 via-gray-950 to-black text-gray-100 antialiased">
<div className="container mx-auto px-6 py-16 lg:py-24">
<Hero />
<div className="mt-16 space-y-20">
<Features />
<HowItWorks />
</div>
</div>
<Footer />
</main>
<PageWrapper
contentClassName="space-y-14 md:space-y-16"
currentPath="home"
footer={<Footer />}
>
<Hero />
<Features />
<HowItWorks />
</PageWrapper>
);
}
+111 -273
View File
@@ -1,21 +1,46 @@
:root {
--sand-50: #f7f2e8;
--sand-100: #efe3ce;
--ink-900: #1b1815;
--ink-700: #3f3933;
--sun-500: #ec6f08;
--sun-600: #cb5600;
--teal-600: #0f766e;
--teal-700: #0b5f58;
--danger-600: #b91c1c;
--card-shadow: 0 20px 60px rgba(33, 25, 17, 0.15);
--radius-lg: 20px;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
--bg: #060c18;
--bg-elevated: #0a1324;
--surface: rgba(14, 24, 42, 0.78);
--surface-strong: rgba(22, 35, 59, 0.88);
--surface-subtle: rgba(140, 167, 205, 0.1);
--surface-overlay: rgba(6, 12, 24, 0.82);
--foreground: #e7eefb;
--foreground-muted: #9aabc8;
--primary: #4f8cff;
--primary-hover: #66a0ff;
--accent: #2dd4bf;
--danger: #ef5f7a;
--border-subtle: rgba(163, 184, 215, 0.26);
--border-muted: rgba(163, 184, 215, 0.18);
--shadow-soft: 0 18px 40px rgba(2, 6, 18, 0.4);
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #f5f8fe;
--bg-elevated: #ffffff;
--surface: rgba(255, 255, 255, 0.86);
--surface-strong: rgba(246, 250, 255, 0.96);
--surface-subtle: rgba(64, 102, 176, 0.08);
--surface-overlay: rgba(245, 248, 254, 0.8);
--foreground: #101a2f;
--foreground-muted: #4d6286;
--primary: #2f62d9;
--primary-hover: #3a73f7;
--accent: #0f9a88;
--danger: #d63d5c;
--border-subtle: rgba(47, 98, 217, 0.22);
--border-muted: rgba(16, 26, 47, 0.12);
--shadow-soft: 0 14px 32px rgba(26, 40, 66, 0.12);
}
}
* {
box-sizing: border-box;
@@ -25,284 +50,112 @@ html,
body {
margin: 0;
padding: 0;
min-height: 100%;
}
body {
min-height: 100vh;
position: relative;
font-family: var(--font-heading), sans-serif;
color: var(--ink-900);
background:
radial-gradient(circle at 15% 20%, rgba(15, 118, 110, 0.18), transparent 35%),
radial-gradient(circle at 80% 0%, rgba(236, 111, 8, 0.22), transparent 40%),
linear-gradient(140deg, var(--sand-50), #f8f7f4 55%, #fff3e3);
color: var(--foreground);
background: var(--bg);
}
.locale-switcher-shell {
a {
color: inherit;
text-decoration: none;
}
.ui-background {
pointer-events: none;
position: fixed;
top: 1rem;
right: 1rem;
z-index: 80;
inset: 0;
z-index: -1;
background:
radial-gradient(circle at 18% 10%, color-mix(in srgb, var(--primary) 28%, transparent), transparent 44%),
radial-gradient(circle at 82% 0%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 36%),
linear-gradient(180deg, color-mix(in srgb, var(--bg-elevated) 86%, var(--bg)), var(--bg));
}
.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;
place-items: center;
padding: 2rem 1rem;
}
.panel {
width: min(1100px, 100%);
background: linear-gradient(170deg, rgba(255, 255, 255, 0.95), rgba(255, 247, 237, 0.86));
border: 1px solid rgba(203, 86, 0, 0.16);
border-radius: var(--radius-lg);
box-shadow: var(--card-shadow);
backdrop-filter: blur(6px);
}
.panel-login {
width: min(560px, 100%);
padding: 2rem;
}
.panel-dashboard {
padding: 1.75rem;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--teal-700);
font-size: 0.75rem;
font-weight: 700;
.ui-background::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--foreground-muted) 10%, transparent) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at 55% 42%, black, transparent 78%);
opacity: 0.2;
}
h1,
h2,
h3,
h4 {
margin: 0;
color: var(--foreground);
letter-spacing: -0.02em;
}
h1 {
font-size: clamp(2rem, 3.8vw, 3.4rem);
font-weight: 700;
line-height: 1.08;
}
h2 {
margin: 0 0 0.5rem;
font-size: clamp(1.4rem, 2.3vw, 2rem);
font-weight: 650;
line-height: 1.15;
}
p {
margin: 0;
color: var(--ink-700);
line-height: 1.65;
color: var(--foreground-muted);
}
.stack {
display: grid;
gap: 1.25rem;
}
.stack-tight {
display: grid;
gap: 0.75rem;
}
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.button-primary,
.button-ghost,
.actions button {
border-radius: 12px;
border: 0;
padding: 0.7rem 1rem;
font-family: var(--font-heading), sans-serif;
font-weight: 700;
cursor: pointer;
transition: transform 120ms ease, background-color 120ms ease;
}
.button-primary {
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(140deg, var(--sun-500), var(--sun-600));
color: white;
text-decoration: none;
}
.button-primary:hover {
transform: translateY(-1px);
}
.button-ghost,
.actions button {
background: rgba(15, 118, 110, 0.08);
color: var(--teal-700);
}
.button-ghost:hover,
.actions button:hover {
background: rgba(15, 118, 110, 0.14);
}
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.card {
border-radius: 14px;
border: 1px solid rgba(15, 118, 110, 0.18);
background: rgba(255, 255, 255, 0.88);
padding: 1rem;
display: grid;
gap: 0.75rem;
small {
color: var(--foreground-muted);
}
label {
display: grid;
gap: 0.3rem;
gap: 0.45rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-700);
}
input {
width: 100%;
border: 1px solid rgba(62, 57, 51, 0.2);
border-radius: 10px;
padding: 0.65rem 0.75rem;
font: inherit;
background: #fff;
}
input:focus {
outline: 2px solid rgba(236, 111, 8, 0.32);
border-color: var(--sun-600);
}
.row-between {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.bot-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.75rem;
}
.bot-item {
border: 1px solid rgba(62, 57, 51, 0.14);
border-radius: 12px;
padding: 0.8rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.bot-main {
display: grid;
gap: 0.25rem;
}
.bot-name {
color: var(--ink-900);
font-size: 1.1rem;
font-weight: 700;
}
.status {
display: inline-flex;
padding: 0.2rem 0.55rem;
border-radius: 999px;
width: fit-content;
font-size: 0.8rem;
font-weight: 700;
}
.status-running {
background: rgba(15, 118, 110, 0.16);
color: var(--teal-700);
}
.status-starting,
.status-stopping {
background: rgba(236, 111, 8, 0.16);
color: var(--sun-600);
}
.status-stopped {
background: rgba(63, 57, 51, 0.12);
color: var(--ink-700);
}
.status-error {
background: rgba(185, 28, 28, 0.13);
color: var(--danger-600);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.error-banner,
.error-inline {
color: var(--danger-600);
font-weight: 600;
}
.muted {
color: rgba(63, 57, 51, 0.85);
color: var(--foreground-muted);
}
.mono {
font-family: var(--font-mono), monospace;
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
}
.section-kicker {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 700;
}
.section-title {
max-width: 18ch;
}
.section-description {
max-width: 68ch;
}
.reveal-up {
animation: revealUp 500ms ease-out both;
animation: revealUp 420ms ease-out both;
}
@keyframes revealUp {
from {
opacity: 0;
transform: translateY(16px);
transform: translateY(14px);
}
to {
@@ -311,23 +164,8 @@ input:focus {
}
}
@media (min-width: 920px) {
.card-grid {
grid-template-columns: 0.95fr 1.35fr;
}
.panel-dashboard {
padding: 2rem;
}
}
@media (max-width: 640px) {
.locale-switcher-shell {
top: 0.7rem;
right: 0.7rem;
}
.locale-switcher select {
font-size: 0.78rem;
.section-title {
max-width: 100%;
}
}
+10 -8
View File
@@ -1,5 +1,7 @@
import { ReactNode } from "react";
import { Card, CardContent } from "./ui/Card";
export default function FeatureCard({
title,
description,
@@ -10,17 +12,17 @@ export default function FeatureCard({
icon: ReactNode;
}) {
return (
<div className="p-6 bg-gray-900/40 rounded-xl border border-white/6 hover:shadow-lg transition">
<div className="flex items-start gap-4">
<div className="p-2 bg-white/5 rounded-md text-white flex items-center justify-center">
<Card className="h-full border-[var(--border-muted)] bg-[var(--surface)]">
<CardContent className="flex h-full items-start gap-4">
<div className="flex size-10 items-center justify-center rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-subtle)] text-[var(--foreground)]">
{icon}
</div>
<div>
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-2 text-sm text-gray-300">{description}</p>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-[var(--foreground)]">{title}</h3>
<p className="text-sm text-[var(--foreground-muted)]">{description}</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+7 -5
View File
@@ -9,7 +9,7 @@ export default async function Features() {
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">
<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>
@@ -68,11 +68,13 @@ export default async function Features() {
];
return (
<section>
<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>
<section className="space-y-6">
<div className="space-y-3">
<h2>{t("features.title")}</h2>
<p className="section-description">{t("features.subtitle")}</p>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((it, idx) => (
<FeatureCard key={idx} title={it.title} description={it.description} icon={it.icon} />
))}
+20 -7
View File
@@ -1,18 +1,31 @@
import { getT } from "../i18n/server";
import { Container } from "./ui/Container";
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">
Copyright {new Date().getFullYear()} Shadow - {t("footer.tagline")}
<footer>
<Container className="flex flex-wrap items-center justify-between gap-4 py-6">
<div className="text-sm text-[var(--foreground-muted)]">
{`${t("footer.copyright", {
year: new Date().getFullYear(),
brand: t("common.brand"),
})} - ${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 className="text-sm text-[var(--foreground-muted)]">
<a
className="transition-colors hover:text-[var(--foreground)]"
href="https://github.com/"
rel="noreferrer"
target="_blank"
>
{t("footer.github")}
</a>
</div>
</div>
</Container>
</footer>
);
}
+60 -54
View File
@@ -1,7 +1,9 @@
import { Link } from "../i18n/navigation";
import { getT } from "../i18n/server";
import Logo from "./Logo";
import { Badge } from "./ui/Badge";
import { buttonClassName } from "./ui/Button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/Card";
export default async function Hero() {
const t = await getT();
@@ -13,63 +15,67 @@ export default async function Hero() {
];
return (
<section className="relative z-10">
<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">
{t("hero.navLogin")}
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="reveal-up space-y-6">
<p className="section-kicker">{t("hero.kicker")}</p>
<h1 className="section-title">{t("hero.title")}</h1>
<p className="section-description text-base md:text-lg">
{t("hero.description")}
</p>
<div className="flex flex-wrap items-center gap-3">
<Link className={buttonClassName({ size: "lg", variant: "primary" })} href="/login">
{t("hero.ctaStart")}
</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">
{t("hero.title")}
</h1>
<p className="mt-4 text-lg text-gray-300 max-w-2xl">
{t("hero.description")}
</p>
<div className="mt-8 flex items-center gap-4">
<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">
{t("hero.ctaLogin")}
</Link>
</div>
</div>
<div className="mt-10 lg:mt-0 lg:w-5/12">
<div className="bg-gradient-to-br from-gray-800 to-gray-900 p-6 rounded-2xl shadow-xl ring-1 ring-white/5">
<div className="space-y-4">
{bots.map((b, i) => (
<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">{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" ? 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">
{t("hero.card.manage")}
</button>
</div>
</div>
))}
</div>
<div className="mt-4 text-xs text-gray-400">{t("hero.card.preview")}</div>
</div>
<Link
className={buttonClassName({ size: "lg", variant: "secondary" })}
href="/login"
>
{t("hero.ctaLogin")}
</Link>
</div>
</div>
<Card className="reveal-up lg:mt-2">
<CardHeader className="space-y-3">
<Badge className="w-fit" variant="accent">
{t("hero.card.preview")}
</Badge>
<CardTitle className="text-lg">{t("hero.card.title")}</CardTitle>
<CardDescription>{t("hero.card.subtitle")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bots.map((bot) => (
<div
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--border-muted)] bg-[var(--surface-subtle)] p-3"
key={bot.name}
>
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--foreground)]">{bot.name}</p>
<p className="text-xs text-[var(--foreground-muted)]">
{t("hero.card.instanceMetrics")}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={bot.status === "online" ? "success" : "neutral"}>
{bot.status === "online"
? t("hero.status.online")
: t("hero.status.stopped")}
</Badge>
<button
className={buttonClassName({ size: "sm", variant: "ghost" })}
type="button"
>
{t("hero.card.manage")}
</button>
</div>
</div>
))}
</CardContent>
</Card>
</section>
);
}
+19 -12
View File
@@ -1,5 +1,8 @@
import { getT } from "../i18n/server";
import { Badge } from "./ui/Badge";
import { Card, CardContent } from "./ui/Card";
export default async function HowItWorks() {
const t = await getT();
@@ -19,21 +22,25 @@ export default async function HowItWorks() {
];
return (
<section>
<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>
<section className="space-y-6">
<div className="space-y-3">
<h2>{t("howItWorks.title")}</h2>
<p className="section-description">{t("howItWorks.subtitle")}</p>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{steps.map((s, i) => (
<div key={i} className="p-6 bg-gray-900/30 rounded-xl border border-white/6">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-gradient-to-br from-purple-600 to-cyan-500 text-white font-semibold">{i + 1}</div>
<div>
<h3 className="text-lg font-semibold text-white">{s.title}</h3>
<p className="mt-2 text-sm text-gray-300">{s.desc}</p>
<Card className="h-full border-[var(--border-muted)]" key={i}>
<CardContent className="space-y-3">
<Badge className="w-fit" variant="accent">
{`${i + 1}`}
</Badge>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-[var(--foreground)]">{s.title}</h3>
<p className="text-sm text-[var(--foreground-muted)]">{s.desc}</p>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</section>
+56 -14
View File
@@ -7,6 +7,8 @@ import { useTransition } from "react";
import { useT } from "../i18n/client";
import { usePathname, useRouter } from "../i18n/navigation";
import { routing, type AppLocale } from "../i18n/routing";
import { Switch } from "./ui/Switch";
import { cn } from "./ui/cn";
export default function LanguageSwitcher() {
const t = useT();
@@ -22,6 +24,10 @@ export default function LanguageSwitcher() {
};
const handleLocaleChange = (nextLocale: AppLocale) => {
if (nextLocale === locale) {
return;
}
const query = Object.fromEntries(searchParams.entries());
startTransition(() => {
@@ -29,21 +35,57 @@ export default function LanguageSwitcher() {
});
};
if (routing.locales.length !== 2) {
return (
<label className="inline-flex items-center gap-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-subtle)] px-2.5 py-1.5">
<span className="sr-only">{t("localeSwitcher.label")}</span>
<select
aria-label={t("localeSwitcher.label")}
className="bg-transparent text-xs font-semibold text-[var(--foreground)] focus:outline-none"
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>
);
}
const [leftLocale, rightLocale] = routing.locales;
const checked = locale === rightLocale;
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}
<div className="inline-flex items-center gap-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-subtle)] px-2 py-1.5">
<span
className={cn(
"text-[11px] font-semibold",
checked ? "text-[var(--foreground-muted)]" : "text-[var(--foreground)]",
)}
>
{routing.locales.map((availableLocale) => (
<option key={availableLocale} value={availableLocale}>
{localeLabels[availableLocale]}
</option>
))}
</select>
</label>
{localeLabels[leftLocale]}
</span>
<Switch
aria-label={t("localeSwitcher.label")}
checked={checked}
disabled={isPending}
onCheckedChange={(nextChecked) =>
handleLocaleChange(nextChecked ? rightLocale : leftLocale)
}
size="sm"
/>
<span
className={cn(
"text-[11px] font-semibold",
checked ? "text-[var(--foreground)]" : "text-[var(--foreground-muted)]",
)}
>
{localeLabels[rightLocale]}
</span>
</div>
);
}
+28 -7
View File
@@ -1,20 +1,41 @@
import { getT } from "../i18n/server";
import { Link } from "../i18n/navigation";
export default function Logo({ className = "" }: { className?: string }) {
import { cn } from "./ui/cn";
export default async function Logo({ className = "" }: { className?: string }) {
const t = await getT();
return (
<Link href="/" className={`inline-flex items-center gap-3 ${className}`}>
<svg viewBox="0 0 48 48" className="w-9 h-9" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<Link
className={cn("inline-flex items-center gap-3", className)}
href="/"
>
<svg
aria-hidden
className="h-9 w-9"
viewBox="0 0 48 48"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient id="shadow-gradient" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" />
<stop offset="100%" stopColor="#06b6d4" />
<stop offset="0%" stopColor="#4f8cff" />
<stop offset="100%" stopColor="#2dd4bf" />
</linearGradient>
</defs>
<rect width="48" height="48" rx="10" fill="url(#shadow-gradient)" />
<path d="M14 32c6-6 10-10 20-12" stroke="rgba(255,255,255,0.95)" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M13 31c5.5-5.5 10-9.5 21-12"
stroke="rgba(255,255,255,0.95)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
/>
</svg>
<span className="text-white font-semibold tracking-tight text-lg">Shadow</span>
<span className="text-lg font-semibold tracking-tight text-[var(--foreground)]">
{t("common.brand")}
</span>
</Link>
);
}
+181 -83
View File
@@ -1,7 +1,12 @@
"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useT } from "../i18n/client";
import { Badge } from "./ui/Badge";
import { Button, buttonClassName } from "./ui/Button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/Card";
import { Input } from "./ui/Input";
type User = {
id: string;
@@ -47,6 +52,17 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
restart: t("dashboard.actions.restart"),
};
const statusBadgeVariant: Record<
BotStatus,
"neutral" | "warning" | "success" | "danger"
> = {
stopped: "neutral",
starting: "warning",
running: "success",
stopping: "warning",
error: "danger",
};
const [user, setUser] = useState<User | null>(null);
const [bots, setBots] = useState<Bot[]>([]);
const [token, setToken] = useState("");
@@ -166,105 +182,187 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
};
if (loading) {
return <p className="muted">{t("dashboard.loading")}</p>;
return (
<Card className="max-w-xl">
<CardContent>
<p className="text-sm text-[var(--foreground-muted)]">{t("dashboard.loading")}</p>
</CardContent>
</Card>
);
}
if (!user) {
return (
<div className="stack">
<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`}>
{t("dashboard.loginCta")}
</a>
</div>
<Card className="mx-auto max-w-2xl">
<CardHeader className="space-y-3">
<Badge className="w-fit" variant="warning">
{t("dashboard.sessionRequired")}
</Badge>
<CardTitle>{t("dashboard.loginRequired")}</CardTitle>
<CardDescription>{t("dashboard.loginDescription")}</CardDescription>
</CardHeader>
<CardContent>
<a
className={buttonClassName({ size: "lg", variant: "primary" })}
href={`${apiBaseUrl}/auth/discord/login`}
>
{t("dashboard.loginCta")}
</a>
</CardContent>
</Card>
);
}
const userRoleLabel =
user.role === "owner"
? t("dashboard.roles.owner")
: t("dashboard.roles.member");
return (
<div className="stack">
<header className="dashboard-header">
<div>
<p className="eyebrow">{t("dashboard.tenant", { tenantId: user.tenantId })}</p>
<h1>{user.username}</h1>
<p className="muted">{t("dashboard.role", { role: user.role })}</p>
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<Badge variant="accent">{t("dashboard.tenant", { tenantId: user.tenantId })}</Badge>
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground)]">
{user.username}
</h1>
<p className="text-sm text-[var(--foreground-muted)]">
{t("dashboard.role", { role: userRoleLabel })}
</p>
</div>
<button className="button-ghost" onClick={handleLogout} type="button">
<Button onClick={handleLogout} variant="secondary">
{t("dashboard.logout")}
</button>
</Button>
</header>
{error ? <p className="error-banner">{error}</p> : null}
{error ? (
<Card className="border-[color:color-mix(in_srgb,var(--danger)_35%,transparent)] bg-[color:color-mix(in_srgb,var(--danger)_10%,var(--surface))]">
<CardContent>
<p className="text-sm font-semibold text-[var(--danger)]">{error}</p>
</CardContent>
</Card>
) : null}
<section className="card-grid">
<article className="card add-bot-card">
<h2>{t("dashboard.addBot.title")}</h2>
<p>{t("dashboard.addBot.description")}</p>
<form className="stack-tight" onSubmit={handleAddBot}>
<label>
{t("dashboard.addBot.tokenLabel")}
<input
autoComplete="off"
name="token"
onChange={(event) => setToken(event.target.value)}
placeholder="MTIz..."
required
type="password"
value={token}
/>
</label>
<label>
{t("dashboard.addBot.displayNameLabel")}
<input
name="displayName"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Bot support EU"
type="text"
value={displayName}
/>
</label>
<button className="button-primary" disabled={submitting} type="submit">
{submitting ? t("dashboard.addBot.submitPending") : t("dashboard.addBot.submit")}
</button>
</form>
<section className="grid gap-4 xl:grid-cols-[minmax(340px,0.95fr)_minmax(0,1.2fr)]">
<article>
<Card className="h-full">
<CardHeader className="space-y-2">
<CardTitle>{t("dashboard.addBot.title")}</CardTitle>
<CardDescription>{t("dashboard.addBot.description")}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleAddBot}>
<label htmlFor="bot-token">
<span>{t("dashboard.addBot.tokenLabel")}</span>
<Input
autoComplete="off"
id="bot-token"
name="token"
onChange={(event) => setToken(event.target.value)}
placeholder={t("dashboard.addBot.tokenPlaceholder")}
required
type="password"
value={token}
/>
</label>
<label htmlFor="bot-display-name">
<span>{t("dashboard.addBot.displayNameLabel")}</span>
<Input
id="bot-display-name"
name="displayName"
onChange={(event) => setDisplayName(event.target.value)}
placeholder={t("dashboard.addBot.displayNamePlaceholder")}
type="text"
value={displayName}
/>
</label>
<Button disabled={submitting} fullWidth type="submit" variant="primary">
{submitting
? t("dashboard.addBot.submitPending")
: t("dashboard.addBot.submit")}
</Button>
</form>
</CardContent>
</Card>
</article>
<article className="card bots-card">
<div className="row-between">
<h2>{t("dashboard.bots.title")}</h2>
<button className="button-ghost" onClick={() => void refreshData()} type="button">
{t("dashboard.bots.refresh")}
</button>
</div>
<article>
<Card className="h-full">
<CardHeader className="mb-4 flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
<CardTitle>{t("dashboard.bots.title")}</CardTitle>
<Button onClick={() => void refreshData()} size="sm" variant="secondary">
{t("dashboard.bots.refresh")}
</Button>
</CardHeader>
{bots.length === 0 ? (
<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">{t("dashboard.bots.discordId", { discordBotId: bot.discordBotId })}</p>
<p className={`status status-${bot.status}`}>{statusLabel[bot.status]}</p>
{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">
{actionLabel.start}
</button>
<button onClick={() => void triggerAction(bot.id, "stop")} type="button">
{actionLabel.stop}
</button>
<button onClick={() => void triggerAction(bot.id, "restart")} type="button">
{actionLabel.restart}
</button>
</div>
</li>
))}
</ul>
)}
<CardContent className="space-y-3">
{bots.length === 0 ? (
<p className="text-sm text-[var(--foreground-muted)]">
{t("dashboard.bots.empty")}
</p>
) : (
<ul className="m-0 grid list-none gap-3 p-0">
{bots.map((bot) => (
<li
className="space-y-4 rounded-xl border border-[var(--border-muted)] bg-[var(--surface-subtle)] p-4"
key={bot.id}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-base font-semibold text-[var(--foreground)]">
{bot.displayName}
</p>
<p className="mono text-xs text-[var(--foreground-muted)]">
{t("dashboard.bots.discordId", {
discordBotId: bot.discordBotId,
})}
</p>
</div>
<Badge variant={statusBadgeVariant[bot.status]}>
{statusLabel[bot.status]}
</Badge>
</div>
{bot.lastError ? (
<p className="text-sm font-medium text-[var(--danger)]">
{t("dashboard.bots.lastError", { message: bot.lastError })}
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
onClick={() => void triggerAction(bot.id, "start")}
size="sm"
variant="secondary"
>
{actionLabel.start}
</Button>
<Button
onClick={() => void triggerAction(bot.id, "stop")}
size="sm"
variant="secondary"
>
{actionLabel.stop}
</Button>
<Button
onClick={() => void triggerAction(bot.id, "restart")}
size="sm"
variant="secondary"
>
{actionLabel.restart}
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</article>
</section>
</div>
+39
View File
@@ -0,0 +1,39 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn";
type BadgeVariant = "neutral" | "accent" | "success" | "warning" | "danger";
const variantStyles: Record<BadgeVariant, string> = {
neutral:
"border-[var(--border-subtle)] bg-[var(--surface-subtle)] text-[var(--foreground-muted)]",
accent:
"border-[color:color-mix(in_srgb,var(--accent)_45%,transparent)] bg-[color:color-mix(in_srgb,var(--accent)_16%,transparent)] text-[var(--accent)]",
success:
"border-[color:color-mix(in_srgb,#34d399_45%,transparent)] bg-[color:color-mix(in_srgb,#34d399_16%,transparent)] text-[#34d399]",
warning:
"border-[color:color-mix(in_srgb,#fbbf24_45%,transparent)] bg-[color:color-mix(in_srgb,#fbbf24_16%,transparent)] text-[#fbbf24]",
danger:
"border-[color:color-mix(in_srgb,var(--danger)_45%,transparent)] bg-[color:color-mix(in_srgb,var(--danger)_16%,transparent)] text-[var(--danger)]",
};
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant;
};
export function Badge({
className,
variant = "neutral",
...props
}: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold",
variantStyles[variant],
className,
)}
{...props}
/>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { ButtonHTMLAttributes } from "react";
import { cn } from "./cn";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";
const baseStyles =
"inline-flex items-center justify-center gap-2 rounded-xl border font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg)] disabled:cursor-not-allowed disabled:opacity-50";
const variantStyles: Record<ButtonVariant, string> = {
primary:
"border-transparent bg-[var(--primary)] text-white hover:bg-[var(--primary-hover)]",
secondary:
"border-[var(--border-subtle)] bg-[var(--surface-subtle)] text-[var(--foreground)] hover:bg-[var(--surface-strong)]",
ghost:
"border-transparent bg-transparent text-[var(--foreground-muted)] hover:bg-[var(--surface-subtle)] hover:text-[var(--foreground)]",
danger:
"border-transparent bg-[var(--danger)] text-white hover:brightness-95",
};
const sizeStyles: Record<ButtonSize, string> = {
sm: "h-9 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-11 px-5 text-sm",
};
type ButtonClassNameOptions = {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
className?: string;
};
export function buttonClassName({
variant = "primary",
size = "md",
fullWidth = false,
className,
}: ButtonClassNameOptions = {}) {
return cn(
baseStyles,
variantStyles[variant],
sizeStyles[size],
fullWidth ? "w-full" : "",
className,
);
}
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
};
export function Button({
className,
variant,
size,
fullWidth,
type = "button",
...props
}: ButtonProps) {
return (
<button
className={buttonClassName({
variant: variant ?? "primary",
size: size ?? "md",
fullWidth: fullWidth ?? false,
className: className ?? "",
})}
type={type}
{...props}
/>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-[var(--border-subtle)] bg-[var(--surface)] p-6 shadow-[var(--shadow-soft)]",
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("mb-5 space-y-2", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(
"text-xl font-semibold tracking-tight text-[var(--foreground)]",
className,
)}
{...props}
/>
);
}
export function CardDescription({
className,
...props
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn("text-sm leading-relaxed text-[var(--foreground-muted)]", className)}
{...props}
/>
);
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("space-y-4", className)} {...props} />;
}
+12
View File
@@ -0,0 +1,12 @@
import type { HTMLAttributes } from "react";
import { cn } from "./cn";
export function Container({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("mx-auto w-full max-w-[1200px] px-4 sm:px-6 lg:px-8", className)}
{...props}
/>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "./cn";
export type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, ...props },
ref,
) {
return (
<input
className={cn(
"h-11 w-full rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-subtle)] px-3 text-sm text-[var(--foreground)] placeholder:text-[var(--foreground-muted)]/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg)]",
className,
)}
ref={ref}
{...props}
/>
);
});
+63
View File
@@ -0,0 +1,63 @@
import { Link } from "../../i18n/navigation";
import { getT } from "../../i18n/server";
import LanguageSwitcher from "../LanguageSwitcher";
import Logo from "../Logo";
import { buttonClassName } from "./Button";
import { Container } from "./Container";
type NavbarPath = "home" | "login" | "dashboard";
type NavbarProps = {
currentPath?: NavbarPath | undefined;
};
export default async function Navbar({ currentPath }: NavbarProps) {
const t = await getT();
const links: Array<{ href: "/" | "/dashboard"; key: NavbarPath; label: string }> = [
{ href: "/", key: "home", label: t("nav.home") },
{ href: "/dashboard", key: "dashboard", label: t("nav.dashboard") },
];
return (
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)]/80 bg-[var(--surface-overlay)]/85 backdrop-blur-md">
<Container className="flex h-16 items-center justify-between gap-4">
<Logo />
<div className="flex items-center gap-2">
<nav className="hidden items-center gap-1 md:flex">
{links.map((item) => {
const isActive = item.key === currentPath;
return (
<Link
className={buttonClassName({
variant: isActive ? "secondary" : "ghost",
size: "sm",
})}
href={item.href}
key={item.key}
>
{item.label}
</Link>
);
})}
</nav>
<Link
className={buttonClassName({
variant: currentPath === "login" ? "secondary" : "primary",
size: "sm",
})}
href="/login"
>
{t("nav.login")}
</Link>
<LanguageSwitcher />
</div>
</Container>
</header>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { cn } from "./cn";
import { Container } from "./Container";
import Navbar from "./Navbar";
type PagePath = "home" | "login" | "dashboard";
type PageWrapperProps = {
children: ReactNode;
currentPath?: PagePath;
className?: string;
contentClassName?: string;
footer?: ReactNode;
};
export default async function PageWrapper({
children,
currentPath,
className,
contentClassName,
footer,
}: PageWrapperProps) {
return (
<div className="relative min-h-screen overflow-x-clip text-[var(--foreground)]">
<div aria-hidden className="ui-background" />
<Navbar currentPath={currentPath} />
<main className={cn("relative py-10 md:py-14", className)}>
<Container className={cn("space-y-12", contentClassName)}>{children}</Container>
</main>
{footer ? (
<div className="relative border-t border-[var(--border-subtle)]/80 bg-[var(--surface-overlay)]/35">
{footer}
</div>
) : null}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import type { ButtonHTMLAttributes, MouseEvent } from "react";
import { cn } from "./cn";
type SwitchSize = "sm" | "md";
type SwitchProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> & {
checked: boolean;
onCheckedChange?: (nextChecked: boolean) => void;
size?: SwitchSize;
};
const switchSizeStyles: Record<SwitchSize, string> = {
sm: "h-5 w-9",
md: "h-6 w-11",
};
const thumbSizeStyles: Record<SwitchSize, string> = {
sm: "size-4",
md: "size-5",
};
export function Switch({
checked,
onCheckedChange,
className,
disabled,
onClick,
size = "md",
...props
}: SwitchProps) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) {
return;
}
onCheckedChange?.(!checked);
};
return (
<button
aria-checked={checked}
className={cn(
"relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg)] disabled:cursor-not-allowed disabled:opacity-50",
switchSizeStyles[size],
checked ? "bg-[var(--primary)]" : "bg-[var(--surface-strong)]",
className,
)}
disabled={disabled}
onClick={handleClick}
role="switch"
type="button"
{...props}
>
<span
className={cn(
"pointer-events-none inline-block rounded-full bg-white shadow-sm transition-transform",
thumbSizeStyles[size],
checked
? size === "sm"
? "translate-x-4"
: "translate-x-5"
: "translate-x-0.5",
)}
/>
</button>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
+20 -2
View File
@@ -3,6 +3,14 @@
"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."
},
"common": {
"brand": "Shadow"
},
"nav": {
"home": "Home",
"dashboard": "Dashboard",
"login": "Sign in"
},
"localeSwitcher": {
"label": "Language",
"locales": {
@@ -11,7 +19,7 @@
}
},
"hero": {
"navLogin": "Sign in",
"kicker": "Discord bot hosting platform",
"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",
@@ -26,6 +34,8 @@
"stopped": "Stopped"
},
"card": {
"title": "Live fleet preview",
"subtitle": "Track every bot instance from one operational surface.",
"instanceMetrics": "Instances: 1 | CPU 35%",
"manage": "Manage",
"preview": "Real-time preview of your bot status"
@@ -80,7 +90,9 @@
}
},
"footer": {
"tagline": "Discord bot hosting - plans from EUR 2/month per bot"
"tagline": "Discord bot hosting - plans from EUR 2/month per bot",
"github": "GitHub",
"copyright": "Copyright {year} {brand}"
},
"login": {
"eyebrow": "Shadow - Discord bot hosting",
@@ -96,12 +108,18 @@
"loginCta": "Sign in",
"tenant": "Tenant {tenantId}",
"role": "Role: {role}",
"roles": {
"owner": "Owner",
"member": "Member"
},
"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",
"tokenPlaceholder": "MTIz...",
"displayNameLabel": "Display name (optional)",
"displayNamePlaceholder": "Support bot EU",
"submit": "Add bot",
"submitPending": "Validating..."
},
+20 -2
View File
@@ -3,6 +3,14 @@
"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."
},
"common": {
"brand": "Shadow"
},
"nav": {
"home": "Accueil",
"dashboard": "Dashboard",
"login": "Se connecter"
},
"localeSwitcher": {
"label": "Langue",
"locales": {
@@ -11,7 +19,7 @@
}
},
"hero": {
"navLogin": "Se connecter",
"kicker": "Plateforme SaaS pour bots Discord",
"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",
@@ -26,6 +34,8 @@
"stopped": "Arrete"
},
"card": {
"title": "Apercu live de la flotte",
"subtitle": "Suivez chaque instance bot depuis une surface operationnelle unique.",
"instanceMetrics": "Instances: 1 | CPU 35%",
"manage": "Gerer",
"preview": "Apercu en temps reel du statut de vos bots"
@@ -80,7 +90,9 @@
}
},
"footer": {
"tagline": "Hebergement de bots Discord - plans des 2 EUR/mois par bot"
"tagline": "Hebergement de bots Discord - plans des 2 EUR/mois par bot",
"github": "GitHub",
"copyright": "Copyright {year} {brand}"
},
"login": {
"eyebrow": "Shadow - Hebergement de bots Discord",
@@ -96,12 +108,18 @@
"loginCta": "Se connecter",
"tenant": "Tenant {tenantId}",
"role": "Role: {role}",
"roles": {
"owner": "Proprietaire",
"member": "Membre"
},
"logout": "Se deconnecter",
"addBot": {
"title": "Ajouter un bot",
"description": "Le token est verifie cote API puis chiffre avant stockage.",
"tokenLabel": "Token bot Discord",
"tokenPlaceholder": "MTIz...",
"displayNameLabel": "Nom daffichage (optionnel)",
"displayNamePlaceholder": "Bot support EU",
"submit": "Ajouter le bot",
"submitPending": "Validation en cours..."
},