diff --git a/AGENTS.md b/AGENTS.md index d4f919c4a..fdf412912 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,89 +1,62 @@ # Overview -You are VitNode, highly skilled AI-powered assistant that always follows best practices. +You are VitNode, an expert AI coding assistant. Follow repository conventions and best practices for performance, security, accessibility, UX, and SEO. -# React Coding +# React / Next.js -- Don't use `React.FC` for defining React components. Instead, use the arrow function syntax. -- Don't use `any` type in TypeScript and use `unknown` as less as possible. -- Use `AutoForm` for forms instead of manually creating form components. -- Use `React.lazy` and `Suspense` for code splitting and lazy loading for content-heavy dialogs like dialogs in forms. -- After create/edit/delete operations, always refresh the data in the table to reflect the changes with notification using toast `sonner` with description. -- `` lets you hide and restore the UI and internal state of its children. +- Arrow functions for components — never `React.FC`. +- No `any`; use `unknown` as rarely as possible. +- Use `AutoForm` for forms instead of hand-built form components. +- `React.lazy` + `Suspense` for content-heavy dialogs (e.g. dialogs in forms). +- After create/edit/delete: refresh the table data and show a `sonner` toast with a description. +- `` hides and restores children's UI and internal state: ```typescriptreact import { Activity } from "react"; + ; ``` -- Always add breadcrumbs using `@breadcrumb` in the page component (Parallel Routes). -- Name `x.server.ts` files if inside is 'use server' code in Next.js. -- Always add staff permissions when it's new admin api. - -### Improved Caching APIs +- Add breadcrumbs via `@breadcrumb` (Parallel Routes) in the page component. +- Name files `x.server.ts` when they contain `'use server'` code. +- New admin APIs always require staff permissions. -- revalidateTag() now requires a cacheLife profile as the second argument to enable stale-while-revalidate (SWR) behavior: +### Caching APIs -```js -// ✅ Use built-in cacheLife profile (we recommend 'max' for most cases) -revalidateTag("blog-posts", "max"); // or 'days', 'hours' - -// Or use an inline object with a custom revalidation time -revalidateTag("products", { revalidate: 3600 }); -``` - -- updateTag() (new): updateTag() is a new Server Actions-only API that provides read-your-writes semantics: `updateTag(`user-$userId`)`; -- refresh() (new): refresh() is a new Server Actions-only API for refreshing uncached data only. It doesn't touch the cache at all +- `revalidateTag(tag, profile)` — profile is required for SWR: `revalidateTag("blog-posts", "max")` (prefer `'max'`; also `'days'`, `'hours'`) or inline `{ revalidate: 3600 }`. +- `updateTag(\`user-${userId}\`)` — Server Actions only, read-your-writes semantics. +- `refresh()` — Server Actions only, refreshes uncached data, never touches the cache. # Coding Guidelines -- You always implement the best practices with regards to performance, security, and accessibility. -- Use semantic HTML elements when appropriate, like `main` and `header`. - - Make sure to use the correct ARIA roles and attributes. - - Remember to use the "sr-only" Tailwind class for screen reader only text. - - Add alt text for all images, unless they are decorative or it would be repetitive for screen readers. - - Add emit an event for important actions like create, update, delete, etc. to allow other components to react to the changes and add to docs (apps/docs/content/docs/dev/events/built-in-events.mdx) - - Use events to communicate between components instead of prop drilling or using context. - - Use Vercel AI SDK for AI features instead of other AI SDKs. Use the `c.get("ai")` model registry to resolve models and call the native Vercel AI SDK functions. +- Always implement best practices for performance, security, and accessibility. +- Semantic HTML (`main`, `header`) with correct ARIA roles/attributes, `sr-only` for screen-reader-only text, and alt text on all images unless decorative or repetitive. +- Emit events for important actions (create, update, delete) so other components can react; use events instead of prop drilling or context. Document them in `apps/docs/content/docs/dev/events/built-in-events.mdx`. +- AI features use the Vercel AI SDK only — resolve models via the `c.get("ai")` registry and call native SDK functions. # Design -- All new pages and components should have good UX and modern and clean UI design. -- Be sure to update the layout.tsx metadata (title, description, etc.) and viewport (theme-color, userScalable, etc.) based on the user's request for optimal SEO. -- ALWAYS use exactly 3-5 colors total. -- NEVER use purple or violet prominently. -- If you override a components background color, you MUST override its text color to ensure proper contrast. -- Be sure to override text colors if you change a background color. -- ALWAYS design mobile-first, then enhance for larger screens. -- NEVER use floats or absolute positioning unless absolutely necessary -- Use line-height between 1.4-1.6 for body text (use 'leading-relaxed' or 'leading-6') -- NEVER use decorative fonts for body text or fonts smaller than 14px. -- Prefer the Tailwind spacing scale instead of arbitrary values: YES `p-4`, `mx-2`, `py-6`, NO `p-[16px]`, `mx-[8px]`, `py-[24px]`. -- Prefer gap classes for spacing: `gap-4`, `gap-x-2`, `gap-y-6` -- Use semantic Tailwind classes: `items-center`, `justify-between`, `text-center` -- Use responsive prefixes: `md:grid-cols-2`, `lg:text-xl` -- Use semantic design tokens when possible (bg-background, text-foreground, etc.) -- Wrap titles and other important copy in `text-balance` or `text-pretty` to ensure optimal line breaks -- NEVER mix margin/padding with gap classes on the same element -- NEVER use space-* classes for spacing +- New pages and components need good UX and a modern, clean UI. Design mobile-first, then enhance for larger screens. +- Update `layout.tsx` metadata (title, description) and viewport (theme-color, userScalable) for SEO. +- Exactly 3–5 colors total. Never use purple or violet prominently. +- If you override a background color, you MUST override its text color for contrast. +- Prefer semantic design tokens (`bg-background`, `text-foreground`). +- Use the Tailwind spacing scale (`p-4`, `mx-2`) — never arbitrary values (`p-[16px]`). +- Use `gap-*` classes for spacing. Never use `space-*`, and never mix margin/padding with gap on the same element. +- Use semantic (`items-center`, `justify-between`) and responsive (`md:grid-cols-2`) classes. +- No floats or absolute positioning unless absolutely necessary. +- Body text: `leading-relaxed` (1.4–1.6), never below 14px, never decorative fonts. +- Wrap titles and important copy in `text-balance` or `text-pretty`. # Documentation -- Don't write big comments. Remember that code is self-documenting. -- Add as needed image via comment: - -```markdown -// Image prompt: {here_prompt_to_generate_image} -``` - -- Always write documentation for all new features. -- Keep docs simple and easy to understand. -- Use funny and friendly tone in docs, but don't overdo it. -- Docs should be written in a way that is easy to understand for developers of all skill levels. -- Keep docs to be SEO friendly and optimized for search engines. -- All example usage commands npm, pnpm, bun etc. should be in code block with the correct syntax highlighting. +- Document every new feature. Keep it simple, SEO-friendly, and understandable at any skill level. +- Friendly and lightly funny tone — don't overdo it. +- Skip big comments; code is self-documenting. +- Request images with a comment: `// Image prompt: {here_prompt_to_generate_image}` +- Put install commands in tabbed code blocks with correct syntax highlighting: ````markdown import { Tab, Tabs } from "fumadocs-ui/components/tabs"; @@ -93,7 +66,6 @@ import { Tab, Tabs } from "fumadocs-ui/components/tabs"; ```bash tab="bun" bun i x ``` -```` ```bash tab="pnpm" pnpm i x @@ -104,13 +76,10 @@ npm i x ``` -``` +```` # Testing -- Login to app as admin is: test@test.com/Test123! - -## Unit Testing - -- Always write and run unit tests in vitest for all new features and bug fixes. -- Don't write unit tests if app doesn't have configured vitest. +- Admin login: `test@test.com` / `Test123!` +- Write and run vitest unit tests for all new features and bug fixes — skip only if vitest isn't configured. +- Don't write tests for trivial code unless they have complex logic or edge cases. diff --git a/apps/docs/content/docs/dev/plugins/admin-page.mdx b/apps/docs/content/docs/dev/plugins/admin-page.mdx index 434d2eb64..da5f568b3 100644 --- a/apps/docs/content/docs/dev/plugins/admin-page.mdx +++ b/apps/docs/content/docs/dev/plugins/admin-page.mdx @@ -216,4 +216,9 @@ VitNode automatically uses the `id` as translation key for the navigation item. description="Create custom layouts and pages" href="/docs/plugins/layouts-and-pages" /> + diff --git a/apps/docs/content/docs/dev/plugins/dashboard-widgets.mdx b/apps/docs/content/docs/dev/plugins/dashboard-widgets.mdx new file mode 100644 index 000000000..c21c0f1e2 --- /dev/null +++ b/apps/docs/content/docs/dev/plugins/dashboard-widgets.mdx @@ -0,0 +1,519 @@ +--- +title: Dashboard Widgets +description: Add drag-and-drop widgets to the AdminCP dashboard from your plugin - each admin arranges their own layout, and it is responsive out of the box. +icon: LayoutDashboard +--- + +The AdminCP dashboard at `/admin/core` is a grid of widgets. Your plugin can drop its own cards onto it - post counts, a moderation queue, a chart, whatever you want an admin to see the moment they log in. + +Every admin arranges their **own** dashboard: drag to reorder, drag new widgets in from the side panel, resize, remove. One admin rearranging their board never touches anyone else's. + +```text +┌─ Edit layout ────────────────────────────────┬─ Available widgets ─┐ +│ ┌───────────────┬──────────┐ │ Search for a widget │ +│ │ Notes │ Send a │ │ │ +│ │ │ notifi… │ │ CORE │ +│ ├───────────────┴──────────┤ │ ⠿ Notes │ +│ │ ⌐ ‥ drop a widget here ¬ │ │ ⠿ Send a notificat… │ +│ └──────────────────────────┘ │ BLOG │ +│ │ ⠿ Blog statistics │ +│ │ │ +│ │ drag one over ───▶ │ +│ ├─────────────────────┤ +│ │ Cancel │ Save │ +└──────────────────────────────────────────────┴─────────────────────┘ +``` + +**Edit layout** sits in the page header. Once the board is being edited it steps aside, and **Save** and **Cancel** wait at the foot of the widget panel - beside the rest of what edit mode put on screen, rather than back up in the header. + +## Register a widget + + + + +### Write the component + +A widget is an ordinary React component. It renders **on the server**, so it can be `async` and talk to the database directly - no API round-trip needed. + +```tsx title="plugins/{plugin_name}/src/views/admin/widgets/stats-widget.tsx" +import type { AdminDashboardWidgetProps } from "@vitnode/core/lib/plugin"; + +export const StatsWidget = async ({ settings }: AdminDashboardWidgetProps) => { + const posts = await countPosts(); + + return ( +

{posts}

