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/layouts-and-pages.mdx b/apps/docs/content/docs/dev/layouts-and-pages.mdx deleted file mode 100644 index c87631a70..000000000 --- a/apps/docs/content/docs/dev/layouts-and-pages.mdx +++ /dev/null @@ -1,210 +0,0 @@ ---- -title: Layouts and Pages -description: Create beautiful layouts and pages in VitNode plugins using Next.js App Router patterns. ---- - -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! - - -## Creating Pages - -Pages are React components that live in `page.tsx` files. Each page represents a unique URL route. - -### Basic Page - -```tsx title="plugins/blog/src/app/page.tsx" -export default function BlogHomePage() { - return ( -
-

Welcome to Our Blog! 🚀

-

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() { - return ( -
-

Dashboard

-

Manage your blog content here

-
- ); -} -``` - -This creates `/dashboard` route. - -### Dynamic Routes - -Use `[param]` for dynamic URLs: - -```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; - - return ( -
-

Post: {slug}

-

Your post content goes here

-
- ); -} -``` - -## Creating Layouts - -Layouts wrap your pages and provide shared UI elements like headers, navigation, and footers. - -### 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', -}; - -export default function BlogLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
-
-
-

✨ My Blog

-
-
- -
{children}
- - -
- ); -} -``` - -### Nested Layout for Specific Sections - -```tsx title="plugins/blog/src/app/dashboard/layout.tsx" -export default function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
- {/* Sidebar */} - - - {/* Main content */} -
{children}
-
- ); -} -``` - -## Adding Metadata - -Improve SEO and social sharing with metadata: - -```tsx title="plugins/blog/src/app/posts/[slug]/page.tsx" -import { Metadata } from 'next'; // [!code highlight] - -interface PostPageProps { - params: Promise<{ slug: string }>; -} - -// [!code highlight:10] -export async function generateMetadata({ - params, -}: PostPageProps): Promise { - const { slug } = await params; - - return { - title: `Post: ${slug}`, - description: `Read about ${slug} on our blog`, - }; -} - -export default async function PostPage({ params }: PostPageProps) { - const { slug } = await params; - - return ( -
-

Post: {slug}

-

Your post content here...

-
- ); -} -``` - -## Learn More - - - - - - 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 77% rename from apps/docs/content/docs/dev/admin-page.mdx rename to apps/docs/content/docs/dev/plugins/admin-page.mdx index af14d1df8..57b346ad5 100644 --- a/apps/docs/content/docs/dev/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/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..99fe87ac2 --- /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 `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. + + + + +### 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/plugins/layouts-and-pages.mdx b/apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx new file mode 100644 index 000000000..5a84e1988 --- /dev/null +++ b/apps/docs/content/docs/dev/plugins/layouts-and-pages.mdx @@ -0,0 +1,270 @@ +--- +title: Layouts and Pages +description: Create beautiful layouts and pages in VitNode plugins using Next.js App Router patterns. +--- + +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 `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 + +Pages are React components that live in `page.tsx` files. Each page represents a unique URL route. + +### Basic Page + +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 ( +
+

