diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 0511b0a..98a2bc8 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -13,7 +13,7 @@ export default defineConfig({ plugins: [ starlightThemeNova({ nav: [ - { label: 'Демо', href: '/demo/' }, + { label: 'Демо', href: '/demo/home' }, { label: 'Установка', href: '/getting-started/setup/' }, { label: 'GitHub', href: 'https://github.com/3252a8/remnawave-minishop' }, { label: 'Telegram', href: 'https://t.me/remnawave_minishop' } diff --git a/docs-site/package.json b/docs-site/package.json index f1be9ed..ba9a4d4 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -5,7 +5,7 @@ "sync:docs": "node ./scripts/sync-docs.mjs", "build:demo": "node ./scripts/build-demo-runtime.mjs", "dev": "npm run sync:docs && npm run build:demo && astro dev", - "build": "npm run sync:docs && npm run build:demo && astro build", + "build": "npm run sync:docs && npm run build:demo && astro build && node ./scripts/materialize-demo-routes.mjs", "preview": "astro preview" }, "dependencies": { diff --git a/docs-site/public/_redirects b/docs-site/public/_redirects new file mode 100644 index 0000000..ea15b6c --- /dev/null +++ b/docs-site/public/_redirects @@ -0,0 +1,20 @@ +# Cloudflare Pages rewrites for the static docs demo SPA. +# Keep these scoped to app routes so runtime JS/CSS/assets are served directly. +/demo/runtime /demo/runtime/app.html 200 +/demo/runtime/ /demo/runtime/app.html 200 +/demo/runtime/home /demo/runtime/app.html 200 +/demo/runtime/home/* /demo/runtime/app.html 200 +/demo/runtime/install /demo/runtime/app.html 200 +/demo/runtime/install/* /demo/runtime/app.html 200 +/demo/runtime/trial /demo/runtime/app.html 200 +/demo/runtime/trial/* /demo/runtime/app.html 200 +/demo/runtime/invite /demo/runtime/app.html 200 +/demo/runtime/invite/* /demo/runtime/app.html 200 +/demo/runtime/devices /demo/runtime/app.html 200 +/demo/runtime/devices/* /demo/runtime/app.html 200 +/demo/runtime/support /demo/runtime/app.html 200 +/demo/runtime/support/* /demo/runtime/app.html 200 +/demo/runtime/settings /demo/runtime/app.html 200 +/demo/runtime/settings/* /demo/runtime/app.html 200 +/demo/runtime/admin /demo/runtime/app.html 200 +/demo/runtime/admin/* /demo/runtime/app.html 200 diff --git a/docs-site/public/demo/demo-shell.js b/docs-site/public/demo/demo-shell.js new file mode 100644 index 0000000..f0db86b --- /dev/null +++ b/docs-site/public/demo/demo-shell.js @@ -0,0 +1,160 @@ +const frame = document.getElementById("demo-frame"); +const runtimeBase = "/demo/runtime"; +const demoBase = "/demo"; +const defaultMock = "tariffs"; +const stateMocks = new Set([ + "tariffs", + "depleted", + "no-subscription", + "trial", + "expiring", + "traffic", + "devices", +]); +const routeMocks = new Set([...stateMocks, "guides", "install"]); +const params = new URLSearchParams(window.location.search); + +const normalizePath = (value) => { + const raw = String(value || "").trim(); + if (!raw) return "/home"; + const withSlash = raw.startsWith("/") ? raw : `/${raw}`; + return withSlash.replace(/\/{2,}/g, "/").replace(/\/+$/, "") || "/home"; +}; + +const normalizeRouteMock = (value) => { + const mock = String(value || "").trim().toLowerCase(); + return routeMocks.has(mock) ? mock : defaultMock; +}; + +const normalizeStateMock = (value) => { + const mock = normalizeRouteMock(value); + return stateMocks.has(mock) ? mock : defaultMock; +}; + +const routeFromPublicPath = () => { + const pathname = window.location.pathname.replace(/\/+$/, "") || "/"; + const lowerPathname = pathname.toLowerCase(); + if (lowerPathname === demoBase) return ""; + if (!lowerPathname.startsWith(`${demoBase}/`)) return ""; + + const publicRoute = pathname.slice(demoBase.length); + if (!publicRoute || publicRoute.toLowerCase().startsWith("/runtime")) return ""; + return normalizePath(publicRoute); +}; + +const routeFromParams = () => { + const publicRoute = routeFromPublicPath(); + if (publicRoute) return publicRoute; + + const explicitPath = params.get("path"); + if (explicitPath) return normalizePath(explicitPath); + + const screen = String(params.get("screen") || "home") + .trim() + .toLowerCase(); + if (screen === "admin") { + const adminSection = String(params.get("admin_section") || "stats") + .trim() + .toLowerCase(); + return `/admin/${adminSection || "stats"}`; + } + if ( + [ + "home", + "install", + "trial", + "invite", + "devices", + "support", + "settings", + ].includes(screen) + ) { + return `/${screen}`; + } + return "/home"; +}; + +const initialRoute = routeFromParams(); +params.set("mock", normalizeRouteMock(params.get("mock"))); +params.delete("path"); +params.delete("screen"); +params.delete("admin_section"); +params.set("path", initialRoute); +frame.src = `${runtimeBase}/app.html?${params.toString()}${window.location.hash || ""}`; + +const routeFromRuntimeUrl = (url) => { + if (url.origin !== window.location.origin) return ""; + if (!url.pathname.toLowerCase().startsWith(runtimeBase.toLowerCase())) return ""; + const runtimePath = normalizePath(url.pathname.slice(runtimeBase.length) || "/home"); + if (runtimePath === "/app.html") { + return normalizePath(url.searchParams.get("path") || "/home"); + } + return runtimePath; +}; + +const materializedRouteFromRuntime = (route) => { + const normalized = normalizePath(route); + if (/^\/admin\/users\/-?\d+$/i.test(normalized)) return "/admin/users"; + if (/^\/admin\/payments\/\d+$/i.test(normalized)) return "/admin/payments"; + if (/^\/admin\/payments\/users\/-?\d+$/i.test(normalized)) return "/admin/payments"; + if (/^\/admin\/support\/\d+$/i.test(normalized)) return "/admin/support"; + if (/^\/support\/\d+$/i.test(normalized)) return "/support"; + return normalized; +}; + +const publicPathFromRoute = (route) => `${demoBase}${materializedRouteFromRuntime(route)}`; +const topbar = document.querySelector(".demo-topbar"); +const toggle = document.querySelector(".demo-topbar__toggle"); +const hide = document.querySelector(".demo-topbar__hide"); +const stateSelect = document.querySelector(".demo-topbar__state-select"); + +const syncParentUrlFromFrame = () => { + try { + const frameUrl = new URL(frame.contentWindow.location.href); + const route = routeFromRuntimeUrl(frameUrl); + if (!route) return; + + const nextUrl = new URL(window.location.href); + nextUrl.pathname = publicPathFromRoute(route); + nextUrl.searchParams.delete("path"); + + const mock = normalizeRouteMock(frameUrl.searchParams.get("mock") || params.get("mock")); + if (mock === defaultMock) nextUrl.searchParams.delete("mock"); + else nextUrl.searchParams.set("mock", mock); + if (stateSelect) stateSelect.value = normalizeStateMock(mock); + + nextUrl.searchParams.delete("screen"); + nextUrl.searchParams.delete("admin_section"); + const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; + const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextPath !== currentPath) window.history.replaceState(null, "", nextPath); + } catch (_error) { + // The iframe is same-origin in docs builds; this keeps local oddities harmless. + } +}; + +frame.addEventListener("load", syncParentUrlFromFrame); +window.setInterval(syncParentUrlFromFrame, 750); + +const setCollapsed = (collapsed) => { + topbar?.toggleAttribute("data-collapsed", collapsed); + toggle?.setAttribute("aria-expanded", String(!collapsed)); +}; + +toggle?.addEventListener("click", () => setCollapsed(false)); +hide?.addEventListener("click", () => setCollapsed(true)); +if (stateSelect) stateSelect.value = normalizeStateMock(params.get("mock")); +stateSelect?.addEventListener("change", () => { + const mock = normalizeStateMock(stateSelect.value); + const nextParams = new URLSearchParams(window.location.search); + nextParams.delete("path"); + nextParams.delete("screen"); + nextParams.delete("admin_section"); + if (mock === defaultMock) nextParams.delete("mock"); + else nextParams.set("mock", mock); + + const query = nextParams.toString(); + const publicUrl = `${demoBase}/home${query ? `?${query}` : ""}`; + window.history.replaceState(null, "", publicUrl); + frame.src = `${runtimeBase}/home?mock=${mock}`; +}); diff --git a/docs-site/scripts/materialize-demo-routes.mjs b/docs-site/scripts/materialize-demo-routes.mjs new file mode 100644 index 0000000..dd79fa5 --- /dev/null +++ b/docs-site/scripts/materialize-demo-routes.mjs @@ -0,0 +1,65 @@ +import { copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const siteRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const distRoot = path.join(siteRoot, "dist"); + +const userRoutes = [ + "home", + "install", + "trial", + "invite", + "devices", + "support", + "settings", +]; + +const adminRoutes = [ + "stats", + "users", + "payments", + "promos", + "ads", + "broadcast", + "logs", + "support", + "tariffs", + "appearance", + "translations", + "backups", + "settings", +]; + +const demoRoutes = [ + ...userRoutes.map((route) => `demo/${route}`), + "demo/admin", + ...adminRoutes.map((route) => `demo/admin/${route}`), +]; + +const runtimeRoutes = [ + ...userRoutes.map((route) => `demo/runtime/${route}`), + "demo/runtime/admin", + ...adminRoutes.map((route) => `demo/runtime/admin/${route}`), +]; + +async function copyHtml(source, route) { + const targetDir = path.join(distRoot, route); + await mkdir(targetDir, { recursive: true }); + await copyFile(source, path.join(targetDir, "index.html")); +} + +const demoShell = path.join(distRoot, "demo", "index.html"); +const runtimeApp = path.join(distRoot, "demo", "runtime", "app.html"); + +for (const route of demoRoutes) { + await copyHtml(demoShell, route); +} + +for (const route of runtimeRoutes) { + await copyHtml(runtimeApp, route); +} + +console.log( + `Materialized ${demoRoutes.length} public demo routes and ${runtimeRoutes.length} runtime routes`, +); diff --git a/docs-site/scripts/sync-docs.mjs b/docs-site/scripts/sync-docs.mjs index 8fface4..79aa695 100644 --- a/docs-site/scripts/sync-docs.mjs +++ b/docs-site/scripts/sync-docs.mjs @@ -102,7 +102,7 @@ function extraFrontmatter(sourceRelativePath) { ' html: \'Интерфейс Remnawave Minishop\'', ' actions:', ' - text: "Демо"', - ' link: /demo/', + ' link: /demo/home', ' icon: right-arrow', ' - text: "Установка"', ' link: /getting-started/setup/', diff --git a/docs-site/src/pages/demo.astro b/docs-site/src/pages/demo.astro index a7e31de..eb19f6a 100644 --- a/docs-site/src/pages/demo.astro +++ b/docs-site/src/pages/demo.astro @@ -1,5 +1,5 @@ --- -const defaultDemoSrc = '/demo/runtime/app.html?screen=home&mock=tariffs'; +const defaultDemoSrc = '/demo/runtime/app.html?path=/home&mock=tariffs'; const docsHref = '/getting-started/demo/'; --- @@ -80,6 +80,43 @@ const docsHref = '/getting-started/demo/'; line-height: 1.2; } + .demo-topbar__state { + display: flex; + flex: 0 1 24rem; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; + min-width: 0; + } + + .demo-topbar__state-label { + flex: 0 0 auto; + color: #94a3b8; + font-size: 0.78rem; + font-weight: 650; + line-height: 1; + } + + .demo-topbar__state-select { + min-width: 13.25rem; + max-width: 100%; + border: 1px solid rgb(148 163 184 / 28%); + border-radius: 7px; + padding: 0.42rem 1.9rem 0.42rem 0.65rem; + background: rgb(15 23 42 / 82%); + color: #f8fafc; + cursor: pointer; + font: inherit; + font-size: 0.82rem; + font-weight: 650; + line-height: 1.2; + } + + .demo-topbar__state-select:focus-visible { + outline: 2px solid rgb(0 254 122 / 72%); + outline-offset: 2px; + } + .demo-topbar__back { flex: 0 0 auto; border: 1px solid rgb(0 254 122 / 45%); @@ -158,6 +195,7 @@ const docsHref = '/getting-started/demo/'; .demo-topbar__panel { display: flex; + flex-wrap: wrap; gap: 0.55rem; align-items: center; width: min(20rem, calc(100vw - 1.1rem)); @@ -182,6 +220,23 @@ const docsHref = '/getting-started/demo/'; white-space: nowrap; } + .demo-topbar__state { + flex: 1 1 100%; + order: 3; + width: 100%; + justify-content: stretch; + } + + .demo-topbar__state-label { + display: none; + } + + .demo-topbar__state-select { + width: 100%; + min-width: 0; + font-size: 0.78rem; + } + .demo-topbar__actions { gap: 0.35rem; } @@ -229,6 +284,18 @@ const docsHref = '/getting-started/demo/';
remnawave-minishop demo
+
К документации