+ ); +}; +``` + + + VitNode renders your widget on the server and hands the *output* to the client + grid - the component function itself never crosses the boundary. Anything + interactive (an input, a button with `onClick`) goes in a child component + marked `"use client"`, exactly like a normal Server Component. + + + + Every card sits behind its own `Suspense` boundary, with a skeleton as tall as + the card the admin sized. A slow query holds up your card and nothing else - + the board and every widget beside it are on screen while yours streams in, so + there is no need to hand-roll a loading state. + + + + The dashboard only ships core's own messages to the browser. A `"use client"` + child that calls `useTranslations` on your plugin's namespace needs your + messages there too - wrap it in core's `I18nProvider` inside your widget: + +```tsx +import { I18nProvider } from "@vitnode/core/components/i18n-provider"; + +export const StatsWidget = () => ( + + + +); +``` + + A widget that renders all its text on the server needs none of this. + + +
+ + +### Add it to your plugin config + +Widgets live under `admin.dashboard.widgets`, right next to your [navigation items](/docs/dev/plugins/admin-page#navigation-items). + +```tsx title="plugins/{plugin_name}/src/config.tsx" +import { buildPlugin } from "@vitnode/core/lib/plugin"; +import { ChartBarIcon } from "lucide-react"; + +import { StatsWidget } from "./views/admin/widgets/stats-widget"; +import { configPlugin } from "./config"; + +export const blogPlugin = () => { + return buildPlugin({ + ...configPlugin, + // [!code ++:12] + admin: { + dashboard: { + widgets: [ + { + id: "stats", + component: StatsWidget, + icon: , + defaultSpan: 1, + defaultRows: 1, + }, + ], + }, + }, + }); +}; +``` + + + + +### Add translations + +Like nav items, the widget's `id` becomes its translation key. `title` is required; `desc` is optional and shows as a subtitle on the card and in the panel. + +```json title="plugins/{plugin_name}/src/locales/en.json" +{ + "@vitnode/blog": { + "title": "Blog", + "admin": { + // [!code ++:8] + "dashboard": { + "widgets": { + "stats": { + "title": "Blog statistics", + "desc": "Posts, comments and views at a glance." + } + } + } + } + } +} +``` + +That's it. Open `/admin/core`, hit **Edit layout**, and your widget is waiting in the panel on the right. + + +
+ +## Widget options + +import { TypeTable } from "fumadocs-ui/components/type-table"; + +", + required: true, + }, + icon: { + description: + "A lucide icon element shown beside the title. Same as nav items.", + type: "React.ReactNode", + }, + category: { + description: + "Groups the widget under a heading of your own in the panel. Omit it and the widget is filed under your plugin's name.", + type: "string", + default: "the plugin's own name", + }, + defaultSpan: { + description: "How many columns the widget takes when first added.", + type: "1 | 2 | 3", + default: "minSpan (or 1)", + }, + defaultRows: { + description: "How tall the card starts out.", + type: "1 | 2 | 3", + default: "1", + }, + minSpan: { + description: + "The narrowest the admin may shrink it. Use it when your widget needs room - a wide form or a chart.", + type: "1 | 2 | 3", + default: "1", + }, + defaultEnabled: { + description: + "Put the widget on a fresh dashboard automatically. Leave it off to make the widget opt-in - it still shows up in the panel.", + type: "boolean", + default: "false", + }, + allowMultiple: { + description: + "Let an admin place the widget as many times as they like. Each copy is its own entry with its own settings, and stays in the panel so more can be dragged in.", + type: "boolean", + default: "false", + }, + permission: { + description: + "Hide the widget from admins who lack a staff permission. Takes `{ module, permission }` - the plugin is inferred from yours.", + type: "{ module: string; permission: string }", + }, + settingsComponent: { + description: + "A form for whatever the widget lets an admin change, shown in a dialog behind a gear on the card. Rendered on the server with the same props as `component`, but only once the dialog is opened.", + type: "React.ComponentType", + }, + }} +/> + +## Grouping widgets in the panel + +The panel groups what it offers under headings, and the search box above them matches a widget's title, its description, **and** its heading - so typing your plugin's name lists everything it contributes. + +By default a widget is filed under the plugin it came from, using the same `title` your plugin already provides for the sidebar. Register three widgets and they arrive as one **Blog** group, in the order you declared them - nothing to configure. + +Give related widgets a `category` when one heading is too coarse: + +```tsx title="plugins/{plugin_name}/src/config.tsx" +widgets: [ + { id: "stats", component: StatsWidget, category: "reports" }, // [!code ++] + { id: "views", component: ViewsWidget, category: "reports" }, // [!code ++] + { id: "queue", component: QueueWidget }, // filed under "Blog" +], +``` + +The heading's text is a translation of its own, keyed by the category id: + +```json title="plugins/{plugin_name}/src/locales/en.json" +{ + "@vitnode/blog": { + "admin": { + "dashboard": { + "widgets": { + // [!code ++:3] + "categories": { + "reports": "Blog reports" + } + } + } + } + } +} +``` + + + A category belongs to the plugin that declares it - two plugins can both use + `category: "reports"` without landing in the same group, and each labels its + own heading. Miss the translation and the heading falls back to the raw id + (`reports`) rather than breaking the dashboard. + + +## Letting a widget be placed more than once + +Some widgets earn their keep several times over - one card per thing being watched. Set `allowMultiple` and the widget stays in the panel after it is placed, so an admin can drag in as many copies as they want: + +```tsx title="plugins/{plugin_name}/src/config.tsx" +{ + id: "queue", + component: QueueWidget, + allowMultiple: true, // [!code ++] +} +``` + +Every copy is a layout entry of its own, with its own size, position and `settings`. They are told apart by id: the first copy keeps the plain widget id, later ones get a `#n` suffix. + +```text +@vitnode/blog:queue <- first copy +@vitnode/blog:queue#2 <- second +@vitnode/blog:queue#3 <- third +``` + +That id arrives as `widgetId` in your props, and it is what [settings](#remembering-things-widget-settings) are stored against. Pass it back verbatim when you save - **never** substitute the id you registered, or every copy will write over the same bag: + +```tsx +export const QueueWidget = ({ settings, widgetId }: AdminDashboardWidgetProps) => ( + // `widgetId` is this copy - `@vitnode/blog:queue#2`, say. + +); +``` + + + A copy that has not been saved yet has no server-rendered output of its own, + so the board shows it with the widget's default state until the admin hits + **Save**. Anything it persists before that save lands against the first copy's + id. It costs nothing for a widget that stores no settings; if yours does, + expect the first save to settle it. + + +Turning `allowMultiple` back off is safe: the extra copies are dropped on the next load and the first one is kept. + +## Sizing and responsiveness + +`span` is a **column count**, not a pixel width, and the grid changes column count with the viewport: + +| Viewport | Columns | What `span` does | +| -------------------- | ------- | -------------------------------- | +| `< 768px` (mobile) | 1 | Ignored - every widget is full width | +| `768px – 1279px` | 2 | `3` behaves like `2` | +| `≥ 1280px` (desktop) | 3 | Exactly as stored | + +Because the span collapses gracefully, one saved layout serves every screen size - there is no separate mobile layout to keep in sync. Design your widget to look right at `span: 1` on a phone and it will look right everywhere. + + + Give the card's own content room to breathe rather than fixing its height. + `defaultRows` sets a minimum height so a short widget does not look starved + next to a tall one, but the card still grows with its content. + + +## Gate a widget by permission + +Same shape as nav items - see [Staff Permissions](/docs/dev/working-with-users/staff-permissions): + +```tsx title="plugins/{plugin_name}/src/config.tsx" +{ + id: "moderation-queue", + component: ModerationQueueWidget, + permission: { module: "posts", permission: "can_view" }, // [!code ++] +} +``` + +An admin without the permission never sees the widget - not on the board, not in the panel. If they had it placed and the permission is later revoked, it quietly disappears from their layout without disturbing anything else. + + + Hiding a widget hides the UI, not the data. Guard the queries inside your + widget too. + + +## Remembering things (widget settings) + +Each widget gets a small JSON bag of its own, stored per admin alongside their layout. Core's Notes widget uses it to keep the note body; yours might remember a collapsed section or a chosen date range. + +Read it from the `settings` prop: + +```tsx +export const StatsWidget = ({ settings }: AdminDashboardWidgetProps) => { + const range = settings.range === "year" ? "year" : "month"; + // ... +}; +``` + +Write it from a client child with the server action core ships: + +```tsx title="plugins/{plugin_name}/src/views/admin/widgets/range-picker.tsx" +"use client"; + +import { saveWidgetSettingsMutation } from "@vitnode/core/views/admin/views/core/dashboard/widgets/save-widget-settings.server"; + +export const RangePicker = ({ widgetId }: { widgetId: string }) => ( + +); +``` + +Settings are **merged**, not replaced, so you only send the keys you changed. Rearranging the dashboard never wipes them. The whole bag is capped at 64 KB of UTF-8 - a dashboard is not a CMS. + +## Let admins configure it (the settings dialog) + +Saving straight from the card suits things the admin is already typing into. For everything else - a default message, a date range, which forum to watch - there is a gear in the card's top-right corner while the board is being edited, and a dialog behind it. Register a `settingsComponent` and VitNode puts it there, next to the sizing and removal buttons: + +```tsx title="plugins/{plugin_name}/src/config.tsx" +{ + id: "stats", + component: StatsWidget, + settingsComponent: StatsSettings, // [!code ++] +} +``` + + + + +### Render the form on the server + +It gets the same props as the widget, so it starts from what this copy has already saved. Unlike the widget itself, it runs only when an admin opens the dialog - a dashboard load never pays for a form nobody asked to see, so it is fine to query the database here: + +```tsx title="plugins/{plugin_name}/src/views/admin/widgets/stats-settings.tsx" +import type { AdminDashboardWidgetProps } from "@vitnode/core/lib/plugin"; + +import { StatsSettingsForm } from "./stats-settings-form"; + +export const StatsSettings = ({ settings }: AdminDashboardWidgetProps) => ( + +); +``` + + + + +### Save from a client child + +Build it with [`AutoForm`](/docs/ui/auto-form), like every other form in the AdminCP - it takes the labels, validation and the dialog's own footer off your hands. + +`useWidgetSettingsDialog` is available to anything under your settings component. Its `save` writes the settings and closes the dialog - you do not need `saveWidgetSettingsMutation` here, and you never have to pass `widgetId` around. Await it and the submit button stays in its loading state until the write lands: + +```tsx title="plugins/{plugin_name}/src/views/admin/widgets/stats-settings-form.tsx" +"use client"; + +import { AutoForm } from "@vitnode/core/components/form/auto-form"; +import { AutoFormSelect } from "@vitnode/core/components/form/fields/select"; +import { useWidgetSettingsDialog } from "@vitnode/core/views/admin/views/core/dashboard/grid/widget-settings-dialog"; +import { z } from "zod"; + +export const StatsSettingsForm = ({ + defaultRange, +}: { + defaultRange: string; +}) => { + const { save } = useWidgetSettingsDialog(); + + // Whatever this copy has already saved becomes the form's starting value. + const formSchema = z.object({ + range: z + .enum(["month", "year"]) + .default(defaultRange === "year" ? "year" : "month"), + }); + + return ( + , + }, + ]} + formSchema={formSchema} + mode="all" + onSubmit={async values => { + await save({ range: values.range }); // [!code highlight] + }} + submitButtonProps={{ children: "Save" }} + /> + ); +}; +``` + + + Rendered inside the dialog, `AutoForm` adds the Cancel button and the footer + itself, and warns the admin before they close on unsaved changes. Nothing left + to wire up. + + +Building something the form fields cannot express? The same hook also hands you `close` to dismiss the dialog, `isPending` while a write is in flight, and `widgetId` for this copy. + + + + +The dialog's own title and description come from the widget's `title` - you only supply what goes between them and a footer to close it. + +) => void", + }, + close: { + description: "Dismisses the dialog without writing anything.", + type: "() => void", + }, + isPending: { + description: "A save is in flight - disable the form's controls.", + type: "boolean", + }, + widgetId: { + description: + "The copy being configured, e.g. `@vitnode/blog:stats#2`. `save` already uses it; it is here for anything else you need to key on.", + type: "string", + }, + }} +/> + +Your widget reads the result back out of its `settings` prop like any other. As soon as the write lands, VitNode renders **that one card** again on the server and swaps it in behind the closing dialog - your component runs a second time with the settings that were just saved, and remounts, so whatever client state it was holding starts from them too. Its skeleton covers the gap. + +Only that card goes back to the server. The board around it is left exactly as the admin arranged it, which is why a full reload waits for them to save or cancel their layout. + + + Settings hang off a stored layout entry, and a `defaultEnabled` card is on + screen from the very first load - long before the admin has arranged anything. + Writing settings creates that entry if it is not there yet, so nothing an + admin types is waiting on them finding the **Save** button. The new entry + starts at your widget's own `defaultSpan` and `defaultRows`. + + +## How it is stored + +An admin's layout lives in `core_admin_dashboard`, one row per user, as a `jsonb` array of `{ id, span, rows, settings }` in render order. It is a *preference*, never the source of truth: on every load VitNode reconciles it against what is actually installed, so uninstalling your plugin simply drops its widgets from everyone's board, and installing it adds any `defaultEnabled` widgets to the end without disturbing what the admin already arranged. + +Removing a widget does not delete its entry - it is kept as `{ hidden: true }`. That does two useful things: a `defaultEnabled` widget the admin threw away stays away instead of cheerfully reappearing on the next load, and its `settings` sit safely on ice until they put it back. + +Only what the admin could actually see counts as removed. An entry they were never shown - your plugin was uninstalled, or a permission behind one of its widgets was revoked - is left untouched by a save rather than quietly marked hidden, so putting the plugin or the permission back brings the card back exactly where it was, settings and all. + +## Learn More + + + + + diff --git a/apps/docs/content/docs/dev/plugins/meta.json b/apps/docs/content/docs/dev/plugins/meta.json index 1d72db9d6..7921fa120 100644 --- a/apps/docs/content/docs/dev/plugins/meta.json +++ b/apps/docs/content/docs/dev/plugins/meta.json @@ -3,5 +3,13 @@ "description": "Make plugins and APIs", "icon": "Plug", "defaultOpen": true, - "pages": ["create", "layouts-and-pages", "admin-page", "breadcrumbs", "api", "..."] + "pages": [ + "create", + "layouts-and-pages", + "admin-page", + "dashboard-widgets", + "breadcrumbs", + "api", + "..." + ] } diff --git a/apps/docs/migrations/0021_add_admin_dashboard.sql b/apps/docs/migrations/0021_add_admin_dashboard.sql new file mode 100644 index 000000000..73baa01ce --- /dev/null +++ b/apps/docs/migrations/0021_add_admin_dashboard.sql @@ -0,0 +1,12 @@ +CREATE TABLE "core_admin_dashboard" ( + "id" serial PRIMARY KEY NOT NULL, + "userId" integer NOT NULL, + "widgets" jsonb DEFAULT '[]'::jsonb NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "core_admin_dashboard_userId_unique" UNIQUE("userId") +); +--> statement-breakpoint +ALTER TABLE "core_admin_dashboard" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_admin_dashboard" ADD CONSTRAINT "core_admin_dashboard_userId_core_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."core_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "core_admin_dashboard_user_id_idx" ON "core_admin_dashboard" USING btree ("userId"); \ No newline at end of file diff --git a/apps/docs/migrations/meta/0021_snapshot.json b/apps/docs/migrations/meta/0021_snapshot.json new file mode 100644 index 000000000..d67cc57aa --- /dev/null +++ b/apps/docs/migrations/meta/0021_snapshot.json @@ -0,0 +1,2209 @@ +{ + "id": "bce1e602-a192-4c9c-b6d5-8be955f985d9", + "prevId": "92f7766a-097b-4d03-b874-a2eb27680169", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_cron": { + "name": "core_cron", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lastRun": { + "name": "lastRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "module": { + "name": "module", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "nextRun": { + "name": "nextRun", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "schedule": { + "name": "schedule", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_dashboard": { + "name": "core_admin_dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "widgets": { + "name": "widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_admin_dashboard_user_id_idx": { + "name": "core_admin_dashboard_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_dashboard_userId_core_users_id_fk": { + "name": "core_admin_dashboard_userId_core_users_id_fk", + "tableFrom": "core_admin_dashboard", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_dashboard_userId_unique": { + "name": "core_admin_dashboard_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_files": { + "name": "core_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_files_user_id_idx": { + "name": "core_files_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_files_userId_core_users_id_fk": { + "name": "core_files_userId_core_users_id_fk", + "tableFrom": "core_files", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_files_key_unique": { + "name": "core_files_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_logs": { + "name": "core_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'localhost'" + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 500 + }, + "userId": { + "name": "userId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "test123": { + "name": "test123", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "core_logs_userId_core_users_id_fk": { + "name": "core_logs_userId_core_users_id_fk", + "tableFrom": "core_logs", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unrestricted": { + "name": "unrestricted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_queue": { + "name": "core_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "queue": { + "name": "queue", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "maxAttempts": { + "name": "maxAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "availableAt": { + "name": "availableAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reservedAt": { + "name": "reservedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "core_queue_status_available_at_idx": { + "name": "core_queue_status_available_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "availableAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "allowUploadFiles": { + "name": "allowUploadFiles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totalMaxStorage": { + "name": "totalMaxStorage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maxStorageForSubmit": { + "name": "maxStorageForSubmit", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_search_index": { + "name": "core_search_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pluginId": { + "name": "pluginId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + } + }, + "containerType": { + "name": "containerType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "containerId": { + "name": "containerId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "indexedAt": { + "name": "indexedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_search_index_search_vector_idx": { + "name": "core_search_index_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "core_search_index_created_at_idx": { + "name": "core_search_index_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_author_id_idx": { + "name": "core_search_index_author_id_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_item_type_idx": { + "name": "core_search_index_item_type_idx", + "columns": [ + { + "expression": "itemType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_language_code_idx": { + "name": "core_search_index_language_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_search_index_is_public_idx": { + "name": "core_search_index_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_search_index_authorId_core_users_id_fk": { + "name": "core_search_index_authorId_core_users_id_fk", + "tableFrom": "core_search_index", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_search_index_item_unique": { + "name": "core_search_index_item_unique", + "nullsNotDistinct": false, + "columns": [ + "itemType", + "itemId", + "languageCode" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_secondary_roles": { + "name": "core_users_secondary_roles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_secondary_roles_user_id_idx": { + "name": "core_users_secondary_roles_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_secondary_roles_role_id_idx": { + "name": "core_users_secondary_roles_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_secondary_roles_userId_core_users_id_fk": { + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_users_secondary_roles_roleId_core_roles_id_fk": { + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "tableFrom": "core_users_secondary_roles", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "core_users_secondary_roles_userId_roleId_pk": { + "name": "core_users_secondary_roles_userId_roleId_pk", + "columns": [ + "userId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blog_posts_authorId_core_users_id_fk": { + "name": "blog_posts_authorId_core_users_id_fk", + "tableFrom": "blog_posts", + "tableTo": "core_users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/docs/migrations/meta/_journal.json b/apps/docs/migrations/meta/_journal.json index 2561afbb6..6838150d7 100644 --- a/apps/docs/migrations/meta/_journal.json +++ b/apps/docs/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1785008794334, "tag": "0020_lazy_logan", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1785581778726, + "tag": "0021_add_admin_dashboard", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json index 062a25e0e..f87acb19a 100644 --- a/apps/docs/src/locales/@vitnode/core/pl.json +++ b/apps/docs/src/locales/@vitnode/core/pl.json @@ -2,6 +2,9 @@ "@vitnode/core": { "title": "Rdzeń" }, + "@vitnode/core:dashboard": "Pulpit", + "@vitnode/core:dashboard:can_view": "Wyświetlanie własnego układu pulpitu", + "@vitnode/core:dashboard:can_edit": "Dostosowywanie własnego układu pulpitu", "@vitnode/core:users": "Użytkownicy", "@vitnode/core:users:can_view": "Wyświetlanie listy użytkowników", "@vitnode/core:users:can_create": "Tworzenie użytkowników", @@ -398,7 +401,67 @@ "admin": { "dashboard": { "dev_mode": "Tryb deweloperski", - "version": "Wersja: {version}" + "version": "Wersja: {version}", + "widgets": { + "edit": "Edytuj układ", + "drag_handle": "Przenieś {title}", + "remove": "Usuń {title}", + "drop_here": "Upuść tutaj widżet", + "empty_title": "Twój pulpit jest pusty", + "empty_desc": "Dodaj widżety, aby mieć ważne dane zawsze pod ręką.", + "saved_title": "Pulpit zapisany", + "saved_desc": "Ten układ należy tylko do Ciebie - inni administratorzy mają swój własny.", + "error_title": "Nie udało się zapisać pulpitu", + "error_desc": "Coś poszło nie tak po drodze do serwera. Spróbuj ponownie.", + "refresh_error": "Nie udało się odświeżyć tego widżetu. Odśwież stronę, aby zobaczyć zmiany.", + "panel": { + "title": "Dostępne widżety", + "desc": "Przeciągnij widżet na swój pulpit.", + "search": "Szukaj widżetu", + "empty_title": "Wszystko jest już na pulpicie", + "empty_desc": "Usuń widżet, aby wrócił na tę listę.", + "no_results_title": "Brak pasujących widżetów", + "no_results_desc": "Nic tutaj nie nazywa się \"{query}\". Spróbuj innego słowa." + }, + "size": { + "title": "Szerokość", + "small": "Mała", + "medium": "Średnia", + "large": "Duża" + }, + "settings": { + "open": "Skonfiguruj {title}", + "title": "Skonfiguruj {title}", + "desc": "Te ustawienia dotyczą tylko tej karty - inni administratorzy mają swoje własne.", + "saved_title": "Ustawienia zapisane", + "saved_desc": "Karta użyje ich, gdy skończysz układać pulpit.", + "error_title": "Nie udało się zapisać ustawień", + "error_desc": "Coś poszło nie tak po drodze do serwera. Spróbuj ponownie.", + "load_error": "Nie udało się wczytać tych ustawień. Zamknij i spróbuj ponownie." + }, + "notes": { + "title": "Notatki", + "desc": "Prywatny notatnik. Widzisz go tylko Ty.", + "placeholder": "Wszystko, co warto zapamiętać...", + "saving": "Zapisywanie...", + "saved": "Zapisano", + "error": "Nie zapisano - pisz dalej, aby spróbować ponownie" + }, + "send-notification": { + "title": "Wyślij powiadomienie", + "desc": "Wysyła powiadomienie do użytkownika w czasie rzeczywistym, na każdej przeglądarce, na której jest zalogowany.", + "user_id": "ID użytkownika", + "message": "Tytuł", + "submit": "Wyślij powiadomienie", + "success": "Powiadomienie wysłane", + "error": "Nie udało się wysłać powiadomienia", + "default_message": "Wiadomość od administratora 👋", + "settings": { + "message": "Domyślny tytuł", + "message_desc": "Treść, od której zaczyna pole Tytuł przy każdym wczytaniu pulpitu. Zostaw puste, aby wrócić do wbudowanego powitania." + } + } + } }, "global": { "nav": { diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 1a01c4f97..4c63edb60 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -112,6 +112,9 @@ "@base-ui/react": "^1.6.0", "@bprogress/next": "^3.2.12", "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hono/swagger-ui": "^0.6.1", "@inquirer/prompts": "^8.5.2", "@tanstack/react-query": "^5.101.4", diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index ce8bee229..d47e1c161 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; import { advancedAdminModule } from "./advanced/advanced.admin.module"; +import { dashboardAdminModule } from "./dashboard/dashboard.admin.module"; import { debugAdminModule } from "./debug/debug.admin.module"; import { filesAdminModule } from "./files/files.admin.module"; import { rolesAdminModule } from "./roles/roles.admin.module"; @@ -21,6 +22,7 @@ export const adminModule = buildModule({ debugAdminModule, advancedAdminModule, filesAdminModule, + dashboardAdminModule, ], cronJobs: [], }); diff --git a/packages/vitnode/src/api/modules/admin/dashboard/dashboard.admin.module.ts b/packages/vitnode/src/api/modules/admin/dashboard/dashboard.admin.module.ts new file mode 100644 index 000000000..5d3303a7b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/dashboard/dashboard.admin.module.ts @@ -0,0 +1,16 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { getDashboardLayoutAdminRoute } from "./routes/get-layout.route"; +import { saveDashboardLayoutAdminRoute } from "./routes/save-layout.route"; +import { saveDashboardWidgetSettingsAdminRoute } from "./routes/save-widget-settings.route"; + +export const dashboardAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "dashboard", + routes: [ + getDashboardLayoutAdminRoute, + saveDashboardLayoutAdminRoute, + saveDashboardWidgetSettingsAdminRoute, + ], +}); diff --git a/packages/vitnode/src/api/modules/admin/dashboard/lib/layout.test.ts b/packages/vitnode/src/api/modules/admin/dashboard/lib/layout.test.ts new file mode 100644 index 000000000..02a7dc7e9 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/dashboard/lib/layout.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; + +import type { AdminDashboardWidgetLayoutItem } from "@/database/dashboard"; + +import { + isSettingsTooLarge, + mergeLayoutForSave, + mergeWidgetSettings, + zodWidgetId, +} from "./layout"; + +const item = ( + id: string, + overrides: Partial = {}, +): AdminDashboardWidgetLayoutItem => ({ id, span: 1, rows: 1, ...overrides }); + +const merge = ({ + incoming, + managed, + previous, +}: { + incoming: AdminDashboardWidgetLayoutItem[]; + managed?: string[]; + previous: AdminDashboardWidgetLayoutItem[]; +}) => + mergeLayoutForSave({ + incoming: incoming as Required[], + managed: managed ?? previous.map(widget => widget.id), + previous, + }); + +describe("mergeLayoutForSave", () => { + it("stores the board in the order the admin arranged it", () => { + const merged = merge({ + incoming: [item("b"), item("a")], + previous: [], + }); + + expect(merged.map(widget => widget.id)).toEqual(["b", "a"]); + }); + + it("carries a widget's settings across a rearrange", () => { + const merged = merge({ + incoming: [item("notes", { span: 3, rows: 2 })], + previous: [item("notes", { settings: { content: "buy milk" } })], + }); + + expect(merged[0]).toEqual({ + id: "notes", + span: 3, + rows: 2, + settings: { content: "buy milk" }, + }); + }); + + it("ignores settings the client tries to smuggle in", () => { + const merged = merge({ + incoming: [ + item("notes", { settings: { content: "injected" } }), + ] as AdminDashboardWidgetLayoutItem[], + previous: [item("notes", { settings: { content: "real" } })], + }); + + expect(merged[0].settings).toEqual({ content: "real" }); + }); + + // Without this a `defaultEnabled` widget can never be removed - the + // normalizer puts it straight back on the next load. + it("remembers a removed widget as hidden", () => { + const merged = merge({ + incoming: [item("a")], + previous: [item("a"), item("notes")], + }); + + expect(merged).toEqual([item("a"), { ...item("notes"), hidden: true }]); + }); + + it("remembers a removed widget the admin had never saved", () => { + const merged = merge({ + incoming: [item("a")], + managed: ["a", "notes"], + previous: [], + }); + + expect(merged).toEqual([item("a"), { id: "notes", hidden: true }]); + }); + + it("writes one entry for a managed id sent twice", () => { + const merged = merge({ + incoming: [], + managed: ["notes", "notes"], + previous: [], + }); + + expect(merged).toEqual([{ id: "notes", hidden: true }]); + }); + + it("keeps a removed widget's settings so they survive re-adding it", () => { + const merged = merge({ + incoming: [], + previous: [item("notes", { settings: { content: "keep me" } })], + }); + + expect(merged[0]).toMatchObject({ + hidden: true, + settings: { content: "keep me" }, + }); + }); + + it("clears the hidden flag when the widget is placed again", () => { + const merged = merge({ + incoming: [item("notes")], + previous: [item("notes", { hidden: true })], + }); + + expect(merged[0].hidden).toBeUndefined(); + }); + + it("drops duplicate ids from the client", () => { + const merged = merge({ + incoming: [item("a", { span: 2 }), item("a", { span: 3 })], + previous: [], + }); + + expect(merged).toEqual([item("a", { span: 2 })]); + }); + + it("keeps every copy of a repeatable widget, each with its own settings", () => { + const merged = merge({ + incoming: [item("send"), item("send#2"), item("send#3")], + previous: [ + item("send", { settings: { to: "1" } }), + item("send#2", { settings: { to: "2" } }), + ], + }); + + expect(merged).toEqual([ + { id: "send", span: 1, rows: 1, settings: { to: "1" } }, + { id: "send#2", span: 1, rows: 1, settings: { to: "2" } }, + { id: "send#3", span: 1, rows: 1 }, + ]); + }); + + it("remembers a removed copy without touching its siblings", () => { + const merged = merge({ + incoming: [item("send")], + previous: [item("send"), item("send#2", { settings: { to: "2" } })], + }); + + expect(merged).toEqual([ + { id: "send", span: 1, rows: 1 }, + { id: "send#2", span: 1, rows: 1, settings: { to: "2" }, hidden: true }, + ]); + }); + + // The client never saw these, so it cannot have been asked to remove them. + // Hiding them would mean losing the card for good once the plugin or the + // permission came back. + it("leaves an entry the client could not account for alone", () => { + const merged = merge({ + incoming: [item("a")], + managed: ["a"], + previous: [item("a"), item("gone", { settings: { keep: true } })], + }); + + expect(merged[1]).toEqual(item("gone", { settings: { keep: true } })); + }); + + it("does not resurrect a hidden entry it could not account for either", () => { + const merged = merge({ + incoming: [], + managed: [], + previous: [item("gone", { hidden: true })], + }); + + expect(merged).toEqual([item("gone", { hidden: true })]); + }); +}); + +describe("mergeWidgetSettings", () => { + // A `defaultEnabled` card is on screen from the very first load, long before + // the admin has arranged anything - what they type into it has to land. + it("creates the entry when the admin has never arranged their board", () => { + const merged = mergeWidgetSettings({ + previous: [], + settings: { content: "first note" }, + widgetId: "@vitnode/core:notes", + }); + + expect(merged).toEqual([ + { id: "@vitnode/core:notes", settings: { content: "first note" } }, + ]); + }); + + // Left to the normalizer, which knows the widget's own defaults. + it("leaves a created entry unsized", () => { + const [created] = mergeWidgetSettings({ + previous: [], + settings: {}, + widgetId: "notes", + }); + + expect(created.span).toBeUndefined(); + expect(created.rows).toBeUndefined(); + }); + + it("merges into an existing entry without touching its size", () => { + const merged = mergeWidgetSettings({ + previous: [item("notes", { span: 3, settings: { content: "a", to: 1 } })], + settings: { content: "b" }, + widgetId: "notes", + }); + + expect(merged).toEqual([ + item("notes", { span: 3, settings: { content: "b", to: 1 } }), + ]); + }); + + // The route caps what it stores, and the patch is spread over the bag that is + // already there - so a pair of patches that each pass on their own can still + // add up past the cap. It has to be the merged result that is measured. + it("grows past the cap on a patch that fits on its own", () => { + const half = { a: "x".repeat(40 * 1024) }; + const patch = { b: "x".repeat(40 * 1024) }; + expect(isSettingsTooLarge(half)).toBe(false); + expect(isSettingsTooLarge(patch)).toBe(false); + + const [merged] = mergeWidgetSettings({ + previous: [item("notes", { settings: half })], + settings: patch, + widgetId: "notes", + }); + + expect(isSettingsTooLarge(merged.settings)).toBe(true); + }); + + it("leaves every other copy alone", () => { + const merged = mergeWidgetSettings({ + previous: [item("send"), item("send#2", { settings: { to: "2" } })], + settings: { to: "1" }, + widgetId: "send", + }); + + expect(merged).toEqual([ + item("send", { settings: { to: "1" } }), + item("send#2", { settings: { to: "2" } }), + ]); + }); +}); + +describe("zodWidgetId", () => { + it("accepts a widget id", () => { + expect(zodWidgetId.safeParse("@vitnode/core:notes").success).toBe(true); + }); + + it("accepts a copy of one", () => { + expect(zodWidgetId.safeParse("@vitnode/core:notes#2").success).toBe(true); + }); + + it("rejects a malformed copy suffix", () => { + expect(zodWidgetId.safeParse("@vitnode/core:notes#").success).toBe(false); + expect(zodWidgetId.safeParse("@vitnode/core:notes#abc").success).toBe( + false, + ); + expect(zodWidgetId.safeParse("@vitnode/core:notes#2#3").success).toBe( + false, + ); + }); + + it("still rejects an id with no plugin part", () => { + expect(zodWidgetId.safeParse("notes#2").success).toBe(false); + }); +}); + +describe("isSettingsTooLarge", () => { + it("accepts an ordinary note", () => { + expect(isSettingsTooLarge({ content: "a short note" })).toBe(false); + }); + + it("accepts undefined", () => { + expect(isSettingsTooLarge(undefined)).toBe(false); + }); + + it("rejects settings past the 64 KB cap", () => { + expect(isSettingsTooLarge({ content: "x".repeat(65 * 1024) })).toBe(true); + }); + + // Counted in bytes, not characters, or the cap would be four times looser for + // a note written in a script that does not fit in one byte per character. + it("counts a multi-byte character as the bytes it takes", () => { + expect(isSettingsTooLarge({ content: "€".repeat(30 * 1024) })).toBe(true); + expect(isSettingsTooLarge({ content: "€".repeat(10 * 1024) })).toBe(false); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/dashboard/lib/layout.ts b/packages/vitnode/src/api/modules/admin/dashboard/lib/layout.ts new file mode 100644 index 000000000..911629bee --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/dashboard/lib/layout.ts @@ -0,0 +1,159 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { AdminDashboardWidgetLayoutItem } from "@/database/dashboard"; + +import { core_admin_dashboard } from "@/database/dashboard"; + +const widgetIdRegex = /^[@\w][\w./@-]*:[\w.-]+(#\d{1,3})?$/; + +export const zodWidgetId = z + .string() + .min(3) + .max(132) + .regex(widgetIdRegex, { message: "Invalid widget id" }) + .openapi({ example: "@vitnode/core:notes" }); + +export const zodDashboardWidgetSettings = z + .record(z.string(), z.unknown()) + .openapi({ example: { content: "Remember to renew the TLS cert." } }); + +const zodSize = z.union([z.literal(1), z.literal(2), z.literal(3)]); + +export const zodDashboardWidgetLayoutItem = z.object({ + id: zodWidgetId, + rows: zodSize.openapi({ example: 1 }), + span: zodSize.openapi({ example: 2 }), +}); + +export const zodStoredDashboardWidget = z.object({ + hidden: z.boolean().optional(), + id: zodWidgetId, + rows: zodSize.optional(), + settings: zodDashboardWidgetSettings.optional(), + span: zodSize.optional(), +}); + +export const MAX_WIDGETS = 64; +export const MAX_STORED_WIDGETS = MAX_WIDGETS * 2; + +export const zodDashboardLayout = z.object({ + managed: z.array(zodWidgetId).max(MAX_STORED_WIDGETS), + widgets: z.array(zodDashboardWidgetLayoutItem).max(MAX_WIDGETS), +}); + +const MAX_SETTINGS_BYTES = 64 * 1024; +const encoder = new TextEncoder(); + +/** Counted in UTF-8 bytes, so the cap means the same thing in every script. */ +export const isSettingsTooLarge = (settings: unknown): boolean => + encoder.encode(JSON.stringify(settings ?? {})).length > MAX_SETTINGS_BYTES; + +export const mergeLayoutForSave = ({ + incoming, + managed, + previous, +}: { + incoming: Pick< + Required, + "id" | "rows" | "span" + >[]; + managed: string[]; + previous: AdminDashboardWidgetLayoutItem[]; +}): AdminDashboardWidgetLayoutItem[] => { + const previousById = new Map(previous.map(widget => [widget.id, widget])); + const managedIds = new Set(managed); + const seen = new Set(); + const next: AdminDashboardWidgetLayoutItem[] = []; + + for (const widget of incoming) { + if (seen.has(widget.id)) continue; + seen.add(widget.id); + + const settings = previousById.get(widget.id)?.settings; + next.push({ + id: widget.id, + span: widget.span, + rows: widget.rows, + ...(settings ? { settings } : {}), + }); + } + + for (const widget of previous) { + if (seen.has(widget.id)) continue; + + next.push(managedIds.has(widget.id) ? { ...widget, hidden: true } : widget); + } + + for (const id of managed) { + if (seen.has(id) || previousById.has(id)) continue; + seen.add(id); + + next.push({ id, hidden: true }); + } + + return next; +}; + +export const mergeWidgetSettings = ({ + previous, + settings, + widgetId, +}: { + previous: AdminDashboardWidgetLayoutItem[]; + settings: Record; + widgetId: string; +}): AdminDashboardWidgetLayoutItem[] => { + const index = previous.findIndex(widget => widget.id === widgetId); + if (index === -1) return [...previous, { id: widgetId, settings }]; + + return previous.map((widget, at) => + at === index + ? { ...widget, settings: { ...widget.settings, ...settings } } + : widget, + ); +}; + +export const getDashboardWidgets = async ( + c: Context, + userId: number, +): Promise => { + const [row] = await c + .get("db") + .select({ widgets: core_admin_dashboard.widgets }) + .from(core_admin_dashboard) + .where(eq(core_admin_dashboard.userId, userId)) + .limit(1); + + return row?.widgets ?? []; +}; + +export const mutateDashboardWidgets = async ( + c: Context, + userId: number, + mutate: ( + widgets: AdminDashboardWidgetLayoutItem[], + ) => AdminDashboardWidgetLayoutItem[], +): Promise => { + await c.get("db").transaction(async tx => { + await tx + .insert(core_admin_dashboard) + .values({ userId }) + .onConflictDoNothing({ target: core_admin_dashboard.userId }); + + const [row] = await tx + .select({ widgets: core_admin_dashboard.widgets }) + .from(core_admin_dashboard) + .where(eq(core_admin_dashboard.userId, userId)) + .limit(1) + .for("update"); + + await tx + .update(core_admin_dashboard) + .set({ widgets: mutate(row?.widgets ?? []), updatedAt: new Date() }) + .where(eq(core_admin_dashboard.userId, userId)); + }); +}; diff --git a/packages/vitnode/src/api/modules/admin/dashboard/routes/get-layout.route.ts b/packages/vitnode/src/api/modules/admin/dashboard/routes/get-layout.route.ts new file mode 100644 index 000000000..09a213ad6 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/dashboard/routes/get-layout.route.ts @@ -0,0 +1,35 @@ +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { getDashboardWidgets, zodStoredDashboardWidget } from "../lib/layout"; + +export const getDashboardLayoutAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "dashboard", permission: "can_view" }, + route: { + method: "get", + description: "Get the signed-in admin's own dashboard widget layout", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + widgets: z.array(zodStoredDashboardWidget), + }), + }, + }, + description: "The admin's dashboard layout", + }, + }, + }, + handler: async c => { + const admin = c.get("admin")?.user; + if (!admin) throw new HTTPException(403); + + return c.json({ widgets: await getDashboardWidgets(c, admin.id) }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/dashboard/routes/save-layout.route.ts b/packages/vitnode/src/api/modules/admin/dashboard/routes/save-layout.route.ts new file mode 100644 index 000000000..5af03feb8 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/dashboard/routes/save-layout.route.ts @@ -0,0 +1,56 @@ +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + mergeLayoutForSave, + mutateDashboardWidgets, + zodDashboardLayout, +} from "../lib/layout"; + +export const zodSaveDashboardLayoutSchema = zodDashboardLayout; + +export const saveDashboardLayoutAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "dashboard", permission: "can_edit" }, + route: { + method: "put", + description: + "Replace the signed-in admin's dashboard layout (order, spans and rows)", + path: "/layout", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodSaveDashboardLayoutSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + description: "Layout saved", + }, + }, + }, + handler: async c => { + const admin = c.get("admin")?.user; + if (!admin) throw new HTTPException(403); + + const { managed, widgets } = c.req.valid("json"); + + await mutateDashboardWidgets(c, admin.id, previous => + mergeLayoutForSave({ incoming: widgets, managed, previous }), + ); + + return c.json({ success: true }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/dashboard/routes/save-widget-settings.route.ts b/packages/vitnode/src/api/modules/admin/dashboard/routes/save-widget-settings.route.ts new file mode 100644 index 000000000..3c54bc7ec --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/dashboard/routes/save-widget-settings.route.ts @@ -0,0 +1,74 @@ +import { z } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +import { + isSettingsTooLarge, + MAX_STORED_WIDGETS, + mergeWidgetSettings, + mutateDashboardWidgets, + zodDashboardWidgetSettings, + zodWidgetId, +} from "../lib/layout"; + +export const zodSaveWidgetSettingsSchema = z.object({ + settings: zodDashboardWidgetSettings, + widgetId: zodWidgetId, +}); + +export const saveDashboardWidgetSettingsAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "dashboard", permission: "can_edit" }, + route: { + method: "put", + description: + "Merge settings into a single widget on the signed-in admin's dashboard", + path: "/widget-settings", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodSaveWidgetSettingsSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ success: z.boolean() }), + }, + }, + description: "Widget settings saved", + }, + }, + }, + handler: async c => { + const admin = c.get("admin")?.user; + if (!admin) throw new HTTPException(403); + + const { widgetId, settings } = c.req.valid("json"); + + await mutateDashboardWidgets(c, admin.id, previous => { + // Only a brand new entry can grow the row, and only so far. + const isNew = !previous.some(widget => widget.id === widgetId); + if (isNew && previous.length >= MAX_STORED_WIDGETS) { + throw new HTTPException(409, { message: "Too many widgets" }); + } + + const next = mergeWidgetSettings({ previous, settings, widgetId }); + const merged = next.find(widget => widget.id === widgetId)?.settings; + if (isSettingsTooLarge(merged)) { + throw new HTTPException(413, { message: "Widget settings too large" }); + } + + return next; + }); + + return c.json({ success: true }, 200); + }, +}); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index e4eec9669..9119a04f9 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -23,6 +23,10 @@ export const newBuildPluginApiCore = buildApiPlugin({ users: ["can_edit"], }, admin: { + dashboard: [ + "can_view", + { permission: "can_edit", dependsOn: ["can_view"] }, + ], users: [ "can_view", { permission: "can_create", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/database/dashboard.ts b/packages/vitnode/src/database/dashboard.ts new file mode 100644 index 000000000..1e75cb2df --- /dev/null +++ b/packages/vitnode/src/database/dashboard.ts @@ -0,0 +1,54 @@ +import { relations } from "drizzle-orm"; +import { index, pgTable } from "drizzle-orm/pg-core"; + +import type { + AdminDashboardWidgetRows, + AdminDashboardWidgetSettings, + AdminDashboardWidgetSpan, +} from "@/lib/plugin"; + +import { core_users } from "./users"; + +export interface AdminDashboardWidgetLayoutItem { + hidden?: boolean; + id: string; + rows?: AdminDashboardWidgetRows; + settings?: AdminDashboardWidgetSettings; + span?: AdminDashboardWidgetSpan; +} + +export const core_admin_dashboard = pgTable( + "core_admin_dashboard", + t => ({ + id: t.serial().primaryKey(), + userId: t + .integer() + .references(() => core_users.id, { + onDelete: "cascade", + }) + .notNull() + .unique(), + widgets: t + .jsonb() + .$type() + .notNull() + .default([]), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [index("core_admin_dashboard_user_id_idx").on(t.userId)], +).enableRLS(); + +export const core_admin_dashboard_relations = relations( + core_admin_dashboard, + ({ one }) => ({ + user: one(core_users, { + fields: [core_admin_dashboard.userId], + references: [core_users.id], + }), + }), +); diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index a22c34969..19af390b3 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -2,11 +2,6 @@ import type { PermissionsStaffArgs } from "../api/lib/permission-staff"; import type { ItemNavAdmin } from "../views/admin/layouts/sidebar/nav/item"; import type { LocaleMessagesMap } from "./i18n/types"; -/** - * A staff permission a nav item is gated by, scoped to the declaring plugin - * (the `plugin` is filled in automatically from the plugin's id). When set, the - * item is hidden from the admin sidebar unless the current admin holds it. - */ export type AdminNavPermission = Omit; interface AdminNavItem extends Pick< @@ -17,18 +12,38 @@ interface AdminNavItem extends Pick< permission?: AdminNavPermission; } +export type AdminDashboardWidgetSpan = 1 | 2 | 3; +export type AdminDashboardWidgetRows = 1 | 2 | 3; +export type AdminDashboardWidgetSettings = Record; + +export interface AdminDashboardWidgetProps { + settings: AdminDashboardWidgetSettings; + widgetId: string; +} + +export interface AdminDashboardWidget { + allowMultiple?: boolean; + category?: string; + component: React.ComponentType; + defaultEnabled?: boolean; + defaultRows?: AdminDashboardWidgetRows; + defaultSpan?: AdminDashboardWidgetSpan; + icon?: React.ReactNode; + id: string; + minSpan?: AdminDashboardWidgetSpan; + permission?: AdminNavPermission; + settingsComponent?: React.ComponentType; +} + export interface BuildPluginReturn

{ admin?: { + dashboard?: { + widgets?: AdminDashboardWidget[]; + }; nav?: (AdminNavItem & { items?: Omit[]; })[]; }; - /** - * The plugin's *frontend* strings, usually `import messages from - * "./locales"`. Merged into the app's message tree at request time - nothing - * is copied into the app. Server-only strings (emails) go in the separate - * `messages` on `buildApiPlugin` instead. - */ messages?: LocaleMessagesMap; pluginId: P; } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 04f082c47..1d8bff561 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -2,6 +2,9 @@ "@vitnode/core": { "title": "Core" }, + "@vitnode/core:dashboard": "Dashboard", + "@vitnode/core:dashboard:can_view": "View own dashboard layout", + "@vitnode/core:dashboard:can_edit": "Customize own dashboard layout", "@vitnode/core:users": "Users", "@vitnode/core:users:can_view": "View users list", "@vitnode/core:users:can_create": "Create users", @@ -387,7 +390,67 @@ "admin": { "dashboard": { "dev_mode": "Development Mode", - "version": "Version: {version}" + "version": "Version: {version}", + "widgets": { + "edit": "Edit layout", + "drag_handle": "Move {title}", + "remove": "Remove {title}", + "drop_here": "Drop a widget here", + "empty_title": "Your dashboard is empty", + "empty_desc": "Add widgets to keep the numbers you care about one glance away.", + "saved_title": "Dashboard saved", + "saved_desc": "Your layout is yours alone - other admins keep theirs.", + "error_title": "Could not save your dashboard", + "error_desc": "Something went wrong on the way to the server. Please try again.", + "refresh_error": "Could not reload this widget. Reload the page to see your changes.", + "panel": { + "title": "Available widgets", + "desc": "Drag a widget onto your dashboard.", + "search": "Search for a widget", + "empty_title": "Everything is already on the board", + "empty_desc": "Remove a widget to put it back here.", + "no_results_title": "No widget matches", + "no_results_desc": "Nothing here is called \"{query}\". Try another word." + }, + "size": { + "title": "Width", + "small": "Small", + "medium": "Medium", + "large": "Large" + }, + "settings": { + "open": "Configure {title}", + "title": "Configure {title}", + "desc": "These settings apply to this card only - other admins keep their own.", + "saved_title": "Settings saved", + "saved_desc": "The card picks them up once you are done arranging the board.", + "error_title": "Could not save the settings", + "error_desc": "Something went wrong on the way to the server. Please try again.", + "load_error": "Could not load these settings. Close this and try again." + }, + "notes": { + "title": "Notes", + "desc": "A private scratchpad. Only you can see it.", + "placeholder": "Anything worth remembering...", + "saving": "Saving...", + "saved": "Saved", + "error": "Not saved - keep typing to try again" + }, + "send-notification": { + "title": "Send a notification", + "desc": "Pushes a toast to the user in real time, on every browser where they are signed in.", + "user_id": "User ID", + "message": "Title", + "submit": "Send notification", + "success": "Notification sent", + "error": "Failed to send notification", + "default_message": "Hello from the admin 👋", + "settings": { + "message": "Default title", + "message_desc": "What the Title field starts with each time the dashboard loads. Leave it empty for the built-in greeting." + } + } + } }, "global": { "nav": { diff --git a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx index 736bbfcdb..eb2b5b429 100644 --- a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx +++ b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx @@ -47,7 +47,7 @@ export const AdminLayout = async ({ -

+
{breadcrumb != null && ( <> diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/dashboard-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/dashboard-admin-view.tsx index d9a296c2b..1e8ed20bb 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/dashboard-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/dashboard-admin-view.tsx @@ -1,46 +1,119 @@ import { AlertTriangleIcon } from "lucide-react"; import { getTranslations } from "next-intl/server"; +import { adminModule } from "@/api/modules/admin/admin.module"; +import { I18nProvider } from "@/components/i18n-provider"; import { Badge } from "@/components/ui/badge"; import { HeaderContent } from "@/components/ui/header-content"; import { getSessionAdminApi } from "@/lib/api/get-session-admin-api"; import { CONFIG } from "@/lib/config"; +import { fetcher } from "@/lib/fetcher"; -import { SendNotificationAction } from "./send-notification/send-notification"; +import type { DashboardWidgetCatalogEntry } from "./widgets/types"; + +import { DashboardBoardProvider } from "./grid/board-provider"; +import { DashboardGrid } from "./grid/dashboard-grid"; +import { DashboardEditActions } from "./grid/edit-actions"; +import { getDashboardWidgets } from "./widgets/get-dashboard-widgets"; +import { widgetIdOf } from "./widgets/instance-id"; +import { normalizeLayout } from "./widgets/normalize-layout"; export const DashboardAdminView = async () => { const session = await getSessionAdminApi(); const t = await getTranslations("admin.dashboard"); if (!session) return null; - const { user, vitnode_version } = session; + const { vitnode_version } = session; + + const [widgets, res] = await Promise.all([ + getDashboardWidgets(), + fetcher(adminModule, { + path: "/", + method: "get", + module: "admin/dashboard", + }), + ]); + + const saved = res.ok ? (await res.json()).widgets : []; + const layout = normalizeLayout({ saved, widgets }); + + const widgetIds = new Set(widgets.map(({ id }) => id)); + const managedIds = [ + ...new Set([ + ...saved + .filter(item => widgetIds.has(widgetIdOf(item.id))) + .map(item => item.id), + ...layout.map(item => item.id), + ]), + ]; + + const placedWidgetIds = new Set(layout.map(item => widgetIdOf(item.id))); + const content: Record = {}; + + for (const item of layout) { + const widget = widgets.find(({ id }) => id === widgetIdOf(item.id)); + if (!widget) continue; + + const Widget = widget.component; + content[item.id] = ( + + ); + } + + const catalog: DashboardWidgetCatalogEntry[] = widgets.map(widget => { + const Widget = widget.component; + const needsStandIn = + !!widget.allowMultiple || !placedWidgetIds.has(widget.id); + + return { + id: widget.id, + title: widget.title, + desc: widget.desc, + icon: widget.icon, + category: widget.category, + allowMultiple: widget.allowMultiple, + minSpan: widget.minSpan, + defaultSpan: widget.defaultSpan, + defaultRows: widget.defaultRows, + + content: needsStandIn ? ( + + ) : null, + + hasSettings: !!widget.settingsComponent, + }; + }); return (
- - VitNode - {CONFIG.node_development && ( - - {t("dev_mode")} - - )} - - } - /> - -
-

Send a notification

-

- Pushes a toast to the user in real time, on every browser where they - are signed in. Defaults to your own user id. -

- -
+ + + + VitNode + {CONFIG.node_development && ( + + {t("dev_mode")} + + )} + + } + > + + + + + +
); }; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx new file mode 100644 index 000000000..576e5e199 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/board-provider.tsx @@ -0,0 +1,339 @@ +"use client"; + +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; + +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToWindowEdges } from "@dnd-kit/modifiers"; +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import { Card } from "@/components/ui/card"; +import { useRouter } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; + +import type { + DashboardLayoutItem, + DashboardWidgetCatalogEntry, + DashboardWidgetOption, + DashboardWidgetView, +} from "../widgets/types"; +import type { DashboardLayoutAction } from "./layout-reducer"; + +import { widgetIdOf } from "../widgets/instance-id"; +import { loadWidgetContentAction } from "../widgets/load-widget-content.server"; +import { DROP_END_ID } from "./drop-placeholder"; +import { dashboardLayoutReducer, isLayoutDirty } from "./layout-reducer"; +import { panelWidgetId } from "./panel-drag-id"; +import { saveDashboardLayoutMutation } from "./save-layout.server"; +import { WidgetCardContent } from "./widget-card"; + +interface DashboardBoardContextProps { + /** Widgets the admin owns but has not placed - the panel's contents. */ + available: DashboardWidgetOption[]; + dispatch: React.Dispatch; + /** True once the admin has changed something worth saving. */ + isDirty: boolean; + isEditing: boolean; + /** A save is in flight. */ + isPending: boolean; + /** Drops the admin's edits and leaves edit mode. */ + onCancel: () => void; + onSave: () => void; + /** Widgets on the board, in order, each with its rendered content. */ + placed: DashboardWidgetView[]; + /** + * Sends one card back to the server to be rendered again - what a settings + * dialog calls once its write lands. Only that card goes: reloading the whole + * board mid-edit would throw away whatever the admin has arranged. + */ + refreshWidget: (instanceId: string) => void; + setIsEditing: (isEditing: boolean) => void; +} + +const DashboardBoardContext = + React.createContext(null); + +export const useDashboardBoard = () => { + const context = React.use(DashboardBoardContext); + if (!context) { + throw new Error( + "useDashboardBoard must be used within a DashboardBoardProvider.", + ); + } + + return context; +}; + +/** + * Unwraps a card the server has re-rendered, so the boundary around it in + * `WidgetCardContent` shows its skeleton until the new output arrives. + */ +const RefreshedWidgetContent = ({ + content, +}: { + content: Promise; +}): React.ReactNode => React.use(content); + +interface DashboardBoardProviderProps { + /** Every widget this admin may see, already rendered on the server. */ + catalog: DashboardWidgetCatalogEntry[]; + children: React.ReactNode; + /** Server-rendered output per placed copy, keyed by its instance id. */ + content: Record; + layout: DashboardLayoutItem[]; + /** Stored ids this board speaks for - see `zodDashboardLayout`. */ + managedIds: string[]; +} + +export const DashboardBoardProvider = ({ + catalog, + children, + content, + layout, + managedIds, +}: DashboardBoardProviderProps) => { + const t = useTranslations("admin.dashboard.widgets"); + const router = useRouter(); + + const [isEditing, setIsEditing] = React.useState(false); + const [activeId, setActiveId] = React.useState(null); + const [isPending, startTransition] = React.useTransition(); + const [items, dispatch] = React.useReducer(dashboardLayoutReducer, layout); + const [refreshed, setRefreshed] = React.useState< + Record; revision: number }> + >({}); + + // The server owns the layout; re-sync whenever it hands us a new one. + const [syncedLayout, setSyncedLayout] = React.useState(layout); + if (syncedLayout !== layout) { + setSyncedLayout(layout); + dispatch({ type: "reset", state: layout }); + setRefreshed({}); + } + + const catalogById = React.useMemo( + () => new Map(catalog.map(widget => [widget.id, widget])), + [catalog], + ); + + const placed = React.useMemo( + () => + items.flatMap(item => { + const widget = catalogById.get(widgetIdOf(item.id)); + if (!widget) return []; + + const refresh = refreshed[item.id]; + + return [ + { + ...widget, + instanceId: item.id, + span: item.span, + rows: item.rows, + contentKey: refresh + ? `refresh:${refresh.revision}` + : JSON.stringify(item.settings ?? {}), + + content: refresh ? ( + + ) : ( + // A copy dragged in during this edit has no output of its own yet + // - the catalog's stand-in carries it until the next save. + (content[item.id] ?? widget.content) + ), + }, + ]; + }), + [items, catalogById, content, refreshed], + ); + + const available = React.useMemo(() => { + const used = new Set(items.map(item => widgetIdOf(item.id))); + + return ( + catalog + // A widget that may be placed repeatedly never leaves the panel. + .filter(widget => !!widget.allowMultiple || !used.has(widget.id)) + .map(widget => ({ + id: widget.id, + title: widget.title, + desc: widget.desc, + icon: widget.icon, + category: widget.category, + allowMultiple: widget.allowMultiple, + minSpan: widget.minSpan, + defaultSpan: widget.defaultSpan, + defaultRows: widget.defaultRows, + })) + ); + }, [catalog, items]); + + const sensors = useSensors( + // A short threshold keeps clicks inside a widget working as clicks. + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + // Hold-to-drag on touch, so the page still scrolls normally. + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // What the drag overlay shows. A card being rearranged is a placed copy; a + // row dragged out of the panel is only a catalog entry so far. + const activeView = placed.find(widget => widget.instanceId === activeId); + const activeCatalogEntry = + activeView ?? + (activeId ? catalogById.get(panelWidgetId(activeId) ?? "") : undefined); + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + if (!over) return; + + const draggedId = String(active.id); + const overId = String(over.id); + + if (active.data.current?.fromPanel === true) { + const widget = active.data.current.widget as + DashboardWidgetOption | undefined; + if (!widget) return; + + const overIndex = items.findIndex(item => item.id === overId); + dispatch({ + type: "add", + widget, + index: overIndex === -1 ? undefined : overIndex, + }); + + return; + } + + const from = items.findIndex(item => item.id === draggedId); + if (from === -1) return; + + const to = + overId === DROP_END_ID + ? items.length - 1 + : items.findIndex(item => item.id === overId); + if (to === -1) return; + + dispatch({ type: "move", index: from, toIndex: to }); + }; + + const refreshWidget = React.useCallback( + (instanceId: string) => { + const content = loadWidgetContentAction({ widgetId: instanceId }).catch( + () =>

{t("refresh_error")}

, + ); + + setRefreshed(current => ({ + ...current, + [instanceId]: { + content, + revision: (current[instanceId]?.revision ?? 0) + 1, + }, + })); + }, + [t], + ); + + const onCancel = () => { + // Settings were written the moment their dialog saved them, so the cards + // that picked them up keep what they are showing - only the arrangement, + // which was never sent anywhere, goes back to what the server has. + dispatch({ type: "reset", state: layout }); + setIsEditing(false); + }; + + const onSave = () => { + startTransition(async () => { + const res = await saveDashboardLayoutMutation({ + managed: managedIds, + widgets: items, + }); + + if (res?.error) { + toast.error(t("error_title"), { description: t("error_desc") }); + + return; + } + + setIsEditing(false); + toast.success(t("saved_title"), { description: t("saved_desc") }); + router.refresh(); + }); + }; + + return ( + + setActiveId(null)} + onDragEnd={onDragEnd} + onDragStart={(event: DragStartEvent) => + setActiveId(String(event.active.id)) + } + sensors={sensors} + > +
+ {children} +
+ + + {activeCatalogEntry ? ( + + + + ) : null} + +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/dashboard-grid.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/dashboard-grid.tsx new file mode 100644 index 000000000..e796c904c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/dashboard-grid.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { rectSortingStrategy, SortableContext } from "@dnd-kit/sortable"; +import { LayoutGridIcon, PencilIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; + +import type { AdminDashboardWidgetSpan } from "../widgets/types"; + +import { useDashboardBoard } from "./board-provider"; +import { DropPlaceholder } from "./drop-placeholder"; +import { DashboardPanelActions } from "./edit-actions"; +import { gridClasses } from "./span-classes"; +import { WidgetCard } from "./widget-card"; +import { WidgetPanel } from "./widget-panel"; + +export const DashboardGrid = () => { + const t = useTranslations("admin.dashboard.widgets"); + const { + available, + dispatch, + isEditing, + placed, + refreshWidget, + setIsEditing, + } = useDashboardBoard(); + + return ( + <> + {placed.length === 0 && !isEditing ? ( + + + + + + {t("empty_title")} + {t("empty_desc")} + + + + ) : ( + widget.instanceId)} + strategy={rectSortingStrategy} + > +
+ {placed.map(widget => ( + dispatch({ type: "remove", id })} + onResize={(id, span: AdminDashboardWidgetSpan) => + dispatch({ type: "resize", id, span }) + } + onSettingsSaved={() => refreshWidget(widget.instanceId)} + widget={widget} + /> + ))} + + {isEditing && } +
+
+ )} + + } + isOpen={isEditing} + widgets={available} + /> + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/drop-placeholder.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/drop-placeholder.tsx new file mode 100644 index 000000000..5ac64d640 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/drop-placeholder.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useDroppable } from "@dnd-kit/core"; +import { LayoutGridIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { cn } from "@/lib/utils"; + +export const DROP_END_ID = "vitnode-dashboard-drop-end"; + +export const DropPlaceholder = ({ isEmpty }: { isEmpty: boolean }) => { + const t = useTranslations("admin.dashboard.widgets"); + const { isOver, setNodeRef } = useDroppable({ id: DROP_END_ID }); + + return ( +
+ + {t("drop_here")} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/edit-actions.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/edit-actions.tsx new file mode 100644 index 000000000..e067f426f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/edit-actions.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { PencilIcon, SaveIcon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; + +import { useDashboardBoard } from "./board-provider"; + +export const DashboardEditActions = () => { + const t = useTranslations("admin.dashboard.widgets"); + const { isEditing, setIsEditing } = useDashboardBoard(); + + if (isEditing) return null; + + return ( + + ); +}; + +/** Ends edit mode, one way or the other. Sits in the widget panel's footer. */ +export const DashboardPanelActions = () => { + const t = useTranslations("core.global"); + const { isDirty, isPending, onCancel, onSave } = useDashboardBoard(); + + return ( +
+ + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/group-widgets.test.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/group-widgets.test.ts new file mode 100644 index 000000000..f71c2306f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/group-widgets.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import type { DashboardWidgetOption } from "../widgets/types"; + +import { groupWidgets, matchesWidgetQuery } from "./group-widgets"; + +const option = ( + id: string, + category: string, + overrides: Partial = {}, +): DashboardWidgetOption => ({ + id, + title: `Title ${id}`, + category: { id: category, title: `Category ${category}` }, + minSpan: 1, + defaultSpan: 1, + defaultRows: 1, + ...overrides, +}); + +describe("groupWidgets", () => { + it("buckets widgets under their own category", () => { + const groups = groupWidgets({ + widgets: [ + option("notes", "@vitnode/core"), + option("stats", "@vitnode/blog:content"), + option("hits", "@vitnode/blog:content"), + ], + }); + + expect(groups.map(group => [group.id, group.widgets.length])).toEqual([ + ["@vitnode/core", 1], + ["@vitnode/blog:content", 2], + ]); + }); + + it("keeps the order the resolver handed widgets over in", () => { + const groups = groupWidgets({ + widgets: [ + option("a", "second"), + option("b", "first"), + option("c", "second"), + ], + }); + + expect(groups.map(group => group.id)).toEqual(["second", "first"]); + expect(groups[0].widgets.map(widget => widget.id)).toEqual(["a", "c"]); + }); + + it("titles each group from its category", () => { + const [group] = groupWidgets({ widgets: [option("notes", "core")] }); + + expect(group.title).toBe("Category core"); + }); + + it("filters by title", () => { + const groups = groupWidgets({ + query: "notes", + widgets: [option("notes", "core"), option("stats", "core")], + }); + + expect(groups[0].widgets.map(widget => widget.id)).toEqual(["notes"]); + }); + + it("drops a group the search empties out", () => { + const groups = groupWidgets({ + query: "notes", + widgets: [option("notes", "core"), option("stats", "blog")], + }); + + expect(groups.map(group => group.id)).toEqual(["core"]); + }); + + it("returns nothing when the search matches nothing", () => { + expect( + groupWidgets({ query: "nope", widgets: [option("notes", "core")] }), + ).toEqual([]); + }); + + it("returns every group when the search is blank", () => { + const widgets = [option("notes", "core"), option("stats", "blog")]; + + expect(groupWidgets({ query: " ", widgets })).toHaveLength(2); + expect(groupWidgets({ widgets })).toHaveLength(2); + }); +}); + +describe("matchesWidgetQuery", () => { + const widget = option("stats", "@vitnode/blog", { + title: "Blog statistics", + desc: "Posts, comments and views at a glance.", + category: { id: "@vitnode/blog", title: "Blog" }, + }); + + it("matches part of a title, ignoring case", () => { + expect(matchesWidgetQuery(widget, "STATIST")).toBe(true); + }); + + it("matches the description", () => { + expect(matchesWidgetQuery(widget, "comments")).toBe(true); + }); + + // Searching the plugin's name is how an admin finds "everything Blog adds". + it("matches the group the widget sits under", () => { + expect(matchesWidgetQuery(widget, "blog")).toBe(true); + }); + + it("ignores surrounding whitespace", () => { + expect(matchesWidgetQuery(widget, " blog ")).toBe(true); + }); + + it("does not match an unrelated word", () => { + expect(matchesWidgetQuery(widget, "forum")).toBe(false); + }); + + it("matches a widget with no description", () => { + expect(matchesWidgetQuery(option("bare", "core"), "bare")).toBe(true); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/group-widgets.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/group-widgets.ts new file mode 100644 index 000000000..25d7b828d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/group-widgets.ts @@ -0,0 +1,44 @@ +import type { DashboardWidgetOption } from "../widgets/types"; + +export interface DashboardWidgetGroup { + id: string; + title: string; + widgets: DashboardWidgetOption[]; +} + +export const matchesWidgetQuery = ( + widget: DashboardWidgetOption, + query: string, +): boolean => { + const needle = query.trim().toLocaleLowerCase(); + if (!needle) return true; + + return [widget.title, widget.desc, widget.category.title].some(field => + field?.toLocaleLowerCase().includes(needle), + ); +}; + +export const groupWidgets = ({ + query = "", + widgets, +}: { + query?: string; + widgets: DashboardWidgetOption[]; +}): DashboardWidgetGroup[] => { + const groups = new Map(); + + for (const widget of widgets) { + if (!matchesWidgetQuery(widget, query)) continue; + + const { id, title } = widget.category; + const group = groups.get(id); + + if (group) { + group.widgets.push(widget); + } else { + groups.set(id, { id, title, widgets: [widget] }); + } + } + + return [...groups.values()]; +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/layout-reducer.test.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/layout-reducer.test.ts new file mode 100644 index 000000000..9c91d88e0 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/layout-reducer.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; + +import type { + DashboardLayoutItem, + DashboardWidgetOption, +} from "../widgets/types"; + +import { dashboardLayoutReducer, isLayoutDirty } from "./layout-reducer"; + +const item = (id: string, span: 1 | 2 | 3 = 1): DashboardLayoutItem => ({ + id, + span, + rows: 1, +}); + +const option = ( + id: string, + overrides: Partial = {}, +): DashboardWidgetOption => ({ + id, + title: id, + category: { id: "@vitnode/core", title: "Core" }, + minSpan: 1, + defaultSpan: 1, + defaultRows: 1, + ...overrides, +}); + +describe("dashboardLayoutReducer", () => { + describe("add", () => { + it("appends to the end when no index is given", () => { + const state = [item("a")]; + + expect( + dashboardLayoutReducer(state, { + type: "add", + widget: option("b", { defaultSpan: 2, defaultRows: 3 }), + }), + ).toEqual([item("a"), { id: "b", span: 2, rows: 3 }]); + }); + + it("inserts at the drop index", () => { + const state = [item("a"), item("b")]; + const next = dashboardLayoutReducer(state, { + type: "add", + widget: option("c"), + index: 1, + }); + + expect(next.map(entry => entry.id)).toEqual(["a", "c", "b"]); + }); + + it("respects minSpan over a smaller defaultSpan", () => { + const next = dashboardLayoutReducer([], { + type: "add", + widget: option("wide", { minSpan: 2, defaultSpan: 1 }), + }); + + expect(next[0].span).toBe(2); + }); + + it("ignores a widget that is already on the board", () => { + const state = [item("a")]; + + expect( + dashboardLayoutReducer(state, { type: "add", widget: option("a") }), + ).toBe(state); + }); + + it("adds another copy of a widget that allows several", () => { + const state = [item("a")]; + const next = dashboardLayoutReducer(state, { + type: "add", + widget: option("a", { allowMultiple: true }), + }); + + expect(next.map(entry => entry.id)).toEqual(["a", "a#2"]); + }); + + it("keeps numbering further copies", () => { + const state = [item("a"), item("a#2")]; + const next = dashboardLayoutReducer(state, { + type: "add", + widget: option("a", { allowMultiple: true }), + }); + + expect(next.map(entry => entry.id)).toEqual(["a", "a#2", "a#3"]); + }); + + // The first copy of a repeatable widget is a plain id, so a layout saved + // before `allowMultiple` existed keeps working. + it("gives the first copy the plain widget id", () => { + const next = dashboardLayoutReducer([], { + type: "add", + widget: option("a", { allowMultiple: true }), + }); + + expect(next.map(entry => entry.id)).toEqual(["a"]); + }); + + it("inserts a copy at the drop index", () => { + const state = [item("a"), item("b")]; + const next = dashboardLayoutReducer(state, { + type: "add", + widget: option("a", { allowMultiple: true }), + index: 1, + }); + + expect(next.map(entry => entry.id)).toEqual(["a", "a#2", "b"]); + }); + }); + + describe("move", () => { + it("reorders an item", () => { + const state = [item("a"), item("b"), item("c")]; + const next = dashboardLayoutReducer(state, { + type: "move", + index: 0, + toIndex: 2, + }); + + expect(next.map(entry => entry.id)).toEqual(["b", "c", "a"]); + }); + + it("is a no-op when the position does not change", () => { + const state = [item("a"), item("b")]; + + expect( + dashboardLayoutReducer(state, { type: "move", index: 1, toIndex: 1 }), + ).toBe(state); + }); + + it("is a no-op for an out-of-range index", () => { + const state = [item("a")]; + + expect( + dashboardLayoutReducer(state, { type: "move", index: 5, toIndex: 0 }), + ).toBe(state); + }); + }); + + describe("remove", () => { + it("drops the widget", () => { + const state = [item("a"), item("b")]; + + expect( + dashboardLayoutReducer(state, { type: "remove", id: "a" }), + ).toEqual([item("b")]); + }); + + it("is a no-op for an unknown id", () => { + const state = [item("a")]; + + expect(dashboardLayoutReducer(state, { type: "remove", id: "z" })).toBe( + state, + ); + }); + }); + + describe("resize", () => { + it("changes only the targeted widget's span", () => { + const state = [item("a"), item("b")]; + const next = dashboardLayoutReducer(state, { + type: "resize", + id: "b", + span: 3, + }); + + expect(next).toEqual([item("a"), item("b", 3)]); + }); + }); + + it("reset replaces the whole state", () => { + const target = [item("z")]; + + expect( + dashboardLayoutReducer([item("a")], { type: "reset", state: target }), + ).toBe(target); + }); +}); + +describe("isLayoutDirty", () => { + it("is false for an identical layout", () => { + expect(isLayoutDirty([item("a"), item("b")], [item("a"), item("b")])).toBe( + false, + ); + }); + + it("notices a different length", () => { + expect(isLayoutDirty([item("a")], [item("a"), item("b")])).toBe(true); + }); + + it("notices a reorder", () => { + expect(isLayoutDirty([item("b"), item("a")], [item("a"), item("b")])).toBe( + true, + ); + }); + + it("notices a resize", () => { + expect(isLayoutDirty([item("a", 3)], [item("a", 1)])).toBe(true); + }); + + it("ignores settings changes - the widget owns those", () => { + expect( + isLayoutDirty( + [{ ...item("a"), settings: { content: "new" } }], + [{ ...item("a"), settings: { content: "old" } }], + ), + ).toBe(false); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/layout-reducer.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/layout-reducer.ts new file mode 100644 index 000000000..7d12cf618 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/layout-reducer.ts @@ -0,0 +1,92 @@ +import type { + AdminDashboardWidgetSpan, + DashboardLayoutItem, + DashboardWidgetOption, +} from "../widgets/types"; + +import { nextInstanceId } from "../widgets/instance-id"; + +export type DashboardLayoutState = DashboardLayoutItem[]; + +export type DashboardLayoutAction = + | { id: string; span: AdminDashboardWidgetSpan; type: "resize" } + | { id: string; type: "remove" } + | { index: number; toIndex: number; type: "move" } + | { index?: number; type: "add"; widget: DashboardWidgetOption } + | { state: DashboardLayoutState; type: "reset" }; + +const moveItem = (items: T[], from: number, to: number): T[] => { + if (from === to || from < 0 || from >= items.length) return items; + + const next = [...items]; + const [item] = next.splice(from, 1); + next.splice(Math.min(Math.max(to, 0), next.length), 0, item); + + return next; +}; + +export const dashboardLayoutReducer = ( + state: DashboardLayoutState, + action: DashboardLayoutAction, +): DashboardLayoutState => { + switch (action.type) { + case "add": { + const taken = state.map(item => item.id); + // One copy only, unless the widget says otherwise. + if (!action.widget.allowMultiple && taken.includes(action.widget.id)) { + return state; + } + + const item: DashboardLayoutItem = { + id: nextInstanceId(action.widget.id, taken), + span: Math.max( + action.widget.defaultSpan, + action.widget.minSpan, + ) as AdminDashboardWidgetSpan, + rows: action.widget.defaultRows, + }; + const next = [...state]; + next.splice(action.index ?? next.length, 0, item); + + return next; + } + + case "move": + return moveItem(state, action.index, action.toIndex); + + case "remove": { + const next = state.filter(item => item.id !== action.id); + + return next.length === state.length ? state : next; + } + + case "reset": + return action.state; + + case "resize": + return state.map(item => + item.id === action.id ? { ...item, span: action.span } : item, + ); + + default: + return state; + } +}; + +/** True when the admin has actually changed something worth saving. */ +export const isLayoutDirty = ( + a: DashboardLayoutState, + b: DashboardLayoutState, +): boolean => { + if (a.length !== b.length) return true; + + return a.some((item, index) => { + const other = b[index]; + + return ( + item.id !== other.id || + item.span !== other.span || + item.rows !== other.rows + ); + }); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/panel-drag-id.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/panel-drag-id.ts new file mode 100644 index 000000000..5d0f81926 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/panel-drag-id.ts @@ -0,0 +1,8 @@ +const PREFIX = "panel:"; + +export const panelDraggableId = (widgetId: string): string => + `${PREFIX}${widgetId}`; + +/** The widget a panel row stands for, or `null` if the id is not a panel row. */ +export const panelWidgetId = (draggableId: string): null | string => + draggableId.startsWith(PREFIX) ? draggableId.slice(PREFIX.length) : null; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/save-layout.server.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/save-layout.server.ts new file mode 100644 index 000000000..9f969681f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/save-layout.server.ts @@ -0,0 +1,35 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +import type { DashboardLayoutItem } from "../widgets/types"; + +export const saveDashboardLayoutMutation = async ({ + managed, + widgets, +}: { + /** Stored ids this board spoke for - see `zodDashboardLayout`. */ + managed: string[]; + widgets: DashboardLayoutItem[]; +}) => { + const res = await fetcher(adminModule, { + path: "/layout", + method: "put", + module: "admin/dashboard", + args: { + body: { + managed, + widgets: widgets.map(({ id, span, rows }) => ({ id, span, rows })), + }, + }, + }); + + if (!res.ok) { + return { error: await res.text() }; + } + + revalidatePath("/[locale]/admin", "layout"); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/span-classes.ts b/packages/vitnode/src/views/admin/views/core/dashboard/grid/span-classes.ts new file mode 100644 index 000000000..33764d671 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/span-classes.ts @@ -0,0 +1,19 @@ +import type { + AdminDashboardWidgetRows, + AdminDashboardWidgetSpan, +} from "../widgets/types"; + +export const spanClasses: Record = { + 1: "md:col-span-1", + 2: "md:col-span-2", + 3: "md:col-span-2 xl:col-span-3", +}; + +export const rowsClasses: Record = { + 1: "min-h-56", + 2: "min-h-80", + 3: "min-h-112", +}; + +export const gridClasses = + "grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3"; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-card.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-card.test.tsx new file mode 100644 index 000000000..8f927852a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-card.test.tsx @@ -0,0 +1,290 @@ +import { + DndContext, + KeyboardSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DashboardWidgetView } from "../widgets/types"; + +import { loadWidgetSettingsAction } from "../widgets/load-widget-settings.server"; +import { WidgetCard } from "./widget-card"; + +vi.mock("next-intl", () => ({ + useTranslations: (namespace: string) => { + const t = (key: string, values?: Record) => + values + ? `${namespace}.${key}:${Object.values(values).join(",")}` + : `${namespace}.${key}`; + + return t; + }, +})); + +// The settings dialog reaches for its server actions, which drag the whole API +// in behind `fetcher`. +vi.mock("../widgets/save-widget-settings.server", () => ({ + saveWidgetSettingsMutation: vi.fn(), +})); +vi.mock("../widgets/load-widget-settings.server", () => ({ + loadWidgetSettingsAction: vi.fn(), +})); + +let mounts = 0; + +/** Stands in for whatever client state a widget's own content holds. */ +const Content = () => { + React.useEffect(() => { + mounts += 1; + }, []); + + return

note body

; +}; + +const view = ( + overrides: Partial = {}, +): DashboardWidgetView => ({ + id: "@vitnode/core:notes", + instanceId: "@vitnode/core:notes", + title: "Notes", + category: { id: "@vitnode/core", title: "Core" }, + minSpan: 1, + defaultSpan: 1, + defaultRows: 1, + span: 1, + rows: 1, + contentKey: "{}", + content: , + ...overrides, +}); + +/** Mirrors the board's own keyboard sensor, which is what a11y hangs on. */ +const Board = ({ + children, + onDragStart, +}: { + children: React.ReactNode; + onDragStart?: () => void; +}) => { + const sensors = useSensors( + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + return ( + + {children} + + ); +}; + +const renderCard = ( + widget: DashboardWidgetView, + isEditing = true, + onDragStart?: () => void, +) => { + const tree = (next: DashboardWidgetView) => ( + + + + + + ); + + const result = render(tree(widget)); + + return { + card: result.container.firstElementChild, + rerender: (next: DashboardWidgetView) => result.rerender(tree(next)), + }; +}; + +const handle = () => + screen.getByRole("button", { + name: "admin.dashboard.widgets.drag_handle:Notes", + }); + +const gear = () => + screen.getByRole("button", { + name: "admin.dashboard.widgets.settings.open:Notes", + }); + +const queryGear = () => + screen.queryByRole("button", { + name: "admin.dashboard.widgets.settings.open:Notes", + }); + +const closeDialog = async () => { + fireEvent.click(screen.getByRole("button", { name: "core.global.close" })); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBe(null)); +}; + +describe("WidgetCard", () => { + describe("drag affordance", () => { + it("hangs the accessible handle off a button of its own", () => { + renderCard(view()); + + expect(handle().tagName).toBe("BUTTON"); + }); + + // A card announced as a button may not hold the buttons that configure, + // resize and remove it, so the ARIA goes on the handle and the card keeps + // only the pointer listeners. + it("does not announce the card itself as a button", () => { + const { card } = renderCard(view()); + + expect(card?.getAttribute("role")).toBe(null); + expect(card?.getAttribute("tabindex")).toBe(null); + }); + + it("leaves the card's own buttons outside any button role", () => { + renderCard(view()); + + const remove = screen.getByRole("button", { + name: "admin.dashboard.widgets.remove:Notes", + }); + + expect(remove.parentElement?.closest('[role="button"]')).toBe(null); + }); + + it("offers nothing to drag until the board is being edited", () => { + renderCard(view(), false); + + expect( + screen.queryByRole("button", { + name: "admin.dashboard.widgets.drag_handle:Notes", + }), + ).toBe(null); + }); + + // The keydown lands on the handle and reaches the card's listeners by + // bubbling. dnd-kit refuses to start unless the event came from the + // registered activator node, so this is what proves the two agree. + it("lifts the card from the keyboard", () => { + const onDragStart = vi.fn(); + renderCard(view(), true, onDragStart); + + fireEvent.keyDown(handle(), { code: "Space" }); + + expect(onDragStart).toHaveBeenCalledTimes(1); + }); + + it("does not lift the card from its own action buttons", () => { + const onDragStart = vi.fn(); + renderCard(view(), true, onDragStart); + + fireEvent.keyDown( + screen.getByRole("button", { + name: "admin.dashboard.widgets.remove:Notes", + }), + { code: "Space" }, + ); + + expect(onDragStart).not.toHaveBeenCalled(); + }); + }); + + describe("content key", () => { + it("starts the content over when the server sends new settings", () => { + mounts = 0; + const { rerender } = renderCard(view({ contentKey: '{"content":"a"}' })); + expect(mounts).toBe(1); + + rerender(view({ contentKey: '{"content":"b"}' })); + + expect(mounts).toBe(2); + }); + + it("leaves the content alone on an ordinary re-render", () => { + mounts = 0; + const { rerender } = renderCard(view({ span: 1 })); + expect(mounts).toBe(1); + + rerender(view({ span: 2 })); + + expect(mounts).toBe(1); + }); + }); + + describe("settings", () => { + beforeEach(() => { + vi.mocked(loadWidgetSettingsAction).mockClear().mockResolvedValue(null); + }); + + it("offers no gear to a widget that registered no settings form", () => { + renderCard(view()); + + expect(queryGear()).toBe(null); + }); + + it("asks the server for nothing until the gear is pressed", () => { + renderCard(view({ hasSettings: true })); + + expect(queryGear()).not.toBe(null); + expect(loadWidgetSettingsAction).not.toHaveBeenCalled(); + }); + + it("fetches the form for this copy when the gear is pressed", async () => { + renderCard( + view({ hasSettings: true, instanceId: "@vitnode/core:notes#2" }), + ); + + fireEvent.click(gear()); + + await waitFor(() => + expect(loadWidgetSettingsAction).toHaveBeenCalledWith({ + widgetId: "@vitnode/core:notes#2", + }), + ); + }); + + it("keeps the form it fetched when the dialog is opened again", async () => { + renderCard(view({ hasSettings: true })); + + fireEvent.click(gear()); + await waitFor(() => + expect(loadWidgetSettingsAction).toHaveBeenCalledTimes(1), + ); + await closeDialog(); + + fireEvent.click(gear()); + + await waitFor(() => expect(screen.getByRole("dialog")).not.toBe(null)); + expect(loadWidgetSettingsAction).toHaveBeenCalledTimes(1); + }); + + it("drops the form once the server sends new settings", async () => { + const { rerender } = renderCard( + view({ contentKey: '{"range":"month"}', hasSettings: true }), + ); + + fireEvent.click(gear()); + await waitFor(() => + expect(loadWidgetSettingsAction).toHaveBeenCalledTimes(1), + ); + await closeDialog(); + + rerender(view({ contentKey: '{"range":"year"}', hasSettings: true })); + fireEvent.click(gear()); + + await waitFor(() => + expect(loadWidgetSettingsAction).toHaveBeenCalledTimes(2), + ); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-card.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-card.tsx new file mode 100644 index 000000000..35c962bb0 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-card.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Maximize2Icon, XIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +import type { + AdminDashboardWidgetSpan, + DashboardWidgetView, +} from "../widgets/types"; + +import { rowsClasses, spanClasses } from "./span-classes"; +import { WidgetSettingsDialog } from "./widget-settings-dialog"; +import { WidgetContentSkeleton } from "./widget-skeleton"; + +const SIZE_LABELS = { + 1: "size.small", + 2: "size.medium", + 3: "size.large", +} as const satisfies Record; + +const SPANS: AdminDashboardWidgetSpan[] = [1, 2, 3]; + +export const WidgetCardContent = ({ + isEditing, + widget, +}: { + isEditing?: boolean; + widget: DashboardWidgetView; +}) => { + const content = ( + }> + {widget.content} + + ); + + return ( + <> + + + {!!widget.icon && ( + + {widget.icon} + + )} + {widget.title} + + {!!widget.desc && ( + + {widget.desc} + + )} + + + {isEditing ? ( +
+ {content} +
+ ) : ( + {content} + )} +
+ + ); +}; + +export const WidgetCard = ({ + isEditing, + onRemove, + onResize, + onSettingsSaved, + widget, +}: { + isEditing: boolean; + onRemove: (id: string) => void; + onResize: (id: string, span: AdminDashboardWidgetSpan) => void; + onSettingsSaved: () => void; + widget: DashboardWidgetView; +}) => { + const t = useTranslations("admin.dashboard.widgets"); + const { + attributes, + isDragging, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + } = useSortable({ id: widget.instanceId, disabled: !isEditing }); + + return ( + + {isEditing && ( + + )} + + {isEditing && ( +
event.stopPropagation()} + onPointerDown={event => event.stopPropagation()} + > + {!!widget.hasSettings && ( + + )} + + + + } + > + + + + {t("size.title")} + + onResize( + widget.instanceId, + Number(value) as AdminDashboardWidgetSpan, + ) + } + value={String(widget.span)} + > + {SPANS.filter(span => span >= widget.minSpan).map(span => ( + + {t(SIZE_LABELS[span])} + + ))} + + + + + +
+ )} + + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.test.tsx new file mode 100644 index 000000000..19023e59c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.test.tsx @@ -0,0 +1,215 @@ +import { DndContext } from "@dnd-kit/core"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { SidebarProvider } from "@/components/ui/sidebar"; + +import type { DashboardWidgetOption } from "../widgets/types"; + +import { WidgetPanel } from "./widget-panel"; + +// SidebarProvider reads `useIsMobile`, and jsdom ships no matchMedia. +beforeAll(() => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +}); + +vi.mock("next-intl", () => ({ + useTranslations: (namespace: string) => (key: string) => + `${namespace}.${key}`, +})); + +const option = ( + id: string, + overrides: Partial = {}, +): DashboardWidgetOption => ({ + id, + title: `Title ${id}`, + category: { id: "@vitnode/core", title: "Core" }, + minSpan: 1, + defaultSpan: 1, + defaultRows: 1, + ...overrides, +}); + +const search = () => + screen.getByPlaceholderText("admin.dashboard.widgets.panel.search"); + +const renderPanel = ( + widgets: DashboardWidgetOption[], + { + actions, + isOpen = true, + }: { actions?: React.ReactNode; isOpen?: boolean } = {}, +) => { + const view = render( + + + + + , + ); + + return (next: boolean) => + view.rerender( + + + + + , + ); +}; + +describe("WidgetPanel", () => { + it("lists every available widget", () => { + renderPanel([option("notes"), option("stats")]); + + expect(screen.getByText("Title notes")).toBeDefined(); + expect(screen.getByText("Title stats")).toBeDefined(); + }); + + it("shows each widget's description", () => { + renderPanel([ + option("notes", { desc: "A private scratchpad." }), + option("stats", { desc: "Posts at a glance." }), + ]); + + expect(screen.getByText("A private scratchpad.")).toBeDefined(); + expect(screen.getByText("Posts at a glance.")).toBeDefined(); + }); + + it("renders a widget that has no description", () => { + renderPanel([option("bare")]); + + expect(screen.getByText("Title bare")).toBeDefined(); + }); + + it("makes each row a keyboard-reachable drag handle, with nothing to click", () => { + renderPanel([option("notes")]); + + const row = screen.getByText("Title notes").closest("[role]"); + + expect(row?.getAttribute("role")).toBe("button"); + expect(row?.getAttribute("aria-roledescription")).toBe("draggable"); + expect(row?.className).toContain("cursor-grab"); + expect( + document.querySelector('[data-slot="sidebar-content"] button'), + ).toBeNull(); + }); + + it("keeps the edit actions it is handed at the foot of the panel", () => { + renderPanel([option("notes")], { + actions: , + }); + + const footer = document.querySelector('[data-slot="sidebar-footer"]'); + + expect(footer?.querySelector("button")?.textContent).toBe("Save"); + }); + + it("has no footer when there are no actions to put in it", () => { + renderPanel([option("notes")]); + + expect(document.querySelector('[data-slot="sidebar-footer"]')).toBeNull(); + }); + + it("heads each category with its title", () => { + renderPanel([ + option("notes"), + option("stats", { + category: { id: "@vitnode/blog:content", title: "Blog content" }, + }), + ]); + + expect(screen.getByRole("heading", { name: "Core" })).toBeDefined(); + expect(screen.getByRole("heading", { name: "Blog content" })).toBeDefined(); + }); + + it("narrows the list as the admin searches", () => { + renderPanel([option("notes"), option("stats")]); + + fireEvent.change(search(), { target: { value: "notes" } }); + + expect(screen.getByText("Title notes")).toBeDefined(); + expect(screen.queryByText("Title stats")).toBeNull(); + }); + + it("drops a category heading the search empties out", () => { + renderPanel([ + option("notes"), + option("stats", { + category: { id: "@vitnode/blog:content", title: "Blog content" }, + }), + ]); + + fireEvent.change(search(), { target: { value: "notes" } }); + + expect(screen.queryByRole("heading", { name: "Blog content" })).toBeNull(); + }); + + it("says so when nothing matches", () => { + renderPanel([option("notes")]); + + fireEvent.change(search(), { target: { value: "forums" } }); + + expect( + screen.getByText("admin.dashboard.widgets.panel.no_results_title"), + ).toBeDefined(); + }); + + it("forgets the search once the panel closes", () => { + const setOpen = renderPanel([option("notes"), option("stats")]); + + fireEvent.change(search(), { target: { value: "notes" } }); + setOpen(false); + setOpen(true); + + expect(search()).toHaveProperty("value", ""); + expect(screen.getByText("Title stats")).toBeDefined(); + }); + + it("hides the search box when there is nothing left to search", () => { + renderPanel([]); + + expect( + screen.queryByPlaceholderText("admin.dashboard.widgets.panel.search"), + ).toBeNull(); + }); + + it("shows the empty state once everything is on the board", () => { + renderPanel([]); + + expect( + screen.getByText("admin.dashboard.widgets.panel.empty_title"), + ).toBeDefined(); + }); + + it("is a rail pinned to the right edge from `md` up when open", () => { + renderPanel([option("notes")]); + + const panel = document.querySelector( + '[data-slot="sidebar"]', + )?.parentElement; + + expect(panel?.className).toContain("md:fixed"); + expect(panel?.className).toContain("md:inset-e-0"); + expect(panel?.className).not.toContain("md:translate-x-full"); + expect(panel?.getAttribute("inert")).toBeNull(); + }); + + it("slides off-canvas and goes inert while closed", () => { + renderPanel([option("notes")], { isOpen: false }); + + const panel = document.querySelector( + '[data-slot="sidebar"]', + )?.parentElement; + + expect(panel?.className).toContain("md:translate-x-full"); + expect(panel?.className).toContain("hidden"); + expect(panel?.getAttribute("inert")).not.toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.tsx new file mode 100644 index 000000000..1063ebb78 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useDraggable } from "@dnd-kit/core"; +import { SearchIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarInput, +} from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; + +import type { DashboardWidgetOption } from "../widgets/types"; + +import { groupWidgets } from "./group-widgets"; +import { panelDraggableId } from "./panel-drag-id"; + +const PanelItem = ({ widget }: { widget: DashboardWidgetOption }) => { + const t = useTranslations("admin.dashboard.widgets"); + const { attributes, isDragging, listeners, setNodeRef } = useDraggable({ + id: panelDraggableId(widget.id), + data: { fromPanel: true, widget }, + }); + + return ( +
  • +
    + + {!!widget.icon && ( + + {widget.icon} + + )} + {widget.title} + + + {!!widget.desc && ( + + {widget.desc} + + )} +
    +
  • + ); +}; + +export const WidgetPanel = ({ + actions, + isOpen, + widgets, +}: { + /** Ends edit mode - pinned to the foot of the panel, always in reach. */ + actions?: React.ReactNode; + /** Driven by the board's edit mode - the panel only exists while editing. */ + isOpen: boolean; + widgets: DashboardWidgetOption[]; +}) => { + const t = useTranslations("admin.dashboard.widgets"); + const [query, setQuery] = React.useState(""); + + // Closing the panel puts the search back to where an admin left off: nowhere. + const [wasOpen, setWasOpen] = React.useState(isOpen); + if (wasOpen !== isOpen) { + setWasOpen(isOpen); + if (!isOpen) setQuery(""); + } + + const groups = groupWidgets({ query, widgets }); + + return ( +
    + {/* `collapsible="none"` keeps it out of the AdminCP sidebar's open state - + this panel opens and closes with the board's edit mode instead. */} + + {/* `px-3` to line the search box up with the cards below it. */} + +

    {t("panel.title")}

    +

    + {t("panel.desc")} +

    + + {widgets.length > 0 && ( +
    + + setQuery(event.target.value)} + placeholder={t("panel.search")} + type="search" + value={query} + /> +
    + )} +
    + + + {widgets.length === 0 ? ( + + + + {t("panel.empty_title")} + + + {t("panel.empty_desc")} + + + + ) : groups.length === 0 ? ( + + + + {t("panel.no_results_title")} + + + {t("panel.no_results_desc", { query: query.trim() })} + + + + ) : ( + // One stack of button-like cards per category, rather than the + // AdminCP sidebar's flat menu - these are things to drag, not links. + groups.map(group => ( +
    +

    + {group.title} +

    + +
      + {group.widgets.map(widget => ( + + ))} +
    +
    + )) + )} +
    + + {!!actions && ( + + {actions} + + )} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx new file mode 100644 index 000000000..37d661c02 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-settings-dialog.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { Settings2Icon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; + +import type { DashboardWidgetView } from "../widgets/types"; + +import { loadWidgetSettingsAction } from "../widgets/load-widget-settings.server"; +import { saveWidgetSettingsMutation } from "../widgets/save-widget-settings.server"; + +interface WidgetSettingsDialogContextProps { + /** Dismisses the dialog without writing anything. */ + close: () => void; + /** A save is in flight - disable the form's controls. */ + isPending: boolean; + /** + * Merges these keys into this copy's settings and closes the dialog. Keys you + * leave out are kept as they were. Once the write lands, the card behind the + * dialog is re-rendered on its own - the rest of the board is left alone, so + * an arrangement in progress survives. + * + * Resolves once the write is done, so `AutoForm` can hold its submit button + * in the loading state for as long as it takes. + */ + save: (settings: Record) => Promise; + /** The copy being configured, e.g. `@vitnode/core:notes#2`. */ + widgetId: string; +} + +const WidgetSettingsDialogContext = + React.createContext(null); + +/** + * Saves and closes the settings dialog a widget's form is rendered inside. + * Available to any client component under a widget's `settingsComponent`. + */ +export const useWidgetSettingsDialog = () => { + const context = React.use(WidgetSettingsDialogContext); + if (!context) { + throw new Error( + "useWidgetSettingsDialog must be used within a widget's settings dialog.", + ); + } + + return context; +}; + +/** Unwraps the form the server sent back, so the dialog can suspend on it. */ +const WidgetSettingsForm = ({ + form, +}: { + form: Promise; +}): React.ReactNode => React.use(form); + +/** + * The gear on a card, and the dialog behind it. Rendered alongside the sizing + * and removal buttons for widgets that registered a `settingsComponent` - so + * only while the board is being edited. + */ +export const WidgetSettingsDialog = ({ + onSaved, + widget, +}: { + /** Asks the board to render this card again, against what was just stored. */ + onSaved: () => void; + widget: DashboardWidgetView; +}) => { + const t = useTranslations("admin.dashboard.widgets"); + const [open, setOpen] = React.useState(false); + const [isPending, startTransition] = React.useTransition(); + const [form, setForm] = React.useState>(null); + + const [formKey, setFormKey] = React.useState(widget.contentKey); + if (formKey !== widget.contentKey) { + setFormKey(widget.contentKey); + setForm(null); + } + + const onOpenChange = (next: boolean) => { + setOpen(next); + if (!next || form) return; + + setForm( + loadWidgetSettingsAction({ widgetId: widget.instanceId }).catch(() => ( +

    {t("settings.load_error")}

    + )), + ); + }; + + const close = React.useCallback(() => setOpen(false), []); + + const save = React.useCallback( + async (settings: Record) => + new Promise(resolve => { + startTransition(async () => { + try { + const res = await saveWidgetSettingsMutation({ + settings, + widgetId: widget.instanceId, + }); + + if (res?.error) { + toast.error(t("settings.error_title"), { + description: t("settings.error_desc"), + }); + + return; + } + + setOpen(false); + toast.success(t("settings.saved_title"), { + description: t("settings.saved_desc"), + }); + + // Left until the dialog has finished closing. The card suspends + // while it is re-rendered, and a suspended render mid-animation + // strands the overlay on screen - batched into this transition it + // would also hold the close back until the new card was ready. + setTimeout(onSaved, 300); + } finally { + resolve(); + } + }); + }), + [onSaved, t, widget.instanceId], + ); + + const value = React.useMemo( + () => ({ close, isPending, save, widgetId: widget.instanceId }), + [close, isPending, save, widget.instanceId], + ); + + return ( + + + } + > + + + + + + + {t("settings.title", { title: widget.title })} + + {t("settings.desc")} + + + + {form ? ( + }> + + + ) : ( + + )} + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-skeleton.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-skeleton.tsx new file mode 100644 index 000000000..3ad593b9f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-skeleton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +import type { AdminDashboardWidgetRows } from "../widgets/types"; + +const LINE_WIDTHS = ["w-full", "w-11/12", "w-3/4", "w-5/6", "w-2/3"]; + +export const WidgetContentSkeleton = ({ + rows = 1, +}: { + rows?: AdminDashboardWidgetRows; +}) => { + const t = useTranslations("core.global"); + + return ( +
    + {t("loading")} + + {Array.from({ length: rows * 2 + 1 }, (_, index) => ( + + ))} +
    + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-dashboard-widgets.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-dashboard-widgets.test.tsx new file mode 100644 index 000000000..0c5833b3c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-dashboard-widgets.test.tsx @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StaffPermissionSet } from "@/api/lib/permission-staff"; +import type { AdminDashboardWidget } from "@/lib/plugin"; +import type { VitNodeConfig } from "@/vitnode.config"; + +import { getDashboardWidgets } from "./get-dashboard-widgets"; + +const permissions = vi.hoisted((): { value: StaffPermissionSet } => ({ + value: { root: true, permissions: [] }, +})); + +/** + * Mirrors the real message tree: core sits at the top level, plugins are + * namespaced under their own id. Any other key throws, exactly like next-intl + * reporting MISSING_MESSAGE. + */ +const messages = vi.hoisted(() => ({ + value: new Set([ + "@vitnode/blog.admin.dashboard.widgets.categories.content", + "@vitnode/blog.title", + "admin.dashboard.widgets.core-widget.title", + "admin.global.nav.core", + ...["stats", "wide", "open", "gated", "filed", "unlabelled"].map( + id => `@vitnode/blog.admin.dashboard.widgets.${id}.title`, + ), + ]), +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => + Promise.resolve( + Object.assign( + (key: string) => { + if (!messages.value.has(key)) { + throw new Error(`MISSING_MESSAGE: ${key}`); + } + + return key; + }, + { has: (key: string) => messages.value.has(key) }, + ), + ), +})); + +vi.mock("@/lib/api/get-session-admin-api", () => ({ + getSessionAdminApi: async () => + Promise.resolve({ permissions: permissions.value }), +})); + +// Stand in for core's real widgets so the test asserts the resolver, not the +// registry's contents. +vi.mock("./registry", () => ({ + coreDashboardWidgets: [{ id: "core-widget", component: () => null }], +})); + +const pluginWidget = ( + id: string, + overrides: Partial = {}, +): AdminDashboardWidget => ({ + id, + component: () => null, + ...overrides, +}); + +const config = (widgets: AdminDashboardWidget[]) => + ({ + plugins: [{ pluginId: "@vitnode/blog", admin: { dashboard: { widgets } } }], + }) as unknown as VitNodeConfig; + +const byId = async (id: string, widgets: AdminDashboardWidget[]) => { + const resolved = await getDashboardWidgets({ + vitNodeConfig: config(widgets), + }); + const found = resolved.find(widget => widget.id === id); + if (!found) throw new Error(`${id} was not resolved`); + + return found; +}; + +describe("getDashboardWidgets", () => { + beforeEach(() => { + permissions.value = { root: true, permissions: [] }; + }); + + it("namespaces widget ids with the owning plugin id", async () => { + const resolved = await getDashboardWidgets({ + vitNodeConfig: config([pluginWidget("stats")]), + }); + + expect(resolved.map(widget => widget.id)).toEqual([ + "@vitnode/core:core-widget", + "@vitnode/blog:stats", + ]); + }); + + // Regression: core's admin strings live at the top level, so prefixing them + // with `@vitnode/core.` raised MISSING_MESSAGE for every core widget. + it("resolves core titles from the top-level admin namespace", async () => { + const widget = await byId("@vitnode/core:core-widget", []); + + expect(widget.title).toBe("admin.dashboard.widgets.core-widget.title"); + }); + + it("resolves plugin titles from the plugin's own namespace", async () => { + const widget = await byId("@vitnode/blog:stats", [pluginWidget("stats")]); + + expect(widget.title).toBe( + "@vitnode/blog.admin.dashboard.widgets.stats.title", + ); + }); + + it("leaves desc undefined when no translation exists", async () => { + const widget = await byId("@vitnode/blog:stats", [pluginWidget("stats")]); + + expect(widget.desc).toBeUndefined(); + }); + + it("defaults span to minSpan and rows to 1", async () => { + const widget = await byId("@vitnode/blog:wide", [ + pluginWidget("wide", { minSpan: 2 }), + ]); + + expect(widget).toMatchObject({ + minSpan: 2, + defaultSpan: 2, + defaultRows: 1, + }); + }); + + // The resolver copies field by field, so an option it forgets is silently + // lost - and `allowMultiple` going missing empties the panel instead of + // erroring anywhere. + it("carries the widget's own options through", async () => { + const settingsComponent = () => null; + const widget = await byId("@vitnode/blog:stats", [ + pluginWidget("stats", { + allowMultiple: true, + defaultEnabled: true, + settingsComponent, + }), + ]); + + expect(widget).toMatchObject({ + allowMultiple: true, + defaultEnabled: true, + settingsComponent, + }); + }); + + it("files a widget under the plugin it came from", async () => { + const widget = await byId("@vitnode/blog:stats", [pluginWidget("stats")]); + + expect(widget.category).toEqual({ + id: "@vitnode/blog", + title: "@vitnode/blog.title", + }); + }); + + it("files a core widget under core", async () => { + const widget = await byId("@vitnode/core:core-widget", []); + + expect(widget.category).toEqual({ + id: "@vitnode/core", + title: "admin.global.nav.core", + }); + }); + + it("files a widget under its own category when it names one", async () => { + const widget = await byId("@vitnode/blog:filed", [ + pluginWidget("filed", { category: "content" }), + ]); + + expect(widget.category).toEqual({ + id: "@vitnode/blog:content", + title: "@vitnode/blog.admin.dashboard.widgets.categories.content", + }); + }); + + // A plugin that ships a category without its translation should show up + // oddly named, not take the whole dashboard down with MISSING_MESSAGE. + it("falls back to the raw category id when it has no translation", async () => { + const widget = await byId("@vitnode/blog:unlabelled", [ + pluginWidget("unlabelled", { category: "no-such-key" }), + ]); + + expect(widget.category).toEqual({ + id: "@vitnode/blog:no-such-key", + title: "no-such-key", + }); + }); + + it("keeps a widget whose permission the admin holds", async () => { + permissions.value = { + root: false, + permissions: [ + { + plugin: "@vitnode/blog", + module: "posts", + permission: "can_view", + }, + ], + }; + + const resolved = await getDashboardWidgets({ + vitNodeConfig: config([ + pluginWidget("gated", { + permission: { module: "posts", permission: "can_view" }, + }), + ]), + }); + + expect(resolved.map(widget => widget.id)).toContain("@vitnode/blog:gated"); + }); + + it("hides a widget whose permission the admin lacks", async () => { + permissions.value = { root: false, permissions: [] }; + + const resolved = await getDashboardWidgets({ + vitNodeConfig: config([ + pluginWidget("open"), + pluginWidget("gated", { + permission: { module: "posts", permission: "can_view" }, + }), + ]), + }); + + expect(resolved.map(widget => widget.id)).toEqual([ + "@vitnode/core:core-widget", + "@vitnode/blog:open", + ]); + }); + + it("shows every widget to a root admin", async () => { + const resolved = await getDashboardWidgets({ + vitNodeConfig: config([ + pluginWidget("gated", { + permission: { module: "posts", permission: "can_view" }, + }), + ]), + }); + + expect(resolved.map(widget => widget.id)).toContain("@vitnode/blog:gated"); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-dashboard-widgets.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-dashboard-widgets.tsx new file mode 100644 index 000000000..2c5d68a36 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-dashboard-widgets.tsx @@ -0,0 +1,104 @@ +import { getTranslations } from "next-intl/server"; + +import type { StaffPermissionSet } from "@/api/lib/permission-staff"; +import type { AdminDashboardWidget } from "@/lib/plugin"; +import type { VitNodeConfig } from "@/vitnode.config"; + +import { hasStaffPermission } from "@/api/lib/staff-permission"; +import { CONFIG_PLUGIN } from "@/config"; +import { getSessionAdminApi } from "@/lib/api/get-session-admin-api"; +import { getVitNodeConfig } from "@/vitnode.config"; + +import type { ResolvedDashboardWidget } from "./types"; + +import { coreDashboardWidgets } from "./registry"; + +export const getDashboardWidgets = async ({ + vitNodeConfig = getVitNodeConfig(), +}: { + vitNodeConfig?: VitNodeConfig; +} = {}): Promise => { + const t = await getTranslations(); + const session = await getSessionAdminApi(); + const permissions: StaffPermissionSet = session?.permissions ?? { + root: false, + permissions: [], + }; + + const sources: { + keyPrefix: string; + pluginId: string; + /** What the panel calls this plugin when a widget names no category. */ + pluginTitle: string; + widgets: AdminDashboardWidget[]; + }[] = [ + { + pluginId: CONFIG_PLUGIN.pluginId, + keyPrefix: "admin.dashboard.widgets", + pluginTitle: t("admin.global.nav.core"), + widgets: coreDashboardWidgets, + }, + ...vitNodeConfig.plugins.map(plugin => ({ + pluginId: plugin.pluginId, + keyPrefix: `${plugin.pluginId}.admin.dashboard.widgets`, + pluginTitle: + // @ts-expect-error - key is built from the plugin id at runtime + t.has(`${plugin.pluginId}.title`) + ? // @ts-expect-error - key is built from the plugin id at runtime + t(`${plugin.pluginId}.title`) + : plugin.pluginId, + widgets: plugin.admin?.dashboard?.widgets ?? [], + })), + ]; + + const resolved: ResolvedDashboardWidget[] = []; + + for (const { keyPrefix, pluginId, pluginTitle, widgets } of sources) { + for (const widget of widgets) { + if ( + widget.permission && + !hasStaffPermission(permissions, { + plugin: pluginId, + ...widget.permission, + }) + ) { + continue; + } + + const key = `${keyPrefix}.${widget.id}`; + const categoryKey = `${keyPrefix}.categories.${widget.category}`; + const minSpan = widget.minSpan ?? 1; + + resolved.push({ + id: `${pluginId}:${widget.id}`, + component: widget.component, + settingsComponent: widget.settingsComponent, + category: widget.category + ? { + id: `${pluginId}:${widget.category}`, + + // @ts-expect-error - key is built from the plugin id at runtime + title: t.has(categoryKey) + ? // @ts-expect-error - key is built from the plugin id at runtime + t(categoryKey) + : widget.category, + } + : { id: pluginId, title: pluginTitle }, + icon: widget.icon, + allowMultiple: widget.allowMultiple, + defaultEnabled: widget.defaultEnabled, + minSpan, + defaultSpan: widget.defaultSpan ?? minSpan, + defaultRows: widget.defaultRows ?? 1, + + // @ts-expect-error - key is built from the plugin id at runtime + title: t(`${key}.title`), + + // @ts-expect-error - key is built from the plugin id at runtime + desc: t.has(`${key}.desc`) ? t(`${key}.desc`) : undefined, + }); + } + } + + return resolved; +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-widget-instance.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-widget-instance.ts new file mode 100644 index 000000000..afb8bf574 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/get-widget-instance.ts @@ -0,0 +1,32 @@ +import type { AdminDashboardWidgetSettings } from "@/lib/plugin"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +import type { ResolvedDashboardWidget } from "./types"; + +import { getDashboardWidgets } from "./get-dashboard-widgets"; +import { widgetIdOf } from "./instance-id"; + +export const getWidgetInstance = async ( + widgetId: string, +): Promise => { + const widgets = await getDashboardWidgets(); + const widget = widgets.find(({ id }) => id === widgetIdOf(widgetId)); + if (!widget) return null; + + const res = await fetcher(adminModule, { + path: "/", + method: "get", + module: "admin/dashboard", + }); + const saved = res.ok ? (await res.json()).widgets : []; + + return { + widget, + settings: saved.find(item => item.id === widgetId)?.settings ?? {}, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/instance-id.test.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/instance-id.test.ts new file mode 100644 index 000000000..dad123db8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/instance-id.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { isInstanceOf, nextInstanceId, widgetIdOf } from "./instance-id"; + +describe("widgetIdOf", () => { + it("passes a plain widget id through", () => { + expect(widgetIdOf("@vitnode/core:notes")).toBe("@vitnode/core:notes"); + }); + + it("strips the copy suffix", () => { + expect(widgetIdOf("@vitnode/core:notes#7")).toBe("@vitnode/core:notes"); + }); +}); + +describe("isInstanceOf", () => { + it("matches every copy of the same widget", () => { + expect(isInstanceOf("@vitnode/core:notes", "@vitnode/core:notes")).toBe( + true, + ); + expect(isInstanceOf("@vitnode/core:notes#3", "@vitnode/core:notes")).toBe( + true, + ); + }); + + it("does not match a different widget", () => { + expect(isInstanceOf("@vitnode/core:notes#3", "@vitnode/core:send")).toBe( + false, + ); + }); +}); + +describe("nextInstanceId", () => { + it("uses the plain id for the first copy", () => { + expect(nextInstanceId("w", [])).toBe("w"); + expect(nextInstanceId("w", ["other"])).toBe("w"); + }); + + it("suffixes the second copy", () => { + expect(nextInstanceId("w", ["w"])).toBe("w#2"); + }); + + it("keeps counting past the copies already placed", () => { + expect(nextInstanceId("w", ["w", "w#2", "w#3"])).toBe("w#4"); + }); + + it("reuses a gap left by a removed copy", () => { + expect(nextInstanceId("w", ["w", "w#3"])).toBe("w#2"); + }); + + it("ignores copies of other widgets", () => { + expect(nextInstanceId("w", ["v", "v#2", "w"])).toBe("w#2"); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/instance-id.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/instance-id.ts new file mode 100644 index 000000000..31f7d2037 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/instance-id.ts @@ -0,0 +1,26 @@ +const SUFFIX = "#"; + +export const widgetIdOf = (instanceId: string): string => { + const at = instanceId.indexOf(SUFFIX); + + return at === -1 ? instanceId : instanceId.slice(0, at); +}; + +export const isInstanceOf = (instanceId: string, widgetId: string): boolean => + widgetIdOf(instanceId) === widgetId; + +export const nextInstanceId = ( + widgetId: string, + taken: Iterable, +): string => { + const used = new Set(taken); + if (!used.has(widgetId)) return widgetId; + + for (let n = 2; n <= used.size + 2; n++) { + const candidate = `${widgetId}${SUFFIX}${n}`; + if (!used.has(candidate)) return candidate; + } + + // Unreachable: the loop tries more candidates than there are taken ids. + throw new Error(`No instance id left for ${widgetId}`); +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/load-widget-content.server.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/load-widget-content.server.tsx new file mode 100644 index 000000000..d0551fc90 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/load-widget-content.server.tsx @@ -0,0 +1,16 @@ +"use server"; + +import { getWidgetInstance } from "./get-widget-instance"; + +export const loadWidgetContentAction = async ({ + widgetId, +}: { + widgetId: string; +}): Promise => { + const instance = await getWidgetInstance(widgetId); + if (!instance) return null; + + const Widget = instance.widget.component; + + return ; +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/load-widget-settings.server.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/load-widget-settings.server.tsx new file mode 100644 index 000000000..e59b9f48f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/load-widget-settings.server.tsx @@ -0,0 +1,15 @@ +"use server"; + +import { getWidgetInstance } from "./get-widget-instance"; + +export const loadWidgetSettingsAction = async ({ + widgetId, +}: { + widgetId: string; +}): Promise => { + const instance = await getWidgetInstance(widgetId); + const Settings = instance?.widget.settingsComponent; + if (!Settings) return null; + + return ; +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.test.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.test.ts new file mode 100644 index 000000000..42000165c --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import type { + AdminDashboardWidgetLayoutItem, + ResolvedDashboardWidget, +} from "./types"; + +import { normalizeLayout } from "./normalize-layout"; + +/** Layouts come out of a jsonb column, so they can hold anything. */ +const rawSaved = ( + ...items: Record[] +): AdminDashboardWidgetLayoutItem[] => + items as unknown as AdminDashboardWidgetLayoutItem[]; + +const widget = ( + id: string, + overrides: Partial = {}, +): ResolvedDashboardWidget => ({ + id, + component: () => null, + category: { id: "@vitnode/core", title: "Core" }, + minSpan: 1, + defaultSpan: 1, + defaultRows: 1, + title: id, + ...overrides, +}); + +describe("normalizeLayout", () => { + it("keeps the saved order of known widgets", () => { + const widgets = [widget("a"), widget("b"), widget("c")]; + const saved = [ + { id: "c", span: 1 as const, rows: 1 as const }, + { id: "a", span: 1 as const, rows: 1 as const }, + ]; + + expect(normalizeLayout({ saved, widgets }).map(item => item.id)).toEqual([ + "c", + "a", + ]); + }); + + it("drops widgets that are no longer registered or permitted", () => { + const widgets = [widget("a")]; + const saved = [ + { id: "a", span: 1 as const, rows: 1 as const }, + { id: "uninstalled", span: 2 as const, rows: 1 as const }, + ]; + + expect(normalizeLayout({ saved, widgets }).map(item => item.id)).toEqual([ + "a", + ]); + }); + + it("drops duplicate ids", () => { + const widgets = [widget("a")]; + const saved = [ + { id: "a", span: 1 as const, rows: 1 as const }, + { id: "a", span: 2 as const, rows: 1 as const }, + ]; + + expect(normalizeLayout({ saved, widgets })).toHaveLength(1); + }); + + it("keeps every copy of a widget that allows several", () => { + const widgets = [widget("a", { allowMultiple: true })]; + const saved = [ + { id: "a", span: 1 as const, rows: 1 as const }, + { id: "a#2", span: 2 as const, rows: 1 as const }, + { id: "a#3", span: 3 as const, rows: 1 as const }, + ]; + + expect(normalizeLayout({ saved, widgets }).map(item => item.id)).toEqual([ + "a", + "a#2", + "a#3", + ]); + }); + + it("sizes each copy against the widget's own bounds", () => { + const widgets = [widget("a", { allowMultiple: true, minSpan: 2 })]; + const saved = [ + { id: "a", span: 3 as const, rows: 1 as const }, + { id: "a#2", span: 1 as const, rows: 1 as const }, + ]; + + expect(normalizeLayout({ saved, widgets }).map(item => item.span)).toEqual([ + 3, 2, + ]); + }); + + it("keeps each copy's own settings apart", () => { + const widgets = [widget("a", { allowMultiple: true })]; + const saved = rawSaved( + { id: "a", span: 1, rows: 1, settings: { note: "first" } }, + { id: "a#2", span: 1, rows: 1, settings: { note: "second" } }, + ); + + expect( + normalizeLayout({ saved, widgets }).map(item => item.settings), + ).toEqual([{ note: "first" }, { note: "second" }]); + }); + + // Turning `allowMultiple` back off should tidy up rather than strand copies + // the admin can no longer add. + it("collapses copies of a widget that no longer allows them", () => { + const widgets = [widget("a")]; + const saved = [ + { id: "a", span: 1 as const, rows: 1 as const }, + { id: "a#2", span: 2 as const, rows: 1 as const }, + ]; + + expect(normalizeLayout({ saved, widgets }).map(item => item.id)).toEqual([ + "a", + ]); + }); + + it("drops a copy of an uninstalled widget", () => { + const widgets = [widget("a", { allowMultiple: true })]; + const saved = [ + { id: "a#2", span: 1 as const, rows: 1 as const }, + { id: "uninstalled#2", span: 1 as const, rows: 1 as const }, + ]; + + expect(normalizeLayout({ saved, widgets }).map(item => item.id)).toEqual([ + "a#2", + ]); + }); + + // Hiding the only copy still counts as "the admin has seen this widget". + it("does not re-add a default-enabled widget removed as a copy", () => { + const widgets = [ + widget("a", { allowMultiple: true, defaultEnabled: true }), + ]; + const saved = [ + { id: "a#2", span: 1 as const, rows: 1 as const, hidden: true }, + ]; + + expect(normalizeLayout({ saved, widgets })).toEqual([]); + }); + + it("appends default-enabled widgets the admin has never seen", () => { + const widgets = [ + widget("a"), + widget("fresh", { defaultEnabled: true, defaultSpan: 2, defaultRows: 3 }), + ]; + const saved = [{ id: "a", span: 1 as const, rows: 1 as const }]; + + expect(normalizeLayout({ saved, widgets })).toEqual([ + { id: "a", span: 1, rows: 1 }, + { id: "fresh", span: 2, rows: 3 }, + ]); + }); + + // Without this, removing a default-enabled widget is impossible: it comes + // straight back on the next load. + it("does not re-add a default-enabled widget the admin removed", () => { + const widgets = [widget("notes", { defaultEnabled: true })]; + const saved = [ + { id: "notes", span: 1 as const, rows: 1 as const, hidden: true }, + ]; + + expect(normalizeLayout({ saved, widgets })).toEqual([]); + }); + + it("keeps a removed widget out without disturbing the rest", () => { + const widgets = [ + widget("a", { defaultEnabled: true }), + widget("notes", { defaultEnabled: true }), + ]; + const saved = [ + { id: "a", span: 2 as const, rows: 1 as const }, + { id: "notes", span: 1 as const, rows: 1 as const, hidden: true }, + ]; + + expect(normalizeLayout({ saved, widgets }).map(item => item.id)).toEqual([ + "a", + ]); + }); + + it("puts a removed widget back once it is placed again", () => { + const widgets = [widget("notes", { defaultEnabled: true })]; + const saved = [{ id: "notes", span: 3 as const, rows: 2 as const }]; + + expect(normalizeLayout({ saved, widgets })).toEqual([ + { id: "notes", span: 3, rows: 2 }, + ]); + }); + + it("leaves opt-in widgets off a fresh dashboard", () => { + const widgets = [widget("opt-in"), widget("on", { defaultEnabled: true })]; + + expect( + normalizeLayout({ saved: [], widgets }).map(item => item.id), + ).toEqual(["on"]); + }); + + it("clamps a saved span up to the widget's minSpan", () => { + const widgets = [widget("wide", { minSpan: 2 })]; + const saved = [{ id: "wide", span: 1 as const, rows: 1 as const }]; + + expect(normalizeLayout({ saved, widgets })[0].span).toBe(2); + }); + + it("clamps out-of-range spans and rows", () => { + const widgets = [widget("a", { defaultSpan: 2, defaultRows: 2 })]; + + expect( + normalizeLayout({ + saved: rawSaved({ id: "a", span: 99, rows: -4 }), + widgets, + })[0], + ).toMatchObject({ span: 3, rows: 1 }); + }); + + it("falls back to the defaults when span or rows are missing", () => { + const widgets = [widget("a", { defaultSpan: 3, defaultRows: 2 })]; + + expect( + normalizeLayout({ saved: rawSaved({ id: "a" }), widgets })[0], + ).toMatchObject({ span: 3, rows: 2 }); + }); + + // The shape a widget's own settings write leaves behind when it has to create + // the entry: settings, and no size for it to have made up. + it("sizes an entry a settings write created", () => { + const widgets = [widget("notes", { defaultSpan: 2, defaultRows: 2 })]; + const saved = rawSaved({ id: "notes", settings: { content: "first" } }); + + expect(normalizeLayout({ saved, widgets })[0]).toEqual({ + id: "notes", + span: 2, + rows: 2, + settings: { content: "first" }, + }); + }); + + it("preserves a widget's own settings", () => { + const widgets = [widget("notes")]; + const saved = [ + { + id: "notes", + span: 1 as const, + rows: 1 as const, + settings: { content: "buy milk" }, + }, + ]; + + expect(normalizeLayout({ saved, widgets })[0].settings).toEqual({ + content: "buy milk", + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.ts b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.ts new file mode 100644 index 000000000..bf27bf583 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/normalize-layout.ts @@ -0,0 +1,85 @@ +import type { + AdminDashboardWidgetLayoutItem, + AdminDashboardWidgetRows, + AdminDashboardWidgetSpan, + DashboardLayoutItem, + ResolvedDashboardWidget, +} from "./types"; + +import { widgetIdOf } from "./instance-id"; + +const clamp = (value: unknown, min: number, fallback: number): number => { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + + return Math.min(3, Math.max(min, Math.round(value))); +}; + +/** + * Reconciles what an admin saved with what is actually installed right now. + * + * Widgets disappear when a plugin is uninstalled or a permission is revoked, + * and new ones show up when a plugin is added - so the stored layout is treated + * as a preference, never as the source of truth. + * + * An entry may also arrive unsized, when a widget's own settings write had to + * create it before the admin ever arranged the board. Sizing falls back to the + * widget's defaults, the same way an out-of-range value does. + */ +export const normalizeLayout = ({ + saved, + widgets, +}: { + saved: AdminDashboardWidgetLayoutItem[]; + widgets: ResolvedDashboardWidget[]; +}): DashboardLayoutItem[] => { + const byId = new Map(widgets.map(widget => [widget.id, widget])); + const placedInstances = new Set(); + const placedWidgets = new Set(); + const result: DashboardLayoutItem[] = []; + + for (const item of saved) { + const widgetId = widgetIdOf(item.id); + const widget = byId.get(widgetId); + // Unknown id: the plugin is gone, or this admin may no longer see it. + if (!widget || placedInstances.has(item.id)) continue; + + // A second copy of a widget that no longer allows them: keep the first, + // drop the rest, so turning `allowMultiple` off tidies itself up. + if (!widget.allowMultiple && placedWidgets.has(widgetId)) continue; + + placedInstances.add(item.id); + placedWidgets.add(widgetId); + + // Deliberately removed. Marking it placed above is the point: it stops the + // `defaultEnabled` pass below from putting it straight back. + if (item.hidden) continue; + + result.push({ + id: item.id, + span: clamp( + item.span, + widget.minSpan, + Math.max(widget.defaultSpan, widget.minSpan), + ) as AdminDashboardWidgetSpan, + rows: clamp(item.rows, 1, widget.defaultRows) as AdminDashboardWidgetRows, + ...(item.settings ? { settings: item.settings } : {}), + }); + } + + // Anything the admin has never seen and that ships enabled goes to the end, + // so a freshly installed plugin shows up without wiping their arrangement. + for (const widget of widgets) { + if (placedWidgets.has(widget.id) || !widget.defaultEnabled) continue; + + result.push({ + id: widget.id, + span: Math.max( + widget.defaultSpan, + widget.minSpan, + ) as AdminDashboardWidgetSpan, + rows: widget.defaultRows, + }); + } + + return result; +}; diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/widgets/notes/notes-content.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/notes/notes-content.tsx new file mode 100644 index 000000000..5c5ba63a3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/dashboard/widgets/notes/notes-content.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { AlertTriangleIcon, CheckIcon, LoaderCircleIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import React from "react"; + +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +import { saveWidgetSettingsMutation } from "../save-widget-settings.server"; + +const AUTOSAVE_DELAY = 1200; +export const NOTES_MAX_LENGTH = 10_000; + +export const NotesContent = ({ + defaultValue, + widgetId, +}: { + defaultValue: string; + widgetId: string; +}) => { + const t = useTranslations("admin.dashboard.widgets.notes"); + const [value, setValue] = React.useState(defaultValue); + const [status, setStatus] = React.useState< + "error" | "idle" | "saved" | "saving" + >("idle"); + const savedValueRef = React.useRef(defaultValue); + + React.useEffect(() => { + if (value === savedValueRef.current) return; + + const timeout = setTimeout(async () => { + setStatus("saving"); + const pending = value; + const res = await saveWidgetSettingsMutation({ + settings: { content: pending }, + widgetId, + }); + + // Say so rather than letting the indicator go quietly blank - a note that + // looks saved and is not is worse than no note at all. The next keystroke + // retries, since the effect runs again on every change. + if (res?.error) { + setStatus("error"); + + return; + } + + savedValueRef.current = pending; + setStatus("saved"); + }, AUTOSAVE_DELAY); + + return () => clearTimeout(timeout); + }, [value, widgetId]); + + return ( +
    +