Very early. Very canary. Still a bit fluffy.
diff --git a/apps/web/src/vitnode.config.ts b/apps/web/src/vitnode.config.ts
index 733c4024ee..3820646f23 100644
--- a/apps/web/src/vitnode.config.ts
+++ b/apps/web/src/vitnode.config.ts
@@ -3,6 +3,14 @@ import { LogoVitNodeBrand } from '@vitnode/core/components/logo-vitnode'
import { buildConfig } from '@vitnode/core/vitnode.config'
import { examplePlugin } from '@vitnode/example/config'
import { forumPlugin } from '@vitnode/forum/config'
+import {
+ BookOpenIcon,
+ CompassIcon,
+ ListIcon,
+ MessagesSquareIcon,
+ PuzzleIcon,
+ SearchIcon,
+} from 'lucide-react'
export const vitNodeConfig = buildConfig({
debug: false,
@@ -41,6 +49,36 @@ export const vitNodeConfig = buildConfig({
shortTitle: 'VitNode',
title: 'VitNode',
},
+ navigation: {
+ header: [
+ { href: '/docs', icon: BookOpenIcon, title: 'site.menu.docs' },
+ { href: '/plugins', icon: PuzzleIcon, title: 'site.menu.plugins' },
+ {
+ href: '/forum',
+ icon: MessagesSquareIcon,
+ items: [
+ {
+ description: 'site.menu.discover_description',
+ href: '/discover',
+ icon: CompassIcon,
+ title: 'site.menu.discover',
+ },
+ {
+ description: 'site.menu.browse_description',
+ href: '/example/browse',
+ icon: ListIcon,
+ title: 'site.menu.browse',
+ },
+ ],
+ title: 'site.menu.community',
+ },
+ ],
+ mobileBottomBar: [
+ { href: '/forum', icon: MessagesSquareIcon, title: 'site.menu.forum' },
+ { href: '/discover', icon: CompassIcon, title: 'site.menu.discover' },
+ { href: '/search', icon: SearchIcon, title: 'site.menu.search' },
+ ],
+ },
plugins: [blogPlugin(), examplePlugin(), forumPlugin()],
theme: {
defaultTheme: 'system',
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts
index 2a9d3e3e5e..28aabac8ca 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts
@@ -18,14 +18,6 @@ const server = serve(
hostname: process.env.HOST,
port: Number(process.env.PORT ?? 8000),
},
- info => {
- const initMessage = "\x1b[34m[VitNode]\x1b[0m";
-
- // oxlint-disable-next-line no-console
- console.log(
- `${initMessage} API server is running on http://localhost:${info.port}`,
- );
- },
);
export default server;
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app/en.json b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app/en.json
index 69df08c5c3..92866b906f 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app/en.json
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/locales/app/en.json
@@ -37,6 +37,10 @@
"powered_by": "Powered by VitNode",
"docs": "Docs",
"sponsor": "Sponsor"
+ },
+ "menu": {
+ "discover": "Discover",
+ "search": "Search"
}
}
}
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx
index 3e97daeb83..5f0e4c098b 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx
@@ -6,9 +6,9 @@ import { createVitNodeQueryClient } from "@vitnode/core/lib/query-client";
import { createLocaleRewrite } from "@vitnode/core/tanstack/i18n";
import { setupLucideIconSsr } from "@vitnode/core/tanstack/icons";
import {
- Error500Page,
ErrorActions,
NotFound,
+ RouteErrorPage,
} from "@vitnode/core/tanstack/layout";
import { RoutePendingSpinner } from "@vitnode/core/tanstack/pending";
import {
@@ -64,7 +64,9 @@ export function getRouter() {
routeTree,
scrollRestoration: true,
defaultNotFoundComponent: () => } />,
- defaultErrorComponent: () => } />,
+ defaultErrorComponent: ({ error }) => (
+ } error={error} />
+ ),
});
holder.current = router;
diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts
index af90ad3f55..e146beecdd 100644
--- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts
+++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/vitnode.config.ts
@@ -1,4 +1,5 @@
import { buildConfig } from "@vitnode/core/vitnode.config";
+import { CompassIcon, SearchIcon } from "lucide-react";
export const vitNodeConfig = buildConfig({
debug: false,
@@ -11,6 +12,12 @@ export const vitNodeConfig = buildConfig({
shortTitle: "VitNode",
title: "VitNode",
},
+ navigation: {
+ header: [
+ { href: "/discover", icon: CompassIcon, title: "app.menu.discover" },
+ { href: "/search", icon: SearchIcon, title: "app.menu.search" },
+ ],
+ },
plugins: [],
theme: {
defaultTheme: "system",
diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json
index 4a740b0da9..4fd6f8bfd4 100644
--- a/packages/vitnode/package.json
+++ b/packages/vitnode/package.json
@@ -180,6 +180,11 @@
"types": "./dist/src/framework/content-registry/index.d.ts",
"default": "./dist/src/framework/content-registry/index.js"
},
+ "./framework/dev-error": {
+ "import": "./dist/src/framework/dev-error/index.js",
+ "types": "./dist/src/framework/dev-error/index.d.ts",
+ "default": "./dist/src/framework/dev-error/index.js"
+ },
"./framework/package-messages": {
"import": "./dist/src/framework/package-messages/index.js",
"types": "./dist/src/framework/package-messages/index.d.ts",
diff --git a/packages/vitnode/src/api/lib/navigation-presets.test.ts b/packages/vitnode/src/api/lib/navigation-presets.test.ts
deleted file mode 100644
index 2c48ab4c30..0000000000
--- a/packages/vitnode/src/api/lib/navigation-presets.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- collectNavigationPresets,
- findNavigationPreset,
- NavigationPresetError,
-} from "./navigation-presets";
-
-describe("collectNavigationPresets", () => {
- it("stamps every preset with its plugin and a resolved new-tab flag", () => {
- expect(
- collectNavigationPresets([
- {
- navigation: [{ href: "/discover", icon: "compass", id: "discover" }],
- pluginId: "@vitnode/core",
- },
- {
- navigation: [
- { href: "https://vitnode.com", id: "site", isOpenInNewTab: true },
- ],
- pluginId: "@vitnode/blog",
- },
- { pluginId: "@vitnode/example" },
- ]),
- ).toEqual([
- {
- href: "/discover",
- icon: "icon:compass",
- id: "discover",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
- {
- href: "https://vitnode.com",
- icon: null,
- id: "site",
- isOpenInNewTab: true,
- pluginId: "@vitnode/blog",
- },
- ]);
- });
-
- it.each(["Compass", "compass icon", "", 42])("refuses the icon %j", icon => {
- expect(() =>
- collectNavigationPresets([
- {
- navigation: [{ href: "/a", icon: icon as string, id: "home" }],
- pluginId: "@acme/a",
- },
- ]),
- ).toThrow(NavigationPresetError);
- });
-
- it("lets two plugins use the same preset id", () => {
- expect(
- collectNavigationPresets([
- { navigation: [{ href: "/a", id: "home" }], pluginId: "@acme/a" },
- { navigation: [{ href: "/b", id: "home" }], pluginId: "@acme/b" },
- ]),
- ).toHaveLength(2);
- });
-
- it("refuses one plugin declaring an id twice", () => {
- expect(() =>
- collectNavigationPresets([
- {
- navigation: [
- { href: "/a", id: "home" },
- { href: "/b", id: "home" },
- ],
- pluginId: "@acme/a",
- },
- ]),
- ).toThrow(NavigationPresetError);
- });
-
- it.each(["Home", "1 2", "-home", "home/page", ""])(
- "refuses the id %j",
- id => {
- expect(() =>
- collectNavigationPresets([
- { navigation: [{ href: "/a", id }], pluginId: "@acme/a" },
- ]),
- ).toThrow(NavigationPresetError);
- },
- );
-
- it.each(["", "home", "//cdn", "javascript:alert(1)"])(
- "refuses the href %j",
- href => {
- expect(() =>
- collectNavigationPresets([
- { navigation: [{ href, id: "home" }], pluginId: "@acme/a" },
- ]),
- ).toThrow(NavigationPresetError);
- },
- );
-});
-
-describe("findNavigationPreset", () => {
- const presets = collectNavigationPresets([
- { navigation: [{ href: "/a", id: "home" }], pluginId: "@acme/a" },
- { navigation: [{ href: "/b", id: "home" }], pluginId: "@acme/b" },
- ]);
-
- it("matches on the plugin and the id together", () => {
- expect(findNavigationPreset(presets, "@acme/b", "home")?.href).toBe("/b");
- expect(findNavigationPreset(presets, "@acme/c", "home")).toBeUndefined();
- });
-});
diff --git a/packages/vitnode/src/api/lib/navigation-presets.ts b/packages/vitnode/src/api/lib/navigation-presets.ts
deleted file mode 100644
index 483da1a5aa..0000000000
--- a/packages/vitnode/src/api/lib/navigation-presets.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import type {
- NavigationPreset,
- NavigationPresetDeclaration,
-} from "@/lib/navigation";
-
-import { isLucideIconName, serializeEmojiIcon } from "@/lib/emoji-icon";
-import {
- isValidNavigationHref,
- NAVIGATION_PRESET_ID_MAX_LENGTH,
- NAVIGATION_PRESET_ID_PATTERN,
-} from "@/lib/navigation";
-
-export class NavigationPresetError extends Error {
- constructor(message: string) {
- super(`[VitNode navigation] ${message}`);
- this.name = "NavigationPresetError";
- }
-}
-
-export interface NavigationPresetSource {
- navigation?: readonly NavigationPresetDeclaration[];
- pluginId: string;
-}
-
-const assertPresetId = (pluginId: string, id: unknown): string => {
- if (typeof id !== "string" || id.length === 0) {
- throw new NavigationPresetError(
- `Plugin "${pluginId}" declares a navigation preset without an id.`,
- );
- }
-
- if (
- id.length > NAVIGATION_PRESET_ID_MAX_LENGTH ||
- !NAVIGATION_PRESET_ID_PATTERN.test(id)
- ) {
- throw new NavigationPresetError(
- `Plugin "${pluginId}" declares the navigation preset "${id}". A preset id is lowercase letters, digits, "-" and "_", starts with a letter or digit, and is at most ${String(NAVIGATION_PRESET_ID_MAX_LENGTH)} characters.`,
- );
- }
-
- return id;
-};
-
-const assertPresetHref = (pluginId: string, id: string, href: unknown) => {
- if (typeof href !== "string" || !isValidNavigationHref(href)) {
- throw new NavigationPresetError(
- `Navigation preset "${id}" of "${pluginId}" needs an href that starts with "/" or "https://".`,
- );
- }
-
- return href;
-};
-
-const assertPresetIcon = (
- pluginId: string,
- id: string,
- icon: unknown,
-): null | string => {
- if (icon === undefined) return null;
-
- if (typeof icon !== "string" || !isLucideIconName(icon)) {
- throw new NavigationPresetError(
- `Navigation preset "${id}" of "${pluginId}" names the icon ${JSON.stringify(icon)}. An icon is a lucide name such as "compass".`,
- );
- }
-
- // Stored the way every other icon in VitNode is - the same `icon:name` string
- // a role prefix holds - so one picker writes it and one component draws it.
- return serializeEmojiIcon({ type: "icon", value: icon });
-};
-
-export const collectNavigationPresets = (
- plugins: readonly NavigationPresetSource[],
-): NavigationPreset[] => {
- const seen = new Set();
- const presets: NavigationPreset[] = [];
-
- for (const plugin of plugins) {
- for (const declaration of plugin.navigation ?? []) {
- const id = assertPresetId(plugin.pluginId, declaration.id);
- const key = `${plugin.pluginId}:${id}`;
-
- if (seen.has(key)) {
- throw new NavigationPresetError(
- `Plugin "${plugin.pluginId}" declares the navigation preset "${id}" twice.`,
- );
- }
- seen.add(key);
-
- presets.push({
- href: assertPresetHref(plugin.pluginId, id, declaration.href),
- icon: assertPresetIcon(plugin.pluginId, id, declaration.icon),
- id,
- isOpenInNewTab: declaration.isOpenInNewTab ?? false,
- pluginId: plugin.pluginId,
- });
- }
- }
-
- return presets;
-};
-
-export const findNavigationPreset = (
- presets: readonly NavigationPreset[],
- pluginId: string,
- presetId: string,
-): NavigationPreset | undefined =>
- presets.find(
- preset => preset.pluginId === pluginId && preset.id === presetId,
- );
diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts
index ce5de4405f..df7c250b42 100644
--- a/packages/vitnode/src/api/lib/plugin.ts
+++ b/packages/vitnode/src/api/lib/plugin.ts
@@ -8,7 +8,6 @@ import type { AnyContentModel } from "@/content/server/model";
import type { AnyContentTypeDefinition } from "@/content/types";
import type { ApiPluginContract } from "@/lib/fetcher/contract";
import type { LocaleMessagesMap } from "@/lib/i18n/types";
-import type { NavigationPresetDeclaration } from "@/lib/navigation";
import type { PluginManifest } from "@/lib/plugin-manifest";
import { BlockError } from "@/blocks/errors";
@@ -45,7 +44,6 @@ import { collectAiActions } from "./ai/registry";
import { checkPluginId } from "./check-plugin-id";
import { registerEditablePage, validateEditablePages } from "./editable-pages";
import { withModerationPermissions } from "./moderation/registry";
-import { collectNavigationPresets } from "./navigation-presets";
import { validateNotificationTypes } from "./notifications/registry";
import { applyModuleTags } from "./openapi-tags";
@@ -69,7 +67,6 @@ export interface BuildPluginApiReturn<
messages?: LocaleMessagesMap;
moderation?: Required;
modules: Modules;
- navigation?: NavigationPresetDeclaration[];
notificationTypes?: AnyNotificationTypeDefinition[];
openApiTags?: string[];
permissionStaff?: PermissionStaffConfig;
@@ -96,7 +93,6 @@ export function buildApiPlugin<
messages,
moderation,
modules = [] as unknown as Modules,
- navigation,
notificationTypes,
permissionStaff,
searchIndexers,
@@ -108,7 +104,6 @@ export function buildApiPlugin<
messages?: LocaleMessagesMap;
moderation?: ModerationPluginConfig;
modules?: Modules;
- navigation?: NavigationPresetDeclaration[];
notificationTypes?: AnyNotificationTypeDefinition[];
permissionStaff?: PermissionStaffConfig;
searchIndexers?: SearchIndexer[];
@@ -131,8 +126,6 @@ export function buildApiPlugin<
(editablePages ?? []).map(page => registerEditablePage(page, pluginId)),
);
- collectNavigationPresets([{ navigation, pluginId }]);
-
collectAiActions([{ aiActions, pluginId }]);
const hono = new OpenAPIHono();
@@ -229,7 +222,6 @@ export function buildApiPlugin<
manifest,
messages,
modules,
- navigation,
hono,
openApiTags: [...new Set(openApiTags)],
contentContainers: registeredContainers,
diff --git a/packages/vitnode/src/api/lib/response-cache-headers.test.ts b/packages/vitnode/src/api/lib/response-cache-headers.test.ts
deleted file mode 100644
index 0af0c49b58..0000000000
--- a/packages/vitnode/src/api/lib/response-cache-headers.test.ts
+++ /dev/null
@@ -1,277 +0,0 @@
-import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
-import { dirname, join, relative, resolve, sep } from "node:path";
-import { fileURLToPath } from "node:url";
-import { describe, expect, it } from "vitest";
-
-const here = dirname(fileURLToPath(import.meta.url));
-const sourceRoot = resolve(here, "../..");
-
-const SKIP_DIRECTORIES = ["dist", "node_modules"];
-
-const filesUnder = (directory: string): string[] => {
- if (!existsSync(directory)) return [];
-
- const entries: string[] = [];
-
- for (const name of readdirSync(directory)) {
- const path = join(directory, name);
-
- if (statSync(path).isDirectory()) {
- if (SKIP_DIRECTORIES.includes(name)) continue;
- entries.push(...filesUnder(path));
- continue;
- }
-
- if (/\.tsx?$/.test(name) && !name.endsWith(".d.ts")) entries.push(path);
- }
-
- return entries;
-};
-
-const isTest = (path: string): boolean =>
- /\.test(-d)?\.tsx?$/.test(path) || path.includes(`${sep}tests${sep}`);
-
-const runtimeSources = (): string[] =>
- filesUnder(sourceRoot).filter(path => !isTest(path));
-
-const asEntry = (path: string): string =>
- relative(sourceRoot, path).split(sep).join("/");
-
-/**
- * Comments stripped, so prose about caching is not mistaken for a header.
- *
- * This codebase explains its cache decisions at length next to the code that
- * makes them - which is right, and which would make a scan over raw text report
- * every one of those explanations.
- */
-const codeOf = (path: string): string =>
- readFileSync(path, "utf8")
- .replace(/\/\*[\s\S]*?\*\//g, "")
- .replace(/(^|[^:])\/\/.*$/gm, "$1");
-
-/**
- * Every `Cache-Control` value a source sets, however it spells the assignment.
- *
- * Three forms are matched: the object literal a `c.json(body, status, headers)`
- * takes, the `c.header("Cache-Control", value)` call, and a
- * `headers.set("cache-control", NAME)` whose value is a `const` declared in the
- * same file. That last one is not a convenience - the document rule is a named
- * constant on purpose, so the invariant can be documented once and referred to,
- * and a scan that could not follow it would report the file with no value and
- * pin nothing.
- *
- * A value assembled at runtime is still not captured, and would show up here as
- * a file with no values - which fails the "exactly the declared values"
- * assertion below rather than passing quietly.
- *
- * De-duplicated: what matters is which directives a file can produce, not how
- * many statements produce each one.
- */
-const cacheControlValues = (path: string): string[] => {
- const code = codeOf(path);
- const constants = new Map(
- [
- ...code.matchAll(
- /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*["'`]([^"'`]*)["'`]/g,
- ),
- ].map(match => [match[1], match[2]] as const),
- );
-
- return [
- ...new Set(
- [
- ...code.matchAll(
- /["'`]?[Cc]ache-[Cc]ontrol["'`]?\s*[,:]\s*(?:["'`]([^"'`]*)["'`]|([A-Za-z_$][\w$]*))/g,
- ),
- ]
- .map(match => match[1] ?? constants.get(match[2]))
- .filter((value): value is string => value !== undefined),
- ),
- ];
-};
-
-const mentionsCacheControl = (path: string): boolean =>
- /[Cc]ache-[Cc]ontrol/.test(codeOf(path));
-
-const isSharedCacheable = (value: string): boolean => {
- const directives = value
- .toLowerCase()
- .split(",")
- .map(one => one.trim());
-
- if (directives.includes("no-store")) return false;
- if (directives.includes("private")) return false;
- if (directives.includes("public")) return true;
-
- return directives.some(one => {
- const seconds = /^s-maxage=(\d+)$/.exec(one) ?? /^max-age=(\d+)$/.exec(one);
-
- return seconds !== null && Number(seconds[1]) > 0;
- });
-};
-
-const DECLARED = {
- "api/modules/moderation/routes/approvals.routes.ts": ["private, no-store"],
- "content/server/delivery-routes.ts": ["private, no-store"],
- "content/server/public-routes.ts": ["private, no-store"],
- "content/server/public-write-routes.ts": ["private, no-store"],
- // Reads the header of API responses: a private one keeps a page uncached.
- "page-cache/render-context.server.ts": [],
- "tanstack/start/document-headers.ts": ["private, no-store"],
- // The HTML cache. A shared page advertises no lifetime at all - every
- // browser and CDN revalidates it, so a hard invalidation is never outlived
- // downstream. Everything else gets `DOCUMENT_CACHE_CONTROL`, imported.
- "tanstack/page-cache/server.ts": ["no-cache", "private, no-cache"],
-} satisfies Record;
-
-describe("the cacheability predicate says what it means", () => {
- it.each([
- "no-store",
- "private, no-store",
- "private",
- "private, max-age=60",
- "no-cache, no-store, must-revalidate",
- ])("treats %s as private", value => {
- expect(isSharedCacheable(value)).toBe(false);
- });
-
- it.each([
- "public",
- "public, max-age=3600",
- "s-maxage=60",
- "max-age=300",
- "max-age=300, stale-while-revalidate=60",
- ])("treats %s as shared-cacheable", value => {
- // The control for the assertions below: a predicate that answered `false` to
- // everything would satisfy all of them.
- expect(isSharedCacheable(value)).toBe(true);
- });
-
- it("does not mistake max-age=0 for a shared cache directive", () => {
- expect(isSharedCacheable("max-age=0")).toBe(false);
- });
-});
-
-describe("this test is looking at the right tree", () => {
- it("has sources to scan", () => {
- expect(runtimeSources().length).toBeGreaterThan(100);
- });
-
- it("finds the header that provably exists", () => {
- // The control. Every "found nothing" assertion below would also be satisfied
- // by a scan that matched nothing at all.
- expect(
- cacheControlValues(join(sourceRoot, "content/server/public-routes.ts")),
- ).toEqual(["private, no-store"]);
- });
-});
-
-describe("no response is offered to a shared cache", () => {
- const setters = (): string[] =>
- runtimeSources().filter(mentionsCacheControl).map(asEntry).sort();
-
- it("sets the header in exactly the declared places", () => {
- // A new `Cache-Control` anywhere in the package has to be classified here
- // before it can ship. That is the whole enforcement: the values are then
- // checked, and a middleware is refused outright.
- expect(setters()).toEqual(Object.keys(DECLARED).sort());
- });
-
- it("sets exactly the declared values", () => {
- const actual = Object.fromEntries(
- setters().map(entry => [
- entry,
- cacheControlValues(join(sourceRoot, entry)),
- ]),
- );
-
- expect(actual).toStrictEqual(DECLARED);
- });
-
- it.each(Object.values(DECLARED).flat())(
- "%s keeps the response out of a shared cache",
- value => {
- expect(isSharedCacheable(value)).toBe(false);
- },
- );
-});
-
-describe("no middleware applies a cache policy by path", () => {
- const surfaces = () =>
- runtimeSources().filter(path => {
- const entry = asEntry(path);
-
- return (
- entry.startsWith("api/middlewares/") ||
- entry === "api/lib/route.ts" ||
- entry === "api/config.ts"
- );
- });
-
- it("has the middleware layer to check", () => {
- expect(surfaces().length).toBeGreaterThan(3);
- });
-
- it("sets no cache header anywhere in it", () => {
- expect(surfaces().filter(mentionsCacheControl).map(asEntry)).toEqual([]);
- });
-
- it("mounts no cache middleware from hono", () => {
- // `hono/cache` is a Web Cache API middleware. It stores by request URL and
- // knows nothing about a cookie, so mounting it over `/api` would serve one
- // visitor's authenticated response to the next.
- const offenders = runtimeSources()
- .filter(path => /["']hono\/cache["']/.test(codeOf(path)))
- .map(asEntry);
-
- expect(offenders).toEqual([]);
- });
-});
-
-describe("Vary is set only where a header actually chose the response", () => {
- const varyValues = (path: string): string[] =>
- [
- ...codeOf(path).matchAll(
- /["'`]?Vary["'`]?\s*[,:]\s*["'`]([^"'`]*)["'`]/g,
- ),
- ].map(match => match[1]);
-
- const setters = (): string[] =>
- runtimeSources()
- .filter(path => varyValues(path).length > 0)
- .map(asEntry)
- .sort();
-
- it("is set in exactly one place", () => {
- expect(setters()).toEqual(["content/server/public-routes.ts"]);
- });
-
- it("varies on Accept-Language and nothing else", () => {
- expect(
- varyValues(join(sourceRoot, "content/server/public-routes.ts")),
- ).toEqual(["Accept-Language"]);
- });
-
- it("adds it only when the locale was negotiated", () => {
- // An explicit `?locale=` is part of the URL, so the response is already
- // keyed by it. Varying on a header that decided nothing fragments every
- // shared cache for no correctness at all - and varying on it when it *did*
- // decide is what stops a Polish response being served to an English reader.
- expect(codeOf(join(sourceRoot, "content/server/public-routes.ts"))).toMatch(
- /source === "negotiated"\s*\?\s*\{ Vary: "Accept-Language" \}/,
- );
- });
-});
-
-describe("nothing claims conditional-request support it does not have", () => {
- it("sets no ETag", () => {
- // No route computes one, so a `If-None-Match` is answered with a full body.
- // Stated as a test because a hand-written `ETag` on one route would imply a
- // 304 path that does not exist anywhere else.
- const offenders = runtimeSources()
- .filter(path => /["'`]ETag["'`]\s*[,:]/i.test(codeOf(path)))
- .map(asEntry);
-
- expect(offenders).toEqual([]);
- });
-});
diff --git a/packages/vitnode/src/api/lib/updates/status.test.ts b/packages/vitnode/src/api/lib/updates/status.test.ts
index 2b5fdc05fc..5f7695b36c 100644
--- a/packages/vitnode/src/api/lib/updates/status.test.ts
+++ b/packages/vitnode/src/api/lib/updates/status.test.ts
@@ -315,6 +315,7 @@ describe("summarizeUpdates", () => {
checkedComponents: 4,
core: { latestVersion: "2.1.0" },
pluginUpdatesAvailable: 2,
+ totalComponents: 4,
unconfirmedComponents: 0,
updatesAvailable: 3,
});
@@ -354,6 +355,7 @@ describe("summarizeUpdates", () => {
notCheckedComponents: 1,
oldestSuccessAt: null,
stale: true,
+ totalComponents: 6,
unconfirmedComponents: 3,
});
});
diff --git a/packages/vitnode/src/api/lib/updates/status.ts b/packages/vitnode/src/api/lib/updates/status.ts
index d5cf37e4a0..3ae1ac0eea 100644
--- a/packages/vitnode/src/api/lib/updates/status.ts
+++ b/packages/vitnode/src/api/lib/updates/status.ts
@@ -160,6 +160,7 @@ export interface UpdatesSummary {
pluginUpdatesAvailable: number;
running: boolean;
stale: boolean;
+ totalComponents: number;
unconfirmedComponents: number;
updatesAvailable: number;
}
@@ -211,6 +212,7 @@ export const summarizeUpdates = (overview: UpdatesOverview): UpdatesSummary => {
pluginUpdatesAvailable: plugins.filter(isUpdateAvailable).length,
running: overview.running,
stale: components.some(component => component.stale),
+ totalComponents: components.length,
unconfirmedComponents: components.filter(
component =>
component.result?.status !== "up_to_date" &&
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 4104ad083a..b65205e0f6 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -7,7 +7,6 @@ import type { RegisteredContentType } from "@/content/registry";
import type { RegisteredContentModel } from "@/content/server/model";
import type { LocaleRouting } from "@/lib/i18n/locale-routing";
import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types";
-import type { NavigationPreset } from "@/lib/navigation";
import type { UpdateChannel } from "@/lib/updates/manifest";
import type { PersonalInformationFields } from "@/lib/user-personal-information";
import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config";
@@ -83,7 +82,6 @@ import {
type LoggerMiddlewareType,
} from "../lib/logger-middleware";
import { validateModerationRegistry } from "../lib/moderation/registry";
-import { collectNavigationPresets } from "../lib/navigation-presets";
import { resolveNotificationWorkerSettings } from "../lib/notifications/preferences";
import { createNotificationRegistry } from "../lib/notifications/registry";
import { resolvePasskeysConfig } from "../lib/passkey-config";
@@ -175,8 +173,6 @@ export interface EnvVariablesVitNode {
title: string;
};
moderation: ModerationRegistry;
- /** Every prebuilt main-menu item the installed plugins offer. */
- navigation: NavigationPreset[];
notifications?: NotificationRegistry;
notificationWorkers?: NotificationWorkerSettings;
permissionStaff: PermissionStaffCatalogEntry[];
@@ -447,9 +443,6 @@ export const globalMiddleware = ({
assertContentAiActions(contentTypesMetadata, aiActionsMetadata);
assertAiModelPricing(ai?.models ?? []);
- const navigationMetadata: NavigationPreset[] =
- collectNavigationPresets(plugins);
-
const notificationsMetadata = createNotificationRegistry(
plugins.flatMap(plugin =>
(plugin.notificationTypes ?? []).map(definition => ({
@@ -565,7 +558,6 @@ export const globalMiddleware = ({
webSockets: webSocketsMetadata,
permissionStaff: permissionStaffMetadata,
moderation: moderationMetadata,
- navigation: navigationMetadata,
notifications: notificationsMetadata,
notificationWorkers,
contentContainers: contentContainersMetadata,
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 044aecee9f..1c323a2ee2 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -2,7 +2,6 @@ import type { Context } from "hono";
import { randomUUID } from "node:crypto";
-import type { NavigationLocation } from "@/lib/navigation";
import type { SsoProfileField } from "@/lib/sso-profile";
import type { EventListenerConfig } from "../lib/events";
@@ -101,19 +100,6 @@ export interface VitNodeEvents {
reportId: number;
resolution: string;
};
- "navigation.created": {
- navigationId: number;
- };
- "navigation.deleted": {
- navigationId: number;
- };
- "navigation.reordered": {
- items: { children: number[]; id: number }[];
- location: NavigationLocation;
- };
- "navigation.updated": {
- navigationId: number;
- };
"notifications.deleted_all": {
items: number;
};
diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts
index da59e6986d..2626d8c116 100644
--- a/packages/vitnode/src/api/modules/admin/admin.module.ts
+++ b/packages/vitnode/src/api/modules/admin/admin.module.ts
@@ -5,7 +5,6 @@ import { aiAdminModule } from "./ai/ai.admin.module";
import { dashboardAdminModule } from "./dashboard/dashboard.admin.module";
import { debugAdminModule } from "./debug/debug.admin.module";
import { filesAdminModule } from "./files/files.admin.module";
-import { navigationAdminModule } from "./navigation/navigation.admin.module";
import { notificationsAdminModule } from "./notifications/notifications.admin.module";
import { pluginsAdminModule } from "./plugins/plugins.admin.module";
import { rolesAdminModule } from "./roles/roles.admin.module";
@@ -26,7 +25,6 @@ export const adminModule = buildModule({
advancedAdminModule,
filesAdminModule,
dashboardAdminModule,
- navigationAdminModule,
notificationsAdminModule,
pluginsAdminModule,
],
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/cache.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/cache.ts
deleted file mode 100644
index 7eb5319b68..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/cache.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type { Context } from "hono";
-
-import { createHash } from "node:crypto";
-
-import type {
- NavigationPreset,
- PublicNavigationItem,
- PublicNavigationNode,
-} from "@/lib/navigation";
-
-import {
- readNavigationRecords,
- toPublicBottomBar,
- toPublicNavigation,
-} from "./read-navigation";
-
-export const PUBLIC_NAVIGATION_CACHE_KEY = "navigation:public";
-
-export const NAVIGATION_CACHE_TTL_SECONDS = 60 * 60 * 24 * 30;
-
-export interface PublicNavigationMenus {
- bottomBar: PublicNavigationItem[];
- navigation: PublicNavigationNode[];
-}
-
-const presetFingerprints = new WeakMap();
-
-const presetFingerprintOf = (presets: readonly NavigationPreset[]): string => {
- const known = presetFingerprints.get(presets);
- if (known) return known;
-
- const fingerprint = createHash("sha256")
- .update(JSON.stringify(presets))
- .digest("hex")
- .slice(0, 16);
- presetFingerprints.set(presets, fingerprint);
-
- return fingerprint;
-};
-
-export const publicNavigationCacheKey = (
- presets: readonly NavigationPreset[],
-): string => `${PUBLIC_NAVIGATION_CACHE_KEY}:${presetFingerprintOf(presets)}`;
-
-const readPublicNavigationMenus = async (
- c: Context,
-): Promise => {
- const records = await readNavigationRecords(c);
- const presets = c.get("core").navigation;
-
- return {
- bottomBar: toPublicBottomBar(records, presets),
- navigation: toPublicNavigation(records, presets),
- };
-};
-
-export const loadPublicNavigationMenus = async (
- c: Context,
-): Promise =>
- await c
- .get("cache")
- .remember(
- publicNavigationCacheKey(c.get("core").navigation),
- NAVIGATION_CACHE_TTL_SECONDS,
- async () => readPublicNavigationMenus(c),
- );
-
-export const expireNavigationCache = async (c: Context): Promise => {
- await c
- .get("cache")
- .delete(publicNavigationCacheKey(c.get("core").navigation));
-};
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/location.test.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/location.test.ts
deleted file mode 100644
index c9650c2336..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/location.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { NAVIGATION_BOTTOM_BAR_MAX_ITEMS } from "@/lib/navigation";
-
-import {
- navigationCapacityProblem,
- navigationLocationOf,
- navigationOrderProblem,
- navigationPlacementProblem,
-} from "./location";
-
-describe("navigationCapacityProblem", () => {
- it("lets the bottom bar fill up to its limit", () => {
- expect(
- navigationCapacityProblem({
- count: NAVIGATION_BOTTOM_BAR_MAX_ITEMS - 1,
- location: "bottom_bar",
- }),
- ).toBeNull();
- });
-
- it("refuses a link past the bottom bar's limit", () => {
- expect(
- navigationCapacityProblem({
- count: NAVIGATION_BOTTOM_BAR_MAX_ITEMS,
- location: "bottom_bar",
- }),
- ).toBe("bottomBarFull");
- });
-
- it("never limits the header", () => {
- expect(
- navigationCapacityProblem({ count: 50, location: "header" }),
- ).toBeNull();
- });
-});
-
-describe("navigationPlacementProblem", () => {
- it("allows any top-level item", () => {
- expect(
- navigationPlacementProblem({ location: "bottom_bar", parentId: null }),
- ).toBeNull();
- expect(
- navigationPlacementProblem({ location: "header", parentId: null }),
- ).toBeNull();
- });
-
- it("allows a header dropdown under a header parent", () => {
- expect(
- navigationPlacementProblem({
- location: "header",
- parentId: 1,
- parentLocation: "header",
- }),
- ).toBeNull();
- });
-
- it("refuses nesting in the bottom bar", () => {
- expect(
- navigationPlacementProblem({ location: "bottom_bar", parentId: 1 }),
- ).toBe("bottomBarNested");
- });
-
- it("refuses a parent from the other menu", () => {
- expect(
- navigationPlacementProblem({
- location: "header",
- parentId: 1,
- parentLocation: "bottom_bar",
- }),
- ).toBe("otherLocation");
- });
-});
-
-describe("navigationOrderProblem", () => {
- it("accepts a nested header order", () => {
- expect(
- navigationOrderProblem({
- items: [{ children: [2, 3] }],
- location: "header",
- }),
- ).toBeNull();
- });
-
- it("accepts a flat bottom bar order", () => {
- expect(
- navigationOrderProblem({
- items: [{ children: [] }, { children: [] }],
- location: "bottom_bar",
- }),
- ).toBeNull();
- });
-
- it("refuses children in a bottom bar order", () => {
- expect(
- navigationOrderProblem({
- items: [{ children: [2] }],
- location: "bottom_bar",
- }),
- ).toBe("bottomBarNested");
- });
-});
-
-describe("navigationLocationOf", () => {
- it("reads a stored value, treating anything unknown as the header", () => {
- expect(navigationLocationOf("bottom_bar")).toBe("bottom_bar");
- expect(navigationLocationOf("header")).toBe("header");
- expect(navigationLocationOf("sidebar")).toBe("header");
- });
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/location.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/location.ts
deleted file mode 100644
index 9e550e67bc..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/location.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { NavigationLocation } from "@/lib/navigation";
-
-import { NAVIGATION_BOTTOM_BAR_MAX_ITEMS } from "@/lib/navigation";
-
-export const NAVIGATION_LOCATION_ERRORS = {
- bottomBarFull: `The bottom bar holds at most ${NAVIGATION_BOTTOM_BAR_MAX_ITEMS} links`,
- bottomBarNested: "The bottom bar has no dropdowns",
- otherLocation: "A menu item can only be nested inside its own menu",
-} as const;
-
-export type NavigationLocationProblem = keyof typeof NAVIGATION_LOCATION_ERRORS;
-
-export const isNestableLocation = (location: NavigationLocation): boolean =>
- location === "header";
-
-export const navigationCapacityProblem = ({
- count,
- location,
-}: {
- count: number;
- location: NavigationLocation;
-}): NavigationLocationProblem | null =>
- location === "bottom_bar" && count >= NAVIGATION_BOTTOM_BAR_MAX_ITEMS
- ? "bottomBarFull"
- : null;
-
-export const navigationPlacementProblem = ({
- location,
- parentId,
- parentLocation,
-}: {
- location: NavigationLocation;
- parentId: null | number;
- parentLocation?: NavigationLocation;
-}): NavigationLocationProblem | null => {
- if (parentId === null) return null;
- if (!isNestableLocation(location)) return "bottomBarNested";
- if (parentLocation !== undefined && parentLocation !== location) {
- return "otherLocation";
- }
-
- return null;
-};
-
-export const navigationOrderProblem = ({
- items,
- location,
-}: {
- items: readonly { children: readonly number[] }[];
- location: NavigationLocation;
-}): NavigationLocationProblem | null =>
- !isNestableLocation(location) && items.some(item => item.children.length > 0)
- ? "bottomBarNested"
- : null;
-
-export const navigationLocationOf = (value: string): NavigationLocation =>
- value === "bottom_bar" ? "bottom_bar" : "header";
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/position.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/position.ts
deleted file mode 100644
index 209773133c..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/position.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { Context } from "hono";
-
-import { and, count, desc, eq, isNull } from "drizzle-orm";
-
-import type { NavigationLocation } from "@/lib/navigation";
-
-import { core_navigation } from "@/database/navigation";
-
-import { navigationLocationOf } from "./location";
-
-export type NavigationDatabase = Omit;
-
-export const nextNavigationPosition = async (
- db: NavigationDatabase,
- parentId: null | number,
- location: NavigationLocation,
-): Promise => {
- const [last] = await db
- .select({ position: core_navigation.position })
- .from(core_navigation)
- .where(
- parentId === null
- ? and(
- isNull(core_navigation.parentId),
- eq(core_navigation.location, location),
- )
- : eq(core_navigation.parentId, parentId),
- )
- .orderBy(desc(core_navigation.position))
- .limit(1);
-
- return (last?.position ?? -1) + 1;
-};
-
-export type NavigationParentProblem = "depth" | "missing";
-
-export const checkNavigationParent = async (
- c: Context,
- parentId: number,
-): Promise<
- | { location: NavigationLocation; problem: null }
- | { location?: never; problem: NavigationParentProblem }
-> => {
- const [parent] = await c
- .get("db")
- .select({
- id: core_navigation.id,
- location: core_navigation.location,
- parentId: core_navigation.parentId,
- })
- .from(core_navigation)
- .where(eq(core_navigation.id, parentId))
- .limit(1);
-
- if (!parent) return { problem: "missing" };
- if (parent.parentId !== null) return { problem: "depth" };
-
- return { location: navigationLocationOf(parent.location), problem: null };
-};
-
-export const countNavigationRoots = async (
- db: NavigationDatabase,
- location: NavigationLocation,
-): Promise => {
- const [row] = await db
- .select({ total: count() })
- .from(core_navigation)
- .where(
- and(
- isNull(core_navigation.parentId),
- eq(core_navigation.location, location),
- ),
- );
-
- return row?.total ?? 0;
-};
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.test.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.test.ts
deleted file mode 100644
index b81f979626..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.test.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import type { NavigationPreset } from "@/lib/navigation";
-
-import type { NavigationRecord } from "./read-navigation";
-
-import {
- groupNavigationWords,
- navigationTreeOf,
- toPublicBottomBar,
- toPublicNavigation,
- withNavigationPresets,
-} from "./read-navigation";
-
-const at = new Date("2026-09-20T10:00:00Z");
-
-const record = (overrides: Partial): NavigationRecord => ({
- createdAt: at,
- description: [],
- href: null,
- icon: null,
- id: 1,
- isOpenInNewTab: false,
- kind: "preset",
- location: "header",
- parentId: null,
- pluginId: "@vitnode/core",
- position: 0,
- presetId: "discover",
- title: [],
- updatedAt: at,
- ...overrides,
-});
-
-const custom = (
- id: number,
- overrides: Partial = {},
-): NavigationRecord =>
- record({
- href: `/custom-${String(id)}`,
- id,
- kind: "custom",
- pluginId: null,
- presetId: null,
- title: [{ languageCode: "en", value: `Custom ${String(id)}` }],
- ...overrides,
- });
-
-const presets: NavigationPreset[] = [
- {
- href: "/discover",
- icon: "icon:compass",
- id: "discover",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
-];
-
-describe("toPublicNavigation", () => {
- it("lets an item's own icon win over the one its plugin ships", () => {
- const [node] = toPublicNavigation([record({ icon: "icon:star" })], presets);
-
- expect(node.icon).toBe("icon:star");
- });
-
- it("resolves a preset's href from the registry, not the row", () => {
- expect(toPublicNavigation([record({ href: "/stale" })], presets)).toEqual([
- {
- description: [],
- href: "/discover",
- icon: "icon:compass",
- id: 1,
- isOpenInNewTab: false,
- items: [],
- kind: "preset",
- pluginId: "@vitnode/core",
- presetId: "discover",
- title: [],
- },
- ]);
- });
-
- it("skips a preset whose plugin is no longer installed", () => {
- expect(
- toPublicNavigation(
- [record({ pluginId: "@acme/gone", presetId: "page" })],
- presets,
- ),
- ).toEqual([]);
- });
-
- it("nests children under their parent, in position order", () => {
- const nodes = toPublicNavigation(
- [
- record({}),
- custom(3, { parentId: 1, position: 1 }),
- custom(2, { parentId: 1, position: 0 }),
- custom(4, { position: 1 }),
- ].sort((a, b) => a.position - b.position || a.id - b.id),
- presets,
- );
-
- expect(nodes.map(node => node.id)).toEqual([1, 4]);
- expect(nodes[0].items.map(item => item.id)).toEqual([2, 3]);
- expect(nodes[1].items).toEqual([]);
- });
-
- it("promotes the children of a parent the menu has to skip", () => {
- const nodes = toPublicNavigation(
- [
- record({ pluginId: "@acme/gone", presetId: "page" }),
- custom(2, { parentId: 1 }),
- custom(3, { position: 1 }),
- ],
- presets,
- );
-
- expect(nodes.map(node => node.id)).toEqual([2, 3]);
- expect(nodes[0].items).toEqual([]);
- });
-
- it("drops a custom link that lost its href", () => {
- expect(toPublicNavigation([custom(2, { href: null })], presets)).toEqual(
- [],
- );
- });
-});
-
-describe("navigationTreeOf", () => {
- it("treats a child of a child as a root, so the menu never goes two deep", () => {
- const { childrenOf, roots } = navigationTreeOf([
- { id: 1, parentId: null },
- { id: 2, parentId: 1 },
- { id: 3, parentId: 2 },
- { id: 4, parentId: 99 },
- ]);
-
- expect(roots.map(root => root.id)).toEqual([1, 3, 4]);
- expect(childrenOf.get(1)?.map(child => child.id)).toEqual([2]);
- });
-});
-
-describe("withNavigationPresets", () => {
- it("attaches the preset for the admin list and null when it is missing", () => {
- const [found, missing] = withNavigationPresets(
- [record({}), record({ id: 2, presetId: "gone" })],
- presets,
- );
-
- expect(found.preset).toEqual(presets[0]);
- expect(missing.preset).toBeNull();
- });
-});
-
-describe("groupNavigationWords", () => {
- it("splits title and description translations per item", () => {
- const grouped = groupNavigationWords([
- { itemId: 1, languageCode: "en", value: "Docs", variable: "title" },
- {
- itemId: 1,
- languageCode: "en",
- value: "Guides",
- variable: "description",
- },
- { itemId: 2, languageCode: "en", value: "?", variable: "unknown" },
- ]);
-
- expect(grouped.get(1)).toEqual({
- description: [{ languageCode: "en", value: "Guides" }],
- title: [{ languageCode: "en", value: "Docs" }],
- });
- expect(grouped.get(2)).toEqual({ description: [], title: [] });
- });
-});
-
-describe("the bottom bar", () => {
- const bar = (id: number, overrides: Partial = {}) =>
- custom(id, { location: "bottom_bar", position: id, ...overrides });
-
- it("keeps bottom bar items out of the header menu", () => {
- const header = toPublicNavigation([custom(1), bar(2)], []);
-
- expect(header.map(item => item.id)).toEqual([1]);
- });
-
- it("lists only bottom bar items, flat and in order", () => {
- const items = toPublicBottomBar([custom(1), bar(2), bar(3)], []);
-
- expect(items.map(item => item.id)).toEqual([2, 3]);
- expect(items[0]).not.toHaveProperty("items");
- });
-
- it("caps the bottom bar at three links", () => {
- const items = toPublicBottomBar([bar(1), bar(2), bar(3), bar(4)], []);
-
- expect(items.map(item => item.id)).toEqual([1, 2, 3]);
- });
-
- it("skips a nested row that somehow landed in the bottom bar", () => {
- const items = toPublicBottomBar([bar(1), bar(2, { parentId: 1 })], []);
-
- expect(items.map(item => item.id)).toEqual([1]);
- });
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.ts
deleted file mode 100644
index f5c3b3330f..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/read-navigation.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import type { Context } from "hono";
-
-import { and, asc, eq, inArray } from "drizzle-orm";
-
-import type {
- NavigationKind,
- NavigationLocation,
- NavigationPreset,
- NavigationText,
- PublicNavigationItem,
- PublicNavigationNode,
-} from "@/lib/navigation";
-
-import { findNavigationPreset } from "@/api/lib/navigation-presets";
-import { core_languages_words } from "@/database/languages";
-import { core_navigation } from "@/database/navigation";
-import {
- NAVIGATION_BOTTOM_BAR_MAX_ITEMS,
- NAVIGATION_KINDS,
- NAVIGATION_TABLE_NAME,
- NAVIGATION_WORDS,
-} from "@/lib/navigation";
-import { manifest } from "@/manifest";
-
-import { navigationLocationOf } from "./location";
-
-export interface NavigationRecord {
- createdAt: Date;
- description: NavigationText[];
- href: null | string;
- icon: null | string;
- id: number;
- isOpenInNewTab: boolean;
- kind: NavigationKind;
- location: NavigationLocation;
- parentId: null | number;
- pluginId: null | string;
- position: number;
- presetId: null | string;
- title: NavigationText[];
- updatedAt: Date;
-}
-
-export interface AdminNavigationRecord extends NavigationRecord {
- preset: NavigationPreset | null;
-}
-
-interface NavigationWord {
- itemId: number;
- languageCode: string;
- value: string;
- variable: string;
-}
-
-const isNavigationKind = (value: string): value is NavigationKind =>
- (NAVIGATION_KINDS as readonly string[]).includes(value);
-
-export const groupNavigationWords = (
- words: readonly NavigationWord[],
-): Map => {
- const byItem = new Map<
- number,
- { description: NavigationText[]; title: NavigationText[] }
- >();
-
- for (const word of words) {
- const entry = byItem.get(word.itemId) ?? { description: [], title: [] };
- const text = { languageCode: word.languageCode, value: word.value };
-
- if (word.variable === NAVIGATION_WORDS.title) entry.title.push(text);
- if (word.variable === NAVIGATION_WORDS.description) {
- entry.description.push(text);
- }
-
- byItem.set(word.itemId, entry);
- }
-
- return byItem;
-};
-
-export const readNavigationWords = async (
- c: Context,
- itemIds: readonly number[],
-): Promise => {
- if (itemIds.length === 0) return [];
-
- return await c
- .get("db")
- .select({
- itemId: core_languages_words.itemId,
- languageCode: core_languages_words.languageCode,
- value: core_languages_words.value,
- variable: core_languages_words.variable,
- })
- .from(core_languages_words)
- .where(
- and(
- eq(core_languages_words.pluginCode, manifest.id),
- eq(core_languages_words.tableName, NAVIGATION_TABLE_NAME),
- inArray(core_languages_words.variable, [
- NAVIGATION_WORDS.title,
- NAVIGATION_WORDS.description,
- ]),
- inArray(core_languages_words.itemId, [...itemIds]),
- ),
- );
-};
-
-export const readNavigationRecords = async (
- c: Context,
-): Promise => {
- const rows = await c
- .get("db")
- .select()
- .from(core_navigation)
- .orderBy(asc(core_navigation.position), asc(core_navigation.id));
-
- const words = groupNavigationWords(
- await readNavigationWords(
- c,
- rows.map(row => row.id),
- ),
- );
-
- return rows.map(row => ({
- createdAt: row.createdAt,
- description: words.get(row.id)?.description ?? [],
- href: row.href,
- icon: row.icon,
- id: row.id,
- isOpenInNewTab: row.isOpenInNewTab,
- kind: isNavigationKind(row.kind) ? row.kind : "custom",
- location: navigationLocationOf(row.location),
- parentId: row.parentId,
- pluginId: row.pluginId,
- position: row.position,
- presetId: row.presetId,
- title: words.get(row.id)?.title ?? [],
- updatedAt: row.updatedAt,
- }));
-};
-
-const presetOf = (
- record: Pick,
- presets: readonly NavigationPreset[],
-): NavigationPreset | null =>
- record.kind === "preset" && record.pluginId && record.presetId
- ? (findNavigationPreset(presets, record.pluginId, record.presetId) ?? null)
- : null;
-
-export const withNavigationPresets = (
- records: readonly NavigationRecord[],
- presets: readonly NavigationPreset[],
-): AdminNavigationRecord[] =>
- records.map(record => ({ ...record, preset: presetOf(record, presets) }));
-
-export interface NavigationTree<
- T extends { id: number; parentId: null | number },
-> {
- childrenOf: Map;
- roots: T[];
-}
-
-export const navigationTreeOf = <
- T extends { id: number; parentId: null | number },
->(
- records: readonly T[],
-): NavigationTree => {
- const byId = new Map(records.map(record => [record.id, record]));
- const parentOf = (record: T): null | number => {
- if (record.parentId === null) return null;
-
- const parent = byId.get(record.parentId);
-
- return parent?.parentId === null ? parent.id : null;
- };
-
- const roots: T[] = [];
- const childrenOf = new Map();
-
- for (const record of records) {
- const parentId = parentOf(record);
- if (parentId === null) {
- roots.push(record);
- continue;
- }
-
- childrenOf.set(parentId, [...(childrenOf.get(parentId) ?? []), record]);
- }
-
- return { childrenOf, roots };
-};
-
-const publicItemOf = (
- record: NavigationRecord,
- presets: readonly NavigationPreset[],
-): null | PublicNavigationItem => {
- const preset = presetOf(record, presets);
- const href = record.kind === "preset" ? preset?.href : record.href;
- if (!href) return null;
-
- return {
- description: record.description,
- href,
- icon: record.icon ?? preset?.icon ?? null,
- id: record.id,
- isOpenInNewTab: record.isOpenInNewTab,
- kind: record.kind,
- pluginId: record.pluginId,
- presetId: record.presetId,
- title: record.title,
- };
-};
-
-export const toPublicBottomBar = (
- records: readonly NavigationRecord[],
- presets: readonly NavigationPreset[],
-): PublicNavigationItem[] =>
- records
- .filter(record => record.location === "bottom_bar")
- .filter(record => record.parentId === null)
- .flatMap(record => {
- const item = publicItemOf(record, presets);
-
- return item ? [item] : [];
- })
- .slice(0, NAVIGATION_BOTTOM_BAR_MAX_ITEMS);
-
-export const toPublicNavigation = (
- records: readonly NavigationRecord[],
- presets: readonly NavigationPreset[],
-): PublicNavigationNode[] => {
- const { childrenOf, roots } = navigationTreeOf(
- records.filter(record => record.location === "header"),
- );
-
- return roots.flatMap(root => {
- const items = (childrenOf.get(root.id) ?? []).flatMap(child => {
- const item = publicItemOf(child, presets);
-
- return item ? [item] : [];
- });
- const item = publicItemOf(root, presets);
-
- if (!item) return items.map(orphan => ({ ...orphan, items: [] }));
-
- return [{ ...item, items }];
- });
-};
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.test.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.test.ts
deleted file mode 100644
index 56e59cb921..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { reorderNavigationTree } from "./reorder";
-
-describe("reorderNavigationTree", () => {
- it("turns an ordered tree into parent and position per item", () => {
- expect(
- reorderNavigationTree(
- [
- { children: [3], id: 2 },
- { children: [], id: 1 },
- ],
- [1, 2, 3],
- ),
- ).toEqual({
- ok: true,
- placements: [
- { id: 2, parentId: null, position: 0 },
- { id: 3, parentId: 2, position: 0 },
- { id: 1, parentId: null, position: 1 },
- ],
- });
- });
-
- it("refuses an id that appears twice, as a root or a child", () => {
- expect(
- reorderNavigationTree(
- [
- { children: [1], id: 2 },
- { children: [], id: 1 },
- ],
- [1, 2],
- ),
- ).toEqual({ ok: false, reason: "duplicate" });
- });
-
- it("refuses an id that is not in the menu", () => {
- expect(reorderNavigationTree([{ children: [9], id: 1 }], [1, 2])).toEqual({
- ok: false,
- reason: "unknown",
- });
- });
-
- it("refuses an order that leaves an item out", () => {
- expect(reorderNavigationTree([{ children: [], id: 1 }], [1, 2])).toEqual({
- ok: false,
- reason: "incomplete",
- });
- });
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.ts
deleted file mode 100644
index 91a9bc8319..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/reorder.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-export interface NavigationOrderInput {
- children: readonly number[];
- id: number;
-}
-
-export interface NavigationPlacement {
- id: number;
- parentId: null | number;
- position: number;
-}
-
-export type ReorderNavigationOutcome =
- | { ok: false; reason: "duplicate" | "incomplete" | "unknown" }
- | { ok: true; placements: NavigationPlacement[] };
-
-export const reorderNavigationTree = (
- requested: readonly NavigationOrderInput[],
- existing: readonly number[],
-): ReorderNavigationOutcome => {
- const known = new Set(existing);
- const seen = new Set();
- const placements: NavigationPlacement[] = [];
-
- const place = (
- placement: NavigationPlacement,
- ): "duplicate" | "unknown" | null => {
- if (seen.has(placement.id)) return "duplicate";
- if (!known.has(placement.id)) return "unknown";
- seen.add(placement.id);
- placements.push(placement);
-
- return null;
- };
-
- for (const [position, root] of requested.entries()) {
- const rootProblem = place({ id: root.id, parentId: null, position });
- if (rootProblem) return { ok: false, reason: rootProblem };
-
- for (const [childPosition, childId] of root.children.entries()) {
- const childProblem = place({
- id: childId,
- parentId: root.id,
- position: childPosition,
- });
- if (childProblem) return { ok: false, reason: childProblem };
- }
- }
-
- if (seen.size !== known.size) return { ok: false, reason: "incomplete" };
-
- return { ok: true, placements };
-};
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/schema.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/schema.ts
deleted file mode 100644
index f9b130a60e..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/schema.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { z } from "@hono/zod-openapi";
-
-import {
- EMOJI_ICON_MAX_LENGTH,
- parseEmojiIcon,
- serializeEmojiIcon,
-} from "@/lib/emoji-icon";
-import {
- NAVIGATION_DESCRIPTION_MAX_LENGTH,
- NAVIGATION_HREF_MAX_LENGTH,
- NAVIGATION_KINDS,
- NAVIGATION_LOCATIONS,
- NAVIGATION_PRESET_ID_MAX_LENGTH,
- NAVIGATION_TITLE_MAX_LENGTH,
-} from "@/lib/navigation";
-
-const navigationTextSchema = (maxLength: number) =>
- z.array(
- z.object({
- languageCode: z.string().min(1).max(32),
- value: z.string().max(maxLength),
- }),
- );
-
-export const zodNavigationIdSchema = z.number().int().positive();
-
-export const zodNavigationLocationSchema = z.enum(NAVIGATION_LOCATIONS);
-
-export const zodNavigationTitleSchema = navigationTextSchema(
- NAVIGATION_TITLE_MAX_LENGTH,
-);
-
-export const zodNavigationDescriptionSchema = navigationTextSchema(
- NAVIGATION_DESCRIPTION_MAX_LENGTH,
-);
-
-export const zodNavigationHrefSchema = z
- .string()
- .min(1)
- .max(NAVIGATION_HREF_MAX_LENGTH);
-
-export const zodNavigationIconSchema = z.string().max(EMOJI_ICON_MAX_LENGTH);
-
-export const zodNavigationPresetSchema = z.object({
- href: z.string(),
- icon: z.string().nullable(),
- id: z.string(),
- isOpenInNewTab: z.boolean(),
- pluginId: z.string(),
-});
-
-export const zodPublicNavigationItemSchema = z.object({
- id: z.number(),
- kind: z.enum(NAVIGATION_KINDS),
- pluginId: z.string().nullable(),
- presetId: z.string().nullable(),
- href: z.string(),
- icon: z.string().nullable(),
- isOpenInNewTab: z.boolean(),
- title: zodNavigationTitleSchema,
- description: zodNavigationDescriptionSchema,
-});
-
-export const zodPublicNavigationNodeSchema =
- zodPublicNavigationItemSchema.extend({
- items: z.array(zodPublicNavigationItemSchema),
- });
-
-export const zodAdminNavigationItemSchema = z.object({
- id: z.number(),
- parentId: z.number().nullable(),
- location: zodNavigationLocationSchema,
- kind: z.enum(NAVIGATION_KINDS),
- pluginId: z.string().nullable(),
- presetId: z.string().nullable(),
- href: z.string().nullable(),
- icon: z.string().nullable(),
- isOpenInNewTab: z.boolean(),
- position: z.number(),
- title: zodNavigationTitleSchema,
- description: zodNavigationDescriptionSchema,
- preset: zodNavigationPresetSchema.nullable(),
- createdAt: z.date(),
- updatedAt: z.date(),
-});
-
-/**
- * An icon on the way in: the same `icon:name` value a role prefix holds.
- *
- * Emoji are refused rather than stored - the menu offers the icon half of the
- * picker only, and a payload saying otherwise is not a menu this API writes.
- */
-export const normalizeNavigationIcon = (
- icon: null | string | undefined,
-): { ok: false } | { ok: true; value: null | string } => {
- if (icon === undefined) return { ok: true, value: null };
-
- const trimmed = icon?.trim() ?? "";
- if (trimmed === "") return { ok: true, value: null };
-
- const parsed = parseEmojiIcon(trimmed);
-
- return parsed?.type === "icon"
- ? { ok: true, value: serializeEmojiIcon(parsed) }
- : { ok: false };
-};
-
-export const hasNavigationText = (
- values: readonly { value: string }[] | undefined,
-): boolean => (values ?? []).some(item => item.value.trim().length > 0);
-
-const sharedCreateFields = {
- description: zodNavigationDescriptionSchema.optional(),
- icon: zodNavigationIconSchema.nullable().optional(),
- isOpenInNewTab: z.boolean().optional(),
- location: zodNavigationLocationSchema.optional(),
- parentId: zodNavigationIdSchema.nullable().optional(),
-};
-
-export const zodCreateNavigationSchema = z.discriminatedUnion("kind", [
- z.object({
- kind: z.literal("preset"),
- pluginId: z.string().min(1).max(50),
- presetId: z.string().min(1).max(NAVIGATION_PRESET_ID_MAX_LENGTH),
- title: zodNavigationTitleSchema.optional(),
- ...sharedCreateFields,
- }),
- z.object({
- kind: z.literal("custom"),
- href: zodNavigationHrefSchema,
- title: zodNavigationTitleSchema.min(1),
- ...sharedCreateFields,
- }),
-]);
-
-export const zodUpdateNavigationSchema = z
- .object({
- href: zodNavigationHrefSchema,
- title: zodNavigationTitleSchema,
- description: zodNavigationDescriptionSchema,
- icon: zodNavigationIconSchema.nullable(),
- isOpenInNewTab: z.boolean(),
- parentId: zodNavigationIdSchema.nullable(),
- })
- .partial()
- .refine(body => Object.values(body).some(value => value !== undefined), {
- message: "At least one field is required",
- });
-
-export const zodReorderNavigationSchema = z.object({
- location: zodNavigationLocationSchema.optional(),
- items: z
- .array(
- z.object({
- id: zodNavigationIdSchema,
- children: z.array(zodNavigationIdSchema),
- }),
- )
- .min(1),
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/lib/words.ts b/packages/vitnode/src/api/modules/admin/navigation/lib/words.ts
deleted file mode 100644
index 00b5835c7e..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/lib/words.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Context } from "hono";
-
-import type { NavigationText } from "@/lib/navigation";
-
-import { saveLanguageWords } from "@/api/lib/save-language-words";
-import { NAVIGATION_TABLE_NAME, NAVIGATION_WORDS } from "@/lib/navigation";
-import { manifest } from "@/manifest";
-
-const nonEmpty = (values: readonly NavigationText[]): NavigationText[] =>
- values
- .map(item => ({
- languageCode: item.languageCode,
- value: item.value.trim(),
- }))
- .filter(item => item.value.length > 0);
-
-export const saveNavigationWords = async (
- c: Context,
- itemId: number,
- {
- description,
- title,
- }: {
- description?: readonly NavigationText[];
- title?: readonly NavigationText[];
- },
-): Promise => {
- if (title !== undefined) {
- await saveLanguageWords(c, {
- itemId,
- pluginCode: manifest.id,
- tableName: NAVIGATION_TABLE_NAME,
- values: nonEmpty(title),
- variable: NAVIGATION_WORDS.title,
- });
- }
-
- if (description !== undefined) {
- await saveLanguageWords(c, {
- itemId,
- pluginCode: manifest.id,
- tableName: NAVIGATION_TABLE_NAME,
- values: nonEmpty(description),
- variable: NAVIGATION_WORDS.description,
- });
- }
-};
diff --git a/packages/vitnode/src/api/modules/admin/navigation/navigation.admin.module.ts b/packages/vitnode/src/api/modules/admin/navigation/navigation.admin.module.ts
deleted file mode 100644
index 05ccfc5b5d..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/navigation.admin.module.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { buildModule } from "@/api/builders";
-
-import { createNavigationAdminRoute } from "./routes/create.route";
-import { deleteNavigationAdminRoute } from "./routes/delete.route";
-import { listNavigationAdminRoute } from "./routes/list.route";
-import { presetsNavigationAdminRoute } from "./routes/presets.route";
-import { reorderNavigationAdminRoute } from "./routes/reorder.route";
-import { updateNavigationAdminRoute } from "./routes/update.route";
-
-export const navigationAdminModule = buildModule({
- name: "navigation",
- routes: [
- listNavigationAdminRoute,
- presetsNavigationAdminRoute,
- createNavigationAdminRoute,
- reorderNavigationAdminRoute,
- updateNavigationAdminRoute,
- deleteNavigationAdminRoute,
- ],
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/navigation/routes/create.route.ts
deleted file mode 100644
index ddc5c6892b..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/routes/create.route.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { z } from "@hono/zod-openapi";
-import { and, eq, sql } from "drizzle-orm";
-
-import { buildRoute } from "@/api/builders";
-import { findNavigationPreset } from "@/api/lib/navigation-presets";
-import { core_navigation } from "@/database/navigation";
-import { isValidNavigationHref } from "@/lib/navigation";
-
-import { expireNavigationCache } from "../lib/cache";
-import {
- NAVIGATION_LOCATION_ERRORS,
- navigationCapacityProblem,
- navigationPlacementProblem,
-} from "../lib/location";
-import {
- checkNavigationParent,
- countNavigationRoots,
- nextNavigationPosition,
-} from "../lib/position";
-import {
- hasNavigationText,
- normalizeNavigationIcon,
- zodCreateNavigationSchema,
-} from "../lib/schema";
-import { saveNavigationWords } from "../lib/words";
-
-const errorSchema = z.object({ error: z.string() });
-
-export const NAVIGATION_ICON_ERROR =
- "An icon is a lucide icon, written as icon:compass";
-
-export const NAVIGATION_PRESET_TAKEN_ERROR =
- "This prebuilt item is already in the menu";
-
-export const NAVIGATION_PARENT_ERRORS = {
- depth: "A menu item can only be nested one level deep",
- missing: "Parent menu item not found",
-} as const;
-
-export const createNavigationAdminRoute = buildRoute({
- adminStaffPermission: { module: "navigation", permission: "can_create" },
- route: {
- method: "post",
- description:
- "Add a prebuilt page or a custom link to the header menu or the bottom bar (Admin only)",
- path: "/create",
- request: {
- body: {
- required: true,
- content: {
- "application/json": {
- schema: zodCreateNavigationSchema,
- },
- },
- },
- },
- responses: {
- 201: {
- content: {
- "application/json": {
- schema: z.object({ id: z.number() }),
- },
- },
- description: "Navigation item created",
- },
- 400: {
- content: { "application/json": { schema: errorSchema } },
- description: "Invalid item",
- },
- 403: {
- description: "Access Denied",
- },
- 404: {
- content: { "application/json": { schema: errorSchema } },
- description: "Preset or parent not found",
- },
- 409: {
- content: { "application/json": { schema: errorSchema } },
- description: "Preset already in this menu, or the bottom bar is full",
- },
- },
- },
- handler: async c => {
- const body = c.req.valid("json");
- const db = c.get("db");
- const parentId = body.parentId ?? null;
- const location = body.location ?? "header";
-
- const icon = normalizeNavigationIcon(body.icon);
- if (!icon.ok) {
- return c.json({ error: NAVIGATION_ICON_ERROR }, 400);
- }
-
- let parentLocation: typeof location | undefined;
- if (parentId !== null) {
- const parent = await checkNavigationParent(c, parentId);
- if (parent.problem === "missing") {
- return c.json({ error: NAVIGATION_PARENT_ERRORS.missing }, 404);
- }
- if (parent.problem === "depth") {
- return c.json({ error: NAVIGATION_PARENT_ERRORS.depth }, 400);
- }
- parentLocation = parent.location;
- }
-
- const placementProblem = navigationPlacementProblem({
- location,
- parentId,
- parentLocation,
- });
- if (placementProblem) {
- return c.json(
- { error: NAVIGATION_LOCATION_ERRORS[placementProblem] },
- 400,
- );
- }
-
- let values: Pick<
- typeof core_navigation.$inferInsert,
- "href" | "isOpenInNewTab" | "kind" | "pluginId" | "presetId"
- >;
-
- if (body.kind === "preset") {
- const preset = findNavigationPreset(
- c.get("core").navigation,
- body.pluginId,
- body.presetId,
- );
- if (!preset) {
- return c.json({ error: "Navigation preset not found" }, 404);
- }
-
- values = {
- href: null,
- isOpenInNewTab: body.isOpenInNewTab ?? preset.isOpenInNewTab,
- kind: "preset",
- pluginId: body.pluginId,
- presetId: body.presetId,
- };
- } else {
- if (!isValidNavigationHref(body.href)) {
- return c.json({ error: "The URL must start with / or https://" }, 400);
- }
- if (!hasNavigationText(body.title)) {
- return c.json({ error: "A title is required" }, 400);
- }
-
- values = {
- href: body.href,
- isOpenInNewTab: body.isOpenInNewTab ?? false,
- kind: "custom",
- pluginId: null,
- presetId: null,
- };
- }
-
- const created = await db.transaction(
- async (tx): Promise<{ error: string } | { id: number }> => {
- await tx.execute(
- sql`select pg_advisory_xact_lock(hashtext(${`core_navigation:${location}`}))`,
- );
-
- const capacityProblem = navigationCapacityProblem({
- count: await countNavigationRoots(tx, location),
- location,
- });
- if (capacityProblem) {
- return { error: NAVIGATION_LOCATION_ERRORS[capacityProblem] };
- }
-
- if (body.kind === "preset") {
- const [existing] = await tx
- .select({ id: core_navigation.id })
- .from(core_navigation)
- .where(
- and(
- eq(core_navigation.kind, "preset"),
- eq(core_navigation.location, location),
- eq(core_navigation.pluginId, body.pluginId),
- eq(core_navigation.presetId, body.presetId),
- ),
- )
- .limit(1);
- if (existing) {
- return { error: NAVIGATION_PRESET_TAKEN_ERROR };
- }
- }
-
- const [inserted] = await tx
- .insert(core_navigation)
- .values({
- ...values,
- icon: icon.value,
- location,
- parentId,
- position: await nextNavigationPosition(tx, parentId, location),
- updatedAt: new Date(),
- })
- .returning({ id: core_navigation.id });
-
- return { id: inserted.id };
- },
- );
-
- if ("error" in created) {
- return c.json({ error: created.error }, 409);
- }
-
- await saveNavigationWords(c, created.id, {
- description: body.description,
- title: body.title,
- });
-
- await expireNavigationCache(c);
- await c.get("events").emit("navigation.created", {
- navigationId: created.id,
- });
-
- return c.json({ id: created.id }, 201);
- },
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/navigation/routes/delete.route.ts
deleted file mode 100644
index b37c65c293..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/routes/delete.route.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { z } from "@hono/zod-openapi";
-import { and, asc, desc, eq, inArray, isNull } from "drizzle-orm";
-
-import { buildRoute } from "@/api/builders";
-import { core_languages_words } from "@/database/languages";
-import { core_navigation } from "@/database/navigation";
-import { NAVIGATION_TABLE_NAME, NAVIGATION_WORDS } from "@/lib/navigation";
-import { manifest } from "@/manifest";
-
-import { expireNavigationCache } from "../lib/cache";
-
-export const deleteNavigationAdminRoute = buildRoute({
- adminStaffPermission: { module: "navigation", permission: "can_delete" },
- route: {
- method: "delete",
- description:
- "Remove an item from the main menu; anything nested under it moves to the top level (Admin only)",
- path: "/{id}",
- request: {
- params: z.object({
- id: z.string().openapi({ example: "1" }),
- }),
- },
- responses: {
- 200: {
- description: "Navigation item deleted",
- },
- 403: {
- description: "Access Denied",
- },
- 404: {
- content: {
- "application/json": {
- schema: z.object({ error: z.string() }),
- },
- },
- description: "Navigation item not found",
- },
- },
- },
- handler: async c => {
- const { id } = c.req.valid("param");
- const db = c.get("db");
-
- const itemId = Number(id);
- if (!Number.isInteger(itemId)) {
- return c.json({ error: "Navigation item not found" }, 404);
- }
-
- const [item] = await db
- .select({ id: core_navigation.id, location: core_navigation.location })
- .from(core_navigation)
- .where(eq(core_navigation.id, itemId))
- .limit(1);
- if (!item) {
- return c.json({ error: "Navigation item not found" }, 404);
- }
-
- await db.transaction(async tx => {
- const children = await tx
- .select({ id: core_navigation.id })
- .from(core_navigation)
- .where(eq(core_navigation.parentId, itemId))
- .orderBy(asc(core_navigation.position), asc(core_navigation.id));
-
- if (children.length > 0) {
- const [lastRoot] = await tx
- .select({ position: core_navigation.position })
- .from(core_navigation)
- .where(
- and(
- isNull(core_navigation.parentId),
- eq(core_navigation.location, item.location),
- ),
- )
- .orderBy(desc(core_navigation.position))
- .limit(1);
- let position = (lastRoot?.position ?? -1) + 1;
-
- for (const child of children) {
- await tx
- .update(core_navigation)
- .set({ parentId: null, position, updatedAt: new Date() })
- .where(eq(core_navigation.id, child.id));
- position += 1;
- }
- }
-
- await tx
- .delete(core_languages_words)
- .where(
- and(
- eq(core_languages_words.pluginCode, manifest.id),
- eq(core_languages_words.tableName, NAVIGATION_TABLE_NAME),
- inArray(core_languages_words.variable, [
- NAVIGATION_WORDS.title,
- NAVIGATION_WORDS.description,
- ]),
- eq(core_languages_words.itemId, itemId),
- ),
- );
-
- await tx.delete(core_navigation).where(eq(core_navigation.id, itemId));
- });
-
- await expireNavigationCache(c);
- await c.get("events").emit("navigation.deleted", { navigationId: itemId });
-
- return c.body(null, 200);
- },
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/navigation/routes/list.route.ts
deleted file mode 100644
index 924162284f..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/routes/list.route.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { z } from "@hono/zod-openapi";
-
-import { buildRoute } from "@/api/builders";
-
-import {
- readNavigationRecords,
- withNavigationPresets,
-} from "../lib/read-navigation";
-import { zodAdminNavigationItemSchema } from "../lib/schema";
-
-export const listNavigationAdminRoute = buildRoute({
- adminStaffPermission: { module: "navigation", permission: "can_view" },
- route: {
- method: "get",
- description: "Every main menu item, in menu order (Admin only)",
- path: "/list",
- responses: {
- 200: {
- content: {
- "application/json": {
- schema: z.object({
- items: z.array(zodAdminNavigationItemSchema),
- }),
- },
- },
- description: "Navigation items",
- },
- 403: {
- description: "Access Denied",
- },
- },
- },
- handler: async c => {
- const records = await readNavigationRecords(c);
-
- return c.json(
- { items: withNavigationPresets(records, c.get("core").navigation) },
- 200,
- );
- },
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/routes/presets.route.ts b/packages/vitnode/src/api/modules/admin/navigation/routes/presets.route.ts
deleted file mode 100644
index 321c3b780e..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/routes/presets.route.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { z } from "@hono/zod-openapi";
-
-import { buildRoute } from "@/api/builders";
-
-import { zodNavigationPresetSchema } from "../lib/schema";
-
-export const presetsNavigationAdminRoute = buildRoute({
- adminStaffPermission: { module: "navigation", permission: "can_view" },
- route: {
- method: "get",
- description:
- "Every prebuilt menu item the installed plugins offer (Admin only)",
- path: "/presets",
- responses: {
- 200: {
- content: {
- "application/json": {
- schema: z.object({
- presets: z.array(zodNavigationPresetSchema),
- }),
- },
- },
- description: "Navigation presets",
- },
- 403: {
- description: "Access Denied",
- },
- },
- },
- handler: c => c.json({ presets: c.get("core").navigation }, 200),
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/routes/reorder.route.ts b/packages/vitnode/src/api/modules/admin/navigation/routes/reorder.route.ts
deleted file mode 100644
index 0b6491388b..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/routes/reorder.route.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { z } from "@hono/zod-openapi";
-import { eq } from "drizzle-orm";
-
-import { buildRoute } from "@/api/builders";
-import { core_navigation } from "@/database/navigation";
-
-import { expireNavigationCache } from "../lib/cache";
-import {
- NAVIGATION_LOCATION_ERRORS,
- navigationOrderProblem,
-} from "../lib/location";
-import { reorderNavigationTree } from "../lib/reorder";
-import { zodReorderNavigationSchema } from "../lib/schema";
-
-const REORDER_ERRORS = {
- duplicate: "An item appears twice in the new order",
- incomplete: "The new order has to name every menu item",
- unknown: "The new order names an item that does not exist",
-} as const;
-
-export const reorderNavigationAdminRoute = buildRoute({
- adminStaffPermission: { module: "navigation", permission: "can_edit" },
- route: {
- method: "post",
- description:
- "Save one menu - the header or the bottom bar - as an ordered tree, one level deep (Admin only)",
- path: "/reorder",
- request: {
- body: {
- required: true,
- content: {
- "application/json": {
- schema: zodReorderNavigationSchema,
- },
- },
- },
- },
- responses: {
- 200: {
- content: {
- "application/json": {
- schema: zodReorderNavigationSchema,
- },
- },
- description: "Navigation reordered",
- },
- 400: {
- content: {
- "application/json": {
- schema: z.object({ error: z.string() }),
- },
- },
- description: "The order does not cover the menu exactly once",
- },
- 403: {
- description: "Access Denied",
- },
- },
- },
- handler: async c => {
- const { items, location = "header" } = c.req.valid("json");
- const db = c.get("db");
-
- const orderProblem = navigationOrderProblem({ items, location });
- if (orderProblem) {
- return c.json({ error: NAVIGATION_LOCATION_ERRORS[orderProblem] }, 400);
- }
-
- const existing = await db
- .select({ id: core_navigation.id })
- .from(core_navigation)
- .where(eq(core_navigation.location, location));
-
- const outcome = reorderNavigationTree(
- items,
- existing.map(row => row.id),
- );
- if (!outcome.ok) {
- return c.json({ error: REORDER_ERRORS[outcome.reason] }, 400);
- }
-
- await db.transaction(async tx => {
- for (const placement of outcome.placements) {
- await tx
- .update(core_navigation)
- .set({
- parentId: placement.parentId,
- position: placement.position,
- updatedAt: new Date(),
- })
- .where(eq(core_navigation.id, placement.id));
- }
- });
-
- await expireNavigationCache(c);
- await c.get("events").emit("navigation.reordered", { items, location });
-
- return c.json({ items, location }, 200);
- },
-});
diff --git a/packages/vitnode/src/api/modules/admin/navigation/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/navigation/routes/update.route.ts
deleted file mode 100644
index 73c1198590..0000000000
--- a/packages/vitnode/src/api/modules/admin/navigation/routes/update.route.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { z } from "@hono/zod-openapi";
-import { eq } from "drizzle-orm";
-
-import { buildRoute } from "@/api/builders";
-import { core_navigation } from "@/database/navigation";
-import { isValidNavigationHref } from "@/lib/navigation";
-
-import { expireNavigationCache } from "../lib/cache";
-import {
- NAVIGATION_LOCATION_ERRORS,
- navigationLocationOf,
- navigationPlacementProblem,
-} from "../lib/location";
-import { checkNavigationParent, nextNavigationPosition } from "../lib/position";
-import {
- hasNavigationText,
- normalizeNavigationIcon,
- zodUpdateNavigationSchema,
-} from "../lib/schema";
-import { saveNavigationWords } from "../lib/words";
-import {
- NAVIGATION_ICON_ERROR,
- NAVIGATION_PARENT_ERRORS,
-} from "./create.route";
-
-const errorSchema = z.object({ error: z.string() });
-
-export const updateNavigationAdminRoute = buildRoute({
- adminStaffPermission: { module: "navigation", permission: "can_edit" },
- route: {
- method: "patch",
- description: "Edit a main menu item (Admin only)",
- path: "/{id}",
- request: {
- params: z.object({
- id: z.string().openapi({ example: "1" }),
- }),
- body: {
- required: true,
- content: {
- "application/json": {
- schema: zodUpdateNavigationSchema,
- },
- },
- },
- },
- responses: {
- 200: {
- content: {
- "application/json": {
- schema: z.object({ id: z.number() }),
- },
- },
- description: "Navigation item updated",
- },
- 400: {
- content: { "application/json": { schema: errorSchema } },
- description: "Invalid change",
- },
- 403: {
- description: "Access Denied",
- },
- 404: {
- content: { "application/json": { schema: errorSchema } },
- description: "Navigation item or parent not found",
- },
- },
- },
- handler: async c => {
- const { id } = c.req.valid("param");
- const body = c.req.valid("json");
- const db = c.get("db");
-
- const itemId = Number(id);
- if (!Number.isInteger(itemId)) {
- return c.json({ error: "Navigation item not found" }, 404);
- }
-
- const [item] = await db
- .select({
- id: core_navigation.id,
- kind: core_navigation.kind,
- location: core_navigation.location,
- parentId: core_navigation.parentId,
- })
- .from(core_navigation)
- .where(eq(core_navigation.id, itemId))
- .limit(1);
- if (!item) {
- return c.json({ error: "Navigation item not found" }, 404);
- }
-
- const location = navigationLocationOf(item.location);
-
- if (body.href !== undefined) {
- if (item.kind !== "custom") {
- return c.json(
- { error: "A prebuilt item keeps the URL its plugin declares" },
- 400,
- );
- }
- if (!isValidNavigationHref(body.href)) {
- return c.json({ error: "The URL must start with / or https://" }, 400);
- }
- }
-
- if (
- item.kind === "custom" &&
- body.title !== undefined &&
- !hasNavigationText(body.title)
- ) {
- return c.json({ error: "A title is required" }, 400);
- }
-
- const values: Partial = {
- updatedAt: new Date(),
- };
- if (body.href !== undefined) values.href = body.href;
- if (body.isOpenInNewTab !== undefined) {
- values.isOpenInNewTab = body.isOpenInNewTab;
- }
- if (body.icon !== undefined) {
- const icon = normalizeNavigationIcon(body.icon);
- if (!icon.ok) {
- return c.json({ error: NAVIGATION_ICON_ERROR }, 400);
- }
- values.icon = icon.value;
- }
-
- if (body.parentId !== undefined && body.parentId !== item.parentId) {
- if (body.parentId === itemId) {
- return c.json({ error: "A menu item cannot be its own parent" }, 400);
- }
-
- if (body.parentId !== null) {
- const parent = await checkNavigationParent(c, body.parentId);
- if (parent.problem === "missing") {
- return c.json({ error: NAVIGATION_PARENT_ERRORS.missing }, 404);
- }
- if (parent.problem === "depth") {
- return c.json({ error: NAVIGATION_PARENT_ERRORS.depth }, 400);
- }
-
- const placementProblem = navigationPlacementProblem({
- location,
- parentId: body.parentId,
- parentLocation: parent.location,
- });
- if (placementProblem) {
- return c.json(
- { error: NAVIGATION_LOCATION_ERRORS[placementProblem] },
- 400,
- );
- }
-
- const [child] = await db
- .select({ id: core_navigation.id })
- .from(core_navigation)
- .where(eq(core_navigation.parentId, itemId))
- .limit(1);
- if (child) {
- return c.json(
- { error: "An item with nested items cannot be nested itself" },
- 400,
- );
- }
- }
-
- values.parentId = body.parentId;
- values.position = await nextNavigationPosition(
- db,
- body.parentId,
- location,
- );
- }
-
- await db
- .update(core_navigation)
- .set(values)
- .where(eq(core_navigation.id, itemId));
-
- await saveNavigationWords(c, itemId, {
- description: body.description,
- title: body.title,
- });
-
- await expireNavigationCache(c);
- await c.get("events").emit("navigation.updated", { navigationId: itemId });
-
- return c.json({ id: itemId }, 200);
- },
-});
diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts
index 4bd83277e9..d720e671b1 100644
--- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts
+++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts
@@ -74,6 +74,7 @@ export const serializeComponentUpdate = (
export const updatesSummarySchema = z.object({
enabled: z.boolean(),
running: z.boolean(),
+ totalComponents: z.number(),
checkedComponents: z.number(),
updatesAvailable: z.number(),
pluginUpdatesAvailable: z.number(),
diff --git a/packages/vitnode/src/api/modules/middleware/navigation-icons.ts b/packages/vitnode/src/api/modules/middleware/navigation-icons.ts
deleted file mode 100644
index adc09d3e64..0000000000
--- a/packages/vitnode/src/api/modules/middleware/navigation-icons.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { z } from "zod";
-
-import type { PublicNavigationMenus } from "@/api/modules/admin/navigation/lib/cache";
-
-import { loadLucideIcon } from "@/components/ui/icon-registry";
-import { parseEmojiIcon } from "@/lib/emoji-icon";
-
-export const zodLucideIconSchema = z.object({
- aliases: z.array(z.string()).optional(),
- name: z.string().optional(),
- node: z.array(z.tuple([z.string(), z.record(z.string(), z.string())])),
- size: z.number().optional(),
-});
-
-const lucideIconNamesOf = ({
- bottomBar,
- navigation,
-}: PublicNavigationMenus): string[] => {
- const names = new Set();
- const items = [
- ...bottomBar,
- ...navigation,
- ...navigation.flatMap(node => node.items),
- ];
-
- for (const item of items) {
- const parsed = parseEmojiIcon(item.icon);
-
- if (parsed?.type === "icon") names.add(parsed.value);
- }
-
- return [...names];
-};
-
-export const loadNavigationIcons = async (
- menus: PublicNavigationMenus,
-): Promise>> => {
- const loaded = await Promise.all(
- lucideIconNamesOf(menus).map(
- async name =>
- [
- name,
- zodLucideIconSchema.safeParse(await loadLucideIcon(name)),
- ] as const,
- ),
- );
-
- return Object.fromEntries(
- loaded.flatMap(([name, icon]) => (icon.success ? [[name, icon.data]] : [])),
- );
-};
diff --git a/packages/vitnode/src/api/modules/middleware/route.test.ts b/packages/vitnode/src/api/modules/middleware/route.test.ts
deleted file mode 100644
index 4098a0a866..0000000000
--- a/packages/vitnode/src/api/modules/middleware/route.test.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import type { Context } from "hono";
-
-import { OpenAPIHono } from "@hono/zod-openapi";
-import { describe, expect, it, vi } from "vitest";
-
-import type { EnvVitNode } from "@/api/middlewares/global.middleware";
-import type { NavigationPreset } from "@/lib/navigation";
-
-import { expireNavigationCache } from "@/api/modules/admin/navigation/lib/cache";
-import { core_languages_words } from "@/database/languages";
-import { core_navigation } from "@/database/navigation";
-import { createTestCache, createTestCacheClient } from "@/tests/cache";
-import { createMemoryDb } from "@/tests/memory-db";
-
-import { middlewareModule } from "./middleware.module";
-
-const at = new Date("2026-10-01T00:00:00Z");
-
-const presets: NavigationPreset[] = [
- {
- href: "/discover",
- icon: "icon:compass",
- id: "discover",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
-];
-
-const navigationRow = (
- id: number,
- overrides: Partial = {},
-) => ({
- createdAt: at,
- href: null,
- icon: null,
- id,
- isOpenInNewTab: false,
- kind: "custom",
- location: "header",
- parentId: null,
- pluginId: null,
- position: id,
- presetId: null,
- updatedAt: at,
- ...overrides,
-});
-
-const titleWord = (itemId: number, value: string) => ({
- itemId,
- languageCode: "en",
- pluginCode: "@vitnode/core",
- tableName: "core_navigation",
- value,
- variable: "title",
-});
-
-const core = {
- authorization: {
- passkeys: { enabled: false },
- password: { enabled: true },
- ssoAdapters: [],
- },
- navigation: presets,
-} as unknown as Context["var"]["core"];
-
-const ai = { models: () => [] } as unknown as Context["var"]["ai"];
-
-const setup = () => {
- const { db } = createMemoryDb([
- [
- core_navigation,
- [
- navigationRow(1, { href: "/forum" }),
- navigationRow(2, { href: "/forum/latest", parentId: 1 }),
- navigationRow(3, {
- kind: "preset",
- location: "bottom_bar",
- pluginId: "@vitnode/core",
- presetId: "discover",
- }),
- ],
- ],
- [
- core_languages_words,
- [titleWord(1, "Forum"), titleWord(2, "Latest"), titleWord(3, "Discover")],
- ],
- ]);
- const client = createTestCacheClient();
- const cache = createTestCache(client);
- const app = new OpenAPIHono();
-
- app.use("*", async (c, next) => {
- c.set("ai", ai);
- c.set("cache", cache);
- c.set("core", core);
- c.set("db", db as unknown as Context["var"]["db"]);
- await next();
- });
- app.route("/", middlewareModule.hono);
- app.post("/expire", async c => {
- await expireNavigationCache(c);
-
- return c.body(null, 204);
- });
-
- return {
- app,
- cacheGet: vi.spyOn(client, "get"),
- db,
- dbSelect: vi.spyOn(db, "select"),
- };
-};
-
-interface MiddlewareBody {
- bottomBar: { href: string; id: number }[];
- navigation: { href: string; id: number; items: { id: number }[] }[];
-}
-
-const menusOf = async (res: Response) => {
- expect(res.status).toBe(200);
- const body = (await res.json()) as MiddlewareBody;
-
- return {
- bottomBar: body.bottomBar.map(item => [item.id, item.href]),
- navigation: body.navigation.map(node => [
- node.id,
- node.href,
- node.items.map(item => item.id),
- ]),
- };
-};
-
-describe("GET middleware navigation", () => {
- it("builds navigation and bottom bar from one read and one cache entry", async () => {
- const { app, cacheGet, dbSelect } = setup();
-
- const first = await menusOf(await app.request("/"));
-
- expect(first).toEqual({
- bottomBar: [[3, "/discover"]],
- navigation: [[1, "/forum", [2]]],
- });
- expect(dbSelect).toHaveBeenCalledTimes(2);
- expect(cacheGet).toHaveBeenCalledTimes(1);
-
- const second = await menusOf(await app.request("/"));
-
- expect(second).toEqual(first);
- expect(dbSelect).toHaveBeenCalledTimes(2);
- expect(cacheGet).toHaveBeenCalledTimes(2);
- });
-
- it("serves the cached menus until the navigation cache expires", async () => {
- const { app, db } = setup();
-
- await app.request("/");
- await db
- .insert(core_navigation)
- .values([
- navigationRow(4, { href: "/blog" }),
- navigationRow(5, { href: "/help", location: "bottom_bar" }),
- ]);
-
- expect(await menusOf(await app.request("/"))).toEqual({
- bottomBar: [[3, "/discover"]],
- navigation: [[1, "/forum", [2]]],
- });
-
- expect((await app.request("/expire", { method: "POST" })).status).toBe(204);
-
- expect(await menusOf(await app.request("/"))).toEqual({
- bottomBar: [
- [3, "/discover"],
- [5, "/help"],
- ],
- navigation: [
- [1, "/forum", [2]],
- [4, "/blog", []],
- ],
- });
- });
-});
diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts
index d4eb627ace..0ea77fb63e 100644
--- a/packages/vitnode/src/api/modules/middleware/route.ts
+++ b/packages/vitnode/src/api/modules/middleware/route.ts
@@ -2,13 +2,6 @@ import { z } from "zod";
import { buildRoute } from "@/api/builders";
import { AI_MODEL_CAPABILITIES } from "@/api/lib/ai/capabilities";
-import { loadPublicNavigationMenus } from "@/api/modules/admin/navigation/lib/cache";
-import {
- zodPublicNavigationItemSchema,
- zodPublicNavigationNodeSchema,
-} from "@/api/modules/admin/navigation/lib/schema";
-
-import { loadNavigationIcons, zodLucideIconSchema } from "./navigation-icons";
export const routeMiddlewareSchema = z.object({
ai: z.object({
@@ -33,9 +26,6 @@ export const routeMiddlewareSchema = z.object({
isEmail: z.boolean(),
passkeys: z.boolean(),
password: z.boolean(),
- navigation: z.array(zodPublicNavigationNodeSchema),
- bottomBar: z.array(zodPublicNavigationItemSchema),
- icons: z.record(z.string(), zodLucideIconSchema),
captcha: z
.object({
siteKey: z.string(),
@@ -60,9 +50,8 @@ export const routeMiddleware = buildRoute({
},
},
},
- handler: async c => {
+ handler: c => {
const sso = c.get("core").authorization.ssoAdapters;
- const { bottomBar, navigation } = await loadPublicNavigationMenus(c);
return c.json(
{
@@ -70,9 +59,6 @@ export const routeMiddleware = buildRoute({
isEmail: !!c.get("core").email?.adapter,
passkeys: c.get("core").authorization.passkeys.enabled,
password: c.get("core").authorization.password.enabled,
- navigation,
- bottomBar,
- icons: await loadNavigationIcons({ bottomBar, navigation }),
sso: sso.map(s => ({
id: s.id,
name: s.name,
diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts
index 0293399a1a..06d44ee154 100644
--- a/packages/vitnode/src/api/plugin.ts
+++ b/packages/vitnode/src/api/plugin.ts
@@ -35,10 +35,6 @@ export const newBuildPluginApiCore = buildApiPlugin({
],
blocks,
editablePages: [settingsPage],
- navigation: [
- { href: "/discover", icon: "compass", id: "discover" },
- { href: "/search", icon: "search", id: "search" },
- ],
modules: [
middlewareModule,
usersModule,
@@ -93,12 +89,6 @@ export const newBuildPluginApiCore = buildApiPlugin({
{ permission: "can_download", dependsOn: ["can_view"] },
{ permission: "can_delete", dependsOn: ["can_view"] },
],
- navigation: [
- "can_view",
- { permission: "can_create", dependsOn: ["can_view"] },
- { permission: "can_edit", dependsOn: ["can_view"] },
- { permission: "can_delete", dependsOn: ["can_view"] },
- ],
queue: ["can_view"],
plugins: [
"can_view",
diff --git a/packages/vitnode/src/components/side-nav.tsx b/packages/vitnode/src/components/side-nav.tsx
index 35780dc2cd..32de015c55 100644
--- a/packages/vitnode/src/components/side-nav.tsx
+++ b/packages/vitnode/src/components/side-nav.tsx
@@ -45,7 +45,7 @@ export const SideNavGroup = ({
children: React.ReactNode;
title?: string;
}) => (
-
+
{title ? (
{title}
diff --git a/packages/vitnode/src/database/navigation.ts b/packages/vitnode/src/database/navigation.ts
deleted file mode 100644
index fffae69d48..0000000000
--- a/packages/vitnode/src/database/navigation.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { type AnyPgColumn, camelCase, index } from "drizzle-orm/pg-core";
-
-export const core_navigation = camelCase.table.withRLS(
- "core_navigation",
- t => ({
- id: t.serial().primaryKey(),
- parentId: t.integer().references((): AnyPgColumn => core_navigation.id, {
- onDelete: "set null",
- }),
- location: t.varchar({ length: 16 }).notNull().default("header"),
- kind: t.varchar({ length: 16 }).notNull(),
- pluginId: t.varchar({ length: 50 }),
- presetId: t.varchar({ length: 120 }),
- href: t.text(),
- icon: t.varchar({ length: 64 }),
- isOpenInNewTab: t.boolean().notNull().default(false),
- position: t.integer().notNull().default(0),
- createdAt: t.timestamp().notNull().defaultNow(),
- updatedAt: t
- .timestamp()
- .notNull()
- .defaultNow()
- .$onUpdate(() => new Date()),
- }),
- t => [
- index("core_navigation_position_idx").on(t.position),
- index("core_navigation_location_idx").on(t.location),
- index("core_navigation_parent_idx").on(t.parentId),
- ],
-);
diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts
index e1ef265a2d..3325dfe1e5 100644
--- a/packages/vitnode/src/database/relations.ts
+++ b/packages/vitnode/src/database/relations.ts
@@ -12,7 +12,6 @@ import * as languages from "./languages";
import * as logs from "./logs";
import * as moderation from "./moderation";
import * as moderators from "./moderators";
-import * as navigation from "./navigation";
import * as notifications from "./notifications";
import * as passkeys from "./passkeys";
import * as queue from "./queue";
@@ -35,7 +34,6 @@ export const coreSchema = {
...logs,
...moderation,
...moderators,
- ...navigation,
...notifications,
...passkeys,
...queue,
diff --git a/packages/vitnode/src/framework/dev-error/handler.ts b/packages/vitnode/src/framework/dev-error/handler.ts
new file mode 100644
index 0000000000..4691b9b523
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/handler.ts
@@ -0,0 +1,60 @@
+import { devErrorHintsOf } from "./hints";
+import { buildDevErrorPage } from "./page";
+
+interface ServerError extends Error {
+ status?: unknown;
+ unhandled?: unknown;
+}
+
+interface ServerErrorEvent {
+ req: Request;
+}
+
+const unwrap = (error: ServerError): ServerError =>
+ error.name === "HTTPError" &&
+ error.unhandled === true &&
+ error.cause instanceof Error
+ ? error.cause
+ : error;
+
+const statusOf = (error: ServerError): number =>
+ typeof error.status === "number" && error.status >= 400 && error.status < 600
+ ? error.status
+ : 500;
+
+const devErrorHandler = async (
+ thrown: ServerError,
+ event: ServerErrorEvent,
+): Promise => {
+ if (process.env.NODE_ENV === "production") return;
+ if (!event.req.headers.get("accept")?.includes("text/html")) return;
+
+ const status = statusOf(thrown);
+ if (thrown.unhandled !== true && status < 500) return;
+
+ const error = unwrap(thrown);
+ const url = new URL(event.req.url);
+
+ const html = await buildDevErrorPage({
+ error: {
+ message: error.message,
+ name: error.name,
+ stack: error.stack ?? "",
+ },
+ hints: devErrorHintsOf(error),
+ method: event.req.method,
+ origin: url.origin,
+ path: `${url.pathname}${url.search}`,
+ status,
+ });
+
+ return new Response(html, {
+ headers: {
+ "cache-control": "no-store",
+ "content-type": "text/html; charset=utf-8",
+ },
+ status,
+ });
+};
+
+export default devErrorHandler;
diff --git a/packages/vitnode/src/framework/dev-error/highlight.ts b/packages/vitnode/src/framework/dev-error/highlight.ts
new file mode 100644
index 0000000000..ce27eca5a1
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/highlight.ts
@@ -0,0 +1,71 @@
+export const escapeHtml = (text: string): string =>
+ text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+
+const KEYWORDS = new Set([
+ "async",
+ "await",
+ "class",
+ "const",
+ "else",
+ "export",
+ "extends",
+ "false",
+ "for",
+ "from",
+ "function",
+ "if",
+ "import",
+ "in",
+ "interface",
+ "let",
+ "new",
+ "null",
+ "of",
+ "return",
+ "throw",
+ "true",
+ "type",
+ "typeof",
+ "undefined",
+ "var",
+ "void",
+]);
+
+const TOKEN =
+ /(\/\/.*$)|(`(?:\\.|[^`])*`|'(?:\\.|[^'])*'|"(?:\\.|[^"])*")|(\/\^(?:\\.|[^/])+\/[a-z]*)|([A-Za-z_$][\w$]*)(?=\s*[(<])|([A-Za-z_$][\w$]*)|(\d+)|([{}()[\];,.:?=<>|!&+\-*/]+)/g;
+
+const tokenClass = (groups: (string | undefined)[], token: string) => {
+ const [comment, text, regex, call, identifier, digits, punctuation] = groups;
+
+ if (comment) return "tk-c";
+ if (text || regex || digits) return "tk-s";
+ if (KEYWORDS.has(token)) return "tk-k";
+ if (call) return "tk-f";
+ if (identifier && /^[A-Z]/.test(identifier)) return "tk-t";
+ if (punctuation) return "tk-p";
+
+ return null;
+};
+
+export const highlightLine = (line: string): string => {
+ let html = "";
+ let last = 0;
+
+ for (const match of line.matchAll(TOKEN)) {
+ const [token, ...groups] = match;
+ html += escapeHtml(line.slice(last, match.index));
+ last = match.index + token.length;
+
+ const className = tokenClass(groups, token);
+ html += className
+ ? `${escapeHtml(token)} `
+ : escapeHtml(token);
+ }
+
+ return html + escapeHtml(line.slice(last));
+};
diff --git a/packages/vitnode/src/framework/dev-error/hints.ts b/packages/vitnode/src/framework/dev-error/hints.ts
new file mode 100644
index 0000000000..79175bb465
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/hints.ts
@@ -0,0 +1,31 @@
+export interface DevErrorHint {
+ description: string;
+ snippet?: string;
+ title: string;
+}
+
+export interface DevErrorWithHints {
+ readonly hints: readonly DevErrorHint[];
+}
+
+const isHint = (value: unknown): value is DevErrorHint => {
+ if (typeof value !== "object" || value === null) return false;
+
+ const { description, snippet, title } = value as Record;
+
+ return (
+ typeof title === "string" &&
+ typeof description === "string" &&
+ (snippet === undefined || typeof snippet === "string")
+ );
+};
+
+export const devErrorHintsOf = (error: unknown): DevErrorHint[] => {
+ if (typeof error !== "object" || error === null || !("hints" in error)) {
+ return [];
+ }
+
+ const { hints } = error;
+
+ return Array.isArray(hints) ? hints.filter(isHint) : [];
+};
diff --git a/packages/vitnode/src/framework/dev-error/index.ts b/packages/vitnode/src/framework/dev-error/index.ts
new file mode 100644
index 0000000000..7df3eaba65
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/index.ts
@@ -0,0 +1 @@
+export type { DevErrorHint, DevErrorWithHints } from "./hints";
diff --git a/packages/vitnode/src/framework/dev-error/page-assets.ts b/packages/vitnode/src/framework/dev-error/page-assets.ts
new file mode 100644
index 0000000000..c13d392ebb
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/page-assets.ts
@@ -0,0 +1,230 @@
+const LIGHT_TOKENS = `
+ color-scheme: light;
+ --background: oklch(0.985 0.002 264);
+ --foreground: oklch(0.214 0.006 264);
+ --card: oklch(1 0 0);
+ --muted: oklch(0.97 0.004 264);
+ --muted-foreground: oklch(0.5 0.005 264);
+ --border: oklch(0.928 0.004 264);
+ --primary: oklch(0.52 0.2 264);
+ --destructive: oklch(0.565 0.209 27);
+ --success: oklch(0.6 0.14 155);
+ --code-bg: oklch(0.975 0.003 264);
+ --elevation: 0 0 0 1px rgb(0 0 0 / 0.06), 0 1px 3px rgb(0 0 0 / 0.05), 0 12px 32px -12px rgb(0 0 0 / 0.12);
+ --elevation-flat: 0 0 0 1px rgb(0 0 0 / 0.07), 0 1px 2px rgb(0 0 0 / 0.04);
+`;
+
+const DARK_TOKENS = `
+ color-scheme: dark;
+ --background: oklch(0.163 0.009 264);
+ --foreground: oklch(0.976 0 0);
+ --card: oklch(0.199 0.011 264);
+ --muted: oklch(0.235 0.014 264);
+ --muted-foreground: oklch(0.75 0.012 264);
+ --border: oklch(0.269 0.018 264);
+ --primary: oklch(0.7 0.14 264);
+ --destructive: oklch(0.709 0.17 27);
+ --success: oklch(0.78 0.13 155);
+ --code-bg: oklch(0.175 0.01 264);
+ --elevation: 0 0 0 1px rgb(255 255 255 / 0.08);
+ --elevation-flat: 0 0 0 1px rgb(255 255 255 / 0.08);
+`;
+
+export const DEV_ERROR_STYLES = `
+:root {${LIGHT_TOKENS}
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ --radius: 12px;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {${DARK_TOKENS}}
+ :root:not([data-theme="light"]) .icon-moon { display: none; }
+ :root:not([data-theme="light"]) .icon-sun { display: block; }
+}
+:root[data-theme="dark"] {${DARK_TOKENS}}
+:root[data-theme="dark"] .icon-moon { display: none; }
+:root[data-theme="dark"] .icon-sun { display: block; }
+.icon-sun { display: none; }
+
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; }
+body {
+ min-height: 100dvh;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-synthesis: none;
+}
+button, a { touch-action: manipulation; }
+button { font: inherit; color: inherit; }
+h1, h2, h3, p { margin: 0; }
+code { font-family: var(--font-mono); }
+:focus-visible { outline: 2px solid var(--muted-foreground); outline-offset: 2px; }
+[hidden] { display: none !important; }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+.icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.icon-btn {
+ position: relative; display: inline-grid; place-items: center;
+ width: 36px; height: 36px; border: 0; border-radius: 8px;
+ background: transparent; color: var(--muted-foreground); cursor: pointer;
+ transition-property: color, background-color, transform; transition-duration: 150ms; transition-timing-function: ease-out;
+}
+.icon-btn::before { content: ""; position: absolute; inset: -4px; }
+.icon-btn:active { transform: scale(0.96); }
+.chip-btn {
+ display: inline-flex; align-items: center; gap: 6px;
+ height: 32px; padding: 0 12px; border: 0; border-radius: 999px;
+ background: var(--card); color: var(--muted-foreground); box-shadow: var(--elevation-flat);
+ font-size: 13px; font-weight: 500; white-space: nowrap; cursor: pointer;
+ transition-property: color, transform; transition-duration: 150ms; transition-timing-function: ease-out;
+}
+.chip-btn:active { transform: scale(0.96); }
+.chip-btn .icon { width: 14px; height: 14px; }
+.text-btn {
+ display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 8px; border: 0; border-radius: 8px;
+ background: transparent; color: var(--muted-foreground); font-size: 14px; cursor: pointer; transition: color 150ms ease-out;
+}
+@media (hover: hover) and (pointer: fine) {
+ .icon-btn:hover { background: var(--muted); color: var(--foreground); }
+ .chip-btn:hover, .text-btn:hover { color: var(--foreground); }
+ .frame-open:hover { color: var(--primary); }
+}
+[data-copied] .icon-copy, :not([data-copied]) > .icon-check { display: none; }
+[data-copied] { color: var(--success) !important; }
+
+.badge {
+ display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 6px;
+ background: var(--muted); color: var(--muted-foreground);
+ font-family: var(--font-mono); font-size: 12.5px; font-weight: 500; white-space: nowrap;
+}
+.badge-error { background: color-mix(in oklab, var(--destructive) 14%, transparent); color: var(--destructive); }
+
+.page { padding: 24px 16px 48px; }
+.shell { max-width: 1040px; margin: 0 auto; padding: 6px; border-radius: 22px; background: var(--muted); box-shadow: var(--elevation); }
+.bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 8px 10px 12px; }
+.bar-count { font-size: 14px; font-weight: 600; white-space: nowrap; }
+.bar-count span { color: var(--muted-foreground); font-weight: 400; }
+.status-pill { display: inline-flex; align-items: center; height: 32px; padding: 0 12px; border-radius: 999px; background: var(--card); box-shadow: var(--elevation-flat); font-size: 13px; white-space: nowrap; }
+.card { display: flex; flex-direction: column; gap: 28px; padding: 24px 20px; border-radius: 16px; background: var(--card); box-shadow: var(--elevation-flat); }
+.top { display: flex; flex-direction: column; gap: 12px; }
+.badges { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; }
+.badge-list { display: flex; flex-wrap: wrap; gap: 8px; }
+.actions { display: flex; flex: none; gap: 2px; margin-block: -6px; margin-inline-start: auto; }
+.title { font-size: 20px; line-height: 1.3; font-weight: 600; letter-spacing: -0.01em; text-wrap: balance; overflow-wrap: anywhere; }
+.title.is-long { font-size: 16px; line-height: 1.5; text-wrap: pretty; }
+.desc { max-width: 72ch; color: var(--muted-foreground); text-wrap: pretty; overflow-wrap: anywhere; }
+.desc code { font-size: 0.92em; color: var(--foreground); }
+
+.cf { margin: 0; overflow: hidden; border-radius: var(--radius); background: var(--code-bg); box-shadow: var(--elevation-flat); }
+.cf-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 4px 4px 4px 16px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 13px; }
+.cf-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cf-path span { color: var(--muted-foreground); }
+.cf-body { overflow-x: auto; padding: 12px 0; font-family: var(--font-mono); font-size: 13px; line-height: 1.7; font-variant-numeric: tabular-nums; }
+.cf-body::-webkit-scrollbar { height: 8px; }
+.cf-body::-webkit-scrollbar-thumb { background: color-mix(in oklab, var(--muted-foreground) 30%, transparent); border-radius: 4px; }
+.cf-line { display: flex; min-width: max-content; padding-inline-end: 16px; white-space: pre; }
+.cf-num { position: sticky; left: 0; flex: none; width: 56px; padding-inline-end: 16px; text-align: end; color: var(--muted-foreground); background: var(--code-bg); user-select: none; -webkit-user-select: none; }
+.cf-line.is-error { background: color-mix(in oklab, var(--destructive) 13%, transparent); box-shadow: inset 2px 0 0 var(--destructive); }
+.cf-line.is-error .cf-num { color: var(--destructive); background: color-mix(in oklab, var(--destructive) 13%, var(--code-bg)); }
+.cf-caret { color: var(--destructive); font-weight: 700; }
+.cf-caret .cf-num { color: transparent; }
+.tk-k { color: var(--primary); }
+.tk-s { color: var(--success); }
+.tk-f { font-weight: 600; }
+.tk-t { color: color-mix(in oklab, var(--primary) 55%, var(--foreground)); }
+.tk-c, .tk-p { color: var(--muted-foreground); }
+
+.section { display: flex; flex-direction: column; gap: 16px; }
+.h2 { display: flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 600; }
+.fixes { display: grid; grid-template-columns: 1fr; gap: 16px; }
+.fix { display: flex; flex-direction: column; border-radius: var(--radius); background: var(--card); box-shadow: var(--elevation-flat); }
+.fix-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px; }
+.fix-head > div { flex: 1 1 14rem; min-width: 0; }
+.fix-head h3 { font-size: 15px; font-weight: 600; }
+.fix-head p { font-size: 14px; color: var(--muted-foreground); text-wrap: pretty; }
+.snippet { flex: 1; margin: 0 4px 4px; padding: 12px 16px; overflow-x: auto; border-radius: 8px; background: var(--code-bg); font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65; white-space: pre; }
+
+.stack-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 12px; }
+.h2, .text-btn { white-space: nowrap; }
+.count { display: inline-grid; place-items: center; min-width: 26px; height: 22px; padding: 0 6px; border-radius: 999px; background: var(--muted); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
+.frames { display: flex; flex-direction: column; margin: 12px 0 0; padding: 0; list-style: none; }
+.frames li { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-top: 1px solid var(--border); }
+.frames li:first-child { border-top: 0; }
+.frames li.is-lib { opacity: 0.7; }
+.frame-fn { font-family: var(--font-mono); font-weight: 600; overflow-wrap: anywhere; }
+.frame-loc { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted-foreground); overflow-wrap: anywhere; }
+.frame-open { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: none; cursor: pointer; text-align: start; transition: color 150ms ease-out; }
+.frame-open .icon { width: 14px; height: 14px; color: var(--muted-foreground); }
+
+@media (min-width: 820px) {
+ .page { padding-top: 48px; }
+ .card { padding: 28px 32px 32px; }
+ .title { font-size: 22px; }
+ .title.is-long { font-size: 17px; }
+ .fixes[data-count="2"] { grid-template-columns: repeat(2, 1fr); }
+ .fixes[data-count="3"] { grid-template-columns: repeat(3, 1fr); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .icon-btn, .chip-btn, .text-btn, .frame-open { transition: none; }
+}
+`;
+
+export const DEV_ERROR_SCRIPT = `
+(function () {
+ var live = document.getElementById("dev-error-live");
+ var announce = function (text) { live.textContent = text; };
+ var isDark = function () {
+ var theme = document.documentElement.dataset.theme;
+ return theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
+ };
+ var close = function () {
+ if (window.parent !== window) window.parent.postMessage({ type: "vitnode:dev-error:close" }, "*");
+ };
+ if (document.body.hasAttribute("data-dismissible")) {
+ document.addEventListener("keydown", function (event) {
+ if (event.key === "Escape") close();
+ });
+ }
+ document.addEventListener("click", function (event) {
+ if (event.target.closest("[data-close]")) {
+ close();
+ return;
+ }
+ var copy = event.target.closest("[data-copy]");
+ if (copy) {
+ navigator.clipboard.writeText(copy.dataset.copy).then(function () {
+ copy.setAttribute("data-copied", "");
+ announce("Copied to clipboard");
+ setTimeout(function () { copy.removeAttribute("data-copied"); }, 1500);
+ }, function () { announce("Copy failed. Select the text instead."); });
+ return;
+ }
+ var open = event.target.closest("[data-open]");
+ if (open) {
+ announce("Opening in your editor");
+ fetch("/__open-in-editor?file=" + encodeURIComponent(open.dataset.open)).then(function (response) {
+ if (!response.ok) announce("Couldn't open your editor. Open the file from the path instead.");
+ }, function () { announce("Couldn't open your editor. Open the file from the path instead."); });
+ return;
+ }
+ if (event.target.closest("[data-theme-toggle]")) {
+ var next = isDark() ? "light" : "dark";
+ document.documentElement.dataset.theme = next;
+ try { localStorage.setItem("vitnode-dev-error-theme", next); } catch (error) {}
+ return;
+ }
+ var toggle = event.target.closest("[data-toggle-lib]");
+ if (toggle) {
+ var expanded = toggle.getAttribute("aria-expanded") === "true";
+ toggle.setAttribute("aria-expanded", String(!expanded));
+ document.getElementById(toggle.getAttribute("aria-controls")).hidden = expanded;
+ toggle.querySelector(".lbl").textContent = expanded ? toggle.dataset.show : toggle.dataset.hide;
+ }
+ });
+})();
+`;
diff --git a/packages/vitnode/src/framework/dev-error/page.ts b/packages/vitnode/src/framework/dev-error/page.ts
new file mode 100644
index 0000000000..aa13457f00
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/page.ts
@@ -0,0 +1,139 @@
+import { existsSync } from "node:fs";
+import { readFile } from "node:fs/promises";
+import { dirname, extname, join, relative } from "node:path";
+
+import { manifest } from "@/manifest";
+
+import type { DevErrorHint } from "./hints";
+import type {
+ DevErrorPageFrame,
+ DevErrorPageSource,
+ DevErrorRuntime,
+} from "./render";
+import type { DevErrorFrame } from "./stack";
+
+import { renderDevErrorPage } from "./render";
+import { createFrameMapper } from "./source-map";
+import { isUrl, parseStackFrames } from "./stack";
+
+const DEV_ERROR_DOCS_URL = "https://vitnode.com/docs/dev/debugging/error-page";
+
+const SOURCE_CONTEXT_LINES = 5;
+
+const SOURCE_EXTENSIONS = new Set([
+ ".cjs",
+ ".cts",
+ ".js",
+ ".jsx",
+ ".mjs",
+ ".mts",
+ ".ts",
+ ".tsx",
+]);
+
+export interface DevErrorReport {
+ dismissible?: boolean;
+ error: { message: string; name: string; stack: string };
+ hints: DevErrorHint[];
+ method: string;
+ origin?: string;
+ path: string;
+ runtime?: DevErrorRuntime;
+ status: number;
+}
+
+const findWorkspaceRoot = (start: string): string => {
+ let current = start;
+
+ while (dirname(current) !== current) {
+ if (existsSync(join(current, "pnpm-workspace.yaml"))) return current;
+ current = dirname(current);
+ }
+
+ return start;
+};
+
+let workspaceRoot: string | undefined;
+
+const getWorkspaceRoot = (): string => {
+ workspaceRoot ??= findWorkspaceRoot(process.cwd());
+
+ return workspaceRoot;
+};
+
+const displayFile = (file: string): string => {
+ if (isUrl(file)) {
+ return decodeURIComponent(new URL(file).pathname).replace(/^\/(@fs)?/, "");
+ }
+ if (!file.startsWith("/")) return file;
+
+ const nodeModulesAt = file.lastIndexOf("/node_modules/");
+ if (nodeModulesAt !== -1) return file.slice(nodeModulesAt + 1);
+
+ const fromRoot = relative(getWorkspaceRoot(), file);
+
+ return fromRoot.startsWith("..") ? file : fromRoot;
+};
+
+const isReadableSource = (file: string): boolean => {
+ const fromRoot = relative(getWorkspaceRoot(), file);
+
+ return (
+ file.startsWith("/") &&
+ SOURCE_EXTENSIONS.has(extname(file)) &&
+ !fromRoot.startsWith("..") &&
+ !fromRoot.split("/").some(segment => segment.startsWith("."))
+ );
+};
+
+const readSource = async (
+ frame: DevErrorPageFrame,
+): Promise => {
+ if (!isReadableSource(frame.file)) return null;
+
+ const text = await readFile(frame.file, "utf8").catch(() => null);
+ if (text === null) return null;
+
+ const lines = text.split("\n");
+ const start = Math.max(1, frame.line - SOURCE_CONTEXT_LINES);
+ const end = Math.min(lines.length, frame.line + SOURCE_CONTEXT_LINES);
+
+ return { frame, lines: lines.slice(start - 1, end), start };
+};
+
+const withDisplayFile = (frame: DevErrorFrame): DevErrorPageFrame => ({
+ ...frame,
+ displayFile: displayFile(frame.file),
+});
+
+export const buildDevErrorPage = async ({
+ dismissible = false,
+ error,
+ hints,
+ method,
+ origin,
+ path,
+ runtime = "server",
+ status,
+}: DevErrorReport): Promise => {
+ const mapFrame = createFrameMapper({ appRoot: process.cwd(), origin });
+ const mappedFrames = await Promise.all(
+ parseStackFrames(error.stack).map(mapFrame),
+ );
+ const frames = mappedFrames.map(withDisplayFile);
+ const firstAppFrame = frames.find(frame => frame.isApp);
+
+ return renderDevErrorPage({
+ dismissible,
+ docsUrl: DEV_ERROR_DOCS_URL,
+ error: { message: error.message, name: error.name },
+ frames,
+ hints,
+ method,
+ path,
+ runtime,
+ source: firstAppFrame ? await readSource(firstAppFrame) : null,
+ status,
+ version: manifest.version,
+ });
+};
diff --git a/packages/vitnode/src/framework/dev-error/render.ts b/packages/vitnode/src/framework/dev-error/render.ts
new file mode 100644
index 0000000000..fa4356ca1e
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/render.ts
@@ -0,0 +1,198 @@
+import type { DevErrorHint } from "./hints";
+import type { DevErrorFrame } from "./stack";
+
+import { escapeHtml, highlightLine } from "./highlight";
+import { DEV_ERROR_SCRIPT, DEV_ERROR_STYLES } from "./page-assets";
+
+export interface DevErrorPageFrame extends DevErrorFrame {
+ displayFile: string;
+}
+
+export interface DevErrorPageSource {
+ frame: DevErrorPageFrame;
+ lines: string[];
+ start: number;
+}
+
+export type DevErrorRuntime = "browser" | "server";
+
+const RUNTIME_WHERE: Record = {
+ browser: "in the browser",
+ server: "on the server",
+};
+
+const failedRequestText = ({ method, path, runtime }: DevErrorPageInput) =>
+ runtime === "browser"
+ ? `${escapeHtml(path)} failed to render in the browser.`
+ : `${escapeHtml(method)} ${escapeHtml(path)} failed on the server.`;
+
+export interface DevErrorPageInput {
+ dismissible?: boolean;
+ docsUrl: string;
+ error: { message: string; name: string };
+ frames: DevErrorPageFrame[];
+ hints: DevErrorHint[];
+ method: string;
+ path: string;
+ runtime: DevErrorRuntime;
+ source: DevErrorPageSource | null;
+ status: number;
+ version: string;
+}
+
+const ICON_PATHS = {
+ book: ' ',
+ check: ' ',
+ chevronsUpDown: ' ',
+ close: ' ',
+ copy: ' ',
+ external:
+ ' ',
+ moon: ' ',
+ sun: ' ',
+} as const;
+
+type IconName = keyof typeof ICON_PATHS;
+
+const LONG_MESSAGE_LENGTH = 90;
+
+const icon = (name: IconName, className = "") =>
+ `${ICON_PATHS[name]} `;
+
+const copyIcons = () =>
+ `${icon("copy", "icon-copy")}${icon("check", "icon-check")}`;
+
+const location = (frame: DevErrorPageFrame) =>
+ `${frame.displayFile}:${String(frame.line)}:${String(frame.column)}`;
+
+const editorTarget = (frame: DevErrorFrame) =>
+ `${frame.file}:${String(frame.line)}:${String(frame.column)}`;
+
+const stackText = ({ error, frames }: DevErrorPageInput) =>
+ [
+ `${error.name}: ${error.message}`,
+ ...frames.map(frame => ` at ${frame.functionName} (${location(frame)})`),
+ ].join("\n");
+
+const promptText = (input: DevErrorPageInput, hint: DevErrorHint) =>
+ [
+ "Fix this server error in my VitNode app:",
+ "",
+ stackText(input),
+ "",
+ `Suggested approach: ${hint.title}. ${hint.description}`,
+ ...(hint.snippet ? [hint.snippet] : []),
+ ].join("\n");
+
+const codeFrame = ({ frame, lines, start }: DevErrorPageSource) => {
+ const rows = lines
+ .map((text, index) => {
+ const number = start + index;
+ const isError = number === frame.line;
+ const row = `${String(number)} ${highlightLine(text) || " "}
`;
+ if (!isError) return row;
+
+ return `${row}${String(number)} ${" ".repeat(Math.max(frame.column - 1, 0))}^
`;
+ })
+ .join("");
+
+ return `
+
+ ${escapeHtml(frame.displayFile)} (${String(frame.line)}:${String(frame.column)}) @ ${escapeHtml(frame.functionName)}
+ ${icon("external")}
+
+ ${rows}
+ `;
+};
+
+const hintsSection = (input: DevErrorPageInput) => {
+ if (input.hints.length === 0) return "";
+
+ const cards = input.hints
+ .map(
+ hint => `
+
+
${escapeHtml(hint.title)} ${escapeHtml(hint.description)}
+
${copyIcons()}Copy prompt
+
+ ${hint.snippet ? `
${hint.snippet.split("\n").map(highlightLine).join("\n")} ` : ""}
+
`,
+ )
+ .join("");
+
+ return `
+ Ways to fix this
+ ${cards}
+ `;
+};
+
+const frameItem = (frame: DevErrorPageFrame) =>
+ frame.isApp
+ ? `${escapeHtml(frame.functionName)} ${icon("external")} ${escapeHtml(location(frame))} `
+ : `${escapeHtml(frame.functionName)} ${escapeHtml(location(frame))} `;
+
+const stackSection = ({ frames }: DevErrorPageInput) => {
+ if (frames.length === 0) return "";
+
+ const appFrames = frames.filter(frame => frame.isApp);
+ const libFrames = frames.filter(frame => !frame.isApp);
+ const visible = appFrames.length > 0 ? appFrames : libFrames;
+ const hidden = appFrames.length > 0 ? libFrames : [];
+ const show = `Show ${String(hidden.length)} framework frames`;
+ const hide = `Hide ${String(hidden.length)} framework frames`;
+
+ return `
+
+
Call stack ${String(frames.length)}
+ ${hidden.length > 0 ? `${show} ${icon("chevronsUpDown")} ` : ""}
+
+ ${visible.map(frameItem).join("")}
+ ${hidden.length > 0 ? `${hidden.map(frameItem).join("")} ` : ""}
+ `;
+};
+
+export const renderDevErrorPage = (input: DevErrorPageInput): string => {
+ const { error, status } = input;
+
+ return `
+
+
+
+
+
+${escapeHtml(`${error.name}: ${error.message}`.slice(0, 120))}
+
+
+
+
+
+
+
+
1 error ${RUNTIME_WHERE[input.runtime]}
+
VitNode ${escapeHtml(input.version)}
+
+
+
+
+
${escapeHtml(error.name)} ${input.runtime === "server" ? `${String(status)} ` : ""}
+
+
${copyIcons()}
+
${icon("book")}
+
${icon("moon", "icon-moon")}${icon("sun", "icon-sun")}
+ ${input.dismissible ? `
${icon("close")} ` : ""}
+
+
+
${escapeHtml(error.message || error.name)}
+
${failedRequestText(input)} Fix the code and refresh after you save.
+
+ ${input.source ? codeFrame(input.source) : ""}
+ ${hintsSection(input)}
+ ${stackSection(input)}
+
+
+
+
+
+
+`;
+};
diff --git a/packages/vitnode/src/framework/dev-error/report-handler.ts b/packages/vitnode/src/framework/dev-error/report-handler.ts
new file mode 100644
index 0000000000..bda265a6de
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/report-handler.ts
@@ -0,0 +1,78 @@
+import { devErrorHintsOf } from "./hints";
+import { buildDevErrorPage } from "./page";
+
+const MAX_TEXT_LENGTH = 50_000;
+
+interface ReportEvent {
+ req: Request;
+}
+
+interface ReportBody {
+ hints: unknown;
+ message: string;
+ name: string;
+ path: string;
+ runtime: "browser" | "server";
+ stack: string;
+}
+
+const textField = (value: unknown, fallback: string): string =>
+ typeof value === "string" ? value.slice(0, MAX_TEXT_LENGTH) : fallback;
+
+const parseBody = (value: unknown): null | ReportBody => {
+ if (typeof value !== "object" || value === null) return null;
+
+ const body = value as Record;
+ if (typeof body.message !== "string" || typeof body.path !== "string") {
+ return null;
+ }
+
+ return {
+ hints: body.hints,
+ message: textField(body.message, ""),
+ name: textField(body.name, "Error"),
+ path: body.path.startsWith("/") ? textField(body.path, "/") : "/",
+ runtime: body.runtime === "browser" ? "browser" : "server",
+ stack: textField(body.stack, ""),
+ };
+};
+
+const plainResponse = (status: number) =>
+ new Response(null, { headers: { "cache-control": "no-store" }, status });
+
+const devErrorReportHandler = async ({
+ req,
+}: ReportEvent): Promise => {
+ if (process.env.NODE_ENV === "production") return plainResponse(404);
+ if (!req.headers.get("content-type")?.includes("application/json")) {
+ return plainResponse(415);
+ }
+
+ const fetchSite = req.headers.get("sec-fetch-site");
+ if (fetchSite !== null && fetchSite !== "same-origin") {
+ return plainResponse(403);
+ }
+
+ const body = parseBody(await req.json().catch(() => null));
+ if (!body) return plainResponse(400);
+
+ const html = await buildDevErrorPage({
+ dismissible: true,
+ error: { message: body.message, name: body.name, stack: body.stack },
+ hints: devErrorHintsOf({ hints: body.hints }),
+ method: "GET",
+ origin: new URL(req.url).origin,
+ path: body.path,
+ runtime: body.runtime,
+ status: 500,
+ });
+
+ return new Response(html, {
+ headers: {
+ "cache-control": "no-store",
+ "content-type": "text/html; charset=utf-8",
+ },
+ });
+};
+
+export default devErrorReportHandler;
diff --git a/packages/vitnode/src/framework/dev-error/report-route.ts b/packages/vitnode/src/framework/dev-error/report-route.ts
new file mode 100644
index 0000000000..29ae82f173
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/report-route.ts
@@ -0,0 +1 @@
+export const DEV_ERROR_REPORT_ROUTE = "/__vitnode/dev-error";
diff --git a/packages/vitnode/src/framework/dev-error/source-map.ts b/packages/vitnode/src/framework/dev-error/source-map.ts
new file mode 100644
index 0000000000..afcbfddcac
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/source-map.ts
@@ -0,0 +1,133 @@
+import type { SourceMapPayload } from "node:module";
+
+import { readFile } from "node:fs/promises";
+import { SourceMap } from "node:module";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import type { DevErrorFrame } from "./stack";
+
+import { isAppFile, isUrl } from "./stack";
+
+const INLINE_SOURCE_MAP =
+ /\/\/# sourceMappingURL=data:application\/json;(?:charset=utf-8;)?base64,([A-Za-z0-9+/=]+)\s*$/;
+
+export interface FrameMapperOptions {
+ appRoot: string;
+ origin?: string;
+}
+
+const parseSourceMap = (text: string): null | SourceMap => {
+ try {
+ return new SourceMap(JSON.parse(text) as SourceMapPayload);
+ } catch {
+ return null;
+ }
+};
+
+const fetchInlineSourceMap = async (url: string): Promise => {
+ const response = await fetch(url).catch(() => null);
+ if (!response?.ok) return null;
+
+ const match = INLINE_SOURCE_MAP.exec(await response.text());
+
+ return match ? Buffer.from(match[1], "base64").toString("utf8") : null;
+};
+
+const resolveSource = (
+ file: string,
+ sourceRoot: unknown,
+ originalSource: string,
+): string =>
+ originalSource.startsWith("file://")
+ ? fileURLToPath(originalSource)
+ : resolve(
+ dirname(file),
+ typeof sourceRoot === "string" ? sourceRoot : "",
+ originalSource,
+ );
+
+const originalColumnOf = (
+ map: SourceMap,
+ entry: {
+ name?: string;
+ originalColumn: number;
+ originalLine: number;
+ originalSource: string;
+ },
+): number => {
+ const { sources, sourcesContent } = map.payload;
+ const content: unknown = Array.isArray(sourcesContent)
+ ? sourcesContent[sources.indexOf(entry.originalSource)]
+ : undefined;
+ if (!entry.name || typeof content !== "string") return entry.originalColumn;
+
+ const line = content.split("\n")[entry.originalLine] ?? "";
+ const at = line.indexOf(entry.name, entry.originalColumn);
+
+ return at === -1 ? entry.originalColumn : at;
+};
+
+export const createFrameMapper = ({ appRoot, origin }: FrameMapperOptions) => {
+ const maps = new Map>();
+
+ const fileSystemPath = (file: string): string => {
+ if (!isUrl(file)) return file;
+
+ const { pathname } = new URL(file);
+
+ return pathname.startsWith("/@fs/")
+ ? decodeURIComponent(pathname.slice("/@fs".length))
+ : join(appRoot, decodeURIComponent(pathname));
+ };
+
+ const loadSourceMap = async (file: string): Promise => {
+ if (isUrl(file)) {
+ if (!origin || new URL(file).origin !== origin) return null;
+ const text = await fetchInlineSourceMap(file);
+
+ return text === null ? null : parseSourceMap(text);
+ }
+
+ const text = await readFile(`${file}.map`, "utf8").catch(() => null);
+
+ return text === null ? null : parseSourceMap(text);
+ };
+
+ const mapFor = async (file: string) => {
+ const cached = maps.get(file);
+ if (cached) return cached;
+
+ const loading = loadSourceMap(file);
+ maps.set(file, loading);
+
+ return loading;
+ };
+
+ const mapFrame = async (frame: DevErrorFrame): Promise => {
+ const map = await mapFor(frame.file);
+ if (!map) return { ...frame, file: fileSystemPath(frame.file) };
+
+ const entry = map.findEntry(frame.line - 1, frame.column - 1);
+ if (!("originalSource" in entry)) {
+ return { ...frame, file: fileSystemPath(frame.file) };
+ }
+
+ const file = resolveSource(
+ fileSystemPath(frame.file),
+ map.payload.sourceRoot,
+ entry.originalSource,
+ );
+
+ return {
+ ...frame,
+ column: originalColumnOf(map, entry) + 1,
+ file,
+ isApp: isAppFile(file),
+ line: entry.originalLine + 1,
+ };
+ };
+
+ return async (frame: DevErrorFrame): Promise =>
+ frame.isApp ? mapFrame(frame).catch(() => frame) : frame;
+};
diff --git a/packages/vitnode/src/framework/dev-error/stack.ts b/packages/vitnode/src/framework/dev-error/stack.ts
new file mode 100644
index 0000000000..0cae33fe1b
--- /dev/null
+++ b/packages/vitnode/src/framework/dev-error/stack.ts
@@ -0,0 +1,91 @@
+import { fileURLToPath } from "node:url";
+
+export interface DevErrorFrame {
+ column: number;
+ file: string;
+ functionName: string;
+ isApp: boolean;
+ line: number;
+}
+
+const V8_FRAME_WITH_NAME = /^\s*at (?:async )?(.+?) \((.+):(\d+):(\d+)\)$/;
+const V8_FRAME_WITHOUT_NAME = /^\s*at (?:async )?(.+):(\d+):(\d+)$/;
+const GECKO_FRAME = /^\s*(.*?)@(.+):(\d+):(\d+)$/;
+
+export const isUrl = (file: string): boolean => /^https?:\/\//.test(file);
+
+const normalizeFile = (file: string): string => {
+ if (isUrl(file)) return file;
+
+ const withoutQuery = file.replace(/\?[^/]*$/, "");
+
+ return withoutQuery.startsWith("file://")
+ ? fileURLToPath(withoutQuery)
+ : withoutQuery;
+};
+
+const urlPathname = (file: string): string => {
+ const { pathname } = new URL(file);
+
+ return pathname.startsWith("/@fs/")
+ ? pathname.slice("/@fs".length)
+ : pathname;
+};
+
+export const isAppFile = (file: string): boolean => {
+ if (isUrl(file)) {
+ const pathname = urlPathname(file);
+
+ return (
+ !pathname.includes("/node_modules/") &&
+ (!pathname.startsWith("/@") || file.includes("/@fs/"))
+ );
+ }
+
+ return file.startsWith("/") && !file.includes("/node_modules/");
+};
+
+const toFrame = (
+ functionName: string,
+ file: string,
+ line: string,
+ column: string,
+): DevErrorFrame => {
+ const normalized = normalizeFile(file);
+
+ return {
+ column: Number(column),
+ file: normalized,
+ functionName: functionName || "",
+ isApp: isAppFile(normalized),
+ line: Number(line),
+ };
+};
+
+const parseLine = (text: string): DevErrorFrame | null => {
+ const named = V8_FRAME_WITH_NAME.exec(text);
+ if (named) {
+ const [, functionName, file, line, column] = named;
+
+ return toFrame(functionName, file, line, column);
+ }
+
+ const anonymous = V8_FRAME_WITHOUT_NAME.exec(text);
+ if (anonymous) {
+ const [, file, line, column] = anonymous;
+
+ return toFrame("", file, line, column);
+ }
+
+ const gecko = GECKO_FRAME.exec(text);
+ if (gecko) {
+ const [, functionName, file, line, column] = gecko;
+
+ return toFrame(functionName, file, line, column);
+ }
+
+ return null;
+};
+
+export const parseStackFrames = (stack: string): DevErrorFrame[] =>
+ stack.split("\n").flatMap(text => parseLine(text) ?? []);
diff --git a/packages/vitnode/src/framework/vite/dev-error.ts b/packages/vitnode/src/framework/vite/dev-error.ts
new file mode 100644
index 0000000000..0333bf034b
--- /dev/null
+++ b/packages/vitnode/src/framework/vite/dev-error.ts
@@ -0,0 +1,42 @@
+import type { Plugin, UserConfig } from "vite";
+
+import { fileURLToPath } from "node:url";
+
+import { DEV_ERROR_REPORT_ROUTE } from "../dev-error/report-route";
+
+const DEV_ERROR_HANDLER_MODULE = "@vitnode/core/framework/dev-error/handler";
+const DEV_ERROR_REPORT_MODULE =
+ "@vitnode/core/framework/dev-error/report-handler";
+
+interface NitroDevErrorConfig {
+ nitro: {
+ errorHandler: string[];
+ handlers: { handler: string; method: "POST"; route: string }[];
+ };
+}
+
+const modulePath = (specifier: string) =>
+ fileURLToPath(import.meta.resolve(specifier));
+
+const withNitroDevError = (): UserConfig => {
+ const config: NitroDevErrorConfig = {
+ nitro: {
+ errorHandler: [modulePath(DEV_ERROR_HANDLER_MODULE)],
+ handlers: [
+ {
+ handler: modulePath(DEV_ERROR_REPORT_MODULE),
+ method: "POST",
+ route: DEV_ERROR_REPORT_ROUTE,
+ },
+ ],
+ },
+ };
+
+ return config as UserConfig;
+};
+
+export const vitNodeDevError = (): Plugin => ({
+ config: (_userConfig, { command }) =>
+ command === "serve" ? withNitroDevError() : undefined,
+ name: "vitnode:dev-error",
+});
diff --git a/packages/vitnode/src/framework/vite/index.ts b/packages/vitnode/src/framework/vite/index.ts
index 6b2e01427e..bf28fd7ed4 100644
--- a/packages/vitnode/src/framework/vite/index.ts
+++ b/packages/vitnode/src/framework/vite/index.ts
@@ -1,3 +1,4 @@
+export { vitNodeDevError } from "./dev-error";
export type { VitNodeEnvOptions } from "./env";
export { vitNodeEnv } from "./env";
export { vitNodeOptimizeDeps } from "./optimize-deps";
diff --git a/packages/vitnode/src/framework/vite/vitnode.test.ts b/packages/vitnode/src/framework/vite/vitnode.test.ts
index 6f1f4b455b..ed975ad73b 100644
--- a/packages/vitnode/src/framework/vite/vitnode.test.ts
+++ b/packages/vitnode/src/framework/vite/vitnode.test.ts
@@ -5,13 +5,14 @@ import { vitnode } from "./vitnode";
const names = (plugins: { name: string }[]) => plugins.map(({ name }) => name);
describe("the composed plugin", () => {
- it("returns all five, in the order they have to run", () => {
+ it("returns all six, in the order they have to run", () => {
expect(names(vitnode({ appRoot: "/app" }))).toEqual([
"vitnode:env",
"vitnode:optimize-deps",
"vitnode:ssr-externals",
"vitnode:tailwind-sources",
"vitnode:plugin-routes",
+ "vitnode:dev-error",
]);
});
diff --git a/packages/vitnode/src/framework/vite/vitnode.ts b/packages/vitnode/src/framework/vite/vitnode.ts
index 1bd0e9746d..995311e19e 100644
--- a/packages/vitnode/src/framework/vite/vitnode.ts
+++ b/packages/vitnode/src/framework/vite/vitnode.ts
@@ -3,6 +3,7 @@ import type { Plugin } from "vite";
import type { VitNodeEnvOptions } from "./env";
import type { VitNodePluginRoutesOptions } from "./plugin-routes";
+import { vitNodeDevError } from "./dev-error";
import { vitNodeEnv } from "./env";
import { vitNodeOptimizeDeps } from "./optimize-deps";
import { vitNodePluginRoutes } from "./plugin-routes";
@@ -22,4 +23,5 @@ export const vitnode = ({
vitNodeSsrExternals({ appRoot }),
vitNodeTailwindSources({ appRoot }),
vitNodePluginRoutes({ appRoot, hostRoutesDir }),
+ vitNodeDevError(),
];
diff --git a/packages/vitnode/src/lib/navigation.test.ts b/packages/vitnode/src/lib/navigation.test.ts
index 256b94df1d..acfaeacacd 100644
--- a/packages/vitnode/src/lib/navigation.test.ts
+++ b/packages/vitnode/src/lib/navigation.test.ts
@@ -1,157 +1,6 @@
import { describe, expect, it } from "vitest";
-import type { PublicNavigationItem } from "./navigation";
-
-import {
- isExternalNavigationHref,
- isValidNavigationHref,
- navigationItemLabels,
- navigationMessagesNamespace,
- navigationNamespaces,
- navigationPresetKey,
- parseNavigationPresetKey,
- resolveNavigationText,
-} from "./navigation";
-
-const preset = (
- overrides: Partial = {},
-): PublicNavigationItem => ({
- description: [],
- href: "/discover",
- icon: null,
- id: 1,
- isOpenInNewTab: false,
- kind: "preset",
- pluginId: "@vitnode/core",
- presetId: "discover",
- title: [],
- ...overrides,
-});
-
-const messages: Record> = {
- "core.navigation": {
- "discover.description": "Fresh threads",
- "discover.title": "Discover",
- },
-};
-
-const translate = (namespace: string, key: string) =>
- messages[namespace]?.[key];
-
-describe("navigationMessagesNamespace", () => {
- it("maps core onto its own message tree", () => {
- expect(navigationMessagesNamespace("@vitnode/core")).toBe(
- "core.navigation",
- );
- });
-
- it("gives every other plugin a namespace under its id", () => {
- expect(navigationMessagesNamespace("@vitnode/blog")).toBe(
- "@vitnode/blog.navigation",
- );
- });
-});
-
-describe("resolveNavigationText", () => {
- it("prefers the reader's own language", () => {
- expect(
- resolveNavigationText({
- fallback: "Default",
- locale: "pl",
- values: [
- { languageCode: "en", value: "English" },
- { languageCode: "pl", value: "Polski" },
- ],
- }),
- ).toBe("Polski");
- });
-
- it("falls back to the preset default before another language", () => {
- expect(
- resolveNavigationText({
- fallback: "Odkrywaj",
- locale: "pl",
- values: [{ languageCode: "en", value: "Discover" }],
- }),
- ).toBe("Odkrywaj");
- });
-
- it("uses any language rather than nothing", () => {
- expect(
- resolveNavigationText({
- locale: "pl",
- values: [
- { languageCode: "de", value: " " },
- { languageCode: "en", value: "Discover" },
- ],
- }),
- ).toBe("Discover");
- });
-
- it("treats a blank override as no override", () => {
- expect(
- resolveNavigationText({
- fallback: "Discover",
- locale: "en",
- values: [{ languageCode: "en", value: " " }],
- }),
- ).toBe("Discover");
- });
-});
-
-describe("navigationItemLabels", () => {
- it("reads a preset's defaults from its plugin namespace", () => {
- expect(
- navigationItemLabels({ item: preset(), locale: "en", translate }),
- ).toEqual({ description: "Fresh threads", title: "Discover" });
- });
-
- it("lets a stored title win over the preset default", () => {
- expect(
- navigationItemLabels({
- item: preset({ title: [{ languageCode: "en", value: "Explore" }] }),
- locale: "en",
- translate,
- }).title,
- ).toBe("Explore");
- });
-
- it("has no defaults for a custom link", () => {
- expect(
- navigationItemLabels({
- item: preset({
- kind: "custom",
- pluginId: null,
- presetId: null,
- title: [{ languageCode: "en", value: "Docs" }],
- }),
- locale: "en",
- translate,
- }),
- ).toEqual({ description: "", title: "Docs" });
- });
-});
-
-describe("navigationNamespaces", () => {
- it("collects one namespace per plugin with a preset in the menu", () => {
- expect(
- navigationNamespaces([
- preset(),
- preset({ id: 2, presetId: "search" }),
- preset({ id: 3, pluginId: "@vitnode/blog", presetId: "posts" }),
- preset({ id: 4, kind: "custom", pluginId: null, presetId: null }),
- ]),
- ).toEqual(["@vitnode/blog.navigation", "core.navigation"]);
- });
-
- it("is empty when the menu is custom links only", () => {
- expect(
- navigationNamespaces([
- preset({ kind: "custom", pluginId: null, presetId: null }),
- ]),
- ).toEqual([]);
- });
-});
+import { isExternalNavigationHref, isValidNavigationHref } from "./navigation";
describe("hrefs", () => {
it.each([
@@ -183,20 +32,3 @@ describe("hrefs", () => {
expect(isExternalNavigationHref("/discover")).toBe(false);
});
});
-
-describe("preset keys", () => {
- it("round-trips a scoped plugin id", () => {
- const key = navigationPresetKey("@vitnode/blog", "posts");
-
- expect(parseNavigationPresetKey(key)).toEqual({
- pluginId: "@vitnode/blog",
- presetId: "posts",
- });
- });
-
- it("refuses a key with either half missing", () => {
- expect(parseNavigationPresetKey("posts")).toBeNull();
- expect(parseNavigationPresetKey("::posts")).toBeNull();
- expect(parseNavigationPresetKey("@vitnode/blog::")).toBeNull();
- });
-});
diff --git a/packages/vitnode/src/lib/navigation.ts b/packages/vitnode/src/lib/navigation.ts
index 7a83dc2e7b..4309090e75 100644
--- a/packages/vitnode/src/lib/navigation.ts
+++ b/packages/vitnode/src/lib/navigation.ts
@@ -1,94 +1,90 @@
-import { manifest } from "@/manifest";
-export const NAVIGATION_KINDS = ["preset", "custom"] as const;
-export type NavigationKind = (typeof NAVIGATION_KINDS)[number];
+import type React from "react";
-export const NAVIGATION_LOCATIONS = ["header", "bottom_bar"] as const;
-export type NavigationLocation = (typeof NAVIGATION_LOCATIONS)[number];
+import type {
+ DevErrorHint,
+ DevErrorWithHints,
+} from "../framework/dev-error/hints";
-export const NAVIGATION_BOTTOM_BAR_MAX_ITEMS = 3;
-
-export const NAVIGATION_TABLE_NAME = "core_navigation";
+import {
+ MAX_NAMESPACES,
+ namespaceProblem,
+ normalizeNamespaceList,
+} from "../routing/namespaces";
-export const NAVIGATION_WORDS = {
- description: "description",
- title: "title",
-} as const;
+export const NAVIGATION_BOTTOM_BAR_MAX_ITEMS = 3;
-export const NAVIGATION_TITLE_MAX_LENGTH = 100;
-export const NAVIGATION_DESCRIPTION_MAX_LENGTH = 255;
export const NAVIGATION_HREF_MAX_LENGTH = 2048;
-export const NAVIGATION_PRESET_ID_MAX_LENGTH = 120;
-export const NAVIGATION_PRESET_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
-export const NAVIGATION_MAX_DEPTH = 1;
-export interface NavigationPresetDeclaration {
+export type NavigationIcon = React.ComponentType<{ className?: string }>;
+
+export interface NavigationLinkConfig {
+ description?: string;
href: string;
- icon?: string;
- id: string;
+ icon?: NavigationIcon;
isOpenInNewTab?: boolean;
+ title: string;
}
-export interface NavigationPreset extends Omit<
- NavigationPresetDeclaration,
- "icon" | "isOpenInNewTab"
-> {
- icon: null | string;
- isOpenInNewTab: boolean;
- pluginId: string;
+export interface NavigationHeaderItemConfig extends NavigationLinkConfig {
+ items?: NavigationLinkConfig[];
}
-export interface NavigationText {
- languageCode: string;
- value: string;
+export interface NavigationConfig {
+ header?: NavigationHeaderItemConfig[];
+ mobileBottomBar?: NavigationLinkConfig[];
}
-export interface PublicNavigationItem {
- description: NavigationText[];
- href: string;
- icon: null | string;
- id: number;
- isOpenInNewTab: boolean;
- kind: NavigationKind;
- pluginId: null | string;
- presetId: null | string;
- title: NavigationText[];
-}
+export class NavigationConfigError extends Error implements DevErrorWithHints {
+ constructor(message: string, hints: readonly DevErrorHint[]) {
+ super(`[VitNode navigation] ${message}`);
+ this.name = "NavigationConfigError";
+ this.hints = hints;
+ }
-export interface PublicNavigationNode extends PublicNavigationItem {
- items: PublicNavigationItem[];
+ readonly hints: readonly DevErrorHint[];
}
-const CORE_NAVIGATION_NAMESPACE = "core.navigation";
-
-export const navigationMessagesNamespace = (pluginId: string): string =>
- pluginId === manifest.id
- ? CORE_NAVIGATION_NAMESPACE
- : `${pluginId}.navigation`;
-
-export const navigationPresetMessageKeys = (presetId: string) => ({
- description: `${presetId}.description`,
- title: `${presetId}.title`,
-});
-
-const PRESET_KEY_SEPARATOR = "::";
-
-export const navigationPresetKey = (
- pluginId: string,
- presetId: string,
-): string => `${pluginId}${PRESET_KEY_SEPARATOR}${presetId}`;
+const HREF_HINTS: DevErrorHint[] = [
+ {
+ description:
+ 'Start an href with "/" for a page on this site or "https://" for another site.',
+ snippet: "{ href: '/docs', title: 'site.menu.docs' }",
+ title: "Use a local or https link",
+ },
+];
-export const parseNavigationPresetKey = (
- key: string,
-): null | { pluginId: string; presetId: string } => {
- const separatorAt = key.lastIndexOf(PRESET_KEY_SEPARATOR);
- if (separatorAt <= 0) return null;
+const MESSAGE_KEY_HINTS: DevErrorHint[] = [
+ {
+ description:
+ 'Titles and descriptions are i18n keys such as "site.menu.docs", never text or objects.',
+ snippet: "{ href: '/docs',\n title: 'site.menu.docs' }",
+ title: "Use a message key",
+ },
+ {
+ description:
+ "TypeScript flags a navigation item with the wrong shape before the app starts.",
+ snippet: "pnpm tsc --noEmit",
+ title: "Run the type check",
+ },
+];
- const pluginId = key.slice(0, separatorAt);
- const presetId = key.slice(separatorAt + PRESET_KEY_SEPARATOR.length);
- if (!pluginId || !presetId) return null;
+const BOTTOM_BAR_HINTS: DevErrorHint[] = [
+ {
+ description: `The bottom bar fits ${String(NAVIGATION_BOTTOM_BAR_MAX_ITEMS)} links. Move the rest to navigation.header, where they open from the menu.`,
+ snippet:
+ "navigation: {\n header: [/* the extra links */],\n mobileBottomBar: [/* up to 3 */],\n}",
+ title: "Move extra links to the header",
+ },
+];
- return { pluginId, presetId };
-};
+const NAMESPACE_HINTS: DevErrorHint[] = [
+ {
+ description:
+ "Put every menu key under one namespace, then add the keys to that namespace's locale file.",
+ snippet: "title: 'site.menu.docs'\ntitle: 'site.menu.plugins'",
+ title: "Share one namespace",
+ },
+];
export const isExternalNavigationHref = (href: string): boolean =>
href.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(href);
@@ -101,100 +97,88 @@ export const isValidNavigationHref = (href: string): boolean => {
if (trimmed.startsWith("/")) return !trimmed.startsWith("//");
- // https only: a menu item is a link the whole site hands out, and sending
- // readers to a plaintext address is not something an admin should be able to
- // do by typing one character less.
return /^https:\/\/[^/?#]+/i.test(trimmed);
};
-const firstNonEmpty = (values: readonly NavigationText[]): string =>
- values.find(item => item.value.trim().length > 0)?.value.trim() ?? "";
-
-export const resolveNavigationText = ({
- fallback,
- locale,
- values,
-}: {
- fallback?: string;
- locale: string;
- values: readonly NavigationText[];
-}): string => {
- const own = values.find(item => item.languageCode === locale)?.value.trim();
- if (own) return own;
-
- const preset = fallback?.trim();
- if (preset) return preset;
-
- return firstNonEmpty(values);
-};
-
-export type NavigationTranslate = (
- namespace: string,
- key: string,
-) => string | undefined;
-
-export interface NavigationLabelSource {
- description: readonly NavigationText[];
- kind: NavigationKind;
- pluginId: null | string;
- presetId: null | string;
- title: readonly NavigationText[];
-}
-
-const presetDefaults = (
- item: Pick,
- translate: NavigationTranslate,
-): { description?: string; title?: string } => {
- if (item.kind !== "preset" || !item.pluginId || !item.presetId) return {};
+export const navigationMessageNamespace = (key: unknown): null | string => {
+ if (typeof key !== "string") return null;
- const namespace = navigationMessagesNamespace(item.pluginId);
- const keys = navigationPresetMessageKeys(item.presetId);
+ const separatorAt = key.lastIndexOf(".");
+ if (separatorAt <= 0 || separatorAt === key.length - 1) return null;
- return {
- description: translate(namespace, keys.description),
- title: translate(namespace, keys.title),
- };
-};
+ const namespace = key.slice(0, separatorAt);
-export const navigationItemLabels = ({
- item,
- locale,
- translate,
-}: {
- item: NavigationLabelSource;
- locale: string;
- translate: NavigationTranslate;
-}): { description: string; title: string } => {
- const defaults = presetDefaults(item, translate);
-
- return {
- description: resolveNavigationText({
- fallback: defaults.description,
- locale,
- values: item.description,
- }),
- title: resolveNavigationText({
- fallback: defaults.title,
- locale,
- values: item.title,
- }),
- };
+ return namespaceProblem(namespace) === null ? namespace : null;
};
-interface NavigationNamespaceSource {
- items?: readonly NavigationNamespaceSource[];
- kind: NavigationKind;
- pluginId: null | string;
-}
+const messageKeysOf = (link: NavigationLinkConfig): string[] => [
+ link.title,
+ ...(link.description === undefined ? [] : [link.description]),
+];
-const namespacesOf = (item: NavigationNamespaceSource): string[] => [
- ...(item.kind === "preset" && item.pluginId
- ? [navigationMessagesNamespace(item.pluginId)]
- : []),
- ...(item.items ?? []).flatMap(namespacesOf),
+const navigationLinksOf = (
+ navigation: NavigationConfig | undefined,
+): NavigationLinkConfig[] => [
+ ...(navigation?.header ?? []).flatMap(item => [item, ...(item.items ?? [])]),
+ ...(navigation?.mobileBottomBar ?? []),
];
-export const navigationNamespaces = (
- items: readonly NavigationNamespaceSource[],
+export const navigationMessageNamespaces = (
+ navigation: NavigationConfig | undefined,
): string[] =>
- [...new Set(items.flatMap(namespacesOf))].sort((a, b) => a.localeCompare(b));
+ normalizeNamespaceList(
+ navigationLinksOf(navigation)
+ .flatMap(messageKeysOf)
+ .flatMap(key => navigationMessageNamespace(key) ?? []),
+ );
+
+const assertNavigationLink = (
+ link: NavigationLinkConfig,
+ where: string,
+): void => {
+ if (!isValidNavigationHref(link.href)) {
+ throw new NavigationConfigError(
+ `The ${where} item ${JSON.stringify(link.href)} needs an href that starts with "/" or "https://".`,
+ HREF_HINTS,
+ );
+ }
+
+ for (const key of messageKeysOf(link)) {
+ if (navigationMessageNamespace(key) === null) {
+ throw new NavigationConfigError(
+ `The ${where} item "${link.href}" has the title or description ${JSON.stringify(key)}. Both are message keys: a namespace and a name joined by ".", such as "site.menu.docs".`,
+ MESSAGE_KEY_HINTS,
+ );
+ }
+ }
+};
+
+export const assertNavigationConfig = (
+ navigation: NavigationConfig | undefined,
+): void => {
+ for (const item of navigation?.header ?? []) {
+ assertNavigationLink(item, "header");
+
+ for (const child of item.items ?? []) {
+ assertNavigationLink(child, "header dropdown");
+ }
+ }
+
+ const bottomBar = navigation?.mobileBottomBar ?? [];
+ if (bottomBar.length > NAVIGATION_BOTTOM_BAR_MAX_ITEMS) {
+ throw new NavigationConfigError(
+ `The mobile bottom bar holds up to ${String(NAVIGATION_BOTTOM_BAR_MAX_ITEMS)} links, and ${String(bottomBar.length)} are configured.`,
+ BOTTOM_BAR_HINTS,
+ );
+ }
+
+ for (const item of bottomBar) assertNavigationLink(item, "bottom bar");
+
+ const namespaces = navigationMessageNamespaces(navigation);
+ if (namespaces.length > MAX_NAMESPACES) {
+ throw new NavigationConfigError(
+ `The menus read ${String(namespaces.length)} message namespaces, and at most ${String(MAX_NAMESPACES)} load at once. Keep the menu's keys under one namespace, such as "site.menu".`,
+ NAMESPACE_HINTS,
+ );
+ }
+};
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index d308a1890c..8229b5e635 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -30,11 +30,6 @@
"@vitnode/core:files:can_view": "View uploaded files",
"@vitnode/core:files:can_download": "Download files",
"@vitnode/core:files:can_delete": "Delete files",
- "@vitnode/core:navigation": "Navigation",
- "@vitnode/core:navigation:can_view": "View the main menu",
- "@vitnode/core:navigation:can_create": "Add main menu items",
- "@vitnode/core:navigation:can_edit": "Edit and reorder main menu items",
- "@vitnode/core:navigation:can_delete": "Remove main menu items",
"@vitnode/core:ai": "AI",
"@vitnode/core:ai:can_view": "View AI usage, settings and history",
"@vitnode/core:ai:can_manage": "Manage AI settings, models, pricing and access",
@@ -719,16 +714,6 @@
},
"unsaved_changes": "Unsaved changes"
},
- "navigation": {
- "discover": {
- "title": "Discover",
- "description": "The newest posts from everyone here."
- },
- "search": {
- "title": "Search",
- "description": "Find members, posts and pages."
- }
- },
"profile": {
"title": "{name} (@{nameCode})",
"metaDesc": "See {name} (@{nameCode}): their roles and when they joined the community.",
@@ -2665,32 +2650,67 @@
},
"system-updates": {
"title": "System updates",
- "desc": "Core version, available updates and security warnings from the stored update checks.",
+ "desc": "Newer releases and security fixes for VitNode and your plugins.",
"rows": {
- "status": "Status",
"core": "VitNode",
"plugin_updates": "Plugin updates",
- "security": "Security",
- "last_checked": "Last checked"
+ "security": "Security fixes",
+ "no_info": "No update info"
},
- "status": {
- "security": "Security fix needed",
- "updates": "Updates available",
- "up_to_date": "Up to date",
- "unconfirmed": "{count, plural, one {# package not confirmed} other {# packages not confirmed}}",
- "running": "Checking...",
- "not_checked": "Not checked yet",
- "unavailable": "No update sources",
- "disabled": "Checks turned off"
+ "verdict": {
+ "security": {
+ "title": "Security fix needed",
+ "desc": "{count, plural, one {# package has a known security issue that a newer release fixes. Update as soon as you can.} other {# packages have known security issues that a newer release fixes. Update as soon as you can.}}"
+ },
+ "updates": {
+ "title": "Updates available",
+ "desc": "{count, plural, one {# newer release is ready to install.} other {# newer releases are ready to install.}}"
+ },
+ "up_to_date": {
+ "title": "Everything is up to date",
+ "desc": "{count, plural, one {Your package runs its newest release.} other {All # packages run their newest release.}}"
+ },
+ "unconfirmed": {
+ "title": "No updates found",
+ "desc": "{verified} of {total} packages are up to date. {count, plural, one {The other one publishes no update info, so VitNode can't check it.} other {The other # publish no update info, so VitNode can't check them.}}"
+ },
+ "no_info": {
+ "title": "Can't check for updates",
+ "desc": "{count, plural, one {Your package publishes no update info, so VitNode can't tell whether a newer version exists.} other {None of your # packages publishes update info, so VitNode can't tell whether newer versions exist.}}"
+ },
+ "unavailable": {
+ "title": "Can't check for updates",
+ "desc": "{count, plural, one {Your package publishes no update info, so VitNode can't tell whether a newer version exists.} other {None of your # packages publishes update info, so VitNode can't tell whether newer versions exist.}}"
+ },
+ "running": {
+ "title": "Checking for updates...",
+ "desc": "Results show up here when the check finishes."
+ },
+ "not_checked": {
+ "title": "Not checked yet",
+ "desc": "VitNode checks once a day. To check right now, open Plugins."
+ },
+ "disabled": {
+ "title": "Update checks are off",
+ "desc": "This site sets updates.enabled to false in its API config, so nothing is checked."
+ }
},
"core_update": "update to {version} available",
- "security_affected": "{count, plural, one {# package} other {# packages}}",
- "security_none": "None",
- "last_checked": {
- "running": "In progress",
- "failed": "Failed ",
- "stale": "Out of date",
- "never": "Never"
+ "plugin_updates": {
+ "none": "None",
+ "available": "{count} available"
+ },
+ "security": {
+ "affected": "{count, plural, one {Needed for # package} other {Needed for # packages}}",
+ "none": "None found"
+ },
+ "no_info_count": "{total, plural, one {{count} of # package} other {{count} of # packages}}",
+ "checked": {
+ "running": "Checking now...",
+ "failed": "Last check failed ",
+ "stale": "Checked , needs a recheck",
+ "never": "Never checked",
+ "at": "Checked "
},
"unknown": "Unknown",
"load_error": "Could not load the update summary. The Plugins page still shows every result.",
@@ -2723,7 +2743,6 @@
"system": {
"title": "System",
"integrations": "Integrations",
- "navigation": "Navigation",
"files": "Files",
"notifications": "Notifications",
"plugins": "Plugins"
@@ -4081,117 +4100,6 @@
}
}
},
- "navigation": {
- "list": {
- "desc": "Choose what your menus link to, and in what order. Changes go live right away.",
- "custom": "Custom link",
- "missing": "The plugin behind this item is no longer installed, so the menu skips it.",
- "opensInNewTab": "Opens in a new tab",
- "reorderSuccess": "Menu order saved",
- "reorderSuccessDesc": "Visitors see the new order the next time the menu loads.",
- "noResults": {
- "title": "The main menu is empty",
- "description": "Add a prebuilt page or a custom link and it shows up in the header."
- },
- "dragHandle": "Drag to move {name}",
- "label": "Menu items",
- "summary": "{header} in the header · {dropdowns} in dropdowns",
- "collapseAll": "Collapse all",
- "expandAll": "Expand all",
- "showChildren": "Show what's inside {name}",
- "hideChildren": "Hide what's inside {name}",
- "actions": "More actions for {name}",
- "edit": "Edit",
- "addChild": "Add sub-item",
- "moveUp": "Move up",
- "moveDown": "Move down",
- "moveToTop": "Move to top level",
- "moveInside": "Move inside…",
- "delete": "Remove",
- "pluginMissing": "Plugin missing",
- "bottomBarSummary": "{count} of {max} links",
- "bottomBarEmpty": {
- "title": "The bottom bar is empty",
- "description": "Add up to {max} links and phones show them in a bar at the bottom of the screen. Empty means no bar at all."
- }
- },
- "create": {
- "title": "Add menu item",
- "desc": "Pick a page a plugin already provides, or link anywhere you like.",
- "submit": "Add to menu",
- "success": "Menu item added",
- "successDesc": "\"{name}\" is now in the main menu.",
- "childTitle": "Add a page to “{name}”",
- "presets": "Pages from your plugins",
- "back": "Choose something else",
- "inHeader": "Shown in the header bar.",
- "inDropdown": "Shown in the “{name}” dropdown.",
- "fromPlugin": "{placement} A page from {plugin}.",
- "bottomBarTitle": "Add link",
- "bottomBarFull": "The bottom bar holds {max} links. Remove one to add another.",
- "inBottomBar": "Shown in the bottom bar on phones.",
- "successDescBottomBar": "\"{name}\" is now in the bottom bar."
- },
- "edit": {
- "title": "Edit menu item",
- "submit": "Save changes",
- "success": "Menu item updated",
- "successDesc": "Changes to \"{name}\" are live."
- },
- "delete": {
- "title": "Remove menu item",
- "desc": "Remove \"{name}\" from the main menu? Visitors stop seeing it right away. You can add it back later.",
- "confirm": "Remove",
- "cancel": "Cancel",
- "success": "Menu item removed",
- "successDesc": "\"{name}\" is no longer in the main menu.",
- "descWithChildren": "Remove “{name}” from the main menu? {count, plural, one {Its sub-item moves} other {Its # sub-items move}} to the top level."
- },
- "form": {
- "kind": {
- "custom": "Custom link",
- "customDesc": "Any address, on this site or elsewhere."
- },
- "preset": "Page",
- "presetEmpty": "Every prebuilt page is already in the menu.",
- "presetLocked": "A prebuilt item keeps the page its plugin points at. Change the title, the description, or where it opens.",
- "href": "URL",
- "hrefPlaceholder": "/discover or https://example.com",
- "title": "Title",
- "icon": "Icon",
- "iconPlaceholder": "No icon",
- "description": "Description",
- "openInNewTab": "Open in a new tab",
- "openInNewTabDesc": "Handy for links that leave this site.",
- "presetMissing": "Plugin not installed",
- "preview": "How visitors see it",
- "previewUntitled": "Untitled item",
- "descriptionDesc": "Shown under the title when the item sits in a dropdown.",
- "parent": "Placement",
- "parentRoot": "Top level",
- "parentInside": "Inside “{name}”",
- "parentDesc": "Top-level items sit in the header bar. Items inside another one show in its dropdown.",
- "parentLocked": "It holds {count, plural, one {# sub-item} other {# sub-items}}, so it stays at the top level. Menus go two levels deep.",
- "sections": {
- "destination": "Destination",
- "label": "Label",
- "position": "Position"
- }
- },
- "errors": {
- "hrefInvalid": "Start with / or https://",
- "titleRequired": "Give the item a title in at least one language.",
- "presetTaken": "This page is already in the menu.",
- "bottomBarConflict": "This page is already in the bottom bar, or the bar is full."
- },
- "tabs": {
- "label": "Menus",
- "header": "Header",
- "bottomBar": "Bottom bar",
- "headerDesc": "In the header on tablets and desktops, and in the side menu on phones.",
- "bottomBarDesc": "Up to three links at the bottom of the screen on phones, plus a Menu tab with the rest of the header menu."
- }
- },
"ai": {
"cost": {
"unknown": "Unknown",
diff --git a/packages/vitnode/src/pages/admin/system/navigation.tsx b/packages/vitnode/src/pages/admin/system/navigation.tsx
deleted file mode 100644
index 6fc305f804..0000000000
--- a/packages/vitnode/src/pages/admin/system/navigation.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { PluginRoutePageProps } from "@/routing";
-import type { AdminNavigationRouteData } from "@/tanstack/admin/navigation/route";
-
-import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb";
-import { loadAdminNavigationRoute } from "@/tanstack/admin/navigation/route";
-import { AdminNavigationRouteContent } from "@/tanstack/admin/navigation/screen";
-import { defineAdminRoute } from "@/tanstack/plugin-routes";
-
-const AdminNavigationPage = ({
- loaderData,
-}: PluginRoutePageProps) => (
-
-);
-
-export const route = defineAdminRoute({
- load: async ({ context, t }) =>
- await loadAdminNavigationRoute({ ...context, t }),
- head: ({ loaderData }) => ({ ...loaderData }),
-
- breadcrumb: adminBreadcrumb({ segments: ["core", "system", "navigation"] }),
-});
-
-export default AdminNavigationPage;
diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx
index 71d79028cf..b070115a68 100644
--- a/packages/vitnode/src/routes.tsx
+++ b/packages/vitnode/src/routes.tsx
@@ -7,17 +7,23 @@ import {
import { contentListRouteSearch } from "./tanstack/admin/content/route-search";
import { normalizeCronRouteSearch } from "./tanstack/admin/cron/route-search";
import {
- DebugFramePending,
+ DebugAiPending,
+ DebugCachePending,
+ DebugCronPending,
DebugLayoutPending,
+ DebugLogsPending,
DebugOverviewPending,
- DebugTablePending,
+ DebugPluginsPending,
+ DebugQueuePending,
+ DebugSearchPending,
+ DebugServicesPending,
+ DebugVitnodePending,
} from "./tanstack/admin/debug/pending";
import { ADMIN_DEBUG_NAMESPACES } from "./tanstack/admin/debug/route";
import { normalizeDebugLogsSearch } from "./tanstack/admin/debug/route-search";
import { ADMIN_FILES_NAMESPACES } from "./tanstack/admin/files/route";
import { normalizeAdminFilesRouteSearch } from "./tanstack/admin/files/route-search";
import { ADMIN_INTEGRATIONS_NAMESPACES } from "./tanstack/admin/integrations/route";
-import { ADMIN_NAVIGATION_NAMESPACES } from "./tanstack/admin/navigation/route";
import { ADMIN_NOTIFICATIONS_NAMESPACES } from "./tanstack/admin/notifications/route";
import { ADMIN_PLUGINS_NAMESPACES } from "./tanstack/admin/plugins/route";
import { normalizeQueueRouteSearch } from "./tanstack/admin/queue/route-search";
@@ -280,55 +286,48 @@ export const routes = defineRoutes([
}),
page("vitnode", {
component: lazy(() => import("./pages/admin/debug/vitnode")),
- pendingComponent: DebugFramePending,
+ pendingComponent: DebugVitnodePending,
}),
page("plugins", {
component: lazy(() => import("./pages/admin/debug/plugins")),
- pendingComponent: DebugFramePending,
+ pendingComponent: DebugPluginsPending,
}),
page("cron", {
component: lazy(() => import("./pages/admin/debug/cron")),
- pendingComponent: DebugTablePending,
+ pendingComponent: DebugCronPending,
search: normalizeCronRouteSearch,
}),
page("cache", {
component: lazy(() => import("./pages/admin/debug/cache")),
- pendingComponent: DebugFramePending,
+ pendingComponent: DebugCachePending,
}),
page("search", {
component: lazy(() => import("./pages/admin/debug/search")),
- pendingComponent: DebugTablePending,
+ pendingComponent: DebugSearchPending,
search: normalizeSearchIndexRouteSearch,
}),
page("services", {
component: lazy(() => import("./pages/admin/debug/services")),
- pendingComponent: DebugFramePending,
+ pendingComponent: DebugServicesPending,
}),
page("logs", {
component: lazy(() => import("./pages/admin/debug/logs")),
- pendingComponent: DebugTablePending,
+ pendingComponent: DebugLogsPending,
search: normalizeDebugLogsSearch,
}),
page("queue", {
component: lazy(() => import("./pages/admin/debug/queue")),
- pendingComponent: DebugTablePending,
+ pendingComponent: DebugQueuePending,
search: normalizeQueueRouteSearch,
}),
page("ai", {
component: lazy(() => import("./pages/admin/debug/ai")),
- pendingComponent: DebugTablePending,
+ pendingComponent: DebugAiPending,
search: normalizeAiHistoryRouteSearch,
}),
],
}),
- page("/admin/core/system/navigation", {
- area: "admin",
- component: lazy(() => import("./pages/admin/system/navigation")),
- messages: ADMIN_NAVIGATION_NAMESPACES,
- pendingComponent: TablePendingSkeleton,
- }),
-
page("/admin/core/system/files", {
area: "admin",
component: lazy(() => import("./pages/admin/system/files")),
diff --git a/packages/vitnode/src/tanstack/admin/debug/pending.tsx b/packages/vitnode/src/tanstack/admin/debug/pending.tsx
index 3766f3952c..24e8c116c5 100644
--- a/packages/vitnode/src/tanstack/admin/debug/pending.tsx
+++ b/packages/vitnode/src/tanstack/admin/debug/pending.tsx
@@ -1,122 +1,366 @@
+import { useRouterState } from "@tanstack/react-router";
import { cn } from "cn";
+import { DataTableSkeleton } from "@/components/table/data-table-content";
import { Skeleton } from "@/components/ui/skeleton";
+import type { DebugTab } from "./route-search";
+
import {
PendingFrame,
- PendingHeading,
pendingRowKeys,
} from "../../pending/route-pending-skeleton";
+import { debugTabFromPath } from "./route-search";
+
+type FrameRowShape = "check" | "fact" | "issue" | "item" | "link" | "service";
+
+const FRAME_ROW_WIDTHS = ["w-24", "w-32", "w-20", "w-28", "w-36", "w-16"];
+
+const rowWidth = (key: number) =>
+ FRAME_ROW_WIDTHS[key % FRAME_ROW_WIDTHS.length];
+
+const Line = ({ className }: { className: string }) => (
+
+
+
+);
+
+const TwoLines = ({ index }: { index: number }) => (
+
+
+
+
+
+
+);
+
+const FramePendingRow = ({
+ index,
+ shape,
+}: {
+ index: number;
+ shape: FrameRowShape;
+}) => {
+ if (shape === "fact") {
+ return (
+
+
+
+
+ );
+ }
+
+ if (shape === "check") {
+ return (
+
+
+
+
+ );
+ }
+
+ if (shape === "link") {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
-const SectionHeadingPending = () => (
-
-
-
-
+ if (shape === "issue") {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (shape === "service") {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+};
+
+const FramePending = ({
+ action = true,
+ badge = true,
+ rows,
+}: {
+ action?: boolean;
+ badge?: boolean;
+ rows: readonly FrameRowShape[];
+}) => (
+
+
+
+
+
+ {badge ? : null}
+
+
+
+
+
+ {action ? (
+
+ ) : null}
+
+
+ {pendingRowKeys(rows.length).map(key => (
+
+ ))}
-
);
-const FramePending = ({ rows = 4 }: { rows?: number }) => (
-
-
-
-
-
+const SectionHeaderPending = ({
+ action = false,
+ badge = false,
+}: {
+ action?: boolean;
+ badge?: boolean;
+}) => (
+
+
+
+
+ {badge ? : null}
+
+
+
+
+
+ {action ?
: null}
+
+);
+
+const CronSchedulerPending = () => (
+
+
-
- {pendingRowKeys(rows).map(key => (
-
-
-
-
+
+ {pendingRowKeys(3).map(key => (
+
+
+
))}
);
-const TablePending = ({ rows = 6 }: { rows?: number }) => (
-
-
-
-
-
-
- {pendingRowKeys(rows).map(key => (
+const SearchStatsPending = () => (
+
+ {pendingRowKeys(4).map(key => (
);
-export const DebugFramePending = () => (
-
-
-
+const SearchCollectionsPending = () => (
+
);
-export const DebugOverviewPending = () => (
-
-
-
-
-);
+const DEBUG_TAB_CONTENT_PENDING: Record
React.ReactNode> = {
+ ai: () => (
+ <>
+
+
+ >
+ ),
+ cache: () => ,
+ cron: () => (
+ <>
+
+
+
+ >
+ ),
+ logs: () => (
+ <>
+
+
+ >
+ ),
+ overview: () => (
+ <>
+
+
+ >
+ ),
+ plugins: () => ,
+ queue: () => (
+ <>
+
+
+ >
+ ),
+ search: () => (
+ <>
+
+
+
+ >
+ ),
+ services: () => (
+
+ ),
+ vitnode: () => ,
+};
-export const DebugTablePending = () => (
-
-
-
-
-);
+const DebugTabContentPending = ({ tab }: { tab: DebugTab }) => {
+ const Content = DEBUG_TAB_CONTENT_PENDING[tab];
+
+ return (
+
+
+
+ );
+};
+
+const debugTabPending = (tab: DebugTab) =>
+ function DebugTabPending() {
+ return (
+
+
+
+ );
+ };
+
+export const DebugOverviewPending = debugTabPending("overview");
+export const DebugVitnodePending = debugTabPending("vitnode");
+export const DebugPluginsPending = debugTabPending("plugins");
+export const DebugCronPending = debugTabPending("cron");
+export const DebugCachePending = debugTabPending("cache");
+export const DebugSearchPending = debugTabPending("search");
+export const DebugServicesPending = debugTabPending("services");
+export const DebugLogsPending = debugTabPending("logs");
+export const DebugQueuePending = debugTabPending("queue");
+export const DebugAiPending = debugTabPending("ai");
+
+const NAV_PENDING_GROUPS = [
+ { id: "top", tabs: ["overview"], withTitle: false },
+ {
+ id: "systems",
+ tabs: ["vitnode", "plugins", "cron", "cache", "search", "services"],
+ withTitle: true,
+ },
+ { id: "diagnostics", tabs: ["logs", "queue", "ai"], withTitle: true },
+] as const satisfies readonly {
+ id: string;
+ tabs: readonly DebugTab[];
+ withTitle: boolean;
+}[];
-const NavPending = () => (
-
-
- {[5, 3].map(count => (
-
-
- {pendingRowKeys(count).map(key => (
-
-
-
+const NavPending = ({ active }: { active: DebugTab }) => (
+ <>
+
+
+ {NAV_PENDING_GROUPS.map(group => (
+
+ {group.withTitle ? (
+
+
+
+ ) : null}
+
+ {group.tabs.map((tab, index) => (
+
+
+
+
+ ))}
- ))}
-
- ))}
-
+
+ ))}
+
+ >
);
-export const DebugLayoutPending = () => (
-
-
-
-
-
-
-
+export const DebugLayoutPending = () => {
+ const tab = useRouterState({
+ select: state => debugTabFromPath(state.location.pathname),
+ });
+
+ return (
+
+
+
+
+
+
+
+
-
-
-);
+
+
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/tanstack/admin/navigation/index.ts b/packages/vitnode/src/tanstack/admin/navigation/index.ts
deleted file mode 100644
index 8d05802f58..0000000000
--- a/packages/vitnode/src/tanstack/admin/navigation/index.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-export {
- adminNavigationPresetsQuery,
- adminNavigationQuery,
- invalidateAfterAdminNavigationChange,
- useAdminNavigationMutations,
-} from "./query";
-export type { AdminNavigationRouteData } from "./route";
-export { ADMIN_NAVIGATION_NAMESPACES, loadAdminNavigationRoute } from "./route";
-export { AdminNavigationRouteContent } from "./screen";
-
-export type {
- AdminNavigationFormProps,
- AdminNavigationFormValues,
-} from "@/views/admin/views/core/navigation/navigation-form-content";
-export type { NavigationAdminListProps } from "@/views/admin/views/core/navigation/navigation-list-content";
-export type {
- AdminNavigationCreateInput,
- AdminNavigationOrderInput,
- AdminNavigationUpdateInput,
-} from "@/views/admin/views/core/navigation/navigation-mutations";
-export {
- createAdminNavigation,
- deleteAdminNavigation,
- reorderAdminNavigation,
- updateAdminNavigation,
-} from "@/views/admin/views/core/navigation/navigation-mutations";
-export type {
- AdminNavigationItem,
- AdminNavigationList,
- AdminNavigationPresets,
-} from "@/views/admin/views/core/navigation/navigation-query";
-export {
- adminNavigationQueryRoot,
- fetchAdminNavigation,
- fetchAdminNavigationPresets,
-} from "@/views/admin/views/core/navigation/navigation-query";
diff --git a/packages/vitnode/src/tanstack/admin/navigation/query.ts b/packages/vitnode/src/tanstack/admin/navigation/query.ts
deleted file mode 100644
index 0466ca8b05..0000000000
--- a/packages/vitnode/src/tanstack/admin/navigation/query.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import type { QueryClient } from "@tanstack/react-query";
-
-import { useQueryClient } from "@tanstack/react-query";
-import React from "react";
-
-import type { AdminNavigationFormProps } from "@/views/admin/views/core/navigation/navigation-form-content";
-import type { NavigationAdminListProps } from "@/views/admin/views/core/navigation/navigation-list-content";
-import type { AdminNavigationOrderInput } from "@/views/admin/views/core/navigation/navigation-mutations";
-import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation";
-import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope";
-
-import { parseNavigationPresetKey } from "@/lib/navigation";
-import {
- createAdminNavigation,
- deleteAdminNavigation,
- reorderAdminNavigation,
- updateAdminNavigation,
-} from "@/views/admin/views/core/navigation/navigation-mutations";
-import {
- adminNavigationPresetsQueryOptions,
- adminNavigationQueryOptions,
- adminNavigationQueryRoot,
-} from "@/views/admin/views/core/navigation/navigation-query";
-
-import { invalidateMiddlewareConfig } from "../../auth/middleware-config";
-import { useAdminIdentity } from "../identity";
-
-export const adminNavigationQuery = ({
- adminUserId,
-}: {
- adminUserId: AdminIdentity;
-}) => adminNavigationQueryOptions({ adminUserId });
-
-export const adminNavigationPresetsQuery = ({
- adminUserId,
-}: {
- adminUserId: AdminIdentity;
-}) => adminNavigationPresetsQueryOptions({ adminUserId });
-
-export const invalidateAfterAdminNavigationChange = async (
- queryClient: QueryClient,
- adminUserId: AdminIdentity,
-): Promise
=> {
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: adminNavigationQueryRoot(adminUserId),
- }),
- invalidateMiddlewareConfig(queryClient),
- ]);
-};
-
-const INVALID_INPUT: AdminMutationResult = {
- error: { status: 400 },
-};
-
-export const useAdminNavigationMutations = (): {
- onDelete: NavigationAdminListProps["onDelete"];
- onReorder: (
- body: AdminNavigationOrderInput,
- ) => ReturnType;
- onSave: AdminNavigationFormProps["onSave"];
-} => {
- const queryClient = useQueryClient();
- const adminUserId = useAdminIdentity();
-
- return React.useMemo(() => {
- const settle = async (
- result: AdminMutationResult,
- ): Promise> => {
- if ("data" in result) {
- await invalidateAfterAdminNavigationChange(queryClient, adminUserId);
- }
-
- return result;
- };
-
- return {
- onDelete: async id => await settle(await deleteAdminNavigation(id)),
- onReorder: async body => await settle(await reorderAdminNavigation(body)),
- onSave: async ({ id, location, parentId, values }) => {
- const texts = {
- description: values.description,
- icon: values.icon === "" ? null : values.icon,
- isOpenInNewTab: values.isOpenInNewTab,
- title: values.title,
- ...(parentId === undefined ? {} : { parentId }),
- };
-
- if (id !== undefined) {
- return await settle(
- await updateAdminNavigation(id, {
- ...texts,
- ...(values.kind === "custom" ? { href: values.href } : {}),
- }),
- );
- }
-
- if (values.kind === "custom") {
- return await settle(
- await createAdminNavigation({
- ...texts,
- href: values.href,
- kind: "custom",
- location,
- }),
- );
- }
-
- const preset = parseNavigationPresetKey(values.preset);
- if (!preset) return INVALID_INPUT;
-
- return await settle(
- await createAdminNavigation({
- ...texts,
- ...preset,
- kind: "preset",
- location,
- }),
- );
- },
- };
- }, [adminUserId, queryClient]);
-};
diff --git a/packages/vitnode/src/tanstack/admin/navigation/route.tsx b/packages/vitnode/src/tanstack/admin/navigation/route.tsx
deleted file mode 100644
index 3f624b8df7..0000000000
--- a/packages/vitnode/src/tanstack/admin/navigation/route.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { PluginRouteTranslator } from "@/routing";
-import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope";
-
-import { navigationNamespaces } from "@/lib/navigation";
-import { ADMIN_NAVIGATION_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions";
-
-import type { AdminScreenContext } from "../screen";
-
-import { intlQueryOptions } from "../../i18n/query";
-import { adminIdentityOf } from "../identity";
-import { requireAdminPermission } from "../screen";
-import { adminNavigationPresetsQuery, adminNavigationQuery } from "./query";
-
-export const ADMIN_NAVIGATION_NAMESPACES = [
- "admin.global",
- "admin.navigation",
- "core.global",
-] as const;
-
-export interface AdminNavigationRouteData {
- adminUserId: AdminIdentity;
- description: string;
- namespaces: string[];
- title: string;
-}
-
-export const loadAdminNavigationRoute = async ({
- adminAccess,
- locale,
- queryClient,
- t,
-}: AdminScreenContext & {
- t: PluginRouteTranslator;
-}): Promise => {
- requireAdminPermission(adminAccess, ADMIN_NAVIGATION_PERMISSIONS.view);
-
- const adminUserId = adminIdentityOf(adminAccess);
-
- const [, { presets }] = await Promise.all([
- queryClient.query({
- ...adminNavigationQuery({ adminUserId }),
- staleTime: "static",
- }),
- queryClient.query({
- ...adminNavigationPresetsQuery({ adminUserId }),
- staleTime: "static",
- }),
- ]);
-
- const namespaces = navigationNamespaces(
- presets.map(preset => ({
- kind: "preset" as const,
- pluginId: preset.pluginId,
- })),
- );
-
- await queryClient.query({
- ...intlQueryOptions({
- locale,
- namespaces: [...ADMIN_NAVIGATION_NAMESPACES, ...namespaces],
- }),
- staleTime: "static",
- });
-
- return {
- adminUserId,
- description: t("admin.navigation.list.desc"),
- namespaces,
- title: t("admin.global.nav.system.navigation"),
- };
-};
diff --git a/packages/vitnode/src/tanstack/admin/navigation/screen.tsx b/packages/vitnode/src/tanstack/admin/navigation/screen.tsx
deleted file mode 100644
index 4c36d848b2..0000000000
--- a/packages/vitnode/src/tanstack/admin/navigation/screen.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { useSuspenseQuery } from "@tanstack/react-query";
-import { PanelBottomIcon, PanelTopIcon } from "lucide-react";
-import React from "react";
-import { useTranslations } from "use-intl";
-
-import type { NavigationLocation } from "@/lib/navigation";
-import type { AdminNavigationItem } from "@/views/admin/views/core/navigation/navigation-query";
-
-import { AdminStaffPermissionGate } from "@/components/staff-permission/provider";
-import { PageTitle } from "@/components/ui/page-title";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsPanels,
- TabsTrigger,
-} from "@/components/ui/tabs";
-import { NAVIGATION_LOCATIONS } from "@/lib/navigation";
-import { CreateNavigationAction } from "@/views/admin/views/core/navigation/navigation-create-dialog";
-import { NavigationAdminListContent } from "@/views/admin/views/core/navigation/navigation-list-content";
-import { ADMIN_NAVIGATION_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions";
-
-import type { AdminNavigationRouteData } from "./route";
-
-import { RouteMessages } from "../../i18n/route-messages";
-import {
- adminNavigationPresetsQuery,
- adminNavigationQuery,
- useAdminNavigationMutations,
-} from "./query";
-import { ADMIN_NAVIGATION_NAMESPACES } from "./route";
-
-const LOCATION_TABS = {
- bottom_bar: {
- descKey: "bottomBarDesc",
- Icon: PanelBottomIcon,
- labelKey: "bottomBar",
- },
- header: { descKey: "headerDesc", Icon: PanelTopIcon, labelKey: "header" },
-} as const;
-
-const isNavigationLocation = (value: unknown): value is NavigationLocation =>
- NAVIGATION_LOCATIONS.some(location => location === value);
-
-const NavigationLocationTabs = ({
- location,
- onLocationChange,
- panel,
-}: {
- location: NavigationLocation;
- onLocationChange: (location: NavigationLocation) => void;
- panel: (location: NavigationLocation) => React.ReactNode;
-}) => {
- const t = useTranslations("admin.navigation.tabs");
-
- return (
- {
- if (isNavigationLocation(value)) onLocationChange(value);
- }}
- value={location}
- >
-
-
- {NAVIGATION_LOCATIONS.map(value => {
- const { Icon, labelKey } = LOCATION_TABS[value];
-
- return (
-
-
- {t(labelKey)}
-
- );
- })}
-
-
- {t(LOCATION_TABS[location].descKey)}
-
-
-
-
- {NAVIGATION_LOCATIONS.map(value => (
-
- {panel(value)}
-
- ))}
-
-
- );
-};
-
-export const AdminNavigationRouteContent = ({
- adminUserId,
- description,
- namespaces,
- title,
-}: AdminNavigationRouteData) => {
- const { data } = useSuspenseQuery(adminNavigationQuery({ adminUserId }));
- const { data: presetsData } = useSuspenseQuery(
- adminNavigationPresetsQuery({ adminUserId }),
- );
- const { onDelete, onReorder, onSave } = useAdminNavigationMutations();
- const [location, setLocation] = React.useState("header");
-
- const messageNamespaces = React.useMemo(
- () => [...ADMIN_NAVIGATION_NAMESPACES, ...namespaces],
- [namespaces],
- );
- const itemsByLocation = React.useMemo(
- () =>
- Object.fromEntries(
- NAVIGATION_LOCATIONS.map(value => [
- value,
- data.items.filter(item => item.location === value),
- ]),
- ) as Record,
- [data.items],
- );
-
- return (
-
-
-
-
-
-
-
-
-
(
-
- await onReorder({ ...body, location: value })
- }
- onSave={onSave}
- presets={presetsData.presets}
- />
- )}
- />
-
-
- );
-};
diff --git a/packages/vitnode/src/tanstack/auth/auth-methods.test.ts b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts
index 6cfd868812..86ba7dc524 100644
--- a/packages/vitnode/src/tanstack/auth/auth-methods.test.ts
+++ b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts
@@ -8,10 +8,7 @@ const config = (
overrides: Partial = {},
): MiddlewareConfig => ({
ai: { models: [] },
- bottomBar: [],
- icons: {},
isEmail: true,
- navigation: [],
passkeys: true,
password: true,
sso: [],
diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts
index dbb65d90bc..a566951621 100644
--- a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts
+++ b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts
@@ -39,10 +39,7 @@ describe("loadMiddlewareConfig", () => {
it("serves a known configuration from the cache", async () => {
const known = knownMiddlewareConfig({
ai: { models: [] },
- bottomBar: [],
- icons: {},
isEmail: true,
- navigation: [],
passkeys: false,
password: true,
sso: [],
@@ -76,10 +73,7 @@ describe("middlewareConfigQueryOptions", () => {
isStale(
knownMiddlewareConfig({
ai: { models: [] },
- bottomBar: [],
- icons: {},
isEmail: true,
- navigation: [],
passkeys: false,
password: true,
sso: [],
diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts
index 3f94d3696d..659a29bccc 100644
--- a/packages/vitnode/src/tanstack/auth/middleware-config.ts
+++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts
@@ -6,7 +6,6 @@ import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
import type { SSOProvider } from "@/views/auth/sso/providers";
-import { seedLucideIcons } from "@/components/ui/icon-registry";
import { manifest } from "@/manifest";
import { fetcher } from "@/tanstack/fetcher";
import { normalizeSSOProviders } from "@/views/auth/sso/providers";
@@ -20,14 +19,11 @@ export interface MiddlewareConfigState extends MiddlewareConfig {
export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({
ai: { models: [] },
- icons: {},
isDeferred: false,
isEmail: false,
isKnown: false,
- navigation: [],
passkeys: false,
password: true,
- bottomBar: [],
sso: [],
});
@@ -53,8 +49,6 @@ const fetchMiddlewareConfig = async (): Promise => {
if (response.status !== 200) return UNKNOWN_MIDDLEWARE_CONFIG;
const config = await response.json();
- seedLucideIcons(config.icons);
-
return knownMiddlewareConfig(config);
} catch (error) {
// oxlint-disable-next-line no-console
diff --git a/packages/vitnode/src/tanstack/auth/recovery.test.ts b/packages/vitnode/src/tanstack/auth/recovery.test.ts
index ebf4ce2111..b768280d8c 100644
--- a/packages/vitnode/src/tanstack/auth/recovery.test.ts
+++ b/packages/vitnode/src/tanstack/auth/recovery.test.ts
@@ -203,9 +203,6 @@ describe("whether this deployment has password recovery at all", () => {
isEmail: false,
passkeys: false,
password: true,
- navigation: [],
- bottomBar: [],
- icons: {},
sso: [],
}).isKnown,
).toBe(true);
@@ -216,9 +213,6 @@ describe("whether this deployment has password recovery at all", () => {
isEmail: false,
passkeys: false,
password: true,
- navigation: [],
- bottomBar: [],
- icons: {},
sso: [],
}),
),
diff --git a/packages/vitnode/src/tanstack/auth/session-query.ts b/packages/vitnode/src/tanstack/auth/session-query.ts
index b416079dda..59347b7790 100644
--- a/packages/vitnode/src/tanstack/auth/session-query.ts
+++ b/packages/vitnode/src/tanstack/auth/session-query.ts
@@ -1,6 +1,7 @@
import type { QueryClient } from "@tanstack/react-query";
-import { queryOptions, useQuery } from "@tanstack/react-query";
+import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query";
+import React from "react";
import type { SessionApi } from "./session-api";
import type { AuthState } from "./state";
@@ -18,7 +19,42 @@ export const sessionQueryOptions = () =>
staleTime: SESSION_STALE_TIME,
});
-export const useSessionQuery = () => useQuery(sessionQueryOptions());
+export interface SessionQueryView {
+ data: SessionApi | undefined;
+ isError: boolean;
+ isPending: boolean;
+}
+
+const renderedSessions = new WeakMap();
+
+const renderedSessionOf = (queryClient: QueryClient): SessionQueryView => {
+ const rendered = renderedSessions.get(queryClient);
+ if (rendered) return rendered;
+
+ const state = queryClient.getQueryState(sessionQueryOptions().queryKey);
+ const view = {
+ data: state?.data,
+ isError: state?.status === "error",
+ isPending: !state || state.status === "pending",
+ };
+ renderedSessions.set(queryClient, view);
+
+ return view;
+};
+
+const subscribeToNothing = () => () => undefined;
+
+export const useSessionQuery = (): SessionQueryView => {
+ const queryClient = useQueryClient();
+ const { data, isError, isPending } = useQuery(sessionQueryOptions());
+ const rendered = React.useSyncExternalStore(
+ subscribeToNothing,
+ () => null,
+ () => renderedSessionOf(queryClient),
+ );
+
+ return rendered ?? { data, isError, isPending };
+};
export const ensureAuthState = async (
queryClient: QueryClient,
diff --git a/packages/vitnode/src/tanstack/layout/dev-error-overlay.tsx b/packages/vitnode/src/tanstack/layout/dev-error-overlay.tsx
new file mode 100644
index 0000000000..79128f345a
--- /dev/null
+++ b/packages/vitnode/src/tanstack/layout/dev-error-overlay.tsx
@@ -0,0 +1,90 @@
+import { useQuery } from "@tanstack/react-query";
+import { useLocation } from "@tanstack/react-router";
+import { useEffect, useRef, useState } from "react";
+
+import { DEV_ERROR_REPORT_ROUTE } from "@/framework/dev-error/report-route";
+
+import { reportedErrorOf, serverReportFor } from "./dev-error-report";
+
+const CLOSE_MESSAGE = "vitnode:dev-error:close";
+
+const isCloseMessage = (data: unknown): boolean =>
+ typeof data === "object" &&
+ data !== null &&
+ (data as { type?: unknown }).type === CLOSE_MESSAGE;
+
+const requestErrorPage = async (error: unknown): Promise => {
+ const serverReport = serverReportFor(error);
+ const response = await fetch(DEV_ERROR_REPORT_ROUTE, {
+ body: JSON.stringify({
+ ...(serverReport ?? reportedErrorOf(error)),
+ runtime: serverReport ? "server" : "browser",
+ path: `${window.location.pathname}${window.location.search}`,
+ }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ return response.ok ? response.text() : null;
+};
+
+export const DevErrorOverlay = ({ error }: { error: unknown }) => {
+ const [closedError, setClosedError] = useState(null);
+ const dialogRef = useRef(null);
+ const frameRef = useRef(null);
+ const publicHref = useLocation({ select: location => location.publicHref });
+ const { data: html } = useQuery({
+ queryFn: async () => requestErrorPage(error),
+ queryKey: ["vitnode", "dev-error-page", publicHref, reportedErrorOf(error)],
+ retry: false,
+ staleTime: Infinity,
+ });
+ const isOpen = typeof html === "string" && closedError !== error;
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const onMessage = (event: MessageEvent) => {
+ if (
+ event.source === frameRef.current?.contentWindow &&
+ isCloseMessage(event.data)
+ ) {
+ dialogRef.current?.close();
+ }
+ };
+
+ window.addEventListener("message", onMessage);
+
+ return () => {
+ window.removeEventListener("message", onMessage);
+ };
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ return (
+ {
+ setClosedError(error);
+ }}
+ ref={dialog => {
+ dialogRef.current = dialog;
+ if (dialog && !dialog.open) dialog.showModal();
+ }}
+ >
+ {/* react-doctor-disable-next-line react-doctor/iframe-missing-sandbox */}
+
+ );
+};
diff --git a/packages/vitnode/src/tanstack/layout/dev-error-report.ts b/packages/vitnode/src/tanstack/layout/dev-error-report.ts
new file mode 100644
index 0000000000..3f56871306
--- /dev/null
+++ b/packages/vitnode/src/tanstack/layout/dev-error-report.ts
@@ -0,0 +1,43 @@
+export interface ReportedError {
+ hints: unknown;
+ message: string;
+ name: string;
+ stack: string;
+}
+
+export const reportedErrorOf = (error: unknown): ReportedError => {
+ if (typeof error !== "object" || error === null) {
+ return {
+ hints: undefined,
+ message: String(error),
+ name: "Error",
+ stack: "",
+ };
+ }
+
+ const { hints, message, name, stack } = error as Record;
+
+ return {
+ hints,
+ message: typeof message === "string" ? message : JSON.stringify(error),
+ name: typeof name === "string" ? name : "Error",
+ stack: typeof stack === "string" ? stack : "",
+ };
+};
+
+declare global {
+ interface Window {
+ __vitnodeDevError?: unknown;
+ }
+}
+
+export const serverReportScript = (error: unknown): string =>
+ `window.__vitnodeDevError=${JSON.stringify(reportedErrorOf(error)).replaceAll("<", "\\u003c")}`;
+
+export const serverReportFor = (error: unknown): null | ReportedError => {
+ if (window.__vitnodeDevError === undefined) return null;
+
+ const report = reportedErrorOf(window.__vitnodeDevError);
+
+ return report.message === reportedErrorOf(error).message ? report : null;
+};
diff --git a/packages/vitnode/src/tanstack/layout/document-scripts.tsx b/packages/vitnode/src/tanstack/layout/document-scripts.tsx
index b8a279d769..ae75a086ce 100644
--- a/packages/vitnode/src/tanstack/layout/document-scripts.tsx
+++ b/packages/vitnode/src/tanstack/layout/document-scripts.tsx
@@ -24,20 +24,41 @@ const modulePreloadHref = (tag: RouterManagedTag) =>
: undefined;
const loadAfterFirstPaint = (entries: string[], preloads: string[]) => {
+ let isLoaded = false;
const load = () => {
- for (const href of preloads) {
- const link = document.createElement("link");
- link.rel = "modulepreload";
- link.href = href;
- document.head.append(link);
- }
-
- for (const src of entries) {
- const script = document.createElement("script");
- script.type = "module";
- script.src = src;
- document.body.append(script);
- }
+ if (isLoaded) return;
+ isLoaded = true;
+
+ const batchSize = 40;
+ const appendFrom = (start: number) => {
+ const batch = document.createDocumentFragment();
+
+ for (const href of preloads.slice(start, start + batchSize)) {
+ const link = document.createElement("link");
+ link.rel = "modulepreload";
+ link.href = href;
+ batch.append(link);
+ }
+
+ document.head.append(batch);
+
+ if (start + batchSize < preloads.length) {
+ setTimeout(() => {
+ appendFrom(start + batchSize);
+ }, 0);
+
+ return;
+ }
+
+ for (const src of entries) {
+ const script = document.createElement("script");
+ script.type = "module";
+ script.src = src;
+ document.body.append(script);
+ }
+ };
+
+ appendFrom(0);
};
if (document.visibilityState === "hidden") {
@@ -46,9 +67,25 @@ const loadAfterFirstPaint = (entries: string[], preloads: string[]) => {
return;
}
- requestAnimationFrame(() => {
+ if (
+ typeof PerformanceObserver !== "function" ||
+ !PerformanceObserver.supportedEntryTypes.includes("paint")
+ ) {
+ requestAnimationFrame(() => {
+ setTimeout(load, 0);
+ });
+
+ return;
+ }
+
+ new PerformanceObserver((list, observer) => {
+ if (list.getEntriesByName("first-contentful-paint").length === 0) return;
+
+ observer.disconnect();
setTimeout(load, 0);
- });
+ }).observe({ buffered: true, type: "paint" });
+ document.addEventListener("visibilitychange", load, { once: true });
+ setTimeout(load, 3000);
};
const loaderSource = (entries: string[], preloads: string[]) =>
diff --git a/packages/vitnode/src/tanstack/layout/header.tsx b/packages/vitnode/src/tanstack/layout/header.tsx
index 03d748f80e..6528b7a2b1 100644
--- a/packages/vitnode/src/tanstack/layout/header.tsx
+++ b/packages/vitnode/src/tanstack/layout/header.tsx
@@ -5,26 +5,23 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import React from "react";
import { createTranslator, useTranslations } from "use-intl";
-import type {
- PublicNavigationItem,
- PublicNavigationNode,
-} from "@/lib/navigation";
import type { HeaderVariant } from "@/views/layouts/theme/header/header-content";
import type { HeaderNavTranslate } from "@/views/layouts/theme/header/header-nav";
import { useLogo } from "@/components/logo-context";
-import { HeaderLayoutContent } from "@/views/layouts/theme/header/header-content";
import {
- headerNavItemsFrom,
- headerNavNamespaces,
-} from "@/views/layouts/theme/header/header-nav";
+ navigationMessageNamespace,
+ navigationMessageNamespaces,
+} from "@/lib/navigation";
+import { HeaderLayoutContent } from "@/views/layouts/theme/header/header-content";
+import { headerNavItemsFrom } from "@/views/layouts/theme/header/header-nav";
import { MobileNavBar } from "@/views/layouts/theme/header/mobile-nav/mobile-nav-bar";
import { navigationOutsideBottomBar } from "@/views/layouts/theme/header/mobile-nav/mobile-nav-model";
+import { getVitNodeConfig } from "@/vitnode.config";
import {
DEFERRED_MIDDLEWARE_CONFIG,
middlewareConfigQueryOptions,
- useMiddlewareConfigQuery,
} from "../auth/middleware-config";
import { prefetchSession } from "../auth/session-query";
import { useLocale } from "../i18n/locale";
@@ -36,9 +33,14 @@ import { MobileUserMenu } from "./mobile-user-menu";
const NO_MESSAGES: AbstractIntlMessages = {};
-const asNavigationNodes = (
- items: readonly PublicNavigationItem[],
-): PublicNavigationNode[] => items.map(item => ({ ...item, items: [] }));
+const navigationIntlQueryOptions = (locale: string) => {
+ const namespaces = navigationMessageNamespaces(getVitNodeConfig().navigation);
+
+ return intlQueryOptions({
+ locale,
+ namespaces: namespaces.length > 0 ? namespaces : [GLOBAL_NAMESPACE],
+ });
+};
const headerNavTranslator = (
locale: string,
@@ -46,13 +48,17 @@ const headerNavTranslator = (
): HeaderNavTranslate => {
const translators = new Map>();
- return (namespace, key) => {
+ return key => {
+ const namespace = navigationMessageNamespace(key);
+ if (namespace === null) return undefined;
+
const translator =
translators.get(namespace) ??
createTranslator({ locale, messages, namespace });
translators.set(namespace, translator);
+ const name = key.slice(namespace.length + 1);
- return translator.has(key) ? translator(key) : undefined;
+ return translator.has(name) ? translator(name) : undefined;
};
};
@@ -70,32 +76,23 @@ export const Header = ({
const logo = useLogo();
const locale = useLocale();
const t = useTranslations("core.global");
- const { data: config } = useMiddlewareConfigQuery();
- const bottomBarNodes = React.useMemo(
- () => asNavigationNodes(config.bottomBar),
- [config.bottomBar],
- );
- const namespaces = headerNavNamespaces(
- [...config.navigation, ...bottomBarNodes],
- GLOBAL_NAMESPACE,
- );
- const { data, isPlaceholderData } = useQuery({
- ...intlQueryOptions({ locale, namespaces }),
+ const config = getVitNodeConfig().navigation;
+ const { data } = useQuery({
+ ...navigationIntlQueryOptions(locale),
placeholderData: keepPreviousData,
});
const messages = data?.messages ?? NO_MESSAGES;
-
const translate = React.useMemo(
() => headerNavTranslator(locale, messages),
- [messages, locale],
+ [locale, messages],
);
const navigation = React.useMemo(
- () => headerNavItemsFrom({ items: config.navigation, locale, translate }),
- [config.navigation, locale, translate],
+ () => headerNavItemsFrom({ items: config?.header, translate }),
+ [config?.header, translate],
);
const bottomBar = React.useMemo(
- () => headerNavItemsFrom({ items: bottomBarNodes, locale, translate }),
- [bottomBarNodes, locale, translate],
+ () => headerNavItemsFrom({ items: config?.mobileBottomBar, translate }),
+ [config?.mobileBottomBar, translate],
);
const menuNavigation = React.useMemo(
() => navigationOutsideBottomBar(navigation, bottomBar),
@@ -105,7 +102,6 @@ export const Header = ({
return (
<>
}
search={ }
user={user}
@@ -127,26 +122,15 @@ export const Header = ({
);
};
-const loadShellNavigation = async ({
+const loadNavigationMessages = async ({
locale,
queryClient,
}: {
locale: string;
queryClient: QueryClient;
}): Promise => {
- const config = await queryClient.query({
- ...middlewareConfigQueryOptions(),
- staleTime: "static",
- });
-
await queryClient.query({
- ...intlQueryOptions({
- locale,
- namespaces: headerNavNamespaces(
- [...config.navigation, ...asNavigationNodes(config.bottomBar)],
- GLOBAL_NAMESPACE,
- ),
- }),
+ ...navigationIntlQueryOptions(locale),
staleTime: "static",
});
};
@@ -162,13 +146,17 @@ export const loadMainShell = async (
middlewareConfigQueryOptions().queryKey,
DEFERRED_MIDDLEWARE_CONFIG,
);
- await loadShellNavigation(context);
+ await loadNavigationMessages(context);
return;
}
await Promise.all([
- loadShellNavigation(context),
+ loadNavigationMessages(context),
+ context.queryClient.query({
+ ...middlewareConfigQueryOptions(),
+ staleTime: "static",
+ }),
prefetchSession(context.queryClient),
]);
};
diff --git a/packages/vitnode/src/tanstack/layout/index.ts b/packages/vitnode/src/tanstack/layout/index.ts
index b78cf4e241..5a57ea27d4 100644
--- a/packages/vitnode/src/tanstack/layout/index.ts
+++ b/packages/vitnode/src/tanstack/layout/index.ts
@@ -6,6 +6,7 @@ export { LanguageSwitcher } from "./language-switcher";
export { MainHeader } from "./main-header";
export { Error500Page, NotFound } from "./not-found";
export { VitNodeRootProviders } from "./root-providers";
+export { RouteErrorPage } from "./route-error";
export { UserHeader } from "./user-header";
export { ThemeLayoutContent } from "@/views/layouts/theme/layout-content";
diff --git a/packages/vitnode/src/tanstack/layout/main-shell.test.ts b/packages/vitnode/src/tanstack/layout/main-shell.test.ts
index 4a1c9d229c..501874bbe7 100644
--- a/packages/vitnode/src/tanstack/layout/main-shell.test.ts
+++ b/packages/vitnode/src/tanstack/layout/main-shell.test.ts
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { MiddlewareConfigState } from "../auth/middleware-config";
+import { buildConfig } from "../../vitnode.config";
import {
DEFERRED_MIDDLEWARE_CONFIG,
knownMiddlewareConfig,
@@ -13,6 +14,12 @@ import { setAuthTransport } from "../auth/transport";
import { intlQueryOptions } from "../i18n/query";
import { loadMainShell } from "./header";
+buildConfig({
+ i18n: { defaultLocale: "en", locales: [{ code: "en", name: "English" }] },
+ metadata: { title: "VitNode" },
+ plugins: [],
+});
+
let sessionReads = 0;
const unreachable = () => {
@@ -43,10 +50,7 @@ setAuthTransport({
const KNOWN_CONFIG = knownMiddlewareConfig({
ai: { models: [] },
- bottomBar: [],
- icons: {},
isEmail: false,
- navigation: [],
passkeys: false,
password: true,
sso: [],
diff --git a/packages/vitnode/src/tanstack/layout/route-error.tsx b/packages/vitnode/src/tanstack/layout/route-error.tsx
new file mode 100644
index 0000000000..566224a91c
--- /dev/null
+++ b/packages/vitnode/src/tanstack/layout/route-error.tsx
@@ -0,0 +1,37 @@
+import { lazy, Suspense } from "react";
+
+import { serverReportScript } from "./dev-error-report";
+import { Error500Page } from "./not-found";
+
+const DevErrorOverlay = lazy(async () => ({
+ default: (await import("./dev-error-overlay")).DevErrorOverlay,
+}));
+
+const DevErrorDetails = ({ error }: { error: unknown }) => (
+ <>
+
+
+
+
+ >
+);
+
+export const RouteErrorPage = ({
+ actions,
+ error,
+}: {
+ actions?: React.ReactNode;
+ error: unknown;
+}) => (
+ <>
+
+ {process.env.NODE_ENV === "development" && (
+
+ )}
+ >
+);
diff --git a/packages/vitnode/src/tanstack/pending/guard-pending.tsx b/packages/vitnode/src/tanstack/pending/guard-pending.tsx
index 46b71a953f..9d5f197000 100644
--- a/packages/vitnode/src/tanstack/pending/guard-pending.tsx
+++ b/packages/vitnode/src/tanstack/pending/guard-pending.tsx
@@ -6,13 +6,33 @@ import {
useRouter,
useRouterState,
} from "@tanstack/react-router";
-import { createElement, useEffect, useState } from "react";
+import {
+ createContext,
+ createElement,
+ use,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useState,
+} from "react";
+
+type PendingComponent = NonNullable;
interface DestinationPending {
- Component: NonNullable;
+ Component: PendingComponent;
delay: number;
+ ownerRouteId: null | string;
+}
+
+interface GuardPendingOutlets {
+ pending: null | Pick;
+ register: (routeId: string) => () => void;
}
+const GuardPendingOutletsContext = createContext(
+ null,
+);
+
const matchedBranch = (router: AnyRouter): readonly AnyRoute[] => {
try {
return router.getMatchedRoutes(router.state.location.pathname)[0] ?? [];
@@ -21,20 +41,54 @@ const matchedBranch = (router: AnyRouter): readonly AnyRoute[] => {
}
};
-const destinationPending = (router: AnyRouter): DestinationPending | null => {
+const keptLayoutIndex = (
+ branch: readonly AnyRoute[],
+ outlets: ReadonlySet,
+): number => {
+ for (let index = branch.length - 2; index >= 0; index--) {
+ const id: string | undefined = branch[index]?.id;
+
+ if (id !== undefined && outlets.has(id)) return index;
+ }
+
+ return -1;
+};
+
+const pendingAt = (
+ branch: readonly AnyRoute[],
+ index: number,
+): PendingComponent | undefined => branch[index]?.options.pendingComponent;
+
+const destinationPending = (
+ router: AnyRouter,
+ outlets: ReadonlySet,
+ shellRouteId: string | undefined,
+): DestinationPending | null => {
const branch = matchedBranch(router);
const delay =
branch.at(-1)?.options.pendingMs ?? router.options.defaultPendingMs ?? 0;
+ const ownerIndex = keptLayoutIndex(branch, outlets);
+ const ownerRouteId: null | string = branch[ownerIndex]?.id ?? null;
+ const shellIndex = branch.findIndex(route => route.id === shellRouteId);
+ const firstMounting = Math.max(ownerIndex, shellIndex) + 1;
+
+ for (let index = firstMounting; index < branch.length; index++) {
+ const Component = pendingAt(branch, index);
+
+ if (Component) return { Component, delay, ownerRouteId };
+ }
- for (let index = branch.length - 1; index >= 0; index--) {
- const Component = branch[index]?.options.pendingComponent;
+ if (ownerIndex < 0) {
+ for (let index = firstMounting - 1; index >= 0; index--) {
+ const Component = pendingAt(branch, index);
- if (Component) return { Component, delay };
+ if (Component) return { Component, delay, ownerRouteId };
+ }
}
const fallback = router.options.defaultPendingComponent;
- return fallback ? { Component: fallback, delay } : null;
+ return fallback ? { Component: fallback, delay, ownerRouteId } : null;
};
export const RouteGuardPending = ({
@@ -50,8 +104,16 @@ export const RouteGuardPending = ({
select: match => match.isFetching === "beforeLoad",
strict: false,
});
+ const shellRouteId: string | undefined = useMatch({
+ select: match => match.routeId,
+ strict: false,
+ });
const [showing, setShowing] = useState(false);
- const destination = isGuarding || showing ? destinationPending(router) : null;
+ const [outlets, setOutlets] = useState>(() => new Set());
+ const destination =
+ isGuarding || showing
+ ? destinationPending(router, outlets, shellRouteId)
+ : null;
const delay = destination?.delay ?? 0;
if (showing && !isNavigating) setShowing(false);
@@ -65,9 +127,64 @@ export const RouteGuardPending = ({
return () => clearTimeout(timer);
}, [delay, isGuarding]);
- if (!showing || destination === null) return children;
+ const [register] = useState(() => (routeId: string) => {
+ setOutlets(current => new Set(current).add(routeId));
+
+ return () => {
+ setOutlets(current => {
+ const next = new Set(current);
+ next.delete(routeId);
+
+ return next;
+ });
+ };
+ });
+
+ const shown = showing ? destination : null;
+ const pendingComponent = shown?.Component;
+ const pendingOwnerRouteId = shown?.ownerRouteId ?? null;
+ const value = useMemo(
+ () => ({
+ pending:
+ pendingComponent && pendingOwnerRouteId !== null
+ ? { Component: pendingComponent, ownerRouteId: pendingOwnerRouteId }
+ : null,
+ register,
+ }),
+ [pendingComponent, pendingOwnerRouteId, register],
+ );
+
+ if (shown && shown.ownerRouteId === null) {
+ const ShownPending = shown.Component;
+
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const PendingOutlet = (): React.ReactNode => {
+ const outlets = use(GuardPendingOutletsContext);
+ const routeId: string | undefined = useMatch({
+ select: match => match.routeId,
+ strict: false,
+ });
+ const register = outlets?.register;
+ const pending = outlets?.pending;
+
+ useLayoutEffect(() => {
+ if (register && routeId !== undefined) return register(routeId);
+ }, [register, routeId]);
+
+ if (routeId !== undefined && pending?.ownerRouteId === routeId) {
+ return createElement(pending.Component);
+ }
- return createElement(destination.Component);
+ return ;
};
export const GuardedOutlet = () => (
diff --git a/packages/vitnode/src/tanstack/pending/index.ts b/packages/vitnode/src/tanstack/pending/index.ts
index 23d4dcb96d..af856d0896 100644
--- a/packages/vitnode/src/tanstack/pending/index.ts
+++ b/packages/vitnode/src/tanstack/pending/index.ts
@@ -1,4 +1,8 @@
-export { GuardedOutlet, RouteGuardPending } from "./guard-pending";
+export {
+ GuardedOutlet,
+ PendingOutlet,
+ RouteGuardPending,
+} from "./guard-pending";
export {
RoutePendingSkeleton,
RoutePendingSpinner,
diff --git a/packages/vitnode/src/tanstack/plugin-routes/components.tsx b/packages/vitnode/src/tanstack/plugin-routes/components.tsx
index a99dbfc918..8594803035 100644
--- a/packages/vitnode/src/tanstack/plugin-routes/components.tsx
+++ b/packages/vitnode/src/tanstack/plugin-routes/components.tsx
@@ -1,9 +1,4 @@
-import {
- Outlet,
- useLoaderData,
- useNavigate,
- useParams,
-} from "@tanstack/react-router";
+import { useLoaderData, useNavigate, useParams } from "@tanstack/react-router";
import { createElement, Suspense, useCallback } from "react";
import type {
@@ -21,6 +16,7 @@ import type { PluginRouteModuleRef } from "./module-ref";
import { breadcrumbDeferred } from "../breadcrumb/model";
import { RouteMessages } from "../i18n/route-messages";
+import { PendingOutlet } from "../pending/guard-pending";
import {
pluginRouteLoaderData,
pluginRoutePageProps,
@@ -99,7 +95,7 @@ export const pluginLayoutComponent = (
createElement(Layout, {
...usePluginRoutePageProps(),
// oxlint-disable-next-line @eslint-react/jsx-no-children-prop
- children: ,
+ children: ,
}),
);
};
diff --git a/packages/vitnode/src/tests/update-summary.ts b/packages/vitnode/src/tests/update-summary.ts
index 79d14391d1..9176141a68 100644
--- a/packages/vitnode/src/tests/update-summary.ts
+++ b/packages/vitnode/src/tests/update-summary.ts
@@ -14,6 +14,7 @@ export const updateSummary = (
pluginUpdatesAvailable: 0,
running: false,
stale: false,
+ totalComponents: 3,
unconfirmedComponents: 0,
updatesAvailable: 0,
...overrides,
diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts
index 966386902c..ecd13d23f6 100644
--- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts
+++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts
@@ -63,7 +63,6 @@ describe("the core group", () => {
"/admin/core/system/plugins",
"/admin/core/system/integrations",
"/admin/core/system/files",
- "/admin/core/system/navigation",
"/admin/core/system/notifications",
"/admin/core/users",
"/admin/core/users",
diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
index 122cf9eba6..d96131141a 100644
--- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
+++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx
@@ -190,11 +190,6 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({
href: "/admin/core/system/files",
permission: core("files"),
},
- {
- title: key("admin.global.nav.system.navigation"),
- href: "/admin/core/system/navigation",
- permission: core("navigation"),
- },
{
title: key("admin.global.nav.system.notifications"),
href: "/admin/core/system/notifications",
diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx
index e5ae132112..4c24c90d27 100644
--- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx
+++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.test.tsx
@@ -59,7 +59,7 @@ describe("SystemUpdatesWidget", () => {
}),
2,
],
- ["checks are turned off", summary({ enabled: false }), 3],
+ ["checks are turned off", summary({ enabled: false }), 2],
])(
"shows the counts as unknown, never 0, while %s",
async (_, data, unknown) => {
@@ -67,7 +67,7 @@ describe("SystemUpdatesWidget", () => {
expect(screen.getAllByText(`${T}.unknown`)).toHaveLength(unknown);
expect(screen.queryByText("0")).toBeNull();
- expect(screen.queryByText(`${T}.security_none`)).toBeNull();
+ expect(screen.queryByText(`${T}.security.none`)).toBeNull();
},
);
@@ -80,9 +80,9 @@ describe("SystemUpdatesWidget", () => {
}),
);
- expect(screen.getByText("2")).toBeTruthy();
+ expect(screen.getByText(`${T}.plugin_updates.available`)).toBeTruthy();
expect(screen.getByText(`${T}.core_update`)).toBeTruthy();
- expect(screen.getByText(`${T}.security_affected`)).toBeTruthy();
+ expect(screen.getByText(`${T}.security.affected`)).toBeTruthy();
expect(screen.queryByText(`${T}.unknown`)).toBeNull();
});
diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx
index 38e1702952..891444b9af 100644
--- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx
+++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/system-updates-widget.tsx
@@ -1,6 +1,12 @@
import { cn } from "cn";
import {
ArrowRightIcon,
+ ArrowUpCircleIcon,
+ CircleCheckIcon,
+ CircleHelpIcon,
+ CircleOffIcon,
+ ClockIcon,
+ LoaderCircleIcon,
ShieldAlertIcon,
TriangleAlertIcon,
} from "lucide-react";
@@ -23,23 +29,30 @@ import {
updateStatusOf,
} from "./update-status";
+const STATUS_ICON: Record = {
+ disabled: CircleOffIcon,
+ no_info: CircleHelpIcon,
+ not_checked: ClockIcon,
+ running: LoaderCircleIcon,
+ security: ShieldAlertIcon,
+ unavailable: CircleHelpIcon,
+ unconfirmed: CircleCheckIcon,
+ up_to_date: CircleCheckIcon,
+ updates: ArrowUpCircleIcon,
+};
+
const STATUS_TONE: Record = {
disabled: "text-muted-foreground",
+ no_info: "text-muted-foreground",
not_checked: "text-muted-foreground",
running: "text-muted-foreground",
security: "text-destructive",
unavailable: "text-muted-foreground",
- unconfirmed: "text-muted-foreground",
+ unconfirmed: "text-success",
up_to_date: "text-success",
updates: "text-primary",
};
-const STATUS_TEXT_TONE: Partial> = {
- security: STATUS_TONE.security,
- up_to_date: STATUS_TONE.up_to_date,
- updates: STATUS_TONE.updates,
-};
-
const Row = ({
children,
label,
@@ -59,27 +72,73 @@ const Unknown = () => {
const t = useTranslations("admin.dashboard.widgets.system-updates");
return (
- <>
- -
- {t("unknown")}
- >
+ {t("unknown")}
);
};
const Warning = ({ children }: { children: React.ReactNode }) => (
- {children}
+ {children}
);
-const LastChecked = ({ summary }: { summary: AdminUpdateSummary }) => {
- const t = useTranslations(
- "admin.dashboard.widgets.system-updates.last_checked",
+const Verdict = ({
+ status,
+ summary,
+}: {
+ status: UpdateStatus;
+ summary: AdminUpdateSummary;
+}) => {
+ const t = useTranslations("admin.dashboard.widgets.system-updates.verdict");
+ const Icon = STATUS_ICON[status];
+ const unconfirmed = summary.unconfirmedComponents;
+ const count: Record = {
+ disabled: 0,
+ no_info: summary.totalComponents,
+ not_checked: 0,
+ running: 0,
+ security: summary.affectedComponents,
+ unavailable: summary.totalComponents,
+ unconfirmed,
+ up_to_date: summary.totalComponents,
+ updates: summary.updatesAvailable,
+ };
+
+ return (
+
+
+
+
{t(`${status}.title`)}
+
+ {t(`${status}.desc`, {
+ count: count[status],
+ total: summary.totalComponents,
+ verified: summary.totalComponents - unconfirmed,
+ })}
+
+
+
);
+};
+
+const Checked = ({
+ summary,
+}: {
+ summary: AdminUpdateSummary;
+}): React.ReactNode => {
+ const t = useTranslations("admin.dashboard.widgets.system-updates.checked");
const date = (value: string) => () => ;
+ const checkedAt = summary.oldestSuccessAt ?? summary.lastAttemptAt;
- if (!summary.enabled) return ;
+ if (!summary.enabled) return null;
if (summary.running) return t("running");
if (summary.failedComponents > 0 && summary.lastAttemptAt !== null) {
return (
@@ -88,11 +147,12 @@ const LastChecked = ({ summary }: { summary: AdminUpdateSummary }) => {
);
}
- if (summary.stale) return {t("stale")} ;
-
- const checkedAt = summary.oldestSuccessAt ?? summary.lastAttemptAt;
+ if (checkedAt === null) return t("never");
+ if (summary.stale) {
+ return {t.rich("stale", { date: date(checkedAt) })} ;
+ }
- return checkedAt === null ? t("never") : ;
+ return t.rich("at", { date: date(checkedAt) });
};
export const SystemUpdatesWidget = () => {
@@ -117,22 +177,14 @@ export const SystemUpdatesWidget = () => {
const status = updateStatusOf(data);
const known = isKnownUpdateStatus(status);
+ const verified = known
+ ? data.totalComponents - data.unconfirmedComponents
+ : 0;
return (
-
-
-
-
-
- {t(`status.${status}`, { count: data.unconfirmedComponents })}
-
-
+
+
+
{data.core.version === null ? (
@@ -151,26 +203,44 @@ export const SystemUpdatesWidget = () => {
) : null}
- {known ? data.pluginUpdatesAvailable : }
+ {verified === 0 ? (
+
+ ) : data.pluginUpdatesAvailable > 0 ? (
+
+ {t("plugin_updates.available", {
+ count: data.pluginUpdatesAvailable,
+ })}
+
+ ) : (
+ t("plugin_updates.none")
+ )}
- {!known ? (
+ {verified === 0 ? (
) : data.affectedComponents > 0 ? (
- {t("security_affected", { count: data.affectedComponents })}
+ {t("security.affected", { count: data.affectedComponents })}
) : (
- t("security_none")
+ t("security.none")
)}
-
-
-
+ {known && data.unconfirmedComponents > 0 ? (
+
+ {t("no_info_count", {
+ count: data.unconfirmedComponents,
+ total: data.totalComponents,
+ })}
+
+ ) : null}
-
);
diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts
index b52680e226..0fd96fb014 100644
--- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts
+++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.test.ts
@@ -16,6 +16,7 @@ describe("updateStatusOf", () => {
["security", summary({ affectedComponents: 1, updatesAvailable: 2 })],
["updates", summary({ unconfirmedComponents: 1, updatesAvailable: 2 })],
["unconfirmed", summary({ unconfirmedComponents: 1 })],
+ ["no_info", summary({ unconfirmedComponents: 3 })],
["up_to_date", summary()],
])("returns %s", (status, data) => {
expect(updateStatusOf(data)).toBe(status);
diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts
index 59425e7dca..ee28bdb4bf 100644
--- a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts
+++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/system-updates/update-status.ts
@@ -4,6 +4,7 @@ import type { AdminUpdateSummary } from "@/views/admin/views/core/system/plugins
export type UpdateStatus =
| "disabled"
+ | "no_info"
| "not_checked"
| "running"
| "security"
@@ -20,6 +21,9 @@ export const updateStatusOf = (summary: AdminUpdateSummary): UpdateStatus => {
}
if (summary.affectedComponents > 0) return "security";
if (summary.updatesAvailable > 0) return "updates";
+ if (summary.unconfirmedComponents >= summary.totalComponents) {
+ return "no_info";
+ }
if (summary.unconfirmedComponents > 0) return "unconfirmed";
return "up_to_date";
@@ -28,6 +32,7 @@ export const updateStatusOf = (summary: AdminUpdateSummary): UpdateStatus => {
export const isKnownUpdateStatus = (status: UpdateStatus): boolean =>
status === "security" ||
status === "updates" ||
+ status === "no_info" ||
status === "unconfirmed" ||
status === "up_to_date";
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-availability.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-availability.ts
deleted file mode 100644
index b3c9d94af5..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-availability.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { NavigationLocation } from "@/lib/navigation";
-
-import {
- NAVIGATION_BOTTOM_BAR_MAX_ITEMS,
- navigationPresetKey,
-} from "@/lib/navigation";
-
-import type { AdminNavigationItem } from "./navigation-query";
-
-export const usedNavigationPresetKeys = (
- items: readonly AdminNavigationItem[],
- except?: number,
-): string[] =>
- items.flatMap(item =>
- item.kind === "preset" &&
- item.pluginId &&
- item.presetId &&
- item.id !== except
- ? [navigationPresetKey(item.pluginId, item.presetId)]
- : [],
- );
-
-export const isNavigationLocationFull = (
- location: NavigationLocation,
- items: readonly AdminNavigationItem[],
-): boolean =>
- location === "bottom_bar" &&
- items.filter(item => item.parentId === null).length >=
- NAVIGATION_BOTTOM_BAR_MAX_ITEMS;
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-create-dialog.tsx b/packages/vitnode/src/views/admin/views/core/navigation/navigation-create-dialog.tsx
deleted file mode 100644
index 30c9ee767e..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-create-dialog.tsx
+++ /dev/null
@@ -1,386 +0,0 @@
-import { cn } from "cn";
-import {
- ArrowLeftIcon,
- ChevronRightIcon,
- LinkIcon,
- PlusIcon,
-} from "lucide-react";
-import React from "react";
-import { useLocale, useTranslations } from "use-intl";
-
-import type {
- NavigationKind,
- NavigationLocation,
- NavigationPreset,
-} from "@/lib/navigation";
-
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- useDialog,
-} from "@/components/ui/dialog";
-import { EmojiIcon } from "@/components/ui/emoji-icon";
-import { Spinner } from "@/components/ui/spinner";
-import { TooltipWithContent } from "@/components/ui/tooltip";
-import { parseEmojiIcon } from "@/lib/emoji-icon";
-import {
- NAVIGATION_BOTTOM_BAR_MAX_ITEMS,
- navigationItemLabels,
- navigationPresetKey,
-} from "@/lib/navigation";
-
-import type { AdminNavigationFormProps } from "./navigation-form-content";
-import type { AdminNavigationItem } from "./navigation-query";
-
-import {
- isNavigationLocationFull,
- usedNavigationPresetKeys,
-} from "./navigation-availability";
-import { presetLabelSource, useNavigationTranslate } from "./navigation-labels";
-
-const AdminNavigationFormContent = React.lazy(async () =>
- import("./navigation-form-content").then(module => ({
- default: module.AdminNavigationFormContent,
- })),
-);
-
-interface NavigationCreateChoice {
- kind: NavigationKind;
- preset?: NavigationPreset;
-}
-
-export interface NavigationCreateDialogProps {
- items: AdminNavigationItem[];
- location: NavigationLocation;
- onOpenChange: (open: boolean) => void;
- onSave: AdminNavigationFormProps["onSave"];
- onSaved?: () => void;
- open: boolean;
- parentId: null | number;
- presets: NavigationPreset[];
- session: number;
-}
-
-const tileClassName =
- "hover:bg-muted/60 focus-visible:ring-ring/50 flex min-w-0 cursor-pointer items-start gap-3 rounded-lg border p-3 text-start outline-none transition duration-150 ease-out focus-visible:ring-3 active:scale-96 motion-reduce:transition-none";
-
-const NavigationCreateChoiceStep = ({
- items,
- onChoose,
- parentName,
- presets,
- returned,
-}: {
- items: AdminNavigationItem[];
- onChoose: (choice: NavigationCreateChoice) => void;
- parentName: null | string;
- presets: NavigationPreset[];
- returned: boolean;
-}) => {
- const t = useTranslations("admin.navigation");
- const locale = useLocale();
- const translate = useNavigationTranslate();
- const used = usedNavigationPresetKeys(items);
- const available = presets.filter(
- preset => !used.includes(navigationPresetKey(preset.pluginId, preset.id)),
- );
-
- return (
-
-
-
- {parentName
- ? t("create.childTitle", { name: parentName })
- : t("create.title")}
-
- {t("create.desc")}
-
-
-
-
- {t("create.presets")}
-
-
- {available.length > 0 ? (
-
- {available.map(preset => (
- {
- onChoose({ kind: "preset", preset });
- }}
- type="button"
- >
-
-
-
-
-
- {navigationItemLabels({
- item: presetLabelSource(preset),
- locale,
- translate,
- }).title || preset.id}
-
-
- {preset.pluginId}
-
-
-
- ))}
-
- ) : (
-
- {t("form.presetEmpty")}
-
- )}
-
-
-
{
- onChoose({ kind: "custom" });
- }}
- type="button"
- >
-
-
-
-
- {t("form.kind.custom")}
-
- {t("form.kind.customDesc")}
-
-
-
-
-
- );
-};
-
-const NavigationCreateDetailsStep = ({
- choice,
- items,
- location,
- onBack,
- onSave,
- onSaved,
- parentId,
- parentName,
- presets,
-}: {
- choice: NavigationCreateChoice;
- items: AdminNavigationItem[];
- location: NavigationLocation;
- onBack: () => void;
- onSave: AdminNavigationFormProps["onSave"];
- onSaved?: () => void;
- parentId: null | number;
- parentName: null | string;
- presets: NavigationPreset[];
-}) => {
- const t = useTranslations("admin.navigation");
- const locale = useLocale();
- const translate = useNavigationTranslate();
- const { setIsDirty } = useDialog();
- const { preset } = choice;
-
- const title = preset
- ? navigationItemLabels({
- item: presetLabelSource(preset),
- locale,
- translate,
- }).title || preset.id
- : t("form.kind.custom");
- const placement =
- location === "bottom_bar"
- ? t("create.inBottomBar")
- : parentName
- ? t("create.inDropdown", { name: parentName })
- : t("create.inHeader");
-
- return (
-
-
- {
- setIsDirty?.(false);
- onBack();
- }}
- size="sm"
- type="button"
- variant="ghost"
- >
-
- {t("create.back")}
-
- {title}
-
- {preset
- ? t("create.fromPlugin", { placement, plugin: preset.pluginId })
- : placement}
-
-
-
-
-
-
- }
- >
-
-
-
- );
-};
-
-const NavigationCreateSteps = ({
- items,
- location,
- onSave,
- onSaved,
- parentId,
- presets,
-}: Omit) => {
- const [choice, setChoice] = React.useState(
- null,
- );
- const [returned, setReturned] = React.useState(false);
- const locale = useLocale();
- const translate = useNavigationTranslate();
- const parent =
- parentId === null ? undefined : items.find(item => item.id === parentId);
- const parentName = parent
- ? navigationItemLabels({ item: parent, locale, translate }).title
- : null;
-
- return choice ? (
- {
- setReturned(true);
- setChoice(null);
- }}
- onSave={onSave}
- onSaved={onSaved}
- parentId={parentId}
- parentName={parentName}
- presets={presets}
- />
- ) : (
-
- );
-};
-
-export const NavigationCreateDialog = ({
- onOpenChange,
- open,
- session,
- ...props
-}: NavigationCreateDialogProps) => (
-
-
-
-
-
-);
-
-export const CreateNavigationAction = ({
- items,
- location,
- onSave,
- onSaved,
- presets,
-}: Omit<
- NavigationCreateDialogProps,
- "onOpenChange" | "open" | "parentId" | "session"
->) => {
- const t = useTranslations("admin.navigation.create");
- const [session, setSession] = React.useState(0);
- const [open, setOpen] = React.useState(false);
- const isFull = isNavigationLocationFull(location, items);
-
- const button = (
- {
- setSession(current => current + 1);
- setOpen(true);
- }}
- >
-
- {t(location === "bottom_bar" ? "bottomBarTitle" : "title")}
-
- );
-
- return (
- <>
- {isFull ? (
-
-
- {button}
-
-
- ) : (
- button
- )}
-
-
- >
- );
-};
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-content.tsx b/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-content.tsx
deleted file mode 100644
index 609e2f4063..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-content.tsx
+++ /dev/null
@@ -1,636 +0,0 @@
-import { useSelector } from "@tanstack/react-form";
-import { ChevronDownIcon, ExternalLinkIcon, LockIcon } from "lucide-react";
-import { toast } from "sonner";
-import { useLocale, useTranslations } from "use-intl";
-import { z } from "zod";
-
-import type { ItemAutoFormComponentProps } from "@/components/form/auto-form";
-import type { MultiLangValue } from "@/lib/helpers/multi-lang";
-import type {
- NavigationKind,
- NavigationLocation,
- NavigationPreset,
- NavigationText,
-} from "@/lib/navigation";
-import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation";
-
-import {
- AutoForm,
- type AutoFormOnSubmit,
- AutoFormSubmitButton,
- setFormFieldError,
-} from "@/components/form/auto-form";
-import { AutoFormEmojiIcon } from "@/components/form/fields/emoji-icon";
-import { AutoFormInput } from "@/components/form/fields/input";
-import { AutoFormSelect } from "@/components/form/fields/select";
-import { AutoFormSwitch } from "@/components/form/fields/switch";
-import { AutoFormTextarea } from "@/components/form/fields/textarea";
-import { Button } from "@/components/ui/button";
-import { DialogClose, DialogFooter, useDialog } from "@/components/ui/dialog";
-import { EmojiIcon } from "@/components/ui/emoji-icon";
-import { useFormApi } from "@/components/ui/form";
-import { EMOJI_ICON_MAX_LENGTH, parseEmojiIcon } from "@/lib/emoji-icon";
-import { multiLangValueSchema } from "@/lib/helpers/multi-lang";
-import {
- isValidNavigationHref,
- NAVIGATION_DESCRIPTION_MAX_LENGTH,
- NAVIGATION_HREF_MAX_LENGTH,
- NAVIGATION_TITLE_MAX_LENGTH,
- navigationItemLabels,
- navigationPresetKey,
-} from "@/lib/navigation";
-
-import type { AdminNavigationItem } from "./navigation-query";
-
-import {
- prefillNavigationText,
- stripDefaultNavigationText,
-} from "./navigation-form-texts";
-import { presetLabelSource, useNavigationTranslate } from "./navigation-labels";
-import { navigationItemIcon } from "./navigation-tree";
-
-export interface AdminNavigationFormValues {
- description: MultiLangValue;
- href: string;
- icon: string;
- isOpenInNewTab: boolean;
- kind: NavigationKind;
- preset: string;
- title: MultiLangValue;
-}
-
-export interface AdminNavigationSaveArgs {
- id?: number;
- location?: NavigationLocation;
- parentId?: null | number;
- values: AdminNavigationFormValues;
-}
-
-export interface AdminNavigationFormProps {
- data?: AdminNavigationItem;
- items: AdminNavigationItem[];
- kind?: NavigationKind;
- location?: NavigationLocation;
- onSave: (
- args: AdminNavigationSaveArgs,
- ) => Promise>;
- onSaved?: (name: string) => void;
- parentId?: null | number;
- preset?: NavigationPreset;
- presets: NavigationPreset[];
- surface: "dialog" | "sheet";
-}
-
-const ICON_ONLY = ["icon"] as const;
-const TOP_LEVEL = "root";
-
-const hasText = (values: readonly NavigationText[]): boolean =>
- values.some(item => item.value.trim().length > 0);
-
-const parentKey = (parentId: null | number | undefined): string =>
- parentId === null || parentId === undefined ? TOP_LEVEL : String(parentId);
-
-const parentFromKey = (key: string): null | number =>
- key === TOP_LEVEL ? null : Number(key);
-
-export const NavigationPresetSummary = ({
- missingPluginId,
- preset,
-}: {
- missingPluginId?: null | string;
- preset: NavigationPreset | undefined;
-}) => {
- const t = useTranslations("admin.navigation.form");
- const locale = useLocale();
- const translate = useNavigationTranslate();
-
- const title = preset
- ? navigationItemLabels({
- item: presetLabelSource(preset),
- locale,
- translate,
- }).title
- : "";
-
- return (
-
-
{t("preset")}
-
-
-
-
-
-
- {preset ? title || preset.id : (missingPluginId ?? "")}
-
-
- {preset ? preset.href : t("presetMissing")}
-
-
-
-
-
-
-
- {t("presetLocked")}
-
-
- );
-};
-
-interface PreviewSource {
- isBottomBar: boolean;
- items: AdminNavigationItem[];
- kind: NavigationKind;
- preset?: NavigationPreset;
- presetIds: { pluginId: null | string; presetId: null | string };
-}
-
-const NavigationFormPreview = ({
- isBottomBar,
- items,
- kind,
- preset,
- presetIds,
-}: PreviewSource) => {
- const t = useTranslations("admin.navigation.form");
- const locale = useLocale();
- const translate = useNavigationTranslate();
- const { form } = useFormApi();
- const values = useSelector(
- form.store,
- state => state.values as Record,
- );
-
- const title = (values.title ?? []) as NavigationText[];
- const description = (values.description ?? []) as NavigationText[];
- const href = typeof values.href === "string" ? values.href : "";
- const labels = navigationItemLabels({
- item: { description, kind, ...presetIds, title },
- locale,
- translate,
- });
- const label =
- [labels.title, href, preset?.href].find(
- (value): value is string => typeof value === "string" && value !== "",
- ) ?? t("previewUntitled");
- const icon = parseEmojiIcon(
- typeof values.icon === "string" ? values.icon : null,
- );
- const opensInNewTab = values.isOpenInNewTab === true;
- const parentId = parentFromKey(
- typeof values.parentId === "string" ? values.parentId : TOP_LEVEL,
- );
- const parent = items.find(item => item.id === parentId);
- const parentLabel = parent
- ? navigationItemLabels({ item: parent, locale, translate }).title
- : "";
-
- const newTab = opensInNewTab ? (
-
- ) : null;
-
- return (
-
-
- {t("preview")}
-
-
- {isBottomBar ? (
-
-
-
- {label}
-
-
- ) : parent ? (
-
-
-
- {parentLabel}
-
-
-
-
-
-
-
- {label}
- {newTab}
-
- {labels.description ? (
-
- {labels.description}
-
- ) : null}
-
-
-
- ) : (
-
-
- {label}
- {newTab}
-
- )}
-
- );
-};
-
-const SheetSection = ({
- children,
- id,
- title,
-}: {
- children: React.ReactNode;
- id: string;
- title: string;
-}) => (
-
-
- {title}
-
- {children}
-
-);
-
-export const AdminNavigationFormContent = ({
- data,
- items,
- kind: kindProp,
- location: locationProp,
- onSave,
- onSaved,
- parentId: parentIdProp = null,
- preset: chosenPreset,
- presets,
- surface,
-}: AdminNavigationFormProps) => {
- const t = useTranslations("admin.navigation");
- const tCore = useTranslations("core.global");
- const tErrors = useTranslations("core.global.errors");
- const locale = useLocale();
- const translate = useNavigationTranslate();
- const { setIsDirty, setOpen } = useDialog();
-
- const isEdit = data !== undefined;
- const location: NavigationLocation =
- data?.location ?? locationProp ?? "header";
- const isBottomBar = location === "bottom_bar";
- const kind: NavigationKind = data?.kind ?? kindProp ?? "custom";
- const preset =
- chosenPreset ??
- (data?.kind === "preset" && data.pluginId && data.presetId
- ? presets.find(
- entry =>
- entry.pluginId === data.pluginId && entry.id === data.presetId,
- )
- : undefined);
- const presetIds = {
- pluginId: preset?.pluginId ?? data?.pluginId ?? null,
- presetId: preset?.id ?? data?.presetId ?? null,
- };
- const presetDefaults = preset
- ? navigationItemLabels({
- item: presetLabelSource(preset),
- locale,
- translate,
- })
- : undefined;
-
- const currentParentId = data ? data.parentId : parentIdProp;
- const childCount = data
- ? items.filter(item => item.parentId === data.id).length
- : 0;
- const parentOptions = items
- .filter(item => item.parentId === null && item.id !== data?.id)
- .sort((a, b) => a.position - b.position || a.id - b.id);
- const isChild =
- !isBottomBar &&
- (surface === "sheet" ||
- (parentIdProp !== null && parentIdProp !== undefined));
-
- const formSchema = z.object({
- href: z
- .string()
- .max(NAVIGATION_HREF_MAX_LENGTH)
- .refine(value => value === "" || isValidNavigationHref(value), {
- message: t("errors.hrefInvalid"),
- })
- .default(data?.href ?? ""),
- title: multiLangValueSchema({
- maxLength: NAVIGATION_TITLE_MAX_LENGTH,
- }).default(
- prefillNavigationText({
- fallback: presetDefaults?.title,
- locale,
- stored: data?.title ?? [],
- }),
- ),
- icon: z
- .string()
- .max(EMOJI_ICON_MAX_LENGTH)
- .default(data?.icon ?? preset?.icon ?? ""),
- description: multiLangValueSchema({
- maxLength: NAVIGATION_DESCRIPTION_MAX_LENGTH,
- }).default(
- prefillNavigationText({
- fallback: presetDefaults?.description,
- locale,
- stored: data?.description ?? [],
- }),
- ),
- isOpenInNewTab: z
- .boolean()
- .default(data?.isOpenInNewTab ?? preset?.isOpenInNewTab ?? false),
- parentId: z
- .enum([TOP_LEVEL, ...parentOptions.map(item => String(item.id))])
- .default(parentKey(currentParentId)),
- });
-
- const onSubmit: AutoFormOnSubmit = async (
- { parentId: parentValue, ...values },
- form,
- ) => {
- if (kind === "custom") {
- if (!isValidNavigationHref(values.href)) {
- setFormFieldError(form, "href", t("errors.hrefInvalid"));
-
- return;
- }
- if (!hasText(values.title)) {
- setFormFieldError(form, "title", t("errors.titleRequired"));
-
- return;
- }
- }
-
- const nextParentId = isBottomBar
- ? null
- : surface === "sheet"
- ? parentFromKey(parentValue)
- : parentIdProp;
-
- const result = await onSave({
- id: data?.id,
- location,
- parentId:
- isEdit && nextParentId === data.parentId ? undefined : nextParentId,
- values: {
- ...values,
- description: stripDefaultNavigationText({
- fallback: presetDefaults?.description,
- values: values.description,
- }),
- icon: values.icon === preset?.icon ? "" : values.icon,
- kind,
- preset: preset ? navigationPresetKey(preset.pluginId, preset.id) : "",
- title: stripDefaultNavigationText({
- fallback: presetDefaults?.title,
- values: values.title,
- }),
- },
- });
-
- if ("error" in result) {
- toast.error(tErrors("title"), {
- description:
- result.error.status === 409
- ? t(isBottomBar ? "errors.bottomBarConflict" : "errors.presetTaken")
- : tErrors("internal_server_error"),
- });
-
- return;
- }
-
- const name =
- navigationItemLabels({
- item: {
- description: values.description,
- kind,
- ...presetIds,
- title: values.title,
- },
- locale,
- translate,
- }).title || values.href;
-
- toast.success(t(isEdit ? "edit.success" : "create.success"), {
- description: t(
- isEdit
- ? "edit.successDesc"
- : isBottomBar
- ? "create.successDescBottomBar"
- : "create.successDesc",
- { name },
- ),
- });
- setIsDirty?.(false);
- setOpen?.(false);
- onSaved?.(name);
- };
-
- const fields = [
- ...(kind === "custom"
- ? [
- {
- component: (props: ItemAutoFormComponentProps) => (
-
- ),
- id: "href",
- },
- ]
- : []),
- {
- component: (props: ItemAutoFormComponentProps) => (
-
- ),
- id: "title",
- },
- {
- component: (props: ItemAutoFormComponentProps) => (
-
- ),
- id: "icon",
- },
- ...(isChild
- ? [
- {
- component: (props: ItemAutoFormComponentProps) => (
-
- ),
- id: "description",
- },
- ]
- : []),
- {
- component: (props: ItemAutoFormComponentProps) => (
-
- ),
- id: "isOpenInNewTab",
- },
- ...(surface === "sheet" && !isBottomBar
- ? [
- {
- component: (props: ItemAutoFormComponentProps) => (
- 0
- ? t("form.parentLocked", { count: childCount })
- : t("form.parentDesc")
- }
- disabled={childCount > 0}
- label={t("form.parent")}
- labels={[
- { label: t("form.parentRoot"), value: TOP_LEVEL },
- ...parentOptions.map(item => ({
- label: t("form.parentInside", {
- name: navigationItemLabels({ item, locale, translate })
- .title,
- }),
- value: String(item.id),
- })),
- ]}
- />
- ),
- id: "parentId",
- },
- ]
- : []),
- ];
-
- const presetSummary =
- kind === "preset" ? (
-
- ) : null;
-
- if (surface === "sheet") {
- return (
- (
- <>
-
-
-
-
- {presetSummary ?? rendered.href}
- {rendered.isOpenInNewTab}
-
-
-
- {rendered.title}
- {rendered.icon}
- {rendered.description}
-
-
- {isBottomBar ? null : (
-
- {rendered.parentId}
-
- )}
-
-
-
-
{tCore("cancel")}}
- />
- {t("edit.submit")}
-
- >
- )}
- mode="onBlur"
- onSubmit={onSubmit}
- />
- );
- }
-
- return (
- (
- <>
-
- {rendered.href}
-
- {rendered.title}
- {rendered.icon}
-
- {rendered.description}
- {rendered.isOpenInNewTab}
-
-
-
- {t("create.submit")}
-
- >
- )}
- mode="onBlur"
- onSubmit={onSubmit}
- />
- );
-};
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-texts.test.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-texts.test.ts
deleted file mode 100644
index 111341dd1b..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-texts.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- autofillNavigationIcon,
- autofillNavigationText,
- prefillNavigationText,
- stripDefaultNavigationText,
-} from "./navigation-form-texts";
-
-describe("prefillNavigationText", () => {
- it("shows the stored overrides when there are any", () => {
- expect(
- prefillNavigationText({
- fallback: "Discover",
- locale: "pl",
- stored: [{ languageCode: "en", value: "Explore" }],
- }),
- ).toEqual([{ languageCode: "en", value: "Explore" }]);
- });
-
- it("seeds the reader's language with the built-in default", () => {
- expect(
- prefillNavigationText({ fallback: "Discover", locale: "pl", stored: [] }),
- ).toEqual([{ languageCode: "pl", value: "Discover" }]);
- });
-
- it("stays empty for a field with no default to show", () => {
- expect(prefillNavigationText({ locale: "en", stored: [] })).toEqual([]);
- expect(
- prefillNavigationText({ fallback: " ", locale: "en", stored: [] }),
- ).toEqual([]);
- });
-
- it("copies the stored entries rather than handing the row's own array to the form", () => {
- const stored = [{ languageCode: "en", value: "Explore" }];
- const prefilled = prefillNavigationText({ locale: "en", stored });
-
- prefilled[0].value = "Changed";
-
- expect(stored[0].value).toBe("Explore");
- });
-});
-
-describe("stripDefaultNavigationText", () => {
- it("drops a value the admin never changed, so the item keeps following the plugin", () => {
- expect(
- stripDefaultNavigationText({
- fallback: "Discover",
- values: [{ languageCode: "en", value: "Discover" }],
- }),
- ).toEqual([]);
- });
-
- it("keeps a value the admin edited", () => {
- expect(
- stripDefaultNavigationText({
- fallback: "Discover",
- values: [{ languageCode: "en", value: "Explore" }],
- }),
- ).toEqual([{ languageCode: "en", value: "Explore" }]);
- });
-
- it("drops blanks, so clearing the box goes back to the built-in text", () => {
- expect(
- stripDefaultNavigationText({
- fallback: "Discover",
- values: [
- { languageCode: "en", value: " " },
- { languageCode: "pl", value: "Odkrywaj" },
- ],
- }),
- ).toEqual([{ languageCode: "pl", value: "Odkrywaj" }]);
- });
-
- it("keeps every language of a custom link, which has no default to compare against", () => {
- expect(
- stripDefaultNavigationText({
- values: [
- { languageCode: "en", value: "Docs" },
- { languageCode: "pl", value: "Dokumentacja" },
- ],
- }),
- ).toEqual([
- { languageCode: "en", value: "Docs" },
- { languageCode: "pl", value: "Dokumentacja" },
- ]);
- });
-});
-
-describe("autofillNavigationText", () => {
- it("fills an empty field with the page that was just picked", () => {
- expect(
- autofillNavigationText({
- locale: "en",
- nextFallback: "Search",
- values: [],
- }),
- ).toEqual([{ languageCode: "en", value: "Search" }]);
- });
-
- it("replaces the previous page's own words", () => {
- expect(
- autofillNavigationText({
- locale: "en",
- nextFallback: "Search",
- previousFallback: "Discover",
- values: [{ languageCode: "en", value: "Discover" }],
- }),
- ).toEqual([{ languageCode: "en", value: "Search" }]);
- });
-
- it("leaves anything the admin typed alone", () => {
- expect(
- autofillNavigationText({
- locale: "en",
- nextFallback: "Search",
- previousFallback: "Discover",
- values: [{ languageCode: "en", value: "Explore" }],
- }),
- ).toBeUndefined();
- });
-
- it("empties a field when the new page has nothing to call itself", () => {
- expect(
- autofillNavigationText({
- locale: "en",
- previousFallback: "Discover",
- values: [{ languageCode: "en", value: "Discover" }],
- }),
- ).toEqual([]);
- });
-
- it("treats a blank box as empty, whatever language it is in", () => {
- expect(
- autofillNavigationText({
- locale: "pl",
- nextFallback: "Szukaj",
- values: [{ languageCode: "en", value: " " }],
- }),
- ).toEqual([{ languageCode: "pl", value: "Szukaj" }]);
- });
-});
-
-describe("autofillNavigationIcon", () => {
- it("takes the new page's icon when none was chosen", () => {
- expect(autofillNavigationIcon({ icon: "", nextIcon: "icon:search" })).toBe(
- "icon:search",
- );
- });
-
- it("replaces the previous page's icon", () => {
- expect(
- autofillNavigationIcon({
- icon: "icon:compass",
- nextIcon: "icon:search",
- previousIcon: "icon:compass",
- }),
- ).toBe("icon:search");
- });
-
- it("keeps an icon the admin picked", () => {
- expect(
- autofillNavigationIcon({
- icon: "icon:star",
- nextIcon: "icon:search",
- previousIcon: "icon:compass",
- }),
- ).toBeUndefined();
- });
-
- it("clears the icon when the new page ships none", () => {
- expect(
- autofillNavigationIcon({
- icon: "icon:compass",
- previousIcon: "icon:compass",
- }),
- ).toBe("");
- });
-});
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-texts.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-texts.ts
deleted file mode 100644
index 78a00c1910..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-form-texts.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import type { MultiLangValue } from "@/lib/helpers/multi-lang";
-import type { NavigationText } from "@/lib/navigation";
-
-/**
- * What the edit dialog shows for a translated field.
- *
- * Stored overrides win. When there are none, the reader's own language is
- * seeded with the text the item renders with today - the built-in default of a
- * prebuilt page - so the dialog opens showing what is live rather than an empty
- * box next to a placeholder.
- */
-export const prefillNavigationText = ({
- fallback,
- locale,
- stored,
-}: {
- fallback?: string;
- locale: string;
- stored: readonly NavigationText[];
-}): MultiLangValue => {
- if (stored.length > 0) return stored.map(item => ({ ...item }));
-
- const seed = fallback?.trim();
-
- return seed ? [{ languageCode: locale, value: seed }] : [];
-};
-
-/**
- * The same field on the way back out, with anything still equal to the
- * built-in default removed.
- *
- * Seeding the box above would otherwise turn every opened-and-saved dialog into
- * a stored override, and a prebuilt item that holds its own copy of the title
- * stops following the plugin that renames the page.
- */
-export const stripDefaultNavigationText = ({
- fallback,
- values,
-}: {
- fallback?: string;
- values: readonly NavigationText[];
-}): MultiLangValue => {
- const seed = fallback?.trim();
-
- return values.filter(item => {
- const value = item.value.trim();
-
- return value !== "" && value !== seed;
- });
-};
-
-/** Whether the admin has put something of their own in a translated field. */
-const isOwnText = (
- values: readonly NavigationText[],
- fallback?: string,
-): boolean =>
- values.some(item => {
- const value = item.value.trim();
-
- return value !== "" && value !== fallback?.trim();
- });
-
-/**
- * What a field becomes when a different prebuilt page is picked, or
- * `undefined` to leave it as it is.
- *
- * Choosing a page fills in what it is called, which is what makes the dialog
- * usable - but only over a box that is still empty or still holds the previous
- * page's own words. Anything typed here belongs to the admin and survives.
- */
-export const autofillNavigationText = ({
- locale,
- nextFallback,
- previousFallback,
- values,
-}: {
- locale: string;
- nextFallback?: string;
- previousFallback?: string;
- values: readonly NavigationText[];
-}): MultiLangValue | undefined =>
- isOwnText(values, previousFallback)
- ? undefined
- : prefillNavigationText({ fallback: nextFallback, locale, stored: [] });
-
-/** {@link autofillNavigationText}, for the icon, which is one string. */
-export const autofillNavigationIcon = ({
- icon,
- nextIcon,
- previousIcon,
-}: {
- icon: string;
- nextIcon?: null | string;
- previousIcon?: null | string;
-}): string | undefined =>
- icon.trim() === "" || icon === previousIcon ? (nextIcon ?? "") : undefined;
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-labels.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-labels.ts
deleted file mode 100644
index 996b420946..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-labels.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from "react";
-import { useLocale, useTranslations } from "use-intl";
-
-import type {
- NavigationLabelSource,
- NavigationTranslate,
-} from "@/lib/navigation";
-
-import { navigationItemLabels } from "@/lib/navigation";
-
-interface RootTranslator {
- (key: string): string;
- has: (key: string) => boolean;
-}
-
-export const useNavigationTranslate = (): NavigationTranslate => {
- const t = useTranslations() as unknown as RootTranslator;
-
- return React.useCallback(
- (namespace, key) => {
- const path = `${namespace}.${key}`;
-
- return t.has(path) ? t(path) : undefined;
- },
- [t],
- );
-};
-
-export const useNavigationItemLabels = (
- item: NavigationLabelSource,
-): { description: string; title: string } => {
- const locale = useLocale();
- const translate = useNavigationTranslate();
-
- return navigationItemLabels({ item, locale, translate });
-};
-
-export const presetLabelSource = (preset: {
- id: string;
- pluginId: string;
-}): NavigationLabelSource => ({
- description: [],
- kind: "preset",
- pluginId: preset.pluginId,
- presetId: preset.id,
- title: [],
-});
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.test.tsx b/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.test.tsx
deleted file mode 100644
index fe4317eccd..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.test.tsx
+++ /dev/null
@@ -1,368 +0,0 @@
-import {
- fireEvent,
- render,
- screen,
- waitFor,
- within,
-} from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-
-import type { NavigationPreset } from "../../../../../lib/navigation";
-import type { AdminNavigationItem } from "./navigation-query";
-
-import { AdminStaffPermissionProvider } from "../../../../../components/staff-permission/provider";
-import { usedNavigationPresetKeys } from "./navigation-availability";
-import { NavigationAdminListContent } from "./navigation-list-content";
-
-const at = new Date("2026-09-20T10:00:00Z");
-
-const items: AdminNavigationItem[] = [
- {
- createdAt: at,
- description: [],
- href: null,
- icon: null,
- id: 1,
- isOpenInNewTab: false,
- kind: "preset",
- location: "header",
- parentId: null,
- pluginId: "@vitnode/core",
- position: 0,
- preset: {
- href: "/discover",
- icon: "icon:compass",
- id: "discover",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
- presetId: "discover",
- title: [{ languageCode: "en", value: "Explore" }],
- updatedAt: at,
- },
- {
- createdAt: at,
- description: [{ languageCode: "en", value: "Read the guides" }],
- href: "https://vitnode.com/docs",
- icon: "icon:book-open",
- id: 2,
- isOpenInNewTab: true,
- kind: "custom",
- location: "header",
- parentId: 1,
- pluginId: null,
- position: 0,
- preset: null,
- presetId: null,
- title: [{ languageCode: "en", value: "Docs" }],
- updatedAt: at,
- },
- {
- createdAt: at,
- description: [],
- href: "/blog",
- icon: null,
- id: 3,
- isOpenInNewTab: false,
- kind: "custom",
- location: "header",
- parentId: null,
- pluginId: null,
- position: 1,
- preset: null,
- presetId: null,
- title: [{ languageCode: "en", value: "Blog" }],
- updatedAt: at,
- },
-];
-
-const presets: NavigationPreset[] = [
- {
- href: "/discover",
- icon: "icon:compass",
- id: "discover",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
- {
- href: "/search",
- icon: "icon:search",
- id: "search",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
-];
-
-const renderList = (
- overrides: Partial[0]> = {},
- permissions = { permissions: [], root: true },
-) => {
- const props = {
- items,
- location: "header" as const,
- onDelete: vi.fn(async () => Promise.resolve({ data: true })),
- onReorder: vi.fn(async () => Promise.resolve({ data: true })),
- onSave: vi.fn(async () => Promise.resolve({ data: true })),
- presets,
- ...overrides,
- };
-
- render(
-
-
- ,
- );
-
- return props;
-};
-
-const openRowMenu = async (index: number) => {
- const trigger = screen.getAllByRole("button", {
- name: "admin.navigation.list.actions",
- })[index];
- fireEvent.click(trigger);
-
- return await screen.findByRole("menu");
-};
-
-describe("NavigationAdminListContent", () => {
- it("lists every item with its resolved title and destination", () => {
- renderList();
-
- expect(screen.getByText("Explore")).toBeTruthy();
- expect(screen.getByText("Docs")).toBeTruthy();
- expect(screen.getByText("/discover")).toBeTruthy();
- expect(screen.getByText("https://vitnode.com/docs")).toBeTruthy();
- });
-
- it("draws a nested item one level in, right under its parent", () => {
- renderList();
-
- const rows = [...document.querySelectorAll("[data-depth]")];
-
- expect(
- rows.map(row => [
- row.querySelector("[data-testid]")?.getAttribute("data-testid"),
- row.getAttribute("data-depth"),
- ]),
- ).toEqual([
- ["navigation-item-1", "0"],
- ["navigation-item-2", "1"],
- ["navigation-item-3", "0"],
- ]);
- });
-
- it("names where each item comes from", () => {
- renderList();
-
- expect(screen.getAllByText("admin.navigation.list.custom")).toHaveLength(2);
- expect(screen.getByText("@vitnode/core")).toBeTruthy();
- });
-
- it("counts what sits in the header and what sits in dropdowns", () => {
- renderList();
-
- expect(screen.getByText("admin.navigation.list.summary")).toBeTruthy();
- });
-
- it("gives every item a drag handle an editor can grab", () => {
- renderList();
-
- expect(
- screen.getAllByRole("button", {
- name: "admin.navigation.list.dragHandle",
- }),
- ).toHaveLength(3);
- });
-
- it("shows the empty state when the menu has nothing in it", () => {
- renderList({ items: [] });
-
- expect(
- screen.getByText("admin.navigation.list.noResults.title"),
- ).toBeTruthy();
- });
-
- it("folds a parent's dropdown away and brings it back", () => {
- renderList();
-
- fireEvent.click(
- screen.getByRole("button", {
- name: "admin.navigation.list.hideChildren",
- }),
- );
-
- expect(screen.queryByText("Docs")).toBeNull();
- expect(
- screen.getByRole("button", {
- name: "admin.navigation.list.showChildren",
- }),
- ).toBeTruthy();
-
- fireEvent.click(
- screen.getByRole("button", { name: "admin.navigation.list.expandAll" }),
- );
-
- expect(screen.getByText("Docs")).toBeTruthy();
- });
-
- it("keeps the drag handles usable while a new order saves", async () => {
- renderList({
- onReorder: vi.fn(
- async () =>
- new Promise<{ data: true }>(() => {
- return undefined;
- }),
- ),
- });
-
- const menu = await openRowMenu(2);
- fireEvent.click(
- within(menu).getByRole("menuitem", {
- name: "admin.navigation.list.moveUp",
- }),
- );
-
- await waitFor(() => {
- expect(screen.getByText("Blog")).toBeTruthy();
- });
-
- for (const handle of screen.getAllByRole("button", {
- name: "admin.navigation.list.dragHandle",
- })) {
- expect((handle as HTMLButtonElement).disabled).toBe(false);
- }
- });
-
- it("asks before removing and then deletes that item", async () => {
- const props = renderList();
-
- const menu = await openRowMenu(1);
- fireEvent.click(
- within(menu).getByRole("menuitem", {
- name: "admin.navigation.list.delete",
- }),
- );
-
- const confirm = await screen.findByRole("button", {
- name: "admin.navigation.delete.confirm",
- });
- fireEvent.click(confirm);
-
- await waitFor(() => {
- expect(props.onDelete).toHaveBeenCalledWith(2);
- });
- });
-
- it("warns that a parent's children move up when it is removed", async () => {
- renderList();
-
- const menu = await openRowMenu(0);
- fireEvent.click(
- within(menu).getByRole("menuitem", {
- name: "admin.navigation.list.delete",
- }),
- );
-
- expect(
- await screen.findByText("admin.navigation.delete.descWithChildren"),
- ).toBeTruthy();
- });
-
- it("opens the add dialog for a parent, offering only unused pages", async () => {
- renderList();
-
- const menu = await openRowMenu(0);
- fireEvent.click(
- within(menu).getByRole("menuitem", {
- name: "admin.navigation.list.addChild",
- }),
- );
-
- const dialog = await screen.findByRole("dialog");
-
- expect(
- within(dialog).getByText("admin.navigation.create.childTitle"),
- ).toBeTruthy();
- expect(within(dialog).getByText("search")).toBeTruthy();
- expect(within(dialog).queryByText("discover")).toBeNull();
- expect(
- within(dialog).getByText("admin.navigation.form.kind.custom"),
- ).toBeTruthy();
- });
-
- it("opens the editor for an item when its row is clicked", async () => {
- renderList();
-
- fireEvent.click(screen.getByText("Blog"));
-
- expect(await screen.findByText("admin.navigation.edit.title")).toBeTruthy();
- });
-
- it("hides every action and handle from an admin who may only look", () => {
- renderList({}, { permissions: [], root: false });
-
- expect(
- screen.queryByRole("button", {
- name: "admin.navigation.list.dragHandle",
- }),
- ).toBeNull();
- expect(
- screen.queryByRole("button", { name: "admin.navigation.list.actions" }),
- ).toBeNull();
- });
-});
-
-describe("usedNavigationPresetKeys", () => {
- it("names the presets already in the menu, minus the one being edited", () => {
- expect(usedNavigationPresetKeys(items)).toEqual([
- "@vitnode/core::discover",
- ]);
- expect(usedNavigationPresetKeys(items, 1)).toEqual([]);
- });
-
- describe("in the bottom bar", () => {
- const flat = items
- .filter(item => item.parentId === null)
- .map(item => ({ ...item, location: "bottom_bar" as const }));
-
- it("counts links against the bar's limit", () => {
- renderList({ items: flat, location: "bottom_bar" });
-
- expect(
- screen.getByText("admin.navigation.list.bottomBarSummary"),
- ).toBeTruthy();
- expect(screen.queryByText("admin.navigation.list.summary")).toBeNull();
- });
-
- it("offers no sub-items and no nesting", async () => {
- renderList({ items: flat, location: "bottom_bar" });
-
- const menu = await openRowMenu(0);
-
- expect(
- within(menu).queryByRole("menuitem", {
- name: "admin.navigation.list.addChild",
- }),
- ).toBeNull();
- expect(
- within(menu).queryByRole("menuitem", {
- name: /admin.navigation.list.moveInside/,
- }),
- ).toBeNull();
- expect(
- within(menu).getByRole("menuitem", {
- name: "admin.navigation.list.moveDown",
- }),
- ).toBeTruthy();
- });
-
- it("explains the empty bar", () => {
- renderList({ items: [], location: "bottom_bar" });
-
- expect(
- screen.getByText("admin.navigation.list.bottomBarEmpty.title"),
- ).toBeTruthy();
- });
- });
-});
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.tsx b/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.tsx
deleted file mode 100644
index 2f4c2801ad..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.tsx
+++ /dev/null
@@ -1,1240 +0,0 @@
-import type {
- DragEndEvent,
- DragMoveEvent,
- DragOverEvent,
- DragStartEvent,
- KeyboardCoordinateGetter,
-} from "@dnd-kit/core";
-
-import {
- closestCenter,
- DndContext,
- DragOverlay,
- KeyboardSensor,
- MeasuringStrategy,
- PointerSensor,
- TouchSensor,
- useSensor,
- useSensors,
-} from "@dnd-kit/core";
-import {
- SortableContext,
- sortableKeyboardCoordinates,
- useSortable,
- verticalListSortingStrategy,
-} from "@dnd-kit/sortable";
-import { CSS } from "@dnd-kit/utilities";
-import { cn } from "cn";
-import {
- ArrowDownIcon,
- ArrowUpIcon,
- ChevronRightIcon,
- CornerDownRightIcon,
- CornerLeftUpIcon,
- ExternalLinkIcon,
- GripVerticalIcon,
- LayoutTemplateIcon,
- LinkIcon,
- MoreHorizontalIcon,
- PanelBottomIcon,
- PanelTopIcon,
- PencilIcon,
- PlusIcon,
- Trash2Icon,
-} from "lucide-react";
-import React from "react";
-import { toast } from "sonner";
-import { useLocale, useTranslations } from "use-intl";
-
-import type { NavigationLocation, NavigationPreset } from "@/lib/navigation";
-import type { AdminMutationResult } from "@/views/admin/views/core/shared/admin-mutation";
-
-import { useAdminStaffPermission } from "@/components/staff-permission/provider";
-import {
- AlertDialog,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Dialog } from "@/components/ui/dialog";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { EmojiIcon } from "@/components/ui/emoji-icon";
-import {
- Empty,
- EmptyDescription,
- EmptyHeader,
- EmptyMedia,
- EmptyTitle,
-} from "@/components/ui/empty";
-import {
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet";
-import { SlidingNumber } from "@/components/ui/sliding-number";
-import { Spinner } from "@/components/ui/spinner";
-import { TooltipWithContent } from "@/components/ui/tooltip";
-import { parseEmojiIcon } from "@/lib/emoji-icon";
-import {
- NAVIGATION_BOTTOM_BAR_MAX_ITEMS,
- navigationItemLabels,
-} from "@/lib/navigation";
-import { ADMIN_NAVIGATION_PERMISSIONS } from "@/views/admin/views/core/shared/admin-permissions";
-
-import type { AdminNavigationFormProps } from "./navigation-form-content";
-import type { AdminNavigationItem } from "./navigation-query";
-import type {
- FlattenedNavigationItem,
- NavigationDepth,
- NavigationOrderBody,
-} from "./navigation-tree";
-
-import { NavigationCreateDialog } from "./navigation-create-dialog";
-import {
- useNavigationItemLabels,
- useNavigationTranslate,
-} from "./navigation-labels";
-import {
- applyNavigationDrop,
- childrenOfNavigation,
- flattenNavigationItems,
- moveNavigationItem,
- NAVIGATION_INDENTATION_PX,
- navigationItemIcon,
- navigationItemsFrom,
- navigationOrderBody,
- projectNavigationDrop,
- restoreCollapsedChildren,
- sameNavigationOrder,
- shiftNavigationItem,
- withoutNavigationChildrenOf,
-} from "./navigation-tree";
-
-const AdminNavigationFormContent = React.lazy(async () =>
- import("./navigation-form-content").then(module => ({
- default: module.AdminNavigationFormContent,
- })),
-);
-
-export interface NavigationAdminListProps {
- items: AdminNavigationItem[];
- location: NavigationLocation;
- onDelete: (id: number) => Promise>;
- onReorder: (
- body: NavigationOrderBody,
- ) => Promise>;
- onSave: AdminNavigationFormProps["onSave"];
- onSaved?: () => void;
- presets: NavigationPreset[];
-}
-
-interface NavigationRowPermissions {
- canCreate: boolean;
- canDelete: boolean;
- canEdit: boolean;
- canNest: boolean;
-}
-
-interface NavigationRowActions {
- onAddChild: (parentId: number) => void;
- onDelete: (id: number) => void;
- onEdit: (id: number) => void;
- onMove: (id: number, parentId: null | number) => void;
- onShift: (id: number, step: -1 | 1) => void;
- onToggle: (id: number) => void;
-}
-
-const navigationKeyboardCoordinates: KeyboardCoordinateGetter = (
- event,
- args,
-) => {
- if (event.code === "ArrowLeft" || event.code === "ArrowRight") {
- event.preventDefault();
- const shift =
- event.code === "ArrowRight"
- ? NAVIGATION_INDENTATION_PX
- : -NAVIGATION_INDENTATION_PX;
-
- return {
- x: args.currentCoordinates.x + shift,
- y: args.currentCoordinates.y,
- };
- }
-
- return sortableKeyboardCoordinates(event, args);
-};
-
-const useNavigationItemName = (item: AdminNavigationItem) => {
- const { title } = useNavigationItemLabels(item);
- const href = item.kind === "preset" ? item.preset?.href : item.href;
-
- return title !== "" ? title : (href ?? String(item.id));
-};
-
-const NavigationItemGlyph = ({
- className,
- item,
-}: {
- className?: string;
- item: AdminNavigationItem;
-}) => {
- const icon = navigationItemIcon(item);
- if (icon) {
- return (
-
- );
- }
-
- return item.kind === "preset" ? (
-
- ) : (
-
- );
-};
-
-const NavigationSourceTag = ({ item }: { item: AdminNavigationItem }) => {
- const t = useTranslations("admin.navigation.list");
-
- if (item.kind === "custom") {
- return {t("custom")} ;
- }
- if (item.preset === null) {
- return (
-
- {t("pluginMissing")}
-
- );
- }
-
- return {item.pluginId} ;
-};
-
-const NavigationRowMenu = ({
- actions,
- entry,
- hasChildren,
- name,
- parents,
- permissions,
- siblingIndex,
- siblingsCount,
-}: {
- actions: NavigationRowActions;
- entry: FlattenedNavigationItem;
- hasChildren: boolean;
- name: string;
- parents: AdminNavigationItem[];
- permissions: NavigationRowPermissions;
- siblingIndex: number;
- siblingsCount: number;
-}) => {
- const t = useTranslations("admin.navigation.list");
- const locale = useLocale();
- const translate = useNavigationTranslate();
- const { id } = entry.item;
- const canAddChild =
- permissions.canCreate && permissions.canNest && entry.depth === 0;
- const targets = parents.filter(parent => parent.id !== id);
-
- return (
-
-
- }
- >
-
-
-
-
- {permissions.canEdit ? (
- {
- actions.onEdit(id);
- }}
- >
-
- {t("edit")}
-
- ) : null}
- {canAddChild ? (
- {
- actions.onAddChild(id);
- }}
- >
-
- {t("addChild")}
-
- ) : null}
-
- {permissions.canEdit ? (
- <>
-
- {
- actions.onShift(id, -1);
- }}
- >
-
- {t("moveUp")}
-
- {
- actions.onShift(id, 1);
- }}
- >
-
- {t("moveDown")}
-
- {entry.depth === 1 ? (
- {
- actions.onMove(id, null);
- }}
- >
-
- {t("moveToTop")}
-
- ) : hasChildren ||
- targets.length === 0 ||
- !permissions.canNest ? null : (
-
-
-
- {t("moveInside")}
-
-
- {targets.map(parent => (
- {
- actions.onMove(id, parent.id);
- }}
- >
-
-
- {[
- navigationItemLabels({
- item: parent,
- locale,
- translate,
- }).title,
- parent.href,
- ].find(
- (value): value is string =>
- typeof value === "string" && value !== "",
- ) ?? String(parent.id)}
-
-
- ))}
-
-
- )}
- >
- ) : null}
-
- {permissions.canDelete ? (
- <>
- {permissions.canEdit || canAddChild ? (
-
- ) : null}
- {
- actions.onDelete(id);
- }}
- variant="destructive"
- >
-
- {t("delete")}
-
- >
- ) : null}
-
-
- );
-};
-
-const canOpenNavigationRowMenu = (
- permissions: NavigationRowPermissions,
- depth: NavigationDepth,
-): boolean =>
- permissions.canEdit ||
- permissions.canDelete ||
- (permissions.canCreate && permissions.canNest && depth === 0);
-
-const NavigationRowLabel = ({
- childCount,
- isCollapsed,
- item,
- name,
-}: {
- childCount: number;
- isCollapsed: boolean;
- item: AdminNavigationItem;
- name: string;
-}) => {
- const t = useTranslations("admin.navigation.list");
- const isPreset = item.kind === "preset";
- const isMissing = isPreset && item.preset === null;
- const href = isPreset ? item.preset?.href : item.href;
-
- return (
- <>
-
-
- {name}
-
- {isCollapsed && childCount > 0 ? (
-
-
-
- ) : null}
- {item.isOpenInNewTab && !isMissing ? (
-
- ) : null}
-
- {isMissing ? "—" : href}
-
- >
- );
-};
-
-const NavigationRowExpander = ({
- childCount,
- depth,
- isCollapsed,
- name,
- onToggle,
-}: {
- childCount: number;
- depth: NavigationDepth;
- isCollapsed: boolean;
- name: string;
- onToggle: () => void;
-}) => {
- const t = useTranslations("admin.navigation.list");
-
- if (childCount === 0) {
- return depth === 0 ? (
-
- ) : null;
- }
-
- return (
-
-
-
- );
-};
-
-const NavigationRowEditTarget = ({
- canEdit,
- children,
- onEdit,
-}: {
- canEdit: boolean;
- children: React.ReactNode;
- onEdit: () => void;
-}) => {
- const t = useTranslations("admin.navigation.list");
-
- if (!canEdit) {
- return (
-
- {children}
-
- );
- }
-
- return (
-
- {t("edit")}
- {children}
-
- );
-};
-
-const SortableNavigationRow = ({
- actions,
- childCount,
- depth,
- disabled,
- entry,
- isCollapsed,
- parents,
- permissions,
- siblingIndex,
- siblingsCount,
-}: {
- actions: NavigationRowActions;
- childCount: number;
- depth: NavigationDepth;
- disabled: boolean;
- entry: FlattenedNavigationItem;
- isCollapsed: boolean;
- parents: AdminNavigationItem[];
- permissions: NavigationRowPermissions;
- siblingIndex: number;
- siblingsCount: number;
-}) => {
- const t = useTranslations("admin.navigation.list");
- const name = useNavigationItemName(entry.item);
- const {
- attributes,
- isDragging,
- listeners,
- setActivatorNodeRef,
- setNodeRef,
- transform,
- transition,
- } = useSortable({
- disabled: !permissions.canEdit || disabled,
- id: entry.item.id,
- });
-
- const { item } = entry;
-
- return (
-
- {depth === 1 ? (
-
- ) : null}
-
-
- {permissions.canEdit ? (
-
-
-
- ) : null}
-
- {
- actions.onToggle(item.id);
- }}
- />
-
- {
- actions.onEdit(item.id);
- }}
- >
-
-
-
-
-
-
-
- {canOpenNavigationRowMenu(permissions, entry.depth) ? (
- 0}
- name={name}
- parents={parents}
- permissions={permissions}
- siblingIndex={siblingIndex}
- siblingsCount={siblingsCount}
- />
- ) : null}
-
-
- );
-};
-
-const NavigationDragChip = ({
- childCount,
- depth,
- item,
-}: {
- childCount: number;
- depth: NavigationDepth;
- item: AdminNavigationItem;
-}) => {
- const name = useNavigationItemName(item);
-
- return (
-
- {depth === 1 ? (
-
- ) : null}
-
-
-
-
-
- {depth === 0 ? : null}
-
-
- {name}
- {childCount > 0 ? (
-
- +{childCount}
-
- ) : null}
-
-
-
- );
-};
-
-const NavigationEditSheetBody = ({
- item,
- items,
- onSave,
- onSaved,
- presets,
-}: {
- item: AdminNavigationItem;
- items: AdminNavigationItem[];
- onSave: AdminNavigationFormProps["onSave"];
- onSaved?: () => void;
- presets: NavigationPreset[];
-}) => {
- const t = useTranslations("admin.navigation.edit");
- const name = useNavigationItemName(item);
-
- return (
- <>
-
- {t("title")}
- {name}
-
-
-
-
-
- }
- >
-
-
- >
- );
-};
-
-const NavigationEditSheet = ({
- formKey,
- item,
- items,
- onOpenChange,
- onSave,
- onSaved,
- open,
- presets,
-}: {
- formKey: number;
- item: AdminNavigationItem | undefined;
- items: AdminNavigationItem[];
- onOpenChange: (open: boolean) => void;
- onSave: AdminNavigationFormProps["onSave"];
- onSaved?: () => void;
- open: boolean;
- presets: NavigationPreset[];
-}) => (
-
-
- {item ? (
-
- ) : null}
-
-
-);
-
-const NavigationDeleteBody = ({
- childCount,
- item,
- onDelete,
- onOpenChange,
- onSaved,
-}: {
- childCount: number;
- item: AdminNavigationItem;
- onDelete: NavigationAdminListProps["onDelete"];
- onOpenChange: (open: boolean) => void;
- onSaved?: () => void;
-}) => {
- const t = useTranslations("admin.navigation.delete");
- const tError = useTranslations("core.global.errors");
- const name = useNavigationItemName(item);
- const [isPending, startTransition] = React.useTransition();
-
- const onConfirm = () => {
- startTransition(async () => {
- const result = await onDelete(item.id);
-
- if ("error" in result) {
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
-
- return;
- }
-
- toast.success(t("success"), { description: t("successDesc", { name }) });
- onOpenChange(false);
- onSaved?.();
- });
- };
-
- return (
- <>
-
- {t("title")}
-
- {childCount > 0
- ? t("descWithChildren", { count: childCount, name })
- : t("desc", { name })}
-
-
-
-
- {t("cancel")}
-
- {t("confirm")}
-
-
- >
- );
-};
-
-const NavigationDeleteDialog = ({
- childCount,
- item,
- onOpenChange,
- open,
- ...props
-}: Omit, "item"> & {
- item: AdminNavigationItem | undefined;
- open: boolean;
-}) => (
-
-
- {item ? (
-
- ) : null}
-
-
-);
-
-const useOverlayTarget = (items: readonly AdminNavigationItem[]) => {
- const [target, setTarget] = React.useState<{
- id: null | number;
- item?: AdminNavigationItem;
- session: number;
- }>({ id: null, session: 0 });
- const [open, setOpen] = React.useState(false);
-
- return {
- item: items.find(candidate => candidate.id === target.id) ?? target.item,
- open,
- set: (id: number) => {
- setTarget(current => ({
- id,
- item: items.find(candidate => candidate.id === id),
- session: current.session + 1,
- }));
- setOpen(true);
- },
- setOpen,
- target,
- };
-};
-
-export const NavigationAdminListContent = ({
- items,
- location,
- onDelete,
- onReorder,
- onSave,
- onSaved,
- presets,
-}: NavigationAdminListProps) => {
- const t = useTranslations("admin.navigation.list");
- const tError = useTranslations("core.global.errors");
- const canCreate = useAdminStaffPermission(
- ADMIN_NAVIGATION_PERMISSIONS.create,
- );
- const canEdit = useAdminStaffPermission(ADMIN_NAVIGATION_PERMISSIONS.edit);
- const canDelete = useAdminStaffPermission(
- ADMIN_NAVIGATION_PERMISSIONS.delete,
- );
- const isBottomBar = location === "bottom_bar";
- const permissions = {
- canCreate,
- canDelete,
- canEdit,
- canNest: !isBottomBar,
- };
-
- const [ordered, setOrdered] = React.useState(items);
- const [synced, setSynced] = React.useState(items);
- if (synced !== items) {
- setSynced(items);
- setOrdered(items);
- }
-
- const [collapsed, setCollapsed] = React.useState>(
- () => new Set(),
- );
- const [activeId, setActiveId] = React.useState(null);
- const [overId, setOverId] = React.useState(null);
- const [offsetLeft, setOffsetLeft] = React.useState(0);
- const [isSaving, startSaving] = React.useTransition();
- const editing = useOverlayTarget(ordered);
- const deleting = useOverlayTarget(ordered);
- const creating = useOverlayTarget(ordered);
-
- const flattened = React.useMemo(
- () => flattenNavigationItems(ordered),
- [ordered],
- );
- const hidden = React.useMemo(() => {
- const map = new Map();
- for (const id of collapsed) {
- const children = childrenOfNavigation(flattened, id);
- if (children.length > 0) map.set(id, children);
- }
-
- return map;
- }, [collapsed, flattened]);
- const shown = React.useMemo(
- () =>
- flattened.filter(
- entry => entry.parentId === null || !hidden.has(entry.parentId),
- ),
- [flattened, hidden],
- );
- const visible = React.useMemo(
- () =>
- activeId === null ? shown : withoutNavigationChildrenOf(shown, activeId),
- [activeId, shown],
- );
-
- const childCountOf = (id: number) =>
- childrenOfNavigation(flattened, id).length;
- const parents = flattened
- .filter(entry => entry.depth === 0)
- .map(entry => entry.item);
- const parentIds = parents
- .filter(parent => childCountOf(parent.id) > 0)
- .map(parent => parent.id);
- const nestedCount = flattened.length - parents.length;
-
- const activeEntry =
- activeId === null
- ? undefined
- : flattened.find(entry => entry.item.id === activeId);
- const activeChildren =
- activeId === null ? [] : childrenOfNavigation(flattened, activeId);
-
- const projectionFor = (target: null | number) =>
- activeId !== null && target !== null
- ? projectNavigationDrop({
- activeHasChildren: isBottomBar || activeChildren.length > 0,
- activeId,
- flattened: visible,
- indentationWidth: NAVIGATION_INDENTATION_PX,
- offsetLeft,
- overId: target,
- })
- : null;
- const projection = projectionFor(overId);
-
- const sensors = useSensors(
- useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
- useSensor(TouchSensor, {
- activationConstraint: { delay: 150, tolerance: 5 },
- }),
- useSensor(KeyboardSensor, {
- coordinateGetter: navigationKeyboardCoordinates,
- }),
- );
-
- const commit = (next: FlattenedNavigationItem[]) => {
- const body = navigationOrderBody(next);
- if (sameNavigationOrder(body, navigationOrderBody(flattened))) return;
-
- const previous = ordered;
- setOrdered(navigationItemsFrom(next));
-
- startSaving(async () => {
- const result = await onReorder(body);
-
- if ("error" in result) {
- setOrdered(previous);
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
-
- return;
- }
-
- toast.success(t("reorderSuccess"), {
- description: t("reorderSuccessDesc"),
- });
- onSaved?.();
- });
- };
-
- const reset = () => {
- setActiveId(null);
- setOverId(null);
- setOffsetLeft(0);
- };
-
- const onDragStart = ({ active }: DragStartEvent) => {
- setActiveId(Number(active.id));
- setOverId(Number(active.id));
- setOffsetLeft(0);
- };
-
- const onDragMove = ({ delta }: DragMoveEvent) => {
- setOffsetLeft(delta.x);
- };
-
- const onDragOver = ({ over }: DragOverEvent) => {
- setOverId(over ? Number(over.id) : null);
- };
-
- const onDragEnd = ({ over }: DragEndEvent) => {
- const target = over ? projectionFor(Number(over.id)) : null;
- const source = activeEntry;
- const children = hidden.has(activeId ?? -1) ? [] : activeChildren;
- reset();
-
- if (!over || !target || source === undefined) return;
-
- commit(
- restoreCollapsedChildren(
- applyNavigationDrop({
- activeId: source.item.id,
- children,
- flattened: visible,
- overId: Number(over.id),
- projection: target,
- }),
- hidden,
- ),
- );
- };
-
- const expand = (id: number) => {
- setCollapsed(current => {
- const next = new Set(current);
- next.delete(id);
-
- return next;
- });
- };
-
- const actions: NavigationRowActions = {
- onAddChild: parentId => {
- expand(parentId);
- creating.set(parentId);
- },
- onDelete: deleting.set,
- onEdit: editing.set,
- onMove: (id, parentId) => {
- if (parentId !== null) expand(parentId);
- commit(moveNavigationItem({ flattened, id, parentId }));
- },
- onShift: (id, step) => {
- commit(shiftNavigationItem({ flattened, id, step }));
- },
- onToggle: id => {
- setCollapsed(current => {
- const next = new Set(current);
- if (next.has(id)) next.delete(id);
- else next.add(id);
-
- return next;
- });
- },
- };
-
- if (ordered.length === 0) {
- return (
-
-
-
- {isBottomBar ? : }
-
-
- {t(isBottomBar ? "bottomBarEmpty.title" : "noResults.title")}
-
-
- {isBottomBar
- ? t("bottomBarEmpty.description", {
- max: NAVIGATION_BOTTOM_BAR_MAX_ITEMS,
- })
- : t("noResults.description")}
-
-
-
- );
- }
-
- return (
- <>
-
-
-
- {isBottomBar
- ? t("bottomBarSummary", {
- count: parents.length,
- max: NAVIGATION_BOTTOM_BAR_MAX_ITEMS,
- })
- : t("summary", {
- dropdowns: nestedCount,
- header: parents.length,
- })}
-
- {parentIds.length > 0 ? (
- {
- setCollapsed(
- collapsed.size > 0 ? new Set() : new Set(parentIds),
- );
- }}
- size="sm"
- variant="ghost"
- >
- {collapsed.size > 0 ? t("expandAll") : t("collapseAll")}
-
- ) : null}
-
-
-
- entry.item.id)}
- strategy={verticalListSortingStrategy}
- >
-
- {visible.map(entry => {
- const siblings = flattened.filter(
- candidate => candidate.parentId === entry.parentId,
- );
-
- return (
- candidate.item.id === entry.item.id,
- )}
- siblingsCount={siblings.length}
- />
- );
- })}
-
-
-
-
- {activeEntry ? (
-
- ) : null}
-
-
-
-
-
-
-
-
-
- >
- );
-};
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-mutations.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-mutations.ts
deleted file mode 100644
index bc24c5ac10..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-mutations.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import type { z } from "zod";
-
-import type {
- zodCreateNavigationSchema,
- zodReorderNavigationSchema,
- zodUpdateNavigationSchema,
-} from "@/api/modules/admin/navigation/lib/schema";
-
-import { fetcherClient } from "@/lib/fetcher-client";
-import { manifest } from "@/manifest";
-import {
- type AdminMutationResult,
- runAdminApiMutation,
-} from "@/views/admin/views/core/shared/admin-mutation";
-
-export type AdminNavigationCreateInput = z.infer<
- typeof zodCreateNavigationSchema
->;
-
-export type AdminNavigationUpdateInput = z.infer<
- typeof zodUpdateNavigationSchema
->;
-
-export type AdminNavigationOrderInput = z.infer<
- typeof zodReorderNavigationSchema
->;
-
-export const createAdminNavigation = async (
- body: AdminNavigationCreateInput,
-): Promise> =>
- await runAdminApiMutation({
- expected: 201,
- parse: async response => (await response.json()) as { id: number },
- request: async () =>
- await fetcherClient({
- plugin: manifest.id,
- args: { body },
- method: "post",
- module: "admin/navigation",
- options: { credentials: "include" },
- path: "/create",
- }),
- });
-
-export const updateAdminNavigation = async (
- id: number,
- body: AdminNavigationUpdateInput,
-): Promise> =>
- await runAdminApiMutation({
- expected: 200,
- parse: () => true as const,
- request: async () =>
- await fetcherClient({
- plugin: manifest.id,
- args: { body, params: { id: String(id) } },
- method: "patch",
- module: "admin/navigation",
- options: { credentials: "include" },
- path: "/{id}",
- }),
- });
-
-export const reorderAdminNavigation = async (
- body: AdminNavigationOrderInput,
-): Promise> =>
- await runAdminApiMutation({
- expected: 200,
- parse: () => true as const,
- request: async () =>
- await fetcherClient({
- plugin: manifest.id,
- args: { body },
- method: "post",
- module: "admin/navigation",
- options: { credentials: "include" },
- path: "/reorder",
- }),
- });
-
-export const deleteAdminNavigation = async (
- id: number,
-): Promise> =>
- await runAdminApiMutation({
- expected: 200,
- parse: () => true as const,
- request: async () =>
- await fetcherClient({
- plugin: manifest.id,
- args: { params: { id: String(id) } },
- method: "delete",
- module: "admin/navigation",
- options: { credentials: "include" },
- path: "/{id}",
- }),
- });
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-query.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-query.ts
deleted file mode 100644
index 358559b9c0..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-query.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { queryOptions } from "@tanstack/react-query";
-
-import type {
- NavigationKind,
- NavigationLocation,
- NavigationPreset,
- NavigationText,
-} from "@/lib/navigation";
-import type { AdminIdentity } from "@/views/admin/views/core/shared/admin-scope";
-
-import { RECORD_STALE_TIME } from "@/lib/query-freshness";
-import { manifest } from "@/manifest";
-import { fetcher } from "@/tanstack/fetcher";
-import { AdminRequestError } from "@/views/admin/admin-request";
-import {
- ADMIN_NAVIGATION_SCREEN,
- adminScopedQueryKey,
- adminScopedQueryRoot,
-} from "@/views/admin/views/core/shared/admin-scope";
-
-export interface AdminNavigationItem {
- createdAt: Date | string;
- description: NavigationText[];
- href: null | string;
- icon: null | string;
- id: number;
- isOpenInNewTab: boolean;
- kind: NavigationKind;
- location: NavigationLocation;
- parentId: null | number;
- pluginId: null | string;
- position: number;
- preset: NavigationPreset | null;
- presetId: null | string;
- title: NavigationText[];
- updatedAt: Date | string;
-}
-
-export interface AdminNavigationList {
- items: AdminNavigationItem[];
-}
-
-export interface AdminNavigationPresets {
- presets: NavigationPreset[];
-}
-
-export const fetchAdminNavigation = async (): Promise => {
- const response = await fetcher({
- plugin: manifest.id,
- method: "get",
- module: "admin/navigation",
- path: "/list",
- });
-
- if (!response.ok) {
- throw new AdminRequestError(response.status, "the navigation list");
- }
-
- return await response.json();
-};
-
-export const fetchAdminNavigationPresets =
- async (): Promise => {
- const response = await fetcher({
- plugin: manifest.id,
- method: "get",
- module: "admin/navigation",
- path: "/presets",
- });
-
- if (!response.ok) {
- throw new AdminRequestError(response.status, "the navigation presets");
- }
-
- return await response.json();
- };
-
-export const adminNavigationQueryRoot = (adminUserId: AdminIdentity) =>
- adminScopedQueryRoot(ADMIN_NAVIGATION_SCREEN, adminUserId);
-
-export const adminNavigationQueryOptions = ({
- adminUserId,
-}: {
- adminUserId: AdminIdentity;
-}) =>
- queryOptions({
- queryFn: fetchAdminNavigation,
- queryKey: adminScopedQueryKey(ADMIN_NAVIGATION_SCREEN, adminUserId, "list"),
- retry: false,
- staleTime: RECORD_STALE_TIME,
- });
-
-export const adminNavigationPresetsQueryOptions = ({
- adminUserId,
-}: {
- adminUserId: AdminIdentity;
-}) =>
- queryOptions({
- queryFn: fetchAdminNavigationPresets,
- queryKey: adminScopedQueryKey(
- ADMIN_NAVIGATION_SCREEN,
- adminUserId,
- "presets",
- ),
- retry: false,
- staleTime: RECORD_STALE_TIME,
- });
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-tree.test.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-tree.test.ts
deleted file mode 100644
index 8631d3183d..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-tree.test.ts
+++ /dev/null
@@ -1,330 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import type { AdminNavigationItem } from "./navigation-query";
-
-import {
- applyNavigationDrop,
- childrenOfNavigation,
- flattenNavigationItems,
- moveNavigationItem,
- NAVIGATION_INDENTATION_PX,
- navigationItemIcon,
- navigationItemsFrom,
- navigationOrderBody,
- projectNavigationDrop,
- restoreCollapsedChildren,
- sameNavigationOrder,
- shiftNavigationItem,
- withoutNavigationChildrenOf,
-} from "./navigation-tree";
-
-const at = new Date("2026-09-20T10:00:00Z");
-
-const item = (
- id: number,
- position: number,
- parentId: null | number = null,
-): AdminNavigationItem => ({
- createdAt: at,
- description: [],
- href: `/${String(id)}`,
- icon: null,
- id,
- isOpenInNewTab: false,
- kind: "custom",
- location: "header",
- parentId,
- pluginId: null,
- position,
- preset: null,
- presetId: null,
- title: [{ languageCode: "en", value: `Item ${String(id)}` }],
- updatedAt: at,
-});
-
-const menu = [item(1, 0), item(2, 1), item(3, 0, 2), item(4, 1, 2), item(5, 2)];
-
-const ids = (flattened: ReturnType) =>
- flattened.map(entry => `${String(entry.item.id)}@${String(entry.depth)}`);
-
-describe("navigationItemIcon", () => {
- it("prefers the item's own icon", () => {
- expect(
- navigationItemIcon({
- icon: "icon:star",
- preset: {
- href: "/discover",
- icon: "icon:compass",
- id: "discover",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
- }),
- ).toBe("icon:star");
- });
-
- it("falls back to the icon the prebuilt page ships", () => {
- expect(
- navigationItemIcon({
- icon: null,
- preset: {
- href: "/discover",
- icon: "icon:compass",
- id: "discover",
- isOpenInNewTab: false,
- pluginId: "@vitnode/core",
- },
- }),
- ).toBe("icon:compass");
- });
-
- it("has none for a custom link that was given none", () => {
- expect(navigationItemIcon({ icon: null, preset: null })).toBeNull();
- });
-});
-
-describe("flattenNavigationItems", () => {
- it("lists roots in position order with their children right below", () => {
- expect(ids(flattenNavigationItems(menu))).toEqual([
- "1@0",
- "2@0",
- "3@1",
- "4@1",
- "5@0",
- ]);
- });
-
- it("lifts a grandchild to the top level rather than drawing two levels", () => {
- expect(
- ids(flattenNavigationItems([item(1, 0), item(2, 0, 1), item(3, 0, 2)])),
- ).toEqual(["1@0", "2@1", "3@0"]);
- });
-});
-
-describe("projectNavigationDrop", () => {
- const flattened = flattenNavigationItems(menu);
-
- it("nests an item dragged to the right under the item above it", () => {
- expect(
- projectNavigationDrop({
- activeHasChildren: false,
- activeId: 5,
- flattened,
- indentationWidth: NAVIGATION_INDENTATION_PX,
- offsetLeft: NAVIGATION_INDENTATION_PX,
- overId: 5,
- }),
- ).toEqual({ depth: 1, parentId: 2 });
- });
-
- it("never goes deeper than one level", () => {
- expect(
- projectNavigationDrop({
- activeHasChildren: false,
- activeId: 5,
- flattened,
- indentationWidth: NAVIGATION_INDENTATION_PX,
- offsetLeft: NAVIGATION_INDENTATION_PX * 3,
- overId: 5,
- }),
- ).toEqual({ depth: 1, parentId: 2 });
- });
-
- it("keeps a parent with children at the top level", () => {
- expect(
- projectNavigationDrop({
- activeHasChildren: true,
- activeId: 2,
- flattened: withoutNavigationChildrenOf(flattened, 2),
- indentationWidth: NAVIGATION_INDENTATION_PX,
- offsetLeft: NAVIGATION_INDENTATION_PX,
- overId: 5,
- }),
- ).toEqual({ depth: 0, parentId: null });
- });
-
- it("cannot become a root between a parent and its children", () => {
- expect(
- projectNavigationDrop({
- activeHasChildren: false,
- activeId: 1,
- flattened,
- indentationWidth: NAVIGATION_INDENTATION_PX,
- offsetLeft: 0,
- overId: 3,
- }),
- ).toEqual({ depth: 1, parentId: 2 });
- });
-
- it("has nothing to nest under at the very top", () => {
- expect(
- projectNavigationDrop({
- activeHasChildren: false,
- activeId: 5,
- flattened,
- indentationWidth: NAVIGATION_INDENTATION_PX,
- offsetLeft: NAVIGATION_INDENTATION_PX,
- overId: 1,
- }),
- ).toEqual({ depth: 0, parentId: null });
- });
-});
-
-describe("applyNavigationDrop and navigationOrderBody", () => {
- it("moves a parent with its children and re-parents what follows", () => {
- const flattened = flattenNavigationItems(menu);
- const next = applyNavigationDrop({
- activeId: 2,
- children: flattened.filter(entry => entry.parentId === 2),
- flattened: withoutNavigationChildrenOf(flattened, 2),
- overId: 5,
- projection: { depth: 0, parentId: null },
- });
-
- expect(ids(next)).toEqual(["1@0", "5@0", "2@0", "3@1", "4@1"]);
- expect(navigationOrderBody(next)).toEqual({
- items: [
- { children: [], id: 1 },
- { children: [], id: 5 },
- { children: [3, 4], id: 2 },
- ],
- });
- });
-
- it("pulls a child out to the top level and drops it below its old parent", () => {
- const flattened = flattenNavigationItems(menu);
- const next = applyNavigationDrop({
- activeId: 4,
- children: [],
- flattened,
- overId: 5,
- projection: { depth: 0, parentId: null },
- });
-
- expect(navigationOrderBody(next)).toEqual({
- items: [
- { children: [], id: 1 },
- { children: [3], id: 2 },
- { children: [], id: 5 },
- { children: [], id: 4 },
- ],
- });
- });
-
- it("rebuilds positions per parent for the optimistic list", () => {
- const next = navigationItemsFrom(
- applyNavigationDrop({
- activeId: 5,
- children: [],
- flattened: flattenNavigationItems(menu),
- overId: 5,
- projection: { depth: 1, parentId: 2 },
- }),
- );
-
- expect(
- next.map(entry => [entry.id, entry.parentId, entry.position]),
- ).toEqual([
- [1, null, 0],
- [2, null, 1],
- [3, 2, 0],
- [4, 2, 1],
- [5, 2, 2],
- ]);
- });
-});
-
-describe("sameNavigationOrder", () => {
- it("tells an unchanged drop from a real move", () => {
- const body = navigationOrderBody(flattenNavigationItems(menu));
-
- expect(sameNavigationOrder(body, body)).toBe(true);
- expect(
- sameNavigationOrder(body, {
- items: [...body.items].reverse(),
- }),
- ).toBe(false);
- });
-});
-
-describe("shiftNavigationItem", () => {
- const flattened = flattenNavigationItems(menu);
-
- it("moves a parent past its neighbour together with its children", () => {
- expect(ids(shiftNavigationItem({ flattened, id: 2, step: -1 }))).toEqual([
- "2@0",
- "3@1",
- "4@1",
- "1@0",
- "5@0",
- ]);
- });
-
- it("swaps a child with its sibling inside the same dropdown", () => {
- expect(ids(shiftNavigationItem({ flattened, id: 4, step: -1 }))).toEqual([
- "1@0",
- "2@0",
- "4@1",
- "3@1",
- "5@0",
- ]);
- });
-
- it("stays put at either end of its group", () => {
- expect(ids(shiftNavigationItem({ flattened, id: 1, step: -1 }))).toEqual(
- ids(flattened),
- );
- expect(ids(shiftNavigationItem({ flattened, id: 4, step: 1 }))).toEqual(
- ids(flattened),
- );
- });
-});
-
-describe("moveNavigationItem", () => {
- const flattened = flattenNavigationItems(menu);
-
- it("tucks a top-level item at the end of another item's dropdown", () => {
- expect(ids(moveNavigationItem({ flattened, id: 5, parentId: 2 }))).toEqual([
- "1@0",
- "2@0",
- "3@1",
- "4@1",
- "5@1",
- ]);
- });
-
- it("lifts a child to the top level right below its old parent", () => {
- expect(
- ids(moveNavigationItem({ flattened, id: 3, parentId: null })),
- ).toEqual(["1@0", "2@0", "4@1", "3@0", "5@0"]);
- });
-
- it("refuses to nest an item that has children of its own", () => {
- expect(ids(moveNavigationItem({ flattened, id: 2, parentId: 1 }))).toEqual(
- ids(flattened),
- );
- });
-
- it("refuses to nest under a child, which would be a second level", () => {
- expect(ids(moveNavigationItem({ flattened, id: 5, parentId: 3 }))).toEqual(
- ids(flattened),
- );
- });
-});
-
-describe("restoreCollapsedChildren", () => {
- it("puts a collapsed parent's hidden children back right under it", () => {
- const flattened = flattenNavigationItems(menu);
- const hidden = new Map([[2, childrenOfNavigation(flattened, 2)]]);
- const shown = withoutNavigationChildrenOf(flattened, 2);
-
- expect(
- ids(
- restoreCollapsedChildren(
- shiftNavigationItem({ flattened: shown, id: 5, step: -1 }),
- hidden,
- ),
- ),
- ).toEqual(["1@0", "5@0", "2@0", "3@1", "4@1"]);
- });
-});
diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-tree.ts b/packages/vitnode/src/views/admin/views/core/navigation/navigation-tree.ts
deleted file mode 100644
index f4d6ed51b1..0000000000
--- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-tree.ts
+++ /dev/null
@@ -1,343 +0,0 @@
-import { arrayMove } from "@dnd-kit/sortable";
-
-import { NAVIGATION_MAX_DEPTH } from "@/lib/navigation";
-
-import type { AdminNavigationItem } from "./navigation-query";
-
-export type NavigationDepth = 0 | 1;
-
-export interface FlattenedNavigationItem {
- depth: NavigationDepth;
- item: AdminNavigationItem;
- parentId: null | number;
-}
-
-export interface NavigationOrderBody {
- items: { children: number[]; id: number }[];
-}
-
-export interface NavigationDropProjection {
- depth: NavigationDepth;
- parentId: null | number;
-}
-
-export const NAVIGATION_INDENTATION_PX = 40;
-
-/**
- * The icon a row draws: the item's own, else the one its plugin ships with the
- * prebuilt page, else none.
- */
-export const navigationItemIcon = (
- item: Pick,
-): null | string => item.icon ?? item.preset?.icon ?? null;
-
-const byPosition = (a: AdminNavigationItem, b: AdminNavigationItem): number =>
- a.position - b.position || a.id - b.id;
-
-export const flattenNavigationItems = (
- items: readonly AdminNavigationItem[],
-): FlattenedNavigationItem[] => {
- const sorted = [...items].sort(byPosition);
- const byId = new Map(sorted.map(item => [item.id, item]));
- const parentOf = (item: AdminNavigationItem): null | number => {
- if (item.parentId === null) return null;
-
- const parent = byId.get(item.parentId);
-
- return parent?.parentId === null ? parent.id : null;
- };
-
- const children = new Map();
- for (const item of sorted) {
- const parentId = parentOf(item);
- if (parentId === null) continue;
-
- children.set(parentId, [...(children.get(parentId) ?? []), item]);
- }
-
- return sorted
- .filter(item => parentOf(item) === null)
- .flatMap(root => [
- { depth: 0 as const, item: root, parentId: null },
- ...(children.get(root.id) ?? []).map(child => ({
- depth: 1 as const,
- item: child,
- parentId: root.id,
- })),
- ]);
-};
-
-export const childrenOfNavigation = (
- flattened: readonly FlattenedNavigationItem[],
- id: number,
-): FlattenedNavigationItem[] =>
- flattened.filter(entry => entry.parentId === id);
-
-export const withoutNavigationChildrenOf = (
- flattened: readonly FlattenedNavigationItem[],
- id: number,
-): FlattenedNavigationItem[] =>
- flattened.filter(entry => entry.parentId !== id);
-
-const clampDepth = (
- depth: number,
- min: number,
- max: number,
-): NavigationDepth => {
- const clamped = Math.min(Math.max(depth, min), max);
-
- return clamped >= 1 ? 1 : 0;
-};
-
-const parentBefore = (
- entries: readonly FlattenedNavigationItem[],
- index: number,
-): null | number => {
- for (let at = index - 1; at >= 0; at -= 1) {
- if (entries[at].depth === 0) return entries[at].item.id;
- }
-
- return null;
-};
-
-export const projectNavigationDrop = ({
- activeHasChildren,
- activeId,
- flattened,
- indentationWidth,
- offsetLeft,
- overId,
-}: {
- activeHasChildren: boolean;
- activeId: number;
- flattened: readonly FlattenedNavigationItem[];
- indentationWidth: number;
- offsetLeft: number;
- overId: number;
-}): NavigationDropProjection | null => {
- const from = flattened.findIndex(entry => entry.item.id === activeId);
- const to = flattened.findIndex(entry => entry.item.id === overId);
- if (from === -1 || to === -1) return null;
-
- const moved = arrayMove([...flattened], from, to);
- const active = flattened[from];
- const previous = moved[to - 1];
- const next = moved[to + 1];
-
- const projected = active.depth + Math.round(offsetLeft / indentationWidth);
- const maxDepth = activeHasChildren
- ? 0
- : previous
- ? Math.min(previous.depth + 1, NAVIGATION_MAX_DEPTH)
- : 0;
- const minDepth = next ? next.depth : 0;
- const depth = clampDepth(projected, minDepth, maxDepth);
-
- return {
- depth,
- parentId: depth === 0 ? null : parentBefore(moved, to),
- };
-};
-
-export const applyNavigationDrop = ({
- activeId,
- children,
- flattened,
- overId,
- projection,
-}: {
- activeId: number;
- children: readonly FlattenedNavigationItem[];
- flattened: readonly FlattenedNavigationItem[];
- overId: number;
- projection: NavigationDropProjection;
-}): FlattenedNavigationItem[] => {
- const from = flattened.findIndex(entry => entry.item.id === activeId);
- const to = flattened.findIndex(entry => entry.item.id === overId);
- if (from === -1 || to === -1) return [...flattened];
-
- const moved = arrayMove([...flattened], from, to);
- moved[to] = {
- ...moved[to],
- depth: projection.depth,
- parentId: projection.parentId,
- };
- moved.splice(to + 1, 0, ...children);
-
- return moved.map((entry, index, all) =>
- entry.depth === 0
- ? { ...entry, parentId: null }
- : { ...entry, parentId: parentBefore(all, index) },
- );
-};
-
-export const navigationOrderBody = (
- flattened: readonly FlattenedNavigationItem[],
-): NavigationOrderBody => {
- const items: NavigationOrderBody["items"] = [];
-
- for (const entry of flattened) {
- if (entry.depth === 0 || items.length === 0) {
- items.push({ children: [], id: entry.item.id });
- continue;
- }
-
- items[items.length - 1].children.push(entry.item.id);
- }
-
- return { items };
-};
-
-export const sameNavigationOrder = (
- a: NavigationOrderBody,
- b: NavigationOrderBody,
-): boolean =>
- a.items.length === b.items.length &&
- a.items.every(
- (entry, index) =>
- entry.id === b.items[index].id &&
- entry.children.length === b.items[index].children.length &&
- entry.children.every((id, at) => id === b.items[index].children[at]),
- );
-
-export const navigationItemsFrom = (
- flattened: readonly FlattenedNavigationItem[],
-): AdminNavigationItem[] => {
- const positions = new Map();
-
- return flattened.map(entry => {
- const position = positions.get(entry.parentId) ?? 0;
- positions.set(entry.parentId, position + 1);
-
- return { ...entry.item, parentId: entry.parentId, position };
- });
-};
-
-const regroupNavigation = (
- order: NavigationOrderBody,
- flattened: readonly FlattenedNavigationItem[],
-): FlattenedNavigationItem[] => {
- const byId = new Map(flattened.map(entry => [entry.item.id, entry.item]));
-
- return order.items.flatMap(group => {
- const root = byId.get(group.id);
- if (!root) return [];
-
- return [
- { depth: 0 as const, item: root, parentId: null },
- ...group.children.flatMap(childId => {
- const child = byId.get(childId);
-
- return child
- ? [{ depth: 1 as const, item: child, parentId: group.id }]
- : [];
- }),
- ];
- });
-};
-
-export const shiftNavigationItem = ({
- flattened,
- id,
- step,
-}: {
- flattened: readonly FlattenedNavigationItem[];
- id: number;
- step: -1 | 1;
-}): FlattenedNavigationItem[] => {
- const { items } = navigationOrderBody(flattened);
- const rootAt = items.findIndex(group => group.id === id);
-
- if (rootAt !== -1) {
- const target = rootAt + step;
- if (target < 0 || target >= items.length) return [...flattened];
-
- return regroupNavigation(
- { items: arrayMove(items, rootAt, target) },
- flattened,
- );
- }
-
- return regroupNavigation(
- {
- items: items.map(group => {
- const at = group.children.indexOf(id);
- const target = at + step;
- if (at === -1 || target < 0 || target >= group.children.length) {
- return group;
- }
-
- return { ...group, children: arrayMove(group.children, at, target) };
- }),
- },
- flattened,
- );
-};
-
-export const moveNavigationItem = ({
- flattened,
- id,
- parentId,
-}: {
- flattened: readonly FlattenedNavigationItem[];
- id: number;
- parentId: null | number;
-}): FlattenedNavigationItem[] => {
- const { items } = navigationOrderBody(flattened);
- const ownGroup = items.find(group => group.id === id);
- const oldParent = items.find(group => group.children.includes(id));
- const isRoot = ownGroup !== undefined;
-
- if (parentId === null ? isRoot : parentId === id) return [...flattened];
- if (parentId !== null) {
- const canNest =
- (ownGroup?.children.length ?? 0) === 0 &&
- items.some(group => group.id === parentId);
- if (!canNest || oldParent?.id === parentId) return [...flattened];
- }
-
- const rest = items
- .filter(group => group.id !== id)
- .map(group => ({
- ...group,
- children: group.children.filter(childId => childId !== id),
- }));
-
- if (parentId === null) {
- const insertAt = oldParent
- ? rest.findIndex(group => group.id === oldParent.id) + 1
- : rest.length;
-
- return regroupNavigation(
- {
- items: [
- ...rest.slice(0, insertAt),
- { children: [], id },
- ...rest.slice(insertAt),
- ],
- },
- flattened,
- );
- }
-
- return regroupNavigation(
- {
- items: rest.map(group =>
- group.id === parentId
- ? { ...group, children: [...group.children, id] }
- : group,
- ),
- },
- flattened,
- );
-};
-
-export const restoreCollapsedChildren = (
- flattened: readonly FlattenedNavigationItem[],
- hidden: ReadonlyMap,
-): FlattenedNavigationItem[] =>
- flattened.flatMap(entry => {
- const children = entry.depth === 0 ? hidden.get(entry.item.id) : undefined;
-
- return children ? [entry, ...children] : [entry];
- });
diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts
index abd7a97a05..ff15c559e4 100644
--- a/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts
+++ b/packages/vitnode/src/views/admin/views/core/shared/admin-permissions.ts
@@ -45,13 +45,6 @@ export const ADMIN_ROLE_PERMISSIONS = {
view: core("roles", "can_view"),
} as const;
-export const ADMIN_NAVIGATION_PERMISSIONS = {
- create: core("navigation", "can_create"),
- delete: core("navigation", "can_delete"),
- edit: core("navigation", "can_edit"),
- view: core("navigation", "can_view"),
-} as const;
-
export const staffPermissionModuleFor = (
type: PermissionStaffType,
): "staff_admins" | "staff_moderators" =>
diff --git a/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts b/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts
index 50d4512feb..419bab617b 100644
--- a/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts
+++ b/packages/vitnode/src/views/admin/views/core/shared/admin-scope.ts
@@ -19,4 +19,3 @@ export const ADMIN_USERS_SCREEN = "users";
export const ADMIN_ROLES_SCREEN = "roles";
export const ADMIN_STAFF_SCREEN = "staff";
export const ADMIN_DASHBOARD_SCREEN = "dashboard";
-export const ADMIN_NAVIGATION_SCREEN = "navigation";
diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx
deleted file mode 100644
index 37083fd0d5..0000000000
--- a/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import {
- createMemoryHistory,
- createRootRoute,
- createRouter,
- RouterProvider,
-} from "@tanstack/react-router";
-import { act, cleanup, render, screen } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-import { HeaderLayoutContent } from "./header-content";
-
-const mount = async ({
- isNavigationPending,
-}: {
- isNavigationPending: boolean;
-}) => {
- const router = createRouter({
- history: createMemoryHistory({ initialEntries: ["/"] }),
- routeTree: createRootRoute({
- component: () => (
-
- ),
- }),
- });
-
- await act(async () => {
- await router.load();
- });
- render( );
-};
-
-beforeEach(() => {
- vi.stubGlobal(
- "ResizeObserver",
- class {
- disconnect() {}
- observe() {}
- },
- );
-});
-
-afterEach(() => {
- cleanup();
- vi.unstubAllGlobals();
-});
-
-describe("the header while its navigation loads", () => {
- it("holds the navigation's place with an announced placeholder", async () => {
- await mount({ isNavigationPending: true });
-
- expect(screen.getByRole("status").textContent).toBe("Loading navigation");
- expect(screen.queryByRole("link", { name: "Search" })).toBeNull();
- });
-
- it("shows the links once they are known", async () => {
- await mount({ isNavigationPending: false });
-
- expect(screen.getByRole("link", { name: "Search" })).toBeDefined();
- expect(screen.queryByRole("status")).toBeNull();
- });
-});
diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx
index 4a04e3aa9d..9eb74fb9fc 100644
--- a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx
+++ b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx
@@ -9,19 +9,17 @@ import {
import type { HeaderNavItem } from "./header-nav";
import { HEADER_HREF } from "./header-nav";
-import { HeaderNavMenu, HeaderNavSkeleton } from "./header-nav-menu";
+import { HeaderNavMenu } from "./header-nav-menu";
export interface HeaderLayoutContentProps extends Omit<
React.ComponentProps<"header">,
"children"
> {
- isNavigationPending?: boolean;
leading?: React.ReactNode;
logo: React.ReactNode;
mobileUser?: React.ReactNode;
moreNavigationLabel: string;
navigation: HeaderNavItem[];
- navigationLoadingLabel: string;
notifications?: React.ReactNode;
search?: React.ReactNode;
user?: React.ReactNode;
@@ -46,13 +44,11 @@ const HEADER_VARIANT_CLASS_NAMES: Record<
export const HeaderLayoutContent = ({
className,
- isNavigationPending = false,
leading,
logo,
mobileUser,
moreNavigationLabel,
navigation,
- navigationLoadingLabel,
notifications,
search,
user,
@@ -80,14 +76,10 @@ export const HeaderLayoutContent = ({
className="ms-4 hidden min-w-0 flex-1 sm:flex"
style={{ viewTransitionName: HEADER_VIEW_TRANSITION_NAMES.navigation }}
>
- {isNavigationPending ? (
-
- ) : (
-
- )}
+
): boolean => {
const NavIcon = ({
className,
- icon,
+ icon: Icon,
}: {
className?: string;
- icon: string | undefined;
-}) => ;
+ icon: NavigationIcon | undefined;
+}) => (Icon ? : null);
const HeaderNavDropdownLink = ({ item }: { item: HeaderNavChildItem }) => {
const isActive = useIsNavHrefActive();
@@ -208,7 +207,7 @@ const MeasuredNavLabel = ({
label,
}: {
hasDropdown: boolean;
- icon?: string;
+ icon?: NavigationIcon;
label: string;
}) => (
@@ -240,16 +239,21 @@ const useVisibleNavCount = (navigation: HeaderNavItem[]) => {
element?.getBoundingClientRect().width ?? 0;
const update = () => {
- setVisibleCount(
- visibleHeaderNavCount({
- availableWidth: container.clientWidth,
- gap: Number.parseFloat(getComputedStyle(measure).columnGap) || 0,
- itemWidths: [...measure.querySelectorAll(MEASURE_ITEM_SELECTOR)].map(
- widthOf,
- ),
- moreWidth: widthOf(measure.querySelector(MEASURE_MORE_SELECTOR)),
- }),
- );
+ const availableWidth = container.clientWidth;
+ if (availableWidth === 0) return;
+
+ const count = visibleHeaderNavCount({
+ availableWidth,
+ gap: Number.parseFloat(getComputedStyle(measure).columnGap) || 0,
+ itemWidths: [...measure.querySelectorAll(MEASURE_ITEM_SELECTOR)].map(
+ widthOf,
+ ),
+ moreWidth: widthOf(measure.querySelector(MEASURE_MORE_SELECTOR)),
+ });
+
+ React.startTransition(() => {
+ setVisibleCount(count);
+ });
};
const resize = new ResizeObserver(update);
@@ -323,27 +327,3 @@ export const HeaderNavMenu = ({
);
};
-
-const NAV_SKELETON_WIDTHS = ["w-20", "w-24", "w-16"] as const;
-
-export const HeaderNavSkeleton = ({
- className,
- label,
-}: {
- className?: string;
- label: string;
-}) => (
-
- {label}
- {NAV_SKELETON_WIDTHS.map(width => (
-
- ))}
-
-);
diff --git a/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts b/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts
index ee9bc3f2e2..d443a48939 100644
--- a/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts
+++ b/packages/vitnode/src/views/layouts/theme/header/header-nav.test.ts
@@ -1,184 +1,6 @@
import { describe, expect, it } from "vitest";
-import type { PublicNavigationNode } from "@/lib/navigation";
-
-import {
- headerNavItemsFrom,
- headerNavNamespaces,
- visibleHeaderNavCount,
-} from "./header-nav";
-
-const node = (
- overrides: Partial,
-): PublicNavigationNode => ({
- description: [],
- href: "/discover",
- icon: null,
- id: 1,
- isOpenInNewTab: false,
- items: [],
- kind: "preset",
- pluginId: "@vitnode/core",
- presetId: "discover",
- title: [],
- ...overrides,
-});
-
-const custom = (
- id: number,
- title: string,
- overrides: Partial = {},
-): PublicNavigationNode =>
- node({
- href: `/${title.toLowerCase()}`,
- id,
- kind: "custom",
- pluginId: null,
- presetId: null,
- title: [{ languageCode: "en", value: title }],
- ...overrides,
- });
-
-const translate = (namespace: string, key: string) =>
- ({
- "core.navigation": {
- "discover.title": "Discover",
- "search.description": "Find anything",
- "search.title": "Search",
- } as Record,
- })[namespace]?.[key];
-
-describe("the main nav", () => {
- const items = [
- node({}),
- node({ href: "/search", id: 2, presetId: "search" }),
- custom(3, "Docs", {
- href: "https://vitnode.com/docs",
- isOpenInNewTab: true,
- }),
- ];
-
- it("is what the AdminCP saved, in that order", () => {
- expect(headerNavItemsFrom({ items, locale: "en", translate })).toEqual([
- { href: "/discover", id: "1", label: "Discover" },
- {
- description: "Find anything",
- href: "/search",
- id: "2",
- label: "Search",
- },
- {
- href: "https://vitnode.com/docs",
- id: "3",
- isOpenInNewTab: true,
- label: "Docs",
- },
- ]);
- });
-
- it("turns a parent with children into a dropdown", () => {
- const [community] = headerNavItemsFrom({
- items: [
- custom(10, "Community", {
- items: [
- custom(11, "Members", { isOpenInNewTab: true }),
- custom(12, "Events", {
- description: [{ languageCode: "en", value: "What is on" }],
- }),
- ],
- }),
- ],
- locale: "en",
- translate,
- });
-
- expect(community.items).toEqual([
- { href: "/members", id: "11", isOpenInNewTab: true, label: "Members" },
- { description: "What is on", href: "/events", id: "12", label: "Events" },
- ]);
- });
-
- it("leaves out a child nobody can name and keeps the parent a plain link", () => {
- const [parent] = headerNavItemsFrom({
- items: [
- custom(10, "Community", {
- items: [node({ id: 11, presetId: "nameless" })],
- }),
- ],
- locale: "en",
- translate,
- });
-
- expect(parent.items).toBeUndefined();
- });
-
- it("drops an item nobody can name", () => {
- expect(
- headerNavItemsFrom({
- items: [node({ presetId: "nameless" })],
- locale: "en",
- translate,
- }),
- ).toEqual([]);
- });
-
- it("carries the icon the menu item resolved to", () => {
- const [entry] = headerNavItemsFrom({
- items: [node({ icon: "icon:compass" })],
- locale: "en",
- translate,
- });
-
- expect(entry.icon).toBe("icon:compass");
- });
-
- it("carries the label it was given, untouched", () => {
- expect(
- headerNavItemsFrom({
- items: [node({ title: [{ languageCode: "pl", value: "Odkrywaj" }] })],
- locale: "pl",
- translate,
- })[0].label,
- ).toBe("Odkrywaj");
- });
-
- it("gives every link a distinct key", () => {
- const ids = headerNavItemsFrom({
- items: [
- ...items,
- custom(4, "Docs again", { href: "https://vitnode.com/docs" }),
- ],
- locale: "en",
- translate,
- }).map(entry => entry.id);
-
- expect(new Set(ids).size).toBe(ids.length);
- });
-});
-
-describe("the namespaces the header warms", () => {
- it("are the plugins whose presets are in the menu, children included", () => {
- expect(
- headerNavNamespaces(
- [
- custom(10, "Community", {
- items: [
- node({ id: 2, pluginId: "@vitnode/blog", presetId: "posts" }),
- ],
- }),
- node({}),
- ],
- "core.global",
- ),
- ).toEqual(["@vitnode/blog.navigation", "core.navigation"]);
- });
-
- it("fall back to the namespace every page already has", () => {
- expect(headerNavNamespaces([custom(1, "Docs")], "core.global")).toEqual([
- "core.global",
- ]);
- });
-});
+import { visibleHeaderNavCount } from "./header-nav";
describe("how many items fit in the header", () => {
it("keeps every item when they all fit, without room for More", () => {
diff --git a/packages/vitnode/src/views/layouts/theme/header/header-nav.ts b/packages/vitnode/src/views/layouts/theme/header/header-nav.ts
index e19e03389a..f3b1aa7594 100644
--- a/packages/vitnode/src/views/layouts/theme/header/header-nav.ts
+++ b/packages/vitnode/src/views/layouts/theme/header/header-nav.ts
@@ -1,11 +1,9 @@
import type {
- NavigationTranslate,
- PublicNavigationItem,
- PublicNavigationNode,
+ NavigationHeaderItemConfig,
+ NavigationIcon,
+ NavigationLinkConfig,
} from "@/lib/navigation";
-import { navigationItemLabels, navigationNamespaces } from "@/lib/navigation";
-
export const HEADER_HREF = {
home: "/",
} as const;
@@ -13,7 +11,7 @@ export const HEADER_HREF = {
export interface HeaderNavChildItem {
description?: string;
href: string;
- icon?: string;
+ icon?: NavigationIcon;
id: string;
isOpenInNewTab?: boolean;
label: string;
@@ -23,61 +21,43 @@ export interface HeaderNavItem extends HeaderNavChildItem {
items?: HeaderNavChildItem[];
}
-export type HeaderNavTranslate = NavigationTranslate;
+export type HeaderNavTranslate = (key: string) => string | undefined;
const headerNavChildFrom = (
- item: PublicNavigationItem,
- locale: string,
+ link: NavigationLinkConfig,
+ id: string,
translate: HeaderNavTranslate,
-): HeaderNavChildItem | null => {
- const { description, title } = navigationItemLabels({
- item,
- locale,
- translate,
- });
- if (!title) return null;
+): HeaderNavChildItem => {
+ const description =
+ link.description === undefined ? undefined : translate(link.description);
return {
- href: item.href,
- id: String(item.id),
- label: title,
+ href: link.href,
+ id,
+ label: translate(link.title) ?? link.title,
...(description ? { description } : {}),
- ...(item.icon ? { icon: item.icon } : {}),
- ...(item.isOpenInNewTab ? { isOpenInNewTab: true } : {}),
+ ...(link.icon ? { icon: link.icon } : {}),
+ ...(link.isOpenInNewTab ? { isOpenInNewTab: true } : {}),
};
};
export const headerNavItemsFrom = ({
items,
- locale,
translate,
}: {
- items: readonly PublicNavigationNode[];
- locale: string;
+ items: readonly NavigationHeaderItemConfig[] | undefined;
translate: HeaderNavTranslate;
}): HeaderNavItem[] =>
- items.flatMap(node => {
- const entry = headerNavChildFrom(node, locale, translate);
- if (!entry) return [];
-
- const children = node.items.flatMap(child => {
- const childEntry = headerNavChildFrom(child, locale, translate);
-
- return childEntry ? [childEntry] : [];
- });
-
- return [children.length > 0 ? { ...entry, items: children } : entry];
+ (items ?? []).map((item, index) => {
+ const id = String(index);
+ const entry = headerNavChildFrom(item, id, translate);
+ const children = (item.items ?? []).map((child, childIndex) =>
+ headerNavChildFrom(child, `${id}.${String(childIndex)}`, translate),
+ );
+
+ return children.length > 0 ? { ...entry, items: children } : entry;
});
-export const headerNavNamespaces = (
- items: readonly PublicNavigationNode[],
- fallback: string,
-): string[] => {
- const namespaces = navigationNamespaces(items);
-
- return namespaces.length > 0 ? namespaces : [fallback];
-};
-
export const visibleHeaderNavCount = ({
availableWidth,
gap,
diff --git a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx
index 7089f2857d..d54c57fbe3 100644
--- a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx
+++ b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx
@@ -12,6 +12,7 @@ import {
waitFor,
within,
} from "@testing-library/react";
+import { HouseIcon } from "lucide-react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HeaderNavChildItem, HeaderNavItem } from "../header-nav";
@@ -19,7 +20,7 @@ import type { HeaderNavChildItem, HeaderNavItem } from "../header-nav";
import { MobileNavBar } from "./mobile-nav-bar";
const items: HeaderNavChildItem[] = [
- { href: "/", icon: "emoji:🏠", id: "1", label: "Home" },
+ { href: "/", icon: HouseIcon, id: "1", label: "Home" },
{ href: "/blog", id: "2", label: "Blog" },
];
diff --git a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-parts.tsx b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-parts.tsx
index 064f1042a2..a860100456 100644
--- a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-parts.tsx
+++ b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-parts.tsx
@@ -2,24 +2,22 @@ import { Link } from "@tanstack/react-router";
import { cn } from "cn";
import React from "react";
-import { EmojiIcon } from "@/components/ui/emoji-icon";
-import { parseEmojiIcon } from "@/lib/emoji-icon";
+import type { NavigationIcon } from "@/lib/navigation";
+
import { isExternalNavigationHref } from "@/lib/navigation";
import type { HeaderNavChildItem } from "../header-nav";
export const MobileNavIcon = ({
className,
- icon,
+ icon: Icon,
label,
}: {
className?: string;
- icon: string | undefined;
+ icon: NavigationIcon | undefined;
label: string;
}) => {
- const parsed = parseEmojiIcon(icon);
-
- if (parsed) return ;
+ if (Icon) return ;
return (
;
logo?: VitNodeLogo;
metadata: VitNodeMetadata;
+ navigation?: NavigationConfig;
/**
* The server-rendered HTML cache: where it is stored, and per-route
* overrides of the policies plugins declare. See `PageCacheConfig`.
@@ -193,6 +197,8 @@ let registeredVitNodeConfig: undefined | VitNodeConfig;
export function buildConfig(
args: VitNodeConfig,
): VitNodeConfig {
+ assertNavigationConfig(args.navigation);
+
const config = {
...args,
i18n: {
diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts
index 8f2a24caf6..8bf517ed84 100644
--- a/plugins/example/src/config.api.ts
+++ b/plugins/example/src/config.api.ts
@@ -27,7 +27,6 @@ export const exampleApiPlugin = () =>
aiActions: [articleExcerptAiAction],
blocks: widgets,
editablePages: [settingsPage],
- navigation: [{ href: "/example/browse", icon: "list", id: "browse" }],
moderation: { scopes: [exampleCategoryScope] },
permissionStaff: {
moderator: {
diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json
index 3e037b5570..24602b5004 100644
--- a/plugins/example/src/locales/en.json
+++ b/plugins/example/src/locales/en.json
@@ -138,12 +138,6 @@
"from_index": "You arrived here from the guide index - the route's search contract recognised it, and everything else in the query string was dropped."
}
},
- "navigation": {
- "browse": {
- "title": "Browse",
- "description": "A paginated list, shipped by the example plugin."
- }
- },
"browse": {
"title": "Browse",
"desc": "A paginated page whose route declares an eager search schema, so ?page=999 is clamped before anything renders.",
diff --git a/plugins/forum/src/config.api.ts b/plugins/forum/src/config.api.ts
index dec30e9e53..7785ca5fb9 100644
--- a/plugins/forum/src/config.api.ts
+++ b/plugins/forum/src/config.api.ts
@@ -13,7 +13,6 @@ import { manifest } from "@/manifest.gen";
export const forumApiPlugin = () =>
buildApiPlugin({
manifest,
- navigation: [{ href: "/forum", icon: "messages-square", id: "forum" }],
modules: [
adminModule,
forumModule,
diff --git a/plugins/forum/src/locales/en.json b/plugins/forum/src/locales/en.json
index 34ef1deebf..c36ad443f4 100644
--- a/plugins/forum/src/locales/en.json
+++ b/plugins/forum/src/locales/en.json
@@ -1,12 +1,6 @@
{
"@vitnode/forum": {
"title": "Forum",
- "navigation": {
- "forum": {
- "title": "Forum",
- "description": "Topics, comments and the occasional heated debate about tabs versus spaces."
- }
- },
"containers": {
"forums": {
"label": "{count, plural, one {Forum} other {Forums}}",
diff --git a/shots/error-page-new.png b/shots/error-page-new.png
new file mode 100644
index 0000000000..0e2d37a3e1
Binary files /dev/null and b/shots/error-page-new.png differ