{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/routes/main/dashboard/page.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function DashboardPage() { + const t = await getTranslations('@vitnode/blog'); + + return ( +
+

{t('dashboard.title')}

+

{t('dashboard.desc')}

+
+ ); +} +``` + +This creates `/dashboard` route. + +### Dynamic Routes + +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'; + +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 ( +
+

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

+

{t('post.desc')}

+
+ ); +} +``` + +## Creating Layouts + +Layouts wrap your pages and provide shared UI elements like headers, navigation, and footers. + +### Basic Layout + +```tsx title="plugins/blog/src/routes/main/layout.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function BlogLayout({ + children, +}: { + children: React.ReactNode; +}) { + const t = await getTranslations('@vitnode/blog'); + + return ( +
+
+
+

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

+
+
+ +
{children}
+ +
+
+

{t('layout.footer')}

+
+
+
+ ); +} +``` + +### Nested Layout for Specific Sections + +```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 */} + + + {/* Main content */} +
{children}
+
+ ); +} +``` + + + In a Client Component (`'use client'`) use `useTranslations` instead of + `getTranslations`, and wrap it with `I18nProvider` to expose non-`core.global` + namespaces. See [Namespaces](/docs/dev/i18n/namespaces). + + +## Pages without a layout + +Files in `src/routes/main` are wrapped by the main site layout (header, footer). To render a page **without** that chrome — only the root providers — put it in `src/routes/blank` instead. Handy for full-screen views, embeds, or standalone screens. + +```tsx title="plugins/{plugin_name}/src/routes/blank/standalone/page.tsx" +import { getTranslations } from 'next-intl/server'; + +export default async function StandalonePage() { + const t = await getTranslations('@vitnode/blog'); + + return ( +
+

{t('standalone.title')}

+
+ ); +} +``` + +This creates a `/standalone` route that renders on its own. Admin pages live in `src/routes/admin` (see [AdminCP Pages](/docs/dev/plugins/admin-page)). + +## Adding Metadata + +Improve SEO and social sharing with metadata — resolve the title/description from messages too: + +```tsx title="plugins/blog/src/routes/main/posts/[slug]/page.tsx" +import { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; // [!code highlight] + +interface PostPageProps { + params: Promise<{ slug: string }>; +} + +// [!code highlight:11] +export async function generateMetadata({ + params, +}: PostPageProps): Promise { + const { slug } = await params; + const t = await getTranslations('@vitnode/blog'); + + return { + title: t('post.meta.title', { slug }), + description: t('post.meta.desc', { slug }), + }; +} + +export default async function PostPage({ params }: PostPageProps) { + const { slug } = await params; + const t = await getTranslations('@vitnode/blog'); + + return ( +
+

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

+

{t('post.desc')}

+
+ ); +} +``` + +## Learn More + + + + + + + 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]/(blank)/(plugins)/(vitnode-core)/test/page.tsx b/apps/docs/src/app/[locale]/(blank)/(plugins)/(vitnode-core)/test/page.tsx new file mode 100644 index 000000000..ae24cdcd4 --- /dev/null +++ b/apps/docs/src/app/[locale]/(blank)/(plugins)/(vitnode-core)/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/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 new file mode 100644 index 000000000..4c84c0634 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/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/apps/docs/src/app/[locale]/(main)/@breadcrumb/login/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/login/page.tsx new file mode 100644 index 000000000..4805d6f7b --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/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/login/reset-password/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/login/reset-password/page.tsx new file mode 100644 index 000000000..aa304276c --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/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/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/page.tsx new file mode 100644 index 000000000..6ab2ce8a2 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/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/apps/docs/src/app/[locale]/(main)/@breadcrumb/register/page.tsx b/apps/docs/src/app/[locale]/(main)/@breadcrumb/register/page.tsx new file mode 100644 index 000000000..f128344a0 --- /dev/null +++ b/apps/docs/src/app/[locale]/(main)/@breadcrumb/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)/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/[...all]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx new file mode 100644 index 000000000..fde75805c --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/[...all]/page.tsx @@ -0,0 +1,17 @@ +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: 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/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[nameCode]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[nameCode]/page.tsx new file mode 100644 index 000000000..7ff4a3a56 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/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/default.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/default.tsx new file mode 100644 index 000000000..4c84c0634 --- /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/hard loads. +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..f36ffd450 100644 --- a/packages/config/eslint.config.mjs +++ b/packages/config/eslint.config.mjs @@ -12,6 +12,9 @@ export default [ "dist", "**/\\(main\\)/\\(plugins\\)/**", "**/\\(auth\\)/\\(plugins\\)/**", + "**/\\(blank\\)/\\(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)/@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/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..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,10 +84,44 @@ 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), }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "admin"), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "admin", + "(auth)", + "@breadcrumb", + ), + }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "main"), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + ), + }, ); } else if (appType === "api") { sources.push({ @@ -103,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", @@ -115,7 +149,7 @@ const collectSources = ( ), }, { - sourceDir: join(pluginDir, "src", "app"), + sourceDir: join(pluginDir, "src", "routes", "main"), destinationDir: join( cwd, "src", @@ -125,10 +159,44 @@ 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), }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "admin"), + destinationDir: join( + cwd, + "src", + "app", + "[locale]", + "admin", + "(auth)", + "@breadcrumb", + ), + }, + { + sourceDir: join(pluginDir, "src", "routes", "breadcrumb", "main"), + destinationDir: join( + cwd, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + ), + }, ); } 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..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,17 +161,68 @@ 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, }, + // 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", + ), + }, + { + sourceDir: join( + pluginPath, + "src", + "routes", + "breadcrumb", + "main", + ), + destinationDir: join( + appPath, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + ), + }, ); } else if (appType === "api") { // API app: copy only locales @@ -206,17 +257,51 @@ 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, }, + { + sourceDir: join(pluginPath, "src", "routes", "breadcrumb", "admin"), + destinationDir: join( + baseDir, + "src", + "app", + "[locale]", + "admin", + "(auth)", + "@breadcrumb", + ), + }, + { + sourceDir: join(pluginPath, "src", "routes", "breadcrumb", "main"), + destinationDir: join( + baseDir, + "src", + "app", + "[locale]", + "(main)", + "@breadcrumb", + ), + }, ); } 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/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/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/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/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/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/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/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..be1fe0f70 --- /dev/null +++ b/packages/vitnode/src/views/admin/layouts/breadcrumb/resolve-breadcrumb.ts @@ -0,0 +1,62 @@ +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: null | string | undefined, title: string) => { + if (href == null) return; + + 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/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 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")}