From 004e85cdaf12b367c559bb0d5ac52fb8a7c58f28 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Mon, 15 Jun 2026 18:43:54 +0200 Subject: [PATCH 1/3] feat: Add breadcrumbs --- apps/docs/content/docs/dev/database/meta.json | 6 + apps/docs/content/docs/dev/i18n/meta.json | 2 + apps/docs/content/docs/dev/meta.json | 8 +- .../docs/dev/{ => plugins}/admin-page.mdx | 0 .../docs/dev/{ => plugins}/api/meta.json | 1 - .../docs/dev/{ => plugins}/api/modules.mdx | 8 +- .../docs/dev/{ => plugins}/api/routes.mdx | 124 ++++++------- .../content/docs/dev/plugins/breadcrumbs.mdx | 171 ++++++++++++++++++ .../dev/{plugins.mdx => plugins/create.mdx} | 0 .../dev/{ => plugins}/layouts-and-pages.mdx | 0 apps/docs/content/docs/dev/plugins/meta.json | 7 + .../docs/dev/working-with-users/meta.json | 2 + .../(plugins)/(vitnode-core)/login/page.tsx | 11 ++ .../login/reset-password/page.tsx | 20 ++ .../(vitnode-core)/register/page.tsx | 14 ++ .../[locale]/(main)/@breadcrumb/default.tsx | 4 + .../app/[locale]/(main)/@breadcrumb/page.tsx | 3 + apps/docs/src/app/[locale]/(main)/layout.tsx | 9 +- .../core/users/[nameCode]/page.tsx | 12 ++ .../(auth)/@breadcrumb/[...all]/page.tsx | 16 ++ .../admin/(auth)/@breadcrumb/default.tsx | 4 + .../animated-beam/animated-beam-home.tsx | 6 +- apps/docs/src/examples/tooltip.tsx | 4 +- packages/config/eslint.config.mjs | 2 + .../root/src/app/[locale]/(main)/layout.tsx | 9 +- packages/vitnode/scripts/plugin.ts | 50 +++++ .../vitnode/scripts/prepare-plugins-files.ts | 65 +++++++ .../admin/core/users/[nameCode]/page.tsx | 12 ++ .../src/routes/breadcrumb/main/login/page.tsx | 11 ++ .../main/login/reset-password/page.tsx | 20 ++ .../routes/breadcrumb/main/register/page.tsx | 14 ++ .../src/views/admin/layouts/admin-layout.tsx | 37 ++-- .../layouts/breadcrumb/breadcrumb-admin.tsx | 55 ++++++ .../breadcrumb/breadcrumb-user-admin.tsx | 55 ++++++ .../layouts/breadcrumb/resolve-breadcrumb.ts | 60 ++++++ .../layouts/sidebar/nav/get-admin-nav.tsx | 98 ++++++++++ .../views/admin/layouts/sidebar/nav/nav.tsx | 56 +----- .../views/admin/layouts/sidebar/sidebar.tsx | 4 +- .../src/views/breadcrumb/breadcrumb-main.tsx | 32 ++++ .../views/breadcrumb/breadcrumb-render.tsx | 44 +++++ .../vitnode/src/views/breadcrumb/crumb.ts | 17 ++ .../breadcrumb/resolve-main-breadcrumb.ts | 27 +++ .../src/views/layouts/theme/layout.tsx | 5 +- packages/vitnode/src/vitnode.config.ts | 25 ++- .../table/actions/delete/delete-action.tsx | 12 +- .../categories/table/actions/edit-action.tsx | 16 +- .../table/actions/delete/delete-action.tsx | 12 +- .../admin/posts/table/actions/edit-action.tsx | 16 +- 48 files changed, 1002 insertions(+), 184 deletions(-) create mode 100644 apps/docs/content/docs/dev/database/meta.json rename apps/docs/content/docs/dev/{ => plugins}/admin-page.mdx (100%) rename apps/docs/content/docs/dev/{ => plugins}/api/meta.json (71%) rename apps/docs/content/docs/dev/{ => plugins}/api/modules.mdx (85%) rename apps/docs/content/docs/dev/{ => plugins}/api/routes.mdx (76%) create mode 100644 apps/docs/content/docs/dev/plugins/breadcrumbs.mdx rename apps/docs/content/docs/dev/{plugins.mdx => plugins/create.mdx} (100%) rename apps/docs/content/docs/dev/{ => plugins}/layouts-and-pages.mdx (100%) create mode 100644 apps/docs/content/docs/dev/plugins/meta.json create mode 100644 apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/page.tsx create mode 100644 apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/reset-password/page.tsx create mode 100644 apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/register/page.tsx create mode 100644 apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx create mode 100644 apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/admin/core/users/[nameCode]/page.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/main/login/page.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/main/login/reset-password/page.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/main/register/page.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-user-admin.tsx create mode 100644 packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts create mode 100644 packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx create mode 100644 packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx create mode 100644 packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx create mode 100644 packages/vitnode/src/views/breadcrumb/crumb.ts create mode 100644 packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts diff --git a/apps/docs/content/docs/dev/database/meta.json b/apps/docs/content/docs/dev/database/meta.json new file mode 100644 index 000000000..868f87d18 --- /dev/null +++ b/apps/docs/content/docs/dev/database/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Database", + "description": "Learn how to work with databases in VitNode plugins using Drizzle ORM and PostgreSQL.", + "icon": "Database", + "pages": ["..."] +} diff --git a/apps/docs/content/docs/dev/i18n/meta.json b/apps/docs/content/docs/dev/i18n/meta.json index ab92ff814..f01450363 100644 --- a/apps/docs/content/docs/dev/i18n/meta.json +++ b/apps/docs/content/docs/dev/i18n/meta.json @@ -1,4 +1,6 @@ { "title": "Internationalization (I18n)", + "description": "Learn how to make your VitNode plugins multilingual with our I18n guide.", + "icon": "Globe", "pages": ["expand-langs", "namespaces", "messages", "..."] } diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index 1ca164515..1186208ec 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -11,9 +11,10 @@ "deployments", "---Framework---", "plugins", - "api", "database", + "fetcher", "working-with-users", + "i18n", "advanced", "---Adapters---", "captcha", @@ -21,12 +22,7 @@ "sso", "cron", "websocket", - "---Frontend---", - "layouts-and-pages", - "admin-page", - "fetcher", "---UI---", - "i18n", "not-found", "..." ] diff --git a/apps/docs/content/docs/dev/admin-page.mdx b/apps/docs/content/docs/dev/plugins/admin-page.mdx similarity index 100% rename from apps/docs/content/docs/dev/admin-page.mdx rename to apps/docs/content/docs/dev/plugins/admin-page.mdx diff --git a/apps/docs/content/docs/dev/api/meta.json b/apps/docs/content/docs/dev/plugins/api/meta.json similarity index 71% rename from apps/docs/content/docs/dev/api/meta.json rename to apps/docs/content/docs/dev/plugins/api/meta.json index cd6a87808..e7f8365a1 100644 --- a/apps/docs/content/docs/dev/api/meta.json +++ b/apps/docs/content/docs/dev/plugins/api/meta.json @@ -1,5 +1,4 @@ { "title": "REST API", - "defaultOpen": true, "pages": ["modules", "..."] } diff --git a/apps/docs/content/docs/dev/api/modules.mdx b/apps/docs/content/docs/dev/plugins/api/modules.mdx similarity index 85% rename from apps/docs/content/docs/dev/api/modules.mdx rename to apps/docs/content/docs/dev/plugins/api/modules.mdx index c164d7dc8..7a97396ff 100644 --- a/apps/docs/content/docs/dev/api/modules.mdx +++ b/apps/docs/content/docs/dev/plugins/api/modules.mdx @@ -1,6 +1,6 @@ --- title: Modules -description: xxx +description: Learn how to organize your API routes into modules for better structure and maintainability. --- ## Usage @@ -15,7 +15,7 @@ import { CONFIG_PLUGIN } from "@/config"; export const categoriesModule = buildModule({ pluginId: CONFIG_PLUGIN.id, name: "categories", - routes: [] // We'll populate this soon! + routes: [], // We'll populate this soon! }); ``` @@ -34,7 +34,7 @@ export const categoriesModule = buildModule({ pluginId: CONFIG_PLUGIN.id, name: "categories", routes: [], - modules: [postsModule] // [!code ++] + modules: [postsModule], // [!code ++] }); ``` @@ -52,7 +52,7 @@ import { categoriesModule } from "./api/modules/categories/categories.module"; / export const blogApiPlugin = () => { return buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, - modules: [categoriesModule] // [!code ++] + modules: [categoriesModule], // [!code ++] }); }; ``` diff --git a/apps/docs/content/docs/dev/api/routes.mdx b/apps/docs/content/docs/dev/plugins/api/routes.mdx similarity index 76% rename from apps/docs/content/docs/dev/api/routes.mdx rename to apps/docs/content/docs/dev/plugins/api/routes.mdx index b54f65318..38ecffe45 100644 --- a/apps/docs/content/docs/dev/api/routes.mdx +++ b/apps/docs/content/docs/dev/plugins/api/routes.mdx @@ -1,6 +1,6 @@ --- title: Routes -description: xxx +description: Learn how to create API routes in your VitNode plugins, including handling path parameters, query parameters, and request bodies. --- ## Usage @@ -27,25 +27,25 @@ export const getCategoriesRoute = buildRoute({ z.object({ id: z.string(), name: z.string(), - description: z.string().optional() - }) - ) - }) - } + description: z.string().optional(), + }), + ), + }), + }, }, - description: "Successfully retrieved categories" - } - } + description: "Successfully retrieved categories", + }, + }, }, - handler: (c) => { + handler: c => { // Your business logic goes here return c.json({ categories: [ { id: "1", name: "Technology", description: "All things tech" }, - { id: "2", name: "Lifestyle" } - ] + { id: "2", name: "Lifestyle" }, + ], }); - } + }, }); ``` @@ -60,7 +60,7 @@ import { getCategoriesRoute } from "./routes/get.route"; // [!code ++] export const categoriesModule = buildModule({ pluginId: CONFIG_PLUGIN.id, name: "categories", - routes: [getCategoriesRoute] // [!code ++] + routes: [getCategoriesRoute], // [!code ++] }); ``` @@ -88,9 +88,9 @@ export const getCategoryByIdRoute = buildRoute({ params: z.object({ id: z.string().openapi({ description: "Unique identifier for the category", - example: "tech-category-123" - }) - }) + example: "tech-category-123", + }), + }), }, responses: { 200: { @@ -99,18 +99,18 @@ export const getCategoryByIdRoute = buildRoute({ schema: z.object({ id: z.string(), name: z.string(), - description: z.string().optional() - }) - } + description: z.string().optional(), + }), + }, }, - description: "Category details retrieved successfully" + description: "Category details retrieved successfully", }, 404: { - description: "Category not found" - } - } + description: "Category not found", + }, + }, }, - handler: (c) => { + handler: c => { // [!code highlight] const { id } = c.req.valid("param"); // Extract the path parameter @@ -122,9 +122,9 @@ export const getCategoryByIdRoute = buildRoute({ return c.json({ id, name: `Category ${id}`, - description: "A fantastic category for amazing content" + description: "A fantastic category for amazing content", }); - } + }, }); ``` @@ -148,9 +148,9 @@ export const searchCategoriesRoute = buildRoute({ query: z.object({ search: z.string().optional().openapi({ description: "Search term to filter categories", - example: "technology" - }) - }) + example: "technology", + }), + }), }, responses: { 200: { @@ -161,37 +161,39 @@ export const searchCategoriesRoute = buildRoute({ z.object({ id: z.string(), name: z.string(), - description: z.string().optional() - }) + description: z.string().optional(), + }), ), - total: z.number() - }) - } + total: z.number(), + }), + }, }, - description: "Search results with pagination info" - } - } + description: "Search results with pagination info", + }, + }, }, - handler: (c) => { + handler: c => { const { search } = c.req.valid("query"); // [!code highlight] // Your search logic here const mockResults = [ { id: "1", name: "Technology", description: "Tech-related posts" }, - { id: "2", name: "Lifestyle" } + { id: "2", name: "Lifestyle" }, ]; const filteredResults = search - ? mockResults.filter((cat) => cat.name.toLowerCase().includes(search.toLowerCase())) + ? mockResults.filter(cat => + cat.name.toLowerCase().includes(search.toLowerCase()), + ) : mockResults; const paginatedResults = filteredResults.slice(offset, offset + limit); return c.json({ categories: paginatedResults, - total: filteredResults.length + total: filteredResults.length, }); - } + }, }); ``` @@ -208,11 +210,11 @@ import { CONFIG_PLUGIN } from "@/config"; const createCategorySchema = z.object({ name: z.string().min(1).max(100).openapi({ description: "Name of the category", - example: "Web Development" + example: "Web Development", }), description: z.string().optional().openapi({ description: "Optional description for the category", - example: "Everything about building websites and web applications" + example: "Everything about building websites and web applications", }), color: z .string() @@ -220,8 +222,8 @@ const createCategorySchema = z.object({ .optional() .openapi({ description: "Hex color code for the category", - example: "#3B82F6" - }) + example: "#3B82F6", + }), }); export const createCategoryRoute = buildRoute({ @@ -234,12 +236,12 @@ export const createCategoryRoute = buildRoute({ body: { content: { "application/json": { - schema: createCategorySchema - } + schema: createCategorySchema, + }, }, description: "Category data to create", - required: true - } + required: true, + }, }, responses: { 201: { @@ -250,28 +252,28 @@ export const createCategoryRoute = buildRoute({ name: z.string(), description: z.string().optional(), color: z.string().optional(), - createdAt: z.string() - }) - } + createdAt: z.string(), + }), + }, }, - description: "Category created successfully" + description: "Category created successfully", }, 400: { - description: "Invalid input data" - } - } + description: "Invalid input data", + }, + }, }, - handler: async (c) => { + handler: async c => { const data = c.req.valid("json"); // [!code highlight] // Simulate category creation const newCategory = { id: `cat_${Date.now()}`, ...data, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), }; return c.json(newCategory, 201); - } + }, }); ``` diff --git a/apps/docs/content/docs/dev/plugins/breadcrumbs.mdx b/apps/docs/content/docs/dev/plugins/breadcrumbs.mdx new file mode 100644 index 000000000..6da813ca8 --- /dev/null +++ b/apps/docs/content/docs/dev/plugins/breadcrumbs.mdx @@ -0,0 +1,171 @@ +--- +title: Breadcrumbs +description: Learn how to add breadcrumbs to your plugin's admin pages for better navigation and user experience. +--- + +VitNode renders dynamic, localized breadcrumbs in two places: + +- **AdminCP** — in the header, next to the sidebar trigger. +- **Main site** — below the header, on the public pages. + +They are built on [Next.js Parallel Routes](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes) (the `@breadcrumb` slot) and work automatically — in most cases you don't have to do anything. + +## AdminCP + +AdminCP breadcrumbs are generated from your **sidebar navigation**. As soon as you add [navigation items](/docs/dev/plugins/admin-page#navigation-items) and their translations, the breadcrumb reuses the same translated titles — there is nothing extra to configure. + +Using the blog navigation from [AdminCP Pages](/docs/dev/plugins/admin-page#navigation-items), visiting `/admin/blog/settings` renders: + +``` +Blog / Settings +``` + + + Labels come from the same translation keys as the sidebar nav. URL segments + that aren't part of the nav (for example a dynamic id) fall back to a + humanized version of the segment (`reset-password` → `Reset Password`). + + +## Main site + +On the public site the breadcrumb is derived from the URL. Each segment is humanized and links to its path, and the home page (`/`) shows no breadcrumb. + +``` +/login -> Login +/account/settings -> Account / Settings +``` + +## Custom breadcrumbs + +Sometimes the automatic label isn't enough — you want a **translated** label for a public page, or a **real name resolved on the server** for a dynamic route (e.g. `/blog/posts/[id]` showing the post title instead of the id). + +For that, a plugin can ship breadcrumb **slots** in `src/routes/breadcrumb`. The folder mirrors the URL and maps to the matching `@breadcrumb` slot: + +- `routes/breadcrumb/admin` → AdminCP routes (path after `/admin`) +- `routes/breadcrumb/main` → public site (path after `/`) + +VitNode copies these into the app automatically and keeps them in sync while you develop — exactly like the `app` / `app_admin` directories. Your slots are namespaced under your plugin, so they never collide with core or other plugins. Core already provides the generic fallback (and the home page), so you only add a slot for the specific route you want to customize. + + + + +### Translated label + +Render a translated label for one of your public pages. Pass a `labels` map of `path → label`; everything else still falls back to the humanized default. + +```tsx title="plugins/{plugin_name}/src/routes/breadcrumb/main/blog/page.tsx" +import { BreadcrumbMain } from '@vitnode/core/views/breadcrumb/breadcrumb-main'; +import { getTranslations } from 'next-intl/server'; + +export default async function BreadcrumbSlot() { + const t = await getTranslations('@vitnode/blog'); + + return ; +} +``` + + + + +### Server-resolved name + +For a dynamic route whose id is the **last** segment, resolve the real name on the server and pass it as `overrideLastLabel` — it replaces the last crumb. The folder mirrors the dynamic segment. + +```tsx title="plugins/{plugin_name}/src/routes/breadcrumb/main/blog/posts/[id]/page.tsx" +import { BreadcrumbMain } from '@vitnode/core/views/breadcrumb/breadcrumb-main'; + +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const post = await getPost(id); // your data fetching + + return ( + + ); +} +``` + + + + +### AdminCP dynamic page + +In AdminCP, use `BreadcrumbAdmin` instead. `segments` are the path after `/admin`, and the labels for known routes come from your sidebar nav automatically — so you usually only need `overrideLastLabel` for the dynamic part. + +```tsx title="plugins/{plugin_name}/src/routes/breadcrumb/admin/blog/posts/[id]/page.tsx" +import { BreadcrumbAdmin } from '@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin'; + +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const post = await getPost(id); + + return ( + + ); +} +``` + + + + +### Name in the middle of the path + +`overrideLastLabel` only replaces the **last** crumb. When the resolved name sits in the **middle** of the path — for example the post in `/blog/posts/[id]/comments` — use the `labels` prop instead, keyed by that crumb's full path. It works at any position (and the labelled crumb becomes a link). `labels` is available on both `BreadcrumbMain` and `BreadcrumbAdmin`. + +```tsx title="plugins/{plugin_name}/src/routes/breadcrumb/main/blog/posts/[id]/comments/page.tsx" +import { BreadcrumbMain } from '@vitnode/core/views/breadcrumb/breadcrumb-main'; + +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const post = await getPost(id); + + // Blog / Posts / / Comments + return ( + + ); +} +``` + + + + + + The folder under `routes/breadcrumb` must mirror the real route path. A slot + only overrides the matching URL — every other route keeps the automatic + breadcrumb. + + +## Learn More + + + + + diff --git a/apps/docs/content/docs/dev/plugins.mdx b/apps/docs/content/docs/dev/plugins/create.mdx similarity index 100% rename from apps/docs/content/docs/dev/plugins.mdx rename to apps/docs/content/docs/dev/plugins/create.mdx diff --git a/apps/docs/content/docs/dev/layouts-and-pages.mdx b/apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx similarity index 100% rename from apps/docs/content/docs/dev/layouts-and-pages.mdx rename to apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx diff --git a/apps/docs/content/docs/dev/plugins/meta.json b/apps/docs/content/docs/dev/plugins/meta.json new file mode 100644 index 000000000..1d72db9d6 --- /dev/null +++ b/apps/docs/content/docs/dev/plugins/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Plugins", + "description": "Make plugins and APIs", + "icon": "Plug", + "defaultOpen": true, + "pages": ["create", "layouts-and-pages", "admin-page", "breadcrumbs", "api", "..."] +} diff --git a/apps/docs/content/docs/dev/working-with-users/meta.json b/apps/docs/content/docs/dev/working-with-users/meta.json index 4f1634a1d..3506e5448 100644 --- a/apps/docs/content/docs/dev/working-with-users/meta.json +++ b/apps/docs/content/docs/dev/working-with-users/meta.json @@ -1,4 +1,6 @@ { "title": "Working with Users", + "description": "Learn how to manage users and roles in VitNode with our comprehensive guide.", + "icon": "Users", "pages": ["users", "roles", "..."] } diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/page.tsx new file mode 100644 index 000000000..4805d6f7b --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/page.tsx @@ -0,0 +1,11 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const t = await getTranslations("core.global"); + + return ( + + ); +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/reset-password/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/reset-password/page.tsx new file mode 100644 index 000000000..aa304276c --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/reset-password/page.tsx @@ -0,0 +1,20 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const [tGlobal, tAuth] = await Promise.all([ + getTranslations("core.global"), + getTranslations("core.auth"), + ]); + + return ( + + ); +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/register/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/register/page.tsx new file mode 100644 index 000000000..f128344a0 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/register/page.tsx @@ -0,0 +1,14 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const t = await getTranslations("core.global"); + + return ( + + ); +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx new file mode 100644 index 000000000..2e3df1f57 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx @@ -0,0 +1,4 @@ +// Fallback for the @breadcrumb slot — also used on the home page (no segments). +export default function Default() { + return null; +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx new file mode 100644 index 000000000..e66aa3f96 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx @@ -0,0 +1,3 @@ +export default function BreadcrumbSlot() { + return null; +} diff --git a/apps/docs/src/app/[locale]/(main)/layout.tsx b/apps/docs/src/app/[locale]/(main)/layout.tsx index d16c65026..87d8bc5d1 100644 --- a/apps/docs/src/app/[locale]/(main)/layout.tsx +++ b/apps/docs/src/app/[locale]/(main)/layout.tsx @@ -13,9 +13,16 @@ import { ThemeLayout } from "@vitnode/core/views/layouts/theme/layout"; import { vitNodeConfig } from "../../../vitnode.config"; -export default function Layout({ children }: { children: React.ReactNode }) { +export default function Layout({ + children, + breadcrumb, +}: { + breadcrumb: React.ReactNode; + children: React.ReactNode; +}) { return ( } vitNodeConfig={vitNodeConfig} > diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx new file mode 100644 index 000000000..7ff4a3a56 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx @@ -0,0 +1,12 @@ +import { BreadcrumbUserAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-user-admin"; + +// Resolves the real user name server-side for the user detail breadcrumb. +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ nameCode: string }>; +}) { + const { nameCode } = await params; + + return ; +} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx new file mode 100644 index 000000000..018f03ca5 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx @@ -0,0 +1,16 @@ +import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-admin"; + +// Generic catch-all breadcrumb for every authenticated admin route (core + +// plugins). More specific slot folders (e.g. core/users/[nameCode]) override it. +// +// NOTE: keep this a required catch-all `[...all]`, not an optional `[[...all]]` — +// an optional catch-all as a parallel-route slot crashes the Next dev server. +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ all?: string[] }>; +}) { + const { all } = await params; + + return ; +} diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx new file mode 100644 index 000000000..050239cf7 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx @@ -0,0 +1,4 @@ +// Fallback for the @breadcrumb slot on unmatched soft-navigation states. +export default function Default() { + return null; +} diff --git a/apps/docs/src/components/animated-beam/animated-beam-home.tsx b/apps/docs/src/components/animated-beam/animated-beam-home.tsx index 1e030d865..a7b2e40d3 100644 --- a/apps/docs/src/components/animated-beam/animated-beam-home.tsx +++ b/apps/docs/src/components/animated-beam/animated-beam-home.tsx @@ -44,9 +44,9 @@ const Circle = ({ return ( - - - + } + /> {tooltip} diff --git a/apps/docs/src/examples/tooltip.tsx b/apps/docs/src/examples/tooltip.tsx index 3ed643b5d..3071d2670 100644 --- a/apps/docs/src/examples/tooltip.tsx +++ b/apps/docs/src/examples/tooltip.tsx @@ -12,9 +12,7 @@ export default function TooltipDemo() { return ( - - - + Hover} />

