feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats

Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

408
web/src/pages/Landing.tsx Normal file
View File

@@ -0,0 +1,408 @@
import { Logo } from "../components/Logo";
import { Badge } from "../components/primitives";
import { Icons, type IconName } from "../components/icons";
import { useSession } from "../lib/queries";
import { useT } from "../lib/i18n";
import { PublicControls } from "../components/PublicControls";
const GITHUB_URL = "https://github.com/airano-ir/mcphub";
const BLOG_URL = "https://blog.palebluedot.live/";
const SUPPORT_URL = "https://nowpayments.io/donation/airano";
const PUBLIC_PLUGINS = [
"WordPress",
"WooCommerce",
"WordPress Specialist",
"Supabase",
"OpenPanel",
"Gitea",
"n8n",
"Coolify",
];
export function LandingPage() {
const t = useT();
const session = useSession();
const signedIn = session.data?.authenticated === true;
const servedFromPublicRoot =
typeof window !== "undefined" && !window.location.pathname.startsWith("/dashboard");
const dashboardPath = (path: string) => (servedFromPublicRoot ? `/dashboard${path}` : path);
const primaryTo = signedIn ? dashboardPath("/overview") : dashboardPath("/onboarding");
const primaryLabel = signedIn
? t("landing.continue_dashboard", "Continue to dashboard")
: t("landing.start_60", "Start in 60 seconds");
const secondaryTo = signedIn ? dashboardPath("/sites") : dashboardPath("/login");
const secondaryLabel = signedIn ? t("my_sites", "Sites") : t("login.sign_in", "Sign in");
return (
<div className="landing-page" style={{ background: "var(--bg)", position: "relative", overflow: "hidden", minHeight: "100vh" }}>
<nav
style={{
position: "sticky",
top: 0,
zIndex: 50,
background: "oklch(from var(--bg) l c h / 0.7)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
borderBottom: "1px solid var(--border)",
}}
>
<div className="container landing-nav-inner" style={{ display: "flex", alignItems: "center", minHeight: 64, gap: 24 }}>
<span className="logo-wrap">
<Logo size={26} />
<span className="logo-text">
MCP <span>·</span> Hub
</span>
</span>
<div className="landing-nav-links" style={{ display: "flex", gap: 20, marginLeft: 24 }}>
<a href="#features" className="body-sm" style={{ color: "var(--text-muted)" }}>
{t("landing.nav.features", "Features")}
</a>
<a href="#integrations" className="body-sm" style={{ color: "var(--text-muted)" }}>
{t("landing.nav.integrations", "Integrations")}
</a>
<a href={BLOG_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
{t("landing.nav.blog", "Blog")}
</a>
<a href={GITHUB_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
{t("landing.nav.docs", "Docs")}
</a>
</div>
<div style={{ flex: 1 }} />
<PublicControls compact />
<a href={signedIn ? dashboardPath("/overview") : dashboardPath("/login")} className="btn btn-ghost btn-sm">
{signedIn ? t("dashboard", "Dashboard") : t("login.sign_in", "Sign in")}
</a>
<a href={signedIn ? secondaryTo : primaryTo} className="btn btn-primary btn-sm">
{signedIn ? t("nav.sites", "Sites") : t("landing.get_started", "Get started")}{" "}
<Icons.arrow style={{ width: 12, height: 12 }} />
</a>
</div>
</nav>
<section className="landing-hero" style={{ position: "relative", paddingTop: 80, paddingBottom: 100 }}>
<div
className="orb"
style={{
width: 520,
height: 520,
background: "var(--brand-500)",
top: -100,
right: -80,
animation: "float 18s ease-in-out infinite",
}}
/>
<div
className="orb"
style={{
width: 420,
height: 420,
background: "var(--accent-500)",
bottom: -180,
left: -60,
opacity: 0.25,
animation: "float 22s ease-in-out infinite reverse",
}}
/>
<div
className="grid-pattern"
style={{
position: "absolute",
inset: 0,
opacity: 0.4,
maskImage: "radial-gradient(ellipse at center, black 30%, transparent 75%)",
WebkitMaskImage: "radial-gradient(ellipse at center, black 30%, transparent 75%)",
}}
/>
<div className="container" style={{ position: "relative" }}>
<div style={{ maxWidth: 820 }}>
<Badge variant="brand" dot style={{ marginBottom: 20 }}>
{t("landing.hero_badge", "MCP 1.0 · Claude · ChatGPT · Cursor · Gemini")}
</Badge>
<h1 className="h-display" style={{ margin: "0 0 24px", color: "var(--text)" }}>
{t("landing.hero_title_line1", "One hub for every")}
<br />
<em>{t("landing.hero_title_em", "AI connection")}</em>{" "}
{t("landing.hero_title_line2", "to your sites.")}
</h1>
<p
style={{
fontSize: 19,
lineHeight: 1.55,
color: "var(--text-muted)",
maxWidth: 640,
margin: 0,
}}
>
{t(
"landing.hero_body",
"MCP Hub is the control plane between your self-hosted services and the AI tools that work on them. Issue keys, connect Claude.ai, Claude Desktop, ChatGPT, Cursor, or Codex, and review every call from one clean surface.",
)}
</p>
<div className="landing-hero-actions" style={{ display: "flex", gap: 12, marginTop: 32 }}>
<a href={primaryTo} className="btn btn-primary btn-lg">
{primaryLabel} <Icons.arrow style={{ width: 14, height: 14 }} />
</a>
<a href={secondaryTo} className="btn btn-secondary btn-lg">
<Icons.sparkles style={{ width: 14, height: 14 }} />
{secondaryLabel}
</a>
</div>
</div>
</div>
</section>
<section
id="integrations"
style={{ padding: "80px 0", borderTop: "1px solid var(--border)" }}
>
<div className="container">
<div style={{ maxWidth: 620, marginBottom: 36 }}>
<div className="eyebrow" style={{ marginBottom: 12 }}>
{t("landing.integrations.eyebrow", "Integrations")}
</div>
<h2 className="h-1" style={{ margin: 0 }}>
{t("landing.integrations.title", "Service-specific tools, one MCP surface.")}
</h2>
</div>
<div className="landing-integrations-grid" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14 }}>
{PUBLIC_PLUGINS.map((name) => (
<div key={name} className="tile" style={{ padding: 18 }}>
<div className="h-3" style={{ margin: 0 }}>
{name}
</div>
<div className="caption" style={{ marginTop: 6 }}>
{t("landing.integrations.tile", "Scoped tools, keys, health, and audit logs.")}
</div>
</div>
))}
</div>
</div>
</section>
<section
id="features"
style={{ padding: "80px 0", borderTop: "1px solid var(--border)" }}
>
<div className="container">
<div style={{ maxWidth: 600, marginBottom: 48 }}>
<div className="eyebrow" style={{ marginBottom: 12 }}>
{t("landing.features_eyebrow", "Features")}
</div>
<h2 className="h-1" style={{ margin: 0 }}>
{t("landing.features_title", "Everything your AI agents need, nothing they don't.")}
</h2>
</div>
<div className="landing-features-grid" style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 20 }}>
{(
[
{
icon: "sites",
titleKey: "landing.feature.sites.title",
title: "Services as first-class objects",
descKey: "landing.feature.sites.desc",
desc: "Register WordPress, WooCommerce, WordPress Specialist, Supabase, OpenPanel, Gitea, n8n, and Coolify. Each service becomes a discoverable MCP resource with its own tools and access level.",
tagKey: "landing.feature.sites.tag",
tag: "Core",
},
{
icon: "key",
titleKey: "landing.feature.keys.title",
title: "Scoped API keys",
descKey: "landing.feature.keys.desc",
desc: "Create keys for one service or all sites. Tool tiers stay service-specific and can be tightened later.",
tagKey: "landing.feature.keys.tag",
tag: "Security",
},
{
icon: "shield",
titleKey: "landing.feature.oauth.title",
title: "OAuth 2.1 + PKCE",
descKey: "landing.feature.oauth.desc",
desc: "Connect browser-based clients like Claude.ai Connectors and ChatGPT, while desktop clients can use direct URLs or bearer tokens.",
tagKey: "landing.feature.oauth.tag",
tag: "Auth",
},
{
icon: "activity",
titleKey: "landing.feature.health.title",
title: "Service health",
descKey: "landing.feature.health.desc",
desc: "Track credential checks, latency, and service status so agents know what is available before they act.",
tagKey: "landing.feature.health.tag",
tag: "Observability",
},
{
icon: "logs",
titleKey: "landing.feature.audit.title",
title: "Full audit trail",
descKey: "landing.feature.audit.desc",
desc: "Every tool call, auth event, and settings change is searchable and tied back to the user or key.",
tagKey: "landing.feature.audit.tag",
tag: "Compliance",
},
{
icon: "zap",
titleKey: "landing.feature.protocol.title",
title: "MCP-native tools",
descKey: "landing.feature.protocol.desc",
desc: "Expose plugin tools through MCP without forcing users to learn every service API by hand.",
tagKey: "landing.feature.protocol.tag",
tag: "Protocol",
},
] as {
icon: IconName;
titleKey: string;
title: string;
descKey: string;
desc: string;
tagKey: string;
tag: string;
}[]
).map((f) => {
const Ic = Icons[f.icon];
return (
<div key={f.titleKey} className="tile" style={{ padding: 24 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 18,
}}
>
<div
style={{
width: 38,
height: 38,
borderRadius: 10,
background: "oklch(from var(--brand-500) l c h / 0.12)",
color: "var(--brand-400)",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid oklch(from var(--brand-500) l c h / 0.2)",
}}
>
<Ic style={{ width: 18, height: 18 }} />
</div>
<Badge>{t(f.tagKey, f.tag)}</Badge>
</div>
<div className="h-3" style={{ marginBottom: 8 }}>
{t(f.titleKey, f.title)}
</div>
<div className="body-sm" style={{ color: "var(--text-muted)" }}>
{t(f.descKey, f.desc)}
</div>
</div>
);
})}
</div>
</div>
</section>
<section style={{ padding: "80px 0" }}>
<div className="container">
<div
style={{
border: "1px solid var(--border)",
borderRadius: 20,
padding: "56px 48px",
background:
"linear-gradient(135deg, oklch(from var(--brand-500) l c h / 0.15), oklch(from var(--accent-500) l c h / 0.08) 60%, var(--bg-elevated) 100%)",
position: "relative",
overflow: "hidden",
}}
>
<div className="dot-pattern" style={{ position: "absolute", inset: 0, opacity: 0.3 }} />
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 40,
flexWrap: "wrap",
}}
>
<div style={{ maxWidth: 560 }}>
<h2 className="h-1" style={{ margin: "0 0 10px" }}>
{t("landing.cta_title", "Spin up your hub, in a minute.")}
</h2>
<p className="body" style={{ color: "var(--text-muted)", margin: 0 }}>
{t(
"landing.cta_body",
"Deploy on any Coolify instance. Free for personal use. Self-hosted forever.",
)}
</p>
</div>
<div style={{ display: "flex", gap: 10 }}>
<a href={primaryTo} className="btn btn-primary btn-lg">
{signedIn ? t("dashboard", "Dashboard") : t("landing.create_account", "Create account")}{" "}
<Icons.arrow style={{ width: 14, height: 14 }} />
</a>
<a href={GITHUB_URL} className="btn btn-secondary btn-lg">
<Icons.github style={{ width: 14, height: 14 }} />
GitHub
</a>
</div>
</div>
</div>
</div>
</section>
<footer style={{ borderTop: "1px solid var(--border)", padding: "40px 0 60px" }}>
<div
className="container"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 40,
flexWrap: "wrap",
}}
>
<div style={{ maxWidth: 320 }}>
<span className="logo-wrap" style={{ marginBottom: 12 }}>
<Logo size={22} />
<span className="logo-text">
MCP <span>·</span> Hub
</span>
</span>
<div className="caption">
{t(
"landing.footer_tagline",
"The self-hosted MCP control plane for WordPress, Coolify, Gitea, and more.",
)}
</div>
</div>
<div style={{ display: "flex", gap: 18, flexWrap: "wrap" }}>
<a href={BLOG_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
{t("landing.nav.blog", "Blog")}
</a>
<a href={GITHUB_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
GitHub
</a>
<a href={SUPPORT_URL} className="body-sm" style={{ color: "var(--text-muted)" }}>
{t("support_mcphub", "Support MCP Hub")}
</a>
</div>
</div>
<div
className="container"
style={{
marginTop: 32,
paddingTop: 20,
borderTop: "1px solid var(--border)",
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "var(--text-subtle)",
}}
>
<div>© airano.ir · MCP Hub</div>
</div>
</footer>
</div>
);
}