feat: add scoped theme builder system with per-route, per-module, and multi-site support
- Add theme_scopes and theme_scope_values database tables for scoped themes - Implement theme resolver engine with inheritance: global > site > module > route - Add module detection for 20+ routes (shop, guilds, radio, news, etc.) - Create admin UI at /admin/theme-builder with scope tree and color editor - Add ScopedThemeVars component for injecting scoped CSS via data-attributes - Add ThemeScopeDetector client component for runtime module/route detection - Add site-resolver for multi-site domain detection - Add /api/themes/export endpoint (JSON, CSS, variables formats) - Add /api/themes/export/embed.js for external integration widget - Add server actions for full CRUD on scopes and theme values - Add admin nav link and EN/NL translations
This commit is contained in:
1 parent
b57697b2e0
commit
a98b194386
19 files changed
+1843
No files matched your search
@@ -0,0 +1,37 @@
|
|||||||
|
-- Theme Builder: scoped theme system for per-route, per-module, and multi-site theming.
|
||||||
|
-- Idempotent: safe to re-run.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS theme_scopes (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('global','site','module','route') NOT NULL,
|
||||||
|
parent_id BIGINT UNSIGNED NULL,
|
||||||
|
site_domain VARCHAR(255) NULL,
|
||||||
|
route_path VARCHAR(255) NULL,
|
||||||
|
module_id VARCHAR(100) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY theme_scopes_parent_idx (parent_id),
|
||||||
|
KEY theme_scopes_type_idx (type),
|
||||||
|
UNIQUE KEY theme_scopes_unique_lookup (type, site_domain, route_path, module_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS theme_scope_values (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
scope_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
setting_key VARCHAR(100) NOT NULL,
|
||||||
|
setting_val VARCHAR(255) NOT NULL,
|
||||||
|
mode ENUM('light','dark') NOT NULL DEFAULT 'light',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY theme_scope_values_unique (scope_id, setting_key, mode),
|
||||||
|
KEY theme_scope_values_scope_idx (scope_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Seed: create the global scope from existing website_settings theme data.
|
||||||
|
INSERT IGNORE INTO theme_scopes (id, name, type, parent_id, is_active, sort_order)
|
||||||
|
VALUES (1, 'Global', 'global', NULL, 1, 0);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function ColorField({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
defaultValue,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
defaultValue: string;
|
||||||
|
backgroundColorKey?: string;
|
||||||
|
}) {
|
||||||
|
const [val, setVal] = useState(defaultValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg p-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
name={name}
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => setVal(e.target.value)}
|
||||||
|
className="w-[42px] h-[36px] p-0 border-none bg-none cursor-pointer flex-shrink-0 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-semibold text-[var(--admin-text)] leading-tight">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--admin-text-muted)] leading-snug line-clamp-2">
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getThemeBuilderData } from "@/actions/theme-builder";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { ScopeForm } from "./scope-form";
|
||||||
|
import { ScopeTree } from "./scope-tree";
|
||||||
|
import { ThemeEditor } from "./theme-editor";
|
||||||
|
|
||||||
|
export const metadata = { title: "Theme Builder" };
|
||||||
|
|
||||||
|
export default async function ThemeBuilderPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | undefined>>;
|
||||||
|
}) {
|
||||||
|
const ctx = await getAdminContext();
|
||||||
|
if (!ctx || !canAccess(ctx.permissions, PERMS.SETTINGS_VIEW)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const { scopes, tree, moduleIds } = await getThemeBuilderData();
|
||||||
|
|
||||||
|
const selectedId = sp.scope ? Number(sp.scope) : (scopes[0]?.id ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-section-title mb-4">Theme Builder</h1>
|
||||||
|
<p className="text-xs text-[var(--admin-text-muted)] mb-6">
|
||||||
|
Create and manage scoped themes for different parts of your site. Themes
|
||||||
|
inherit from parent scopes — only override what you need.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sp.created ? (
|
||||||
|
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||||
|
Scope created.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sp.updated ? (
|
||||||
|
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||||
|
Scope updated.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sp.deleted ? (
|
||||||
|
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||||
|
Scope deleted.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sp.saved ? (
|
||||||
|
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||||
|
Theme values saved.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sp.duplicated ? (
|
||||||
|
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||||
|
Scope duplicated.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<ScopeTree tree={tree} selectedId={selectedId} />
|
||||||
|
<div className="mt-4">
|
||||||
|
<ScopeForm moduleIds={moduleIds} scopeCount={scopes.length} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-9">
|
||||||
|
{selectedId ? (
|
||||||
|
<ThemeEditor scopeId={selectedId} scopes={scopes} />
|
||||||
|
) : (
|
||||||
|
<div className="admin-card p-8 text-center text-[var(--admin-text-muted)]">
|
||||||
|
Select or create a scope to start editing.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
|||||||
|
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import { resolveTheme } from "@/lib/theme-resolver";
|
||||||
|
|
||||||
|
// GET /api/themes/export/embed.js?module=shop
|
||||||
|
// Returns a self-contained JavaScript snippet that injects theme CSS variables.
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const moduleParam = url.searchParams.get("module") ?? undefined;
|
||||||
|
const route = url.searchParams.get("route") ?? undefined;
|
||||||
|
const site = url.searchParams.get("site") ?? undefined;
|
||||||
|
|
||||||
|
const resolved = await resolveTheme({
|
||||||
|
siteDomain: site ?? undefined,
|
||||||
|
routePath: route ?? undefined,
|
||||||
|
moduleId: moduleParam ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lightVars = THEME_COLOR_KEYS.map(
|
||||||
|
(key) => ` --${resolved.cssVariables[key]}: ${resolved.palette[key]};`,
|
||||||
|
).join("\n");
|
||||||
|
const darkVars = THEME_COLOR_KEYS.map(
|
||||||
|
(key) =>
|
||||||
|
` --${resolved.cssVariables[key]}: ${resolved.darkPalette[key]};`,
|
||||||
|
).join("\n");
|
||||||
|
|
||||||
|
const js = `(function(){
|
||||||
|
var s=document.createElement('style');
|
||||||
|
s.textContent=':root{\\n${lightVars.replace(/'/g, "\\'")}\\n}html.dark{\\n${darkVars.replace(/'/g, "\\'")}\\n}';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
return new Response(js, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/javascript",
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response("/* theme embed failed */", {
|
||||||
|
headers: { "Content-Type": "application/javascript" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { apiJson } from "@/lib/api";
|
||||||
|
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import { generateScopedCss, resolveTheme } from "@/lib/theme-resolver";
|
||||||
|
|
||||||
|
// GET /api/themes/export?module=shop&route=/login
|
||||||
|
// Exports the resolved theme as CSS variables and metadata.
|
||||||
|
// Useful for external integrations, iframes, and embed widgets.
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const module = url.searchParams.get("module") ?? undefined;
|
||||||
|
const route = url.searchParams.get("route") ?? undefined;
|
||||||
|
const site = url.searchParams.get("site") ?? undefined;
|
||||||
|
const format = url.searchParams.get("format") ?? "json";
|
||||||
|
|
||||||
|
const resolved = await resolveTheme({
|
||||||
|
siteDomain: site ?? undefined,
|
||||||
|
routePath: route ?? undefined,
|
||||||
|
moduleId: module ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (format === "css") {
|
||||||
|
const { rootCss, scopedCssBlocks } = generateScopedCss(resolved);
|
||||||
|
const fullCss = [rootCss, ...scopedCssBlocks].join("\n");
|
||||||
|
return new Response(fullCss, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/css",
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "variables") {
|
||||||
|
const variables: Record<string, string> = {};
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
variables[`--${resolved.cssVariables[key]}`] = resolved.palette[key];
|
||||||
|
}
|
||||||
|
return apiJson({
|
||||||
|
light: variables,
|
||||||
|
dark: Object.fromEntries(
|
||||||
|
THEME_COLOR_KEYS.map((key) => [
|
||||||
|
`--${resolved.cssVariables[key]}`,
|
||||||
|
resolved.darkPalette[key],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
scopes: resolved.contributingScopes.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: full JSON export
|
||||||
|
const lightVariables: Record<string, string> = {};
|
||||||
|
const darkVariables: Record<string, string> = {};
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
lightVariables[`--${resolved.cssVariables[key]}`] = resolved.palette[key];
|
||||||
|
darkVariables[`--${resolved.cssVariables[key]}`] =
|
||||||
|
resolved.darkPalette[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson({
|
||||||
|
palette: {
|
||||||
|
light: resolved.palette,
|
||||||
|
dark: resolved.darkPalette,
|
||||||
|
},
|
||||||
|
cssVariables: {
|
||||||
|
light: lightVariables,
|
||||||
|
dark: darkVariables,
|
||||||
|
},
|
||||||
|
scopes: resolved.contributingScopes.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
})),
|
||||||
|
meta: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
format: "atom-theme-v1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return apiJson({ error: "Failed to resolve theme" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import { getLocale, getMessages } from "next-intl/server";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
|
import { ScopedThemeVars } from "@/components/scoped-theme-vars";
|
||||||
import { SmoothScroll } from "@/components/smooth-scroll";
|
import { SmoothScroll } from "@/components/smooth-scroll";
|
||||||
|
import { ThemeScopeDetector } from "@/components/theme-scope-detector";
|
||||||
import { ThemeVars } from "@/components/theme-vars";
|
import { ThemeVars } from "@/components/theme-vars";
|
||||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
@@ -96,6 +98,8 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<ThemeVars nonce={nonce} />
|
<ThemeVars nonce={nonce} />
|
||||||
|
<ScopedThemeVars nonce={nonce} />
|
||||||
|
<ThemeScopeDetector />
|
||||||
{children}
|
{children}
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
<SmoothScroll />
|
<SmoothScroll />
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { detectModule } from "@/lib/theme-modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets data-theme-module and data-theme-route attributes on <html>
|
||||||
|
* based on the current pathname. This enables scoped CSS overrides
|
||||||
|
* from ScopedThemeVars.
|
||||||
|
*/
|
||||||
|
export function ThemeScopeDetector() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const moduleId = detectModule(pathname);
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
if (moduleId) {
|
||||||
|
html.setAttribute("data-theme-module", moduleId);
|
||||||
|
} else {
|
||||||
|
html.removeAttribute("data-theme-module");
|
||||||
|
}
|
||||||
|
|
||||||
|
html.setAttribute("data-theme-route", pathname);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2583,3 +2583,45 @@ export const YoutubePlaylists = mysqlTable(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Theme Builder: scoped theme system ──────────────────────────────────────
|
||||||
|
|
||||||
|
export const ThemeScope = mysqlTable("theme_scopes", {
|
||||||
|
id: bigint("id", { mode: "bigint", unsigned: true })
|
||||||
|
.autoincrement()
|
||||||
|
.primaryKey()
|
||||||
|
.notNull(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
type: mysqlEnum("type", ["global", "site", "module", "route"]).notNull(),
|
||||||
|
parentId: bigint("parent_id", { mode: "bigint", unsigned: true }),
|
||||||
|
siteDomain: varchar("site_domain", { length: 255 }),
|
||||||
|
routePath: varchar("route_path", { length: 255 }),
|
||||||
|
moduleId: varchar("module_id", { length: 100 }),
|
||||||
|
isActive: boolean("is_active").notNull().default(true),
|
||||||
|
sortOrder: int("sort_order").notNull().default(0),
|
||||||
|
createdAt: datetime("created_at").notNull(),
|
||||||
|
updatedAt: datetime("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ThemeScopeValue = mysqlTable(
|
||||||
|
"theme_scope_values",
|
||||||
|
{
|
||||||
|
id: bigint("id", { mode: "bigint", unsigned: true })
|
||||||
|
.autoincrement()
|
||||||
|
.primaryKey()
|
||||||
|
.notNull(),
|
||||||
|
scopeId: bigint("scope_id", { mode: "bigint", unsigned: true }).notNull(),
|
||||||
|
settingKey: varchar("setting_key", { length: 100 }).notNull(),
|
||||||
|
settingVal: varchar("setting_val", { length: 255 }).notNull(),
|
||||||
|
mode: mysqlEnum("mode", ["light", "dark"]).notNull().default("light"),
|
||||||
|
createdAt: datetime("created_at").notNull(),
|
||||||
|
updatedAt: datetime("updated_at").notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("theme_scope_values_unique").on(
|
||||||
|
t.scopeId,
|
||||||
|
t.settingKey,
|
||||||
|
t.mode,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -553,6 +553,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
permission: PERMS.SETTINGS_VIEW,
|
permission: PERMS.SETTINGS_VIEW,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/theme-builder",
|
||||||
|
labelKey: "themeBuilder",
|
||||||
|
icon: Palette,
|
||||||
|
permission: PERMS.SETTINGS_VIEW,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/maintenance",
|
href: "/admin/maintenance",
|
||||||
labelKey: "maintenance",
|
labelKey: "maintenance",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current site domain from request headers.
|
||||||
|
* Used by the theme resolver to apply site-scoped themes.
|
||||||
|
*/
|
||||||
|
export async function resolveSiteDomain(): Promise<string | null> {
|
||||||
|
const h = await headers();
|
||||||
|
// Next.js sets host in the Host header
|
||||||
|
const host = h.get("host") ?? h.get("x-forwarded-host");
|
||||||
|
if (!host) return null;
|
||||||
|
// Strip port if present
|
||||||
|
return host.split(":")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full theme context from the current request.
|
||||||
|
* Combines site domain + route path + module detection.
|
||||||
|
*/
|
||||||
|
export async function buildThemeContext(pathname: string): Promise<{
|
||||||
|
siteDomain: string | null;
|
||||||
|
routePath: string;
|
||||||
|
moduleId: string | null;
|
||||||
|
}> {
|
||||||
|
const siteDomain = await resolveSiteDomain();
|
||||||
|
const { detectModule } = await import("@/lib/theme-modules");
|
||||||
|
const moduleId = detectModule(pathname);
|
||||||
|
|
||||||
|
return {
|
||||||
|
siteDomain,
|
||||||
|
routePath: pathname,
|
||||||
|
moduleId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Module detection: maps route path prefixes to module IDs.
|
||||||
|
// Used by the theme resolver to apply module-scoped themes.
|
||||||
|
|
||||||
|
export const MODULE_MAP: Record<string, string> = {
|
||||||
|
"/shop": "shop",
|
||||||
|
"/marketplace": "marketplace",
|
||||||
|
"/rares": "rares",
|
||||||
|
"/guilds": "guilds",
|
||||||
|
"/radio": "radio",
|
||||||
|
"/news": "news",
|
||||||
|
"/help": "help",
|
||||||
|
"/events": "events",
|
||||||
|
"/photos": "photos",
|
||||||
|
"/badges": "badges",
|
||||||
|
"/rankings": "rankings",
|
||||||
|
"/leaderboard": "leaderboard",
|
||||||
|
"/community": "community",
|
||||||
|
"/staff": "staff",
|
||||||
|
"/settings": "settings",
|
||||||
|
"/messages": "messages",
|
||||||
|
"/friends": "friends",
|
||||||
|
"/apply": "apply",
|
||||||
|
"/developers": "developers",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the module ID for a given pathname.
|
||||||
|
* Returns the longest matching prefix to avoid false positives.
|
||||||
|
*/
|
||||||
|
export function detectModule(pathname: string): string | null {
|
||||||
|
// Sort by key length descending so longest prefix wins
|
||||||
|
const sortedKeys = Object.keys(MODULE_MAP).sort(
|
||||||
|
(a, b) => b.length - a.length,
|
||||||
|
);
|
||||||
|
for (const prefix of sortedKeys) {
|
||||||
|
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
||||||
|
return MODULE_MAP[prefix];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All registered module IDs (for admin UI dropdown).
|
||||||
|
*/
|
||||||
|
export function allModuleIds(): string[] {
|
||||||
|
return [...new Set(Object.values(MODULE_MAP))].sort();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -2598,6 +2598,7 @@
|
|||||||
"autoDj": "Auto DJ",
|
"autoDj": "Auto DJ",
|
||||||
"cms": "CMS",
|
"cms": "CMS",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
|
"themeBuilder": "Theme Builder",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
|
|||||||
@@ -2850,6 +2850,7 @@
|
|||||||
"autoDj": "Auto DJ",
|
"autoDj": "Auto DJ",
|
||||||
"cms": "CMS",
|
"cms": "CMS",
|
||||||
"theme": "Thema",
|
"theme": "Thema",
|
||||||
|
"themeBuilder": "Thema Bouwer",
|
||||||
"maintenance": "Onderhoud",
|
"maintenance": "Onderhoud",
|
||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"emulator": "Emulator",
|
"emulator": "Emulator",
|
||||||
|
|||||||
Reference in new issue
Block a user