Add to library

diff --git a/packages/config/eslint.config.mjs b/packages/config/eslint.config.mjs index 87e46ce1b..68d9dc282 100644 --- a/packages/config/eslint.config.mjs +++ b/packages/config/eslint.config.mjs @@ -12,6 +12,8 @@ export default [ "dist", "**/\\(main\\)/\\(plugins\\)/**", "**/\\(auth\\)/\\(plugins\\)/**", + "**/\\(main\\)/@breadcrumb/**", + "**/\\(auth\\)/@breadcrumb/**", ".prettierrc.mjs", "node_modules", "eslint.config.mjs", diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/layout.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/layout.tsx index 85b599a1d..8b23a17fe 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/layout.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/layout.tsx @@ -3,9 +3,16 @@ import { ThemeLayout } from "@vitnode/core/views/layouts/theme/layout"; import { vitNodeConfig } from "../../../vitnode.config"; -export default function Layout({ children }: { children: React.ReactNode }) { +export default function Layout({ + children, + breadcrumb, +}: { + breadcrumb: React.ReactNode; + children: React.ReactNode; +}) { return ( } vitNodeConfig={vitNodeConfig} > diff --git a/packages/vitnode/scripts/plugin.ts b/packages/vitnode/scripts/plugin.ts index 45cf35518..bda643a20 100644 --- a/packages/vitnode/scripts/plugin.ts +++ b/packages/vitnode/scripts/plugin.ts @@ -88,6 +88,31 @@ const collectSources = ( sourceDir: join(pluginDir, "src", "locales"), destinationDir: join(appPath, "src", "locales", pluginName), }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "admin"), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "admin", + "(auth)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "main"), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, ); } else if (appType === "api") { sources.push({ @@ -129,6 +154,31 @@ const collectSources = ( sourceDir: join(pluginDir, "src", "locales"), destinationDir: join(cwd, "src", "locales", pluginName), }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "admin"), + destinationDir: join( + cwd, + "src", + "app", + "[locale]", + "admin", + "(auth)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "main"), + destinationDir: join( + cwd, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, ); } else if (projectType === "api") { sources.push({ diff --git a/packages/vitnode/scripts/prepare-plugins-files.ts b/packages/vitnode/scripts/prepare-plugins-files.ts index f7768bbd3..571dc000a 100644 --- a/packages/vitnode/scripts/prepare-plugins-files.ts +++ b/packages/vitnode/scripts/prepare-plugins-files.ts @@ -172,6 +172,46 @@ export const preparePluginsFiles = async (flag?: string) => { sourceDir: join(pluginPath, "src", "locales"), destinationDir: langDest, }, + // Breadcrumb parallel-route slots ship as framework routes and copy + // into the `@breadcrumb` slot, namespaced per plugin under + // `(plugins)/()` like every other copied route. + { + sourceDir: join( + pluginPath, + "src", + "routes", + "breadcrumb", + "admin", + ), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "admin", + "(auth)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, + { + sourceDir: join( + pluginPath, + "src", + "routes", + "breadcrumb", + "main", + ), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, ); } else if (appType === "api") { // API app: copy only locales @@ -217,6 +257,31 @@ export const preparePluginsFiles = async (flag?: string) => { sourceDir: join(pluginPath, "src", "locales"), destinationDir: langDest, }, + { + sourceDir: join(pluginPath, "src", "routes", "breadcrumb", "admin"), + destinationDir: join( + baseDir, + "src", + "app", + "[locale]", + "admin", + "(auth)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, + { + sourceDir: join(pluginPath, "src", "routes", "breadcrumb", "main"), + destinationDir: join( + baseDir, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + join("(plugins)", `(${pluginPathName})`), + ), + }, ); } diff --git a/packages/vitnode/src/routes/breadcrumb/admin/core/users/[nameCode]/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/core/users/[nameCode]/page.tsx new file mode 100644 index 000000000..abd4b0d7b --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/admin/core/users/[nameCode]/page.tsx @@ -0,0 +1,12 @@ +import { BreadcrumbUserAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-user-admin"; + +// Resolves the real user name server-side for the user detail breadcrumb. +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ nameCode: string }>; +}) { + const { nameCode } = await params; + + return ; +} diff --git a/packages/vitnode/src/routes/breadcrumb/main/login/page.tsx b/packages/vitnode/src/routes/breadcrumb/main/login/page.tsx new file mode 100644 index 000000000..4e002bfcd --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/main/login/page.tsx @@ -0,0 +1,11 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const t = await getTranslations("core.global"); + + return ( + + ); +} diff --git a/packages/vitnode/src/routes/breadcrumb/main/login/reset-password/page.tsx b/packages/vitnode/src/routes/breadcrumb/main/login/reset-password/page.tsx new file mode 100644 index 000000000..10f8048f0 --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/main/login/reset-password/page.tsx @@ -0,0 +1,20 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const [tGlobal, tAuth] = await Promise.all([ + getTranslations("core.global"), + getTranslations("core.auth"), + ]); + + return ( + + ); +} diff --git a/packages/vitnode/src/routes/breadcrumb/main/register/page.tsx b/packages/vitnode/src/routes/breadcrumb/main/register/page.tsx new file mode 100644 index 000000000..baf71d15d --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/main/register/page.tsx @@ -0,0 +1,14 @@ +import { getTranslations } from "next-intl/server"; + +import { BreadcrumbMain } from "@/views/breadcrumb/breadcrumb-main"; + +export default async function BreadcrumbSlot() { + const t = await getTranslations("core.global"); + + return ( + + ); +} diff --git a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx index b109a81f3..9f35d987a 100644 --- a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx +++ b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx @@ -1,7 +1,7 @@ -import { getTranslations } from "next-intl/server"; import { cookies } from "next/headers"; import { ThemeSwitcher } from "@/components/switchers/themes/theme-switcher"; +import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, @@ -10,7 +10,6 @@ import { import { getSessionAdminApi } from "@/lib/api/get-session-admin-api"; import type { VitNodeConfig } from "../../../vitnode.config"; -import type { NavAdminParent } from "./sidebar/nav/nav"; import { I18nProvider } from "../../../components/i18n-provider"; import { LanguageSwitcher } from "../../../components/switchers/langs/language-switcher"; @@ -18,16 +17,18 @@ import { SidebarAdmin } from "./sidebar/sidebar"; import { UserBarAdmin } from "./user-bar/user-bar"; export interface AdminLayoutProps { + /** `@breadcrumb` parallel-route slot rendered in the header. */ + breadcrumb?: React.ReactNode; children: React.ReactNode; } export const AdminLayout = async ({ children, + breadcrumb, vitNodeConfig, }: AdminLayoutProps & { vitNodeConfig: VitNodeConfig; }) => { - const t = await getTranslations(); const session = await getSessionAdminApi(); const cookieStore = await cookies(); const defaultOpen = @@ -35,35 +36,19 @@ export const AdminLayout = async ({ cookieStore.get("vitnode_admin_sidebar_state")?.value === "true"; if (!session) return null; - const pluginNav: NavAdminParent[] = vitNodeConfig.plugins - .filter(plugin => plugin.admin?.nav) - .map(plugin => ({ - id: plugin.pluginId, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - title: t(`${plugin.pluginId}.title`), - items: (plugin.admin?.nav ?? []).map(item => ({ - ...item, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - title: t(`${plugin.pluginId}.admin.nav.${item.id}`), - items: - item.items?.map(subItem => ({ - ...subItem, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - title: t(`${plugin.pluginId}.admin.nav.${item.id}.${subItem.id}`), - })) ?? [], - })), - })); - return ( - +
+ {breadcrumb != null && ( + <> + + {breadcrumb} + + )}
{vitNodeConfig.i18n.locales.length > 1 && ( diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx new file mode 100644 index 000000000..447b59352 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-admin.tsx @@ -0,0 +1,55 @@ +import type { VitNodeConfig } from "@/vitnode.config"; + +import { BreadcrumbRender } from "@/views/breadcrumb/breadcrumb-render"; + +import type { NavAdminParent } from "../sidebar/nav/get-admin-nav"; + +import { getAdminNav } from "../sidebar/nav/get-admin-nav"; +import { resolveBreadcrumb } from "./resolve-breadcrumb"; + +export interface BreadcrumbAdminProps { + /** + * Overrides crumb labels by their full path (e.g. `/admin/blog/posts/123`), + * at any position. Use this for a server-resolved name that isn't the last + * segment; a labelled crumb also becomes a link (unless it's the current page). + */ + labels?: Record; + /** Pre-built nav to reuse; rebuilt from `vitNodeConfig` when omitted. */ + nav?: NavAdminParent[]; + /** Shortcut to override the label of the last (current) crumb. */ + overrideLastLabel?: string; + /** Path segments after `/admin`, e.g. `["core", "users", "roles"]`. */ + segments: string[]; + /** Defaults to the registered app config when omitted. */ + vitNodeConfig?: VitNodeConfig; +} + +export const BreadcrumbAdmin = async ({ + segments, + vitNodeConfig, + overrideLastLabel, + labels, + nav, +}: BreadcrumbAdminProps) => { + const crumbs = resolveBreadcrumb( + nav ?? (await getAdminNav({ vitNodeConfig })), + segments, + ); + + if (labels) { + for (const crumb of crumbs) { + const label = labels[crumb.href]; + if (label !== undefined) { + crumb.label = label; + // An explicit label means a real, navigable page — link it. + crumb.isLink = !crumb.isCurrent; + } + } + } + + if (overrideLastLabel && crumbs.length > 0) { + crumbs[crumbs.length - 1].label = overrideLastLabel; + } + + return ; +}; diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-user-admin.tsx b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-user-admin.tsx new file mode 100644 index 000000000..9c43aef9b --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/breadcrumb-user-admin.tsx @@ -0,0 +1,55 @@ +import type { VitNodeConfig } from "@/vitnode.config"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +import { getAdminNav } from "../sidebar/nav/get-admin-nav"; +import { BreadcrumbAdmin } from "./breadcrumb-admin"; +import { resolveBreadcrumb } from "./resolve-breadcrumb"; + +/** + * Breadcrumb for the user detail page (`/admin/core/users/`). Resolves + * the real user name server-side so the last crumb shows the name instead of the + * raw url segment — the data-resolution showcase of the parallel-routes pattern. + */ +export const BreadcrumbUserAdmin = async ({ + nameCode, + vitNodeConfig, +}: { + nameCode: string; + /** Defaults to the registered app config when omitted. */ + vitNodeConfig?: VitNodeConfig; +}) => { + const segments = ["core", "users", nameCode]; + const nav = await getAdminNav({ vitNodeConfig }); + + // Static sibling routes (e.g. `/users/roles`) also match this dynamic slot — + // only look up a user when the segment isn't already a known nav route. + const isKnownRoute = + resolveBreadcrumb(nav, segments).at(-1)?.isKnown ?? false; + + let overrideLastLabel: string | undefined; + if (!isKnownRoute) { + const res = await fetcher(adminModule, { + path: "/{nameCode}", + method: "get", + module: "admin/users", + args: { + params: { nameCode }, + }, + }); + + if (res.ok) { + overrideLastLabel = (await res.json()).name; + } + } + + return ( + + ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts new file mode 100644 index 000000000..0051eaca3 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts @@ -0,0 +1,60 @@ +import { type BreadcrumbCrumb, humanize } from "@/views/breadcrumb/crumb"; + +import type { NavAdminParent } from "../sidebar/nav/get-admin-nav"; + +export type { BreadcrumbCrumb }; + +// Mirror the trailing-slash normalization used by the sidebar nav so hrefs like +// "/admin/core/" and "/admin/core" resolve to the same key. +const normalizeUrl = (url: string): string => + url.endsWith("/") && url.length > 1 ? url.slice(0, -1) : url; + +// Flatten the nav tree into a `normalizedHref -> translated title` lookup, +// including nested sub-items. First writer wins, so a parent item keeps its +// label when a sub-item points at the same href (e.g. "Users" vs "User List" +// both at /admin/core/users) — better breadcrumb hierarchy. +const flattenNav = (nav: NavAdminParent[]): Map => { + const labels = new Map(); + const setIfAbsent = (href: string, title: string) => { + const key = normalizeUrl(href); + if (!labels.has(key)) labels.set(key, title); + }; + + for (const parent of nav) { + for (const item of parent.items) { + setIfAbsent(item.href, item.title); + + for (const subItem of item.items ?? []) { + setIfAbsent(subItem.href, subItem.title); + } + } + } + + return labels; +}; + +/** + * Turns the path segments after `/admin` into breadcrumb crumbs, resolving each + * cumulative path against the already-translated admin nav. Segments without a + * nav match fall back to a humanized label and render as plain text. + */ +export const resolveBreadcrumb = ( + nav: NavAdminParent[], + segments: string[], +): BreadcrumbCrumb[] => { + const labels = flattenNav(nav); + + return segments.map((segment, index) => { + const href = `/admin/${segments.slice(0, index + 1).join("/")}`; + const known = labels.get(normalizeUrl(href)); + const isCurrent = index === segments.length - 1; + + return { + href, + isCurrent, + isKnown: known !== undefined, + isLink: known !== undefined && !isCurrent, + label: known ?? humanize(segment), + }; + }); +}; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx new file mode 100644 index 000000000..9577f45cc --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx @@ -0,0 +1,98 @@ +import { LayoutDashboardIcon, UsersRoundIcon, WrenchIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; + +import type { VitNodeConfig } from "@/vitnode.config"; + +import { getVitNodeConfig } from "@/vitnode.config"; + +import type { ItemNavAdmin } from "./item"; + +export interface NavAdminParent { + id: string; + items: React.ComponentProps[]; + title: string; +} + +/** + * Builds the full, already-translated admin navigation tree (core + every + * plugin). Shared between the sidebar ({@link NavSidebarAdmin}) and the + * breadcrumb ({@link BreadcrumbAdmin}) so labels stay consistent and + * plugin-aware without per-route configuration. + * + * `vitNodeConfig` defaults to the registered app config, so framework-owned + * route files (the copied `@breadcrumb` slots) can call it without a prop. + */ +export const getAdminNav = async ({ + vitNodeConfig = getVitNodeConfig(), +}: { + vitNodeConfig?: VitNodeConfig; +} = {}): Promise => { + const t = await getTranslations(); + + const core: NavAdminParent = { + id: "core", + title: t("admin.global.nav.core"), + items: [ + { + href: "/admin/core/", + icon: , + title: t("admin.global.nav.dashboard"), + }, + { + title: "test", + icon: , + href: "/admin/core/test", + }, + { + href: "/admin/core/users", + title: t("admin.global.nav.users.title"), + icon: , + items: [ + { + title: t("admin.global.nav.users.list"), + href: "/admin/core/users", + }, + { + title: t("admin.global.nav.users.roles"), + href: "/admin/core/users/roles", + }, + ], + }, + { + href: "/admin/core/advanced", + title: t("admin.global.nav.advanced.title"), + icon: , + items: [ + { + title: t("admin.global.nav.advanced.cron"), + href: "/admin/core/advanced/cron", + }, + ], + }, + ], + }; + + const pluginNav: NavAdminParent[] = vitNodeConfig.plugins + .filter(plugin => plugin.admin?.nav) + .map(plugin => ({ + id: plugin.pluginId, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + title: t(`${plugin.pluginId}.title`), + items: (plugin.admin?.nav ?? []).map(item => ({ + ...item, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + title: t(`${plugin.pluginId}.admin.nav.${item.id}`), + items: + item.items?.map(subItem => ({ + ...subItem, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + title: t(`${plugin.pluginId}.admin.nav.${item.id}.${subItem.id}`), + })) ?? [], + })), + })); + + return [core, ...pluginNav]; +}; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx index b85492c46..fbb33c51a 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx @@ -1,5 +1,4 @@ -import { LayoutDashboardIcon, UsersRoundIcon, WrenchIcon } from "lucide-react"; -import { getTranslations } from "next-intl/server"; +import type { VitNodeConfig } from "@/vitnode.config"; import { SidebarGroup, @@ -7,60 +6,17 @@ import { SidebarMenu, } from "@/components/ui/sidebar"; +import { getAdminNav } from "./get-admin-nav"; import { ItemNavAdmin } from "./item"; -export interface NavAdminParent { - id: string; - items: React.ComponentProps[]; - title: string; -} +export type { NavAdminParent } from "./get-admin-nav"; export const NavSidebarAdmin = async ({ - pluginNav, + vitNodeConfig, }: { - pluginNav: NavAdminParent[]; + vitNodeConfig: VitNodeConfig; }) => { - const t = await getTranslations("admin.global.nav"); - const rootItems: NavAdminParent[] = [ - { - id: "core", - title: t("core"), - items: [ - { - href: "/admin/core/", - icon: , - title: t("dashboard"), - }, - { - href: "/admin/core/users", - title: t("users.title"), - icon: , - items: [ - { - title: t("users.list"), - href: "/admin/core/users", - }, - { - title: t("users.roles"), - href: "/admin/core/users/roles", - }, - ], - }, - { - href: "/admin/core/advanced", - title: t("advanced.title"), - icon: , - items: [ - { - title: t("advanced.cron"), - href: "/admin/core/advanced/cron", - }, - ], - }, - ], - }, - ...pluginNav, - ]; + const rootItems = await getAdminNav({ vitNodeConfig }); return rootItems.map(parent => ( diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx index e01800c01..099018c6c 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/sidebar.tsx @@ -9,7 +9,7 @@ import { Link } from "@/lib/navigation"; import { NavSidebarAdmin } from "./nav/nav"; export const SidebarAdmin = ({ - pluginNav, + vitNodeConfig, }: React.ComponentProps) => { return ( @@ -19,7 +19,7 @@ export const SidebarAdmin = ({ - + ); diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx new file mode 100644 index 000000000..0c7a8f504 --- /dev/null +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx @@ -0,0 +1,32 @@ +import { BreadcrumbRender } from "./breadcrumb-render"; +import { resolveMainBreadcrumb } from "./resolve-main-breadcrumb"; + +export interface BreadcrumbMainProps { + /** Cumulative href → translated label, for known public pages. */ + labels?: Record; + /** Overrides the label of the last (current) crumb. */ + overrideLastLabel?: string; + /** Path segments after `/`, e.g. `["login", "reset-password"]`. */ + segments: string[]; +} + +export const BreadcrumbMain = ({ + segments, + labels, + overrideLastLabel, +}: BreadcrumbMainProps) => { + const crumbs = resolveMainBreadcrumb(segments, labels); + + // No breadcrumb on the home page (no segments). + if (crumbs.length === 0) return null; + + if (overrideLastLabel) { + crumbs[crumbs.length - 1].label = overrideLastLabel; + } + + return ( +
+ +
+ ); +}; diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx new file mode 100644 index 000000000..c31205fc8 --- /dev/null +++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx @@ -0,0 +1,44 @@ +import { Fragment } from "react"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Link } from "@/lib/navigation"; + +import type { BreadcrumbCrumb } from "./crumb"; + +/** + * Shared presentational renderer for a resolved list of breadcrumb crumbs. + * Used by both the AdminCP and the main-site breadcrumbs. + */ +export const BreadcrumbRender = ({ crumbs }: { crumbs: BreadcrumbCrumb[] }) => { + if (crumbs.length === 0) return null; + + return ( + + + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + {crumb.isCurrent ? ( + {crumb.label} + ) : crumb.isLink ? ( + + {crumb.label} + + ) : ( + {crumb.label} + )} + + + ))} + + + ); +}; diff --git a/packages/vitnode/src/views/breadcrumb/crumb.ts b/packages/vitnode/src/views/breadcrumb/crumb.ts new file mode 100644 index 000000000..890933c07 --- /dev/null +++ b/packages/vitnode/src/views/breadcrumb/crumb.ts @@ -0,0 +1,17 @@ +export interface BreadcrumbCrumb { + href: string; + isCurrent: boolean; + /** Whether this crumb matched a known route (vs. a humanized fallback). */ + isKnown: boolean; + isLink: boolean; + label: string; +} + +/** + * Fallback label for path segments without an explicit label + * (e.g. dev-only routes or dynamic resource ids): "reset-password" → "Reset Password". + */ +export const humanize = (segment: string): string => + decodeURIComponent(segment) + .replace(/[-_]/g, " ") + .replace(/\b\w/g, char => char.toUpperCase()); diff --git a/packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts b/packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts new file mode 100644 index 000000000..78b47c276 --- /dev/null +++ b/packages/vitnode/src/views/breadcrumb/resolve-main-breadcrumb.ts @@ -0,0 +1,27 @@ +import type { BreadcrumbCrumb } from "./crumb"; + +import { humanize } from "./crumb"; + +/** + * Turns the path segments after `/` into breadcrumb crumbs for the public site. + * Unlike the AdminCP nav-based resolver, every non-current segment links to its + * own path, and labels come from the optional `labels` map (cumulative href → + * translated label) with a humanized fallback. + */ +export const resolveMainBreadcrumb = ( + segments: string[], + labels: Record = {}, +): BreadcrumbCrumb[] => + segments.map((segment, index) => { + const href = `/${segments.slice(0, index + 1).join("/")}`; + const isCurrent = index === segments.length - 1; + const known = labels[href]; + + return { + href, + isCurrent, + isKnown: known !== undefined, + isLink: !isCurrent, + label: known ?? humanize(segment), + }; + }); diff --git a/packages/vitnode/src/views/layouts/theme/layout.tsx b/packages/vitnode/src/views/layouts/theme/layout.tsx index ccff32d13..0071b76b9 100644 --- a/packages/vitnode/src/views/layouts/theme/layout.tsx +++ b/packages/vitnode/src/views/layouts/theme/layout.tsx @@ -10,7 +10,9 @@ export const ThemeLayout = async ({ children, logo, vitNodeConfig, + breadcrumb, }: React.ComponentProps & { + breadcrumb?: React.ReactNode; children: React.ReactNode; vitNodeConfig: VitNodeConfig; }) => { @@ -21,7 +23,8 @@ export const ThemeLayout = async ({ <> - {" "} + + {breadcrumb}
{children}
); diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index ac62f308e..8c79be538 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -67,18 +67,41 @@ export interface VitNodeApiConfig { rateLimiter?: Omit; } +let registeredVitNodeConfig: undefined | VitNodeConfig; + export function buildConfig( args: VitNodeConfig, ): VitNodeConfig { - return { + const config = { ...args, i18n: { ...args.i18n, localePrefix: args.i18n.localePrefix ?? "as-needed", }, }; + + // Register the app config so framework-owned route files (e.g. the @breadcrumb + // slots copied into apps) can read it without prop-drilling. + registeredVitNodeConfig = config; + + return config; } +/** + * Returns the app's VitNodeConfig registered by {@link buildConfig} (called once + * in the app's `vitnode.config.ts`). Used by framework route files that need the + * config but aren't passed it as a prop. + */ +export const getVitNodeConfig = (): VitNodeConfig => { + if (!registeredVitNodeConfig) { + throw new Error( + "VitNode config not initialized — ensure `buildConfig` runs in your vitnode.config.ts.", + ); + } + + return registeredVitNodeConfig; +}; + export function buildApiConfig(args: VitNodeApiConfig): VitNodeApiConfig { return args; } diff --git a/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx b/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx index a79303ea1..dc5325d2f 100644 --- a/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx +++ b/plugins/blog/src/views/admin/categories/table/actions/delete/delete-action.tsx @@ -45,11 +45,13 @@ export const DeleteAction = ({ title, id }: { id: number; title: string }) => { textSubmit={t("confirm")} title={t("title")} > - - - + + + + } + /> {t("title")} diff --git a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx index ef24cfd58..d6841f743 100644 --- a/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx +++ b/plugins/blog/src/views/admin/categories/table/actions/edit-action.tsx @@ -36,13 +36,15 @@ export const EditAction = ( - - - - - + + + + } + /> {t("title")} diff --git a/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx b/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx index 58b2a901f..ed9a12689 100644 --- a/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx +++ b/plugins/blog/src/views/admin/posts/table/actions/delete/delete-action.tsx @@ -45,11 +45,13 @@ export const DeleteAction = ({ title, id }: { id: number; title: string }) => { textSubmit={t("confirm")} title={t("title")} > - - - + + + + } + /> {t("title")} diff --git a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx b/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx index 117ae1232..9923e950f 100644 --- a/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx +++ b/plugins/blog/src/views/admin/posts/table/actions/edit-action.tsx @@ -36,13 +36,15 @@ export const EditAction = ( - - - - - + + + + } + /> {t("title")} From be0bb542aa279f05a17a64468e62f907a8bdc51d Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 17 Jun 2026 16:45:17 +0200 Subject: [PATCH 2/3] feat: Move pages to routes folder --- .../content/docs/dev/plugins/admin-page.mdx | 28 +-- .../content/docs/dev/plugins/breadcrumbs.mdx | 2 +- .../docs/dev/plugins/layouts-and-pages.mdx | 168 ++++++++++++------ .../(plugins)/(vitnode-core)/test/page.tsx | 11 ++ .../(main)/@breadcrumb/[...rest]/page.tsx | 17 ++ .../[locale]/(main)/@breadcrumb/default.tsx | 2 +- .../(vitnode-core) => }/login/page.tsx | 0 .../login/reset-password/page.tsx | 0 .../app/[locale]/(main)/@breadcrumb/page.tsx | 3 + .../(vitnode-core) => }/register/page.tsx | 0 .../(auth)/@breadcrumb/[...all]/page.tsx | 5 +- .../core/users/[nameCode]/page.tsx | 0 .../admin/(auth)/@breadcrumb/default.tsx | 2 +- packages/config/eslint.config.mjs | 1 + .../[locale]/(main)/@breadcrumb/default.tsx | 6 + .../app/[locale]/(main)/@breadcrumb/page.tsx | 5 + packages/vitnode/scripts/plugin.ts | 34 +++- .../vitnode/scripts/prepare-plugins-files.ts | 38 +++- packages/vitnode/src/app_admin/core/page.tsx | 5 - .../vitnode/src/app_admin/core/test/page.tsx | 5 - .../admin}/core/advanced/cron/page.tsx | 0 .../admin}/core/debug/page.tsx | 0 .../vitnode/src/routes/admin/core/page.tsx | 5 + .../src/routes/admin/core/test/page.tsx | 5 + .../admin}/core/users/[nameCode]/page.tsx | 0 .../admin}/core/users/page.tsx | 0 .../admin}/core/users/roles/page.tsx | 0 .../vitnode/src/routes/blank/test/page.tsx | 11 ++ .../routes/breadcrumb/admin/[...all]/page.tsx | 17 ++ .../src/routes/breadcrumb/admin/default.tsx | 4 + .../routes/breadcrumb/main/[...rest]/page.tsx | 17 ++ .../src/routes/breadcrumb/main/default.tsx | 4 + .../src/routes/breadcrumb/main/page.tsx | 6 + .../src/{app => routes/main}/login/page.tsx | 2 +- .../main}/login/reset-password/page.tsx | 0 .../main}/login/sso/[providerId]/page.tsx | 0 .../{app => routes/main}/register/page.tsx | 2 +- .../admin}/blog/categories/page.tsx | 0 .../admin}/blog/posts/page.tsx | 0 39 files changed, 307 insertions(+), 98 deletions(-) create mode 100644 apps/docs/src/app/[locale]/(blank)/(plugins)/(vitnode-core)/test/page.tsx create mode 100644 apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx rename apps/docs/src/app/[locale]/(main)/@breadcrumb/{(plugins)/(vitnode-core) => }/login/page.tsx (100%) rename apps/docs/src/app/[locale]/(main)/@breadcrumb/{(plugins)/(vitnode-core) => }/login/reset-password/page.tsx (100%) rename apps/docs/src/app/[locale]/(main)/@breadcrumb/{(plugins)/(vitnode-core) => }/register/page.tsx (100%) rename apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/{(plugins)/(vitnode-core) => }/core/users/[nameCode]/page.tsx (100%) create mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/default.tsx create mode 100644 packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/page.tsx delete mode 100644 packages/vitnode/src/app_admin/core/page.tsx delete mode 100644 packages/vitnode/src/app_admin/core/test/page.tsx rename packages/vitnode/src/{app_admin => routes/admin}/core/advanced/cron/page.tsx (100%) rename packages/vitnode/src/{app_admin => routes/admin}/core/debug/page.tsx (100%) create mode 100644 packages/vitnode/src/routes/admin/core/page.tsx create mode 100644 packages/vitnode/src/routes/admin/core/test/page.tsx rename packages/vitnode/src/{app_admin => routes/admin}/core/users/[nameCode]/page.tsx (100%) rename packages/vitnode/src/{app_admin => routes/admin}/core/users/page.tsx (100%) rename packages/vitnode/src/{app_admin => routes/admin}/core/users/roles/page.tsx (100%) create mode 100644 packages/vitnode/src/routes/blank/test/page.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/admin/[...all]/page.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/admin/default.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/main/[...rest]/page.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/main/default.tsx create mode 100644 packages/vitnode/src/routes/breadcrumb/main/page.tsx rename packages/vitnode/src/{app => routes/main}/login/page.tsx (86%) rename packages/vitnode/src/{app => routes/main}/login/reset-password/page.tsx (100%) rename packages/vitnode/src/{app => routes/main}/login/sso/[providerId]/page.tsx (100%) rename packages/vitnode/src/{app => routes/main}/register/page.tsx (86%) rename plugins/blog/src/{app_admin => routes/admin}/blog/categories/page.tsx (100%) rename plugins/blog/src/{app_admin => routes/admin}/blog/posts/page.tsx (100%) diff --git a/apps/docs/content/docs/dev/plugins/admin-page.mdx b/apps/docs/content/docs/dev/plugins/admin-page.mdx index af14d1df8..57b346ad5 100644 --- a/apps/docs/content/docs/dev/plugins/admin-page.mdx +++ b/apps/docs/content/docs/dev/plugins/admin-page.mdx @@ -5,16 +5,20 @@ description: Create powerful admin interfaces for your plugins with protected pa ## Pages & Layouts -AdminCP follows the same patterns like from [Layouts and Pages](/docs/plugins/layouts-and-pages) but uses the `src/app_admin` directory. +AdminCP follows the same patterns like from [Layouts and Pages](/docs/dev/plugins/layouts-and-pages) but uses the `src/routes/admin` directory. Like everywhere in VitNode, render text with translations — see [Messages](/docs/dev/i18n/messages). ### Pages -```tsx title="plugins/{plugin_name}/src/app_admin/blog/settings/page.tsx" -export default function Page() { +```tsx title="plugins/{plugin_name}/src/routes/admin/blog/settings/page.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function Page() { + const t = await getTranslations('@vitnode/blog.admin.settings'); // [!code highlight] + return (
-

Settings

-

Hello from Blog plugin in AdminCP

+

{t('title')}

+

{t('desc')}

); } @@ -22,16 +26,20 @@ export default function Page() { ### Layouts -```tsx title="plugins/{plugin_name}/src/app_admin/blog/settings/layout.tsx" -export default function AdminRootLayout({ +```tsx title="plugins/{plugin_name}/src/routes/admin/blog/settings/layout.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function AdminRootLayout({ children, }: { children: React.ReactNode; }) { + const t = await getTranslations('@vitnode/blog.admin.settings'); + return (
-

Blog Admin

+

{t('title')}

{children}
@@ -40,8 +48,8 @@ export default function AdminRootLayout({ ``` - All pages & layouts in the `app_admin` directory are automatically protected - and require admin authentication. + All pages & layouts in the `src/routes/admin` directory are automatically + protected and require admin authentication. ## Navigation items diff --git a/apps/docs/content/docs/dev/plugins/breadcrumbs.mdx b/apps/docs/content/docs/dev/plugins/breadcrumbs.mdx index 6da813ca8..99fe87ac2 100644 --- a/apps/docs/content/docs/dev/plugins/breadcrumbs.mdx +++ b/apps/docs/content/docs/dev/plugins/breadcrumbs.mdx @@ -44,7 +44,7 @@ For that, a plugin can ship breadcrumb **slots** in `src/routes/breadcrumb`. The - `routes/breadcrumb/admin` → AdminCP routes (path after `/admin`) - `routes/breadcrumb/main` → public site (path after `/`) -VitNode copies these into the app automatically and keeps them in sync while you develop — exactly like the `app` / `app_admin` directories. Your slots are namespaced under your plugin, so they never collide with core or other plugins. Core already provides the generic fallback (and the home page), so you only add a slot for the specific route you want to customize. +VitNode copies these into the app automatically and keeps them in sync while you develop — exactly like the `src/routes/main` / `src/routes/admin` directories. Your slots are namespaced under your plugin, so they never collide with core or other plugins. Core already provides the generic fallback (and the home page), so you only add a slot for the specific route you want to customize. diff --git a/apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx b/apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx index c87631a70..5a84e1988 100644 --- a/apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx +++ b/apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx @@ -6,8 +6,15 @@ description: Create beautiful layouts and pages in VitNode plugins using Next.js Building layouts and pages in VitNode is just like Next.js - if you know Next.js, you're already a VitNode pro! Think of pages as rooms and layouts as the house structure that holds everything together. - VitNode automatically copies files from your plugin's `app` directory to the - main application. No extra setup needed! + VitNode automatically copies files from your plugin's `src/routes/main` + directory to the main application. No extra setup needed! + + + + VitNode is **i18n-first** — render all user-facing text with translations + instead of hardcoded strings: `getTranslations` in Server Components and + `useTranslations` in Client Components. See [Messages](/docs/dev/i18n/messages) + and [Namespaces](/docs/dev/i18n/namespaces). ## Creating Pages @@ -16,27 +23,50 @@ Pages are React components that live in `page.tsx` files. Each page represents a ### Basic Page -```tsx title="plugins/blog/src/app/page.tsx" -export default function BlogHomePage() { +Resolve text from your plugin's namespace with `getTranslations`: + +```tsx title="plugins/blog/src/routes/main/page.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function BlogHomePage() { + const t = await getTranslations('@vitnode/blog'); // [!code highlight] + return (
-

Welcome to Our Blog! 🚀

-

Where awesome content lives

+

{t('home.title')}

+

{t('home.desc')}

); } ``` +Add the messages under your plugin's namespace: + +```json title="plugins/blog/src/locales/en.json" +{ + "@vitnode/blog": { + "home": { + "title": "Welcome to Our Blog! 🚀", + "desc": "Where awesome content lives" + } + } +} +``` + ### Nested Routes Create folders to organize your routes: -```tsx title="plugins/blog/src/app/dashboard/page.tsx" -export default function DashboardPage() { +```tsx title="plugins/blog/src/routes/main/dashboard/page.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function DashboardPage() { + const t = await getTranslations('@vitnode/blog'); + return (
-

Dashboard

-

Manage your blog content here

+

{t('dashboard.title')}

+

{t('dashboard.desc')}

); } @@ -46,20 +76,24 @@ This creates `/dashboard` route. ### Dynamic Routes -Use `[param]` for dynamic URLs: +Use `[param]` for dynamic URLs. Pass dynamic values straight into the message: + +```tsx title="plugins/blog/src/routes/main/posts/[slug]/page.tsx" +import { getTranslations } from 'next-intl/server'; -```tsx title="plugins/blog/src/app/posts/[slug]/page.tsx" interface PostPageProps { params: Promise<{ slug: string }>; } export default async function PostPage({ params }: PostPageProps) { const { slug } = await params; + const t = await getTranslations('@vitnode/blog'); + // en.json: "post": { "title": "Post: {slug}" } return (
-

Post: {slug}

-

Your post content goes here

+

{t('post.title', { slug })}

+

{t('post.desc')}

); } @@ -71,24 +105,23 @@ Layouts wrap your pages and provide shared UI elements like headers, navigation, ### Basic Layout -```tsx title="plugins/blog/src/app/layout.tsx" -import { Metadata } from 'next'; - -export const metadata: Metadata = { - title: 'My Blog Plugin', - description: 'A fantastic blog plugin for VitNode', -}; +```tsx title="plugins/blog/src/routes/main/layout.tsx" +import { getTranslations } from 'next-intl/server'; -export default function BlogLayout({ +export default async function BlogLayout({ children, }: { children: React.ReactNode; }) { + const t = await getTranslations('@vitnode/blog'); + return (
-

✨ My Blog

+

+ {t('layout.title')} +

@@ -96,7 +129,7 @@ export default function BlogLayout({
-

Made with ❤️ using VitNode

+

{t('layout.footer')}

@@ -106,25 +139,26 @@ export default function BlogLayout({ ### Nested Layout for Specific Sections -```tsx title="plugins/blog/src/app/dashboard/layout.tsx" -export default function DashboardLayout({ +```tsx title="plugins/blog/src/routes/main/dashboard/layout.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { + const t = await getTranslations('@vitnode/blog'); + return (
{/* Sidebar */}
+ ); +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx new file mode 100644 index 000000000..4d61cbd1a --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/[...rest]/page.tsx @@ -0,0 +1,17 @@ +import { BreadcrumbMain } from "@vitnode/core/views/breadcrumb/breadcrumb-main"; + +// Generic catch-all breadcrumb for public pages: humanizes URL segments. +// Specific slot folders (login, register, …) override it with translated labels. +// +// NOTE: must live at the `@breadcrumb` slot ROOT (not under a `(plugins)` route +// group) — a parallel-route slot only matches `children` sharing its route-group +// structure, so a nested catch-all would miss pages outside that group. +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ rest?: string[] }>; +}) { + const { rest } = await params; + + return ; +} diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx index 2e3df1f57..4c84c0634 100644 --- a/apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/default.tsx @@ -1,4 +1,4 @@ -// Fallback for the @breadcrumb slot — also used on the home page (no segments). +// Fallback for the @breadcrumb slot on unmatched soft-navigation/hard loads. export default function Default() { return null; } diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/login/page.tsx similarity index 100% rename from apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/page.tsx rename to apps/docs/src/app/[locale]/(main)/@breadcrumb/login/page.tsx diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/reset-password/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/login/reset-password/page.tsx similarity index 100% rename from apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/login/reset-password/page.tsx rename to apps/docs/src/app/[locale]/(main)/@breadcrumb/login/reset-password/page.tsx diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx index e66aa3f96..6ab2ce8a2 100644 --- a/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx @@ -1,3 +1,6 @@ +// Home page (/) has no breadcrumb. An explicit slot page (not just default.tsx) +// is required so client-side navigation back to "/" clears a previously-rendered +// breadcrumb — otherwise Next.js keeps the slot's stale active state on soft nav. export default function BreadcrumbSlot() { return null; } diff --git a/apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/register/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/register/page.tsx similarity index 100% rename from apps/docs/src/app/[locale]/(main)/@breadcrumb/(plugins)/(vitnode-core)/register/page.tsx rename to apps/docs/src/app/[locale]/(main)/@breadcrumb/register/page.tsx diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx index 018f03ca5..fde75805c 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx @@ -3,8 +3,9 @@ import { BreadcrumbAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/br // Generic catch-all breadcrumb for every authenticated admin route (core + // plugins). More specific slot folders (e.g. core/users/[nameCode]) override it. // -// NOTE: keep this a required catch-all `[...all]`, not an optional `[[...all]]` — -// an optional catch-all as a parallel-route slot crashes the Next dev server. +// NOTE: must live at the `@breadcrumb` slot ROOT (not under a `(plugins)` route +// group) — a parallel-route slot only matches `children` sharing its route-group +// structure, so a nested catch-all would miss pages from other plugin groups. export default async function BreadcrumbSlot({ params, }: { diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[nameCode]/page.tsx similarity index 100% rename from apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx rename to apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[nameCode]/page.tsx diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx index 050239cf7..4c84c0634 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx @@ -1,4 +1,4 @@ -// Fallback for the @breadcrumb slot on unmatched soft-navigation states. +// Fallback for the @breadcrumb slot on unmatched soft-navigation/hard loads. export default function Default() { return null; } diff --git a/packages/config/eslint.config.mjs b/packages/config/eslint.config.mjs index 68d9dc282..f36ffd450 100644 --- a/packages/config/eslint.config.mjs +++ b/packages/config/eslint.config.mjs @@ -12,6 +12,7 @@ export default [ "dist", "**/\\(main\\)/\\(plugins\\)/**", "**/\\(auth\\)/\\(plugins\\)/**", + "**/\\(blank\\)/\\(plugins\\)/**", "**/\\(main\\)/@breadcrumb/**", "**/\\(auth\\)/@breadcrumb/**", ".prettierrc.mjs", diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/default.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/default.tsx new file mode 100644 index 000000000..9e01849ad --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/default.tsx @@ -0,0 +1,6 @@ +// Root fallback for the @breadcrumb slot. Required so unmatched routes (pages +// without an explicit breadcrumb override) don't 404 the slot on hard loads. +// Plugin breadcrumb routes are copied alongside this file by `vitnode`. +export default function Default() { + return null; +} diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/page.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/page.tsx new file mode 100644 index 000000000..403a03499 --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/app/[locale]/(main)/@breadcrumb/page.tsx @@ -0,0 +1,5 @@ +// Home page (/) has no breadcrumb. An explicit slot page (not just default.tsx) +// keeps client-side navigation back to "/" from showing a stale breadcrumb. +export default function BreadcrumbSlot() { + return null; +} diff --git a/packages/vitnode/scripts/plugin.ts b/packages/vitnode/scripts/plugin.ts index bda643a20..64ee79d48 100644 --- a/packages/vitnode/scripts/plugin.ts +++ b/packages/vitnode/scripts/plugin.ts @@ -62,7 +62,7 @@ const collectSources = ( if (appType === "web") { sources.push( { - sourceDir: join(pluginDir, "src", "app_admin"), + sourceDir: join(pluginDir, "src", "routes", "admin"), destinationDir: join( appPath, "src", @@ -74,7 +74,7 @@ const collectSources = ( ), }, { - sourceDir: join(pluginDir, "src", "app"), + sourceDir: join(pluginDir, "src", "routes", "main"), destinationDir: join( appPath, "src", @@ -84,6 +84,17 @@ const collectSources = ( join("(plugins)", `(${pluginPathName})`), ), }, + { + sourceDir: join(pluginDir, "src", "routes", "blank"), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "(blank)", + join("(plugins)", `(${pluginPathName})`), + ), + }, { sourceDir: join(pluginDir, "src", "locales"), destinationDir: join(appPath, "src", "locales", pluginName), @@ -98,7 +109,6 @@ const collectSources = ( "admin", "(auth)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, { @@ -110,7 +120,6 @@ const collectSources = ( "[locale]", "(main)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, ); @@ -128,7 +137,7 @@ const collectSources = ( if (projectType === "web") { sources.push( { - sourceDir: join(pluginDir, "src", "app_admin"), + sourceDir: join(pluginDir, "src", "routes", "admin"), destinationDir: join( cwd, "src", @@ -140,7 +149,7 @@ const collectSources = ( ), }, { - sourceDir: join(pluginDir, "src", "app"), + sourceDir: join(pluginDir, "src", "routes", "main"), destinationDir: join( cwd, "src", @@ -150,6 +159,17 @@ const collectSources = ( join("(plugins)", `(${pluginPathName})`), ), }, + { + sourceDir: join(pluginDir, "src", "routes", "blank"), + destinationDir: join( + cwd, + "src", + "app", + "[locale]", + "(blank)", + join("(plugins)", `(${pluginPathName})`), + ), + }, { sourceDir: join(pluginDir, "src", "locales"), destinationDir: join(cwd, "src", "locales", pluginName), @@ -164,7 +184,6 @@ const collectSources = ( "admin", "(auth)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, { @@ -176,7 +195,6 @@ const collectSources = ( "[locale]", "(main)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, ); diff --git a/packages/vitnode/scripts/prepare-plugins-files.ts b/packages/vitnode/scripts/prepare-plugins-files.ts index 571dc000a..74d759d9d 100644 --- a/packages/vitnode/scripts/prepare-plugins-files.ts +++ b/packages/vitnode/scripts/prepare-plugins-files.ts @@ -139,7 +139,7 @@ export const preparePluginsFiles = async (flag?: string) => { const appType = detectAppType(appPath); if (appType === "web") { - // Web app: copy app, app_admin, and locales + // Web app: copy routes (main + admin) and locales const mainDest = join( appPath, "src", @@ -161,13 +161,26 @@ export const preparePluginsFiles = async (flag?: string) => { sources.push( { - sourceDir: join(pluginPath, "src", "app_admin"), + sourceDir: join(pluginPath, "src", "routes", "admin"), destinationDir: adminDest, }, { - sourceDir: join(pluginPath, "src", "app"), + sourceDir: join(pluginPath, "src", "routes", "main"), destinationDir: mainDest, }, + // Blank routes: pages without the main/admin layout (only the + // root `[locale]` layout applies). + { + sourceDir: join(pluginPath, "src", "routes", "blank"), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "(blank)", + join("(plugins)", `(${pluginPathName})`), + ), + }, { sourceDir: join(pluginPath, "src", "locales"), destinationDir: langDest, @@ -191,7 +204,6 @@ export const preparePluginsFiles = async (flag?: string) => { "admin", "(auth)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, { @@ -209,7 +221,6 @@ export const preparePluginsFiles = async (flag?: string) => { "[locale]", "(main)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, ); @@ -246,13 +257,24 @@ export const preparePluginsFiles = async (flag?: string) => { sources.push( { - sourceDir: join(pluginPath, "src", "app_admin"), + sourceDir: join(pluginPath, "src", "routes", "admin"), destinationDir: adminDest, }, { - sourceDir: join(pluginPath, "src", "app"), + sourceDir: join(pluginPath, "src", "routes", "main"), destinationDir: mainDest, }, + { + sourceDir: join(pluginPath, "src", "routes", "blank"), + destinationDir: join( + baseDir, + "src", + "app", + "[locale]", + "(blank)", + join("(plugins)", `(${pluginPathName})`), + ), + }, { sourceDir: join(pluginPath, "src", "locales"), destinationDir: langDest, @@ -267,7 +289,6 @@ export const preparePluginsFiles = async (flag?: string) => { "admin", "(auth)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, { @@ -279,7 +300,6 @@ export const preparePluginsFiles = async (flag?: string) => { "[locale]", "(main)", "@breadcrumb", - join("(plugins)", `(${pluginPathName})`), ), }, ); diff --git a/packages/vitnode/src/app_admin/core/page.tsx b/packages/vitnode/src/app_admin/core/page.tsx deleted file mode 100644 index 5a81f0e88..000000000 --- a/packages/vitnode/src/app_admin/core/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { DashboardAdminView } from "../../views/admin/views/core/dashboard/dashboard-admin-view"; - -export default function Page() { - return ; -} diff --git a/packages/vitnode/src/app_admin/core/test/page.tsx b/packages/vitnode/src/app_admin/core/test/page.tsx deleted file mode 100644 index b34e7eeae..000000000 --- a/packages/vitnode/src/app_admin/core/test/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TestView } from "../../../views/admin/views/core/test"; - -export default function Page() { - return ; -} diff --git a/packages/vitnode/src/app_admin/core/advanced/cron/page.tsx b/packages/vitnode/src/routes/admin/core/advanced/cron/page.tsx similarity index 100% rename from packages/vitnode/src/app_admin/core/advanced/cron/page.tsx rename to packages/vitnode/src/routes/admin/core/advanced/cron/page.tsx diff --git a/packages/vitnode/src/app_admin/core/debug/page.tsx b/packages/vitnode/src/routes/admin/core/debug/page.tsx similarity index 100% rename from packages/vitnode/src/app_admin/core/debug/page.tsx rename to packages/vitnode/src/routes/admin/core/debug/page.tsx diff --git a/packages/vitnode/src/routes/admin/core/page.tsx b/packages/vitnode/src/routes/admin/core/page.tsx new file mode 100644 index 000000000..72d1b4fc1 --- /dev/null +++ b/packages/vitnode/src/routes/admin/core/page.tsx @@ -0,0 +1,5 @@ +import { DashboardAdminView } from "@/views/admin/views/core/dashboard/dashboard-admin-view"; + +export default function Page() { + return ; +} diff --git a/packages/vitnode/src/routes/admin/core/test/page.tsx b/packages/vitnode/src/routes/admin/core/test/page.tsx new file mode 100644 index 000000000..25d79426c --- /dev/null +++ b/packages/vitnode/src/routes/admin/core/test/page.tsx @@ -0,0 +1,5 @@ +import { TestView } from "@/views/admin/views/core/test"; + +export default function Page() { + return ; +} diff --git a/packages/vitnode/src/app_admin/core/users/[nameCode]/page.tsx b/packages/vitnode/src/routes/admin/core/users/[nameCode]/page.tsx similarity index 100% rename from packages/vitnode/src/app_admin/core/users/[nameCode]/page.tsx rename to packages/vitnode/src/routes/admin/core/users/[nameCode]/page.tsx diff --git a/packages/vitnode/src/app_admin/core/users/page.tsx b/packages/vitnode/src/routes/admin/core/users/page.tsx similarity index 100% rename from packages/vitnode/src/app_admin/core/users/page.tsx rename to packages/vitnode/src/routes/admin/core/users/page.tsx diff --git a/packages/vitnode/src/app_admin/core/users/roles/page.tsx b/packages/vitnode/src/routes/admin/core/users/roles/page.tsx similarity index 100% rename from packages/vitnode/src/app_admin/core/users/roles/page.tsx rename to packages/vitnode/src/routes/admin/core/users/roles/page.tsx diff --git a/packages/vitnode/src/routes/blank/test/page.tsx b/packages/vitnode/src/routes/blank/test/page.tsx new file mode 100644 index 000000000..ae24cdcd4 --- /dev/null +++ b/packages/vitnode/src/routes/blank/test/page.tsx @@ -0,0 +1,11 @@ +export default function Page() { + return ( +
+

Blank test page

+

+ This page renders without the main layout — no header or footer, just + the root providers. +

+
+ ); +} diff --git a/packages/vitnode/src/routes/breadcrumb/admin/[...all]/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/[...all]/page.tsx new file mode 100644 index 000000000..75b098ba5 --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/admin/[...all]/page.tsx @@ -0,0 +1,17 @@ +import { BreadcrumbAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-admin"; + +// Generic catch-all breadcrumb for every authenticated admin route (core + +// plugins). More specific slot folders (e.g. core/users/[nameCode]) override it. +// +// NOTE: must live at the `@breadcrumb` slot ROOT (not under a `(plugins)` route +// group) — a parallel-route slot only matches `children` sharing its route-group +// structure, so a nested catch-all would miss pages from other plugin groups. +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ all?: string[] }>; +}) { + const { all } = await params; + + return ; +} diff --git a/packages/vitnode/src/routes/breadcrumb/admin/default.tsx b/packages/vitnode/src/routes/breadcrumb/admin/default.tsx new file mode 100644 index 000000000..4c84c0634 --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/admin/default.tsx @@ -0,0 +1,4 @@ +// Fallback for the @breadcrumb slot on unmatched soft-navigation/hard loads. +export default function Default() { + return null; +} diff --git a/packages/vitnode/src/routes/breadcrumb/main/[...rest]/page.tsx b/packages/vitnode/src/routes/breadcrumb/main/[...rest]/page.tsx new file mode 100644 index 000000000..8e26f4953 --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/main/[...rest]/page.tsx @@ -0,0 +1,17 @@ +import { BreadcrumbMain } from "@/views/breadcrumb/breadcrumb-main"; + +// Generic catch-all breadcrumb for public pages: humanizes URL segments. +// Specific slot folders (login, register, …) override it with translated labels. +// +// NOTE: must live at the `@breadcrumb` slot ROOT (not under a `(plugins)` route +// group) — a parallel-route slot only matches `children` sharing its route-group +// structure, so a nested catch-all would miss pages outside that group. +export default async function BreadcrumbSlot({ + params, +}: { + params: Promise<{ rest?: string[] }>; +}) { + const { rest } = await params; + + return ; +} diff --git a/packages/vitnode/src/routes/breadcrumb/main/default.tsx b/packages/vitnode/src/routes/breadcrumb/main/default.tsx new file mode 100644 index 000000000..4c84c0634 --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/main/default.tsx @@ -0,0 +1,4 @@ +// Fallback for the @breadcrumb slot on unmatched soft-navigation/hard loads. +export default function Default() { + return null; +} diff --git a/packages/vitnode/src/routes/breadcrumb/main/page.tsx b/packages/vitnode/src/routes/breadcrumb/main/page.tsx new file mode 100644 index 000000000..6ab2ce8a2 --- /dev/null +++ b/packages/vitnode/src/routes/breadcrumb/main/page.tsx @@ -0,0 +1,6 @@ +// Home page (/) has no breadcrumb. An explicit slot page (not just default.tsx) +// is required so client-side navigation back to "/" clears a previously-rendered +// breadcrumb — otherwise Next.js keeps the slot's stale active state on soft nav. +export default function BreadcrumbSlot() { + return null; +} diff --git a/packages/vitnode/src/app/login/page.tsx b/packages/vitnode/src/routes/main/login/page.tsx similarity index 86% rename from packages/vitnode/src/app/login/page.tsx rename to packages/vitnode/src/routes/main/login/page.tsx index 66b7cf78a..bccf3d75d 100644 --- a/packages/vitnode/src/app/login/page.tsx +++ b/packages/vitnode/src/routes/main/login/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next/dist/types"; import { getTranslations } from "next-intl/server"; -import { SignInView } from "../../views/auth/sign-in/sign-in-view"; +import { SignInView } from "@/views/auth/sign-in/sign-in-view"; export const generateMetadata = async ({ params, diff --git a/packages/vitnode/src/app/login/reset-password/page.tsx b/packages/vitnode/src/routes/main/login/reset-password/page.tsx similarity index 100% rename from packages/vitnode/src/app/login/reset-password/page.tsx rename to packages/vitnode/src/routes/main/login/reset-password/page.tsx diff --git a/packages/vitnode/src/app/login/sso/[providerId]/page.tsx b/packages/vitnode/src/routes/main/login/sso/[providerId]/page.tsx similarity index 100% rename from packages/vitnode/src/app/login/sso/[providerId]/page.tsx rename to packages/vitnode/src/routes/main/login/sso/[providerId]/page.tsx diff --git a/packages/vitnode/src/app/register/page.tsx b/packages/vitnode/src/routes/main/register/page.tsx similarity index 86% rename from packages/vitnode/src/app/register/page.tsx rename to packages/vitnode/src/routes/main/register/page.tsx index 004422387..3c92bd555 100644 --- a/packages/vitnode/src/app/register/page.tsx +++ b/packages/vitnode/src/routes/main/register/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next/dist/types"; import { getTranslations } from "next-intl/server"; -import { SignUpView } from "../../views/auth/sign-up/sign-up-view"; +import { SignUpView } from "@/views/auth/sign-up/sign-up-view"; export const generateMetadata = async ({ params, diff --git a/plugins/blog/src/app_admin/blog/categories/page.tsx b/plugins/blog/src/routes/admin/blog/categories/page.tsx similarity index 100% rename from plugins/blog/src/app_admin/blog/categories/page.tsx rename to plugins/blog/src/routes/admin/blog/categories/page.tsx diff --git a/plugins/blog/src/app_admin/blog/posts/page.tsx b/plugins/blog/src/routes/admin/blog/posts/page.tsx similarity index 100% rename from plugins/blog/src/app_admin/blog/posts/page.tsx rename to plugins/blog/src/routes/admin/blog/posts/page.tsx From 3e8965040dbf3b2281d4201294237ee0fdaa5772 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Wed, 17 Jun 2026 16:48:37 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(breadcrumb):=20=F0=9F=90=9B=20Handle=20?= =?UTF-8?q?null=20href=20in=20setIfAbsent=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts index 0051eaca3..be1fe0f70 100644 --- a/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts @@ -15,7 +15,9 @@ const normalizeUrl = (url: string): string => // both at /admin/core/users) — better breadcrumb hierarchy. const flattenNav = (nav: NavAdminParent[]): Map => { const labels = new Map(); - const setIfAbsent = (href: string, title: string) => { + const setIfAbsent = (href: null | string | undefined, title: string) => { + if (href == null) return; + const key = normalizeUrl(href); if (!labels.has(key)) labels.set(key, title); };