create-vitnode-app 2.0.0-canary.2 → 2.0.0-canary.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +1 -19
- package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +1 -22
- package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +1 -12
- package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +1 -9
- package/copy-of-vitnode-app/monorepo/apps/web/.env.example +0 -11
- package/copy-of-vitnode-app/root/.env.example +0 -7
- package/copy-of-vitnode-app/root/global.d.ts +1 -19
- package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +1 -44
- package/copy-of-vitnode-app/root/src/components/main-header.tsx +1 -30
- package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +1 -38
- package/copy-of-vitnode-app/root/src/lib/admin-search.ts +1 -26
- package/copy-of-vitnode-app/root/src/lib/content-registry.ts +1 -42
- package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +4 -53
- package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +1 -9
- package/copy-of-vitnode-app/root/src/lib/navigation.ts +1 -17
- package/copy-of-vitnode-app/root/src/lib/page-head.ts +1 -14
- package/copy-of-vitnode-app/root/src/locales/app.ts +1 -25
- package/copy-of-vitnode-app/root/src/locales/packages.ts +1 -34
- package/copy-of-vitnode-app/root/src/router.tsx +6 -160
- package/copy-of-vitnode-app/root/src/routes/__root.tsx +1 -98
- package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +2 -51
- package/copy-of-vitnode-app/root/src/routes/_admin.tsx +6 -184
- package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +0 -30
- package/copy-of-vitnode-app/root/src/routes/_main.tsx +1 -43
- package/copy-of-vitnode-app/root/src/server/messages.server.ts +0 -13
- package/copy-of-vitnode-app/root/src/start.ts +0 -19
- package/copy-of-vitnode-app/root/src/vitnode.config.ts +0 -56
- package/copy-of-vitnode-app/root/src/vitnode.server.config.ts +0 -11
- package/copy-of-vitnode-app/root/vite.config.ts +0 -84
- package/dist/src/plugin/create/route-templates.js +3 -40
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -14,28 +14,8 @@ import {
|
|
|
14
14
|
withCoreRootRoutes,
|
|
15
15
|
} from "@vitnode/core/tanstack/routes";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* The auth transport, registered by importing the module that declares it.
|
|
19
|
-
*
|
|
20
|
-
* `@vitnode/core/tanstack/auth` owns every auth decision this app makes but may
|
|
21
|
-
* not declare a `createServerFn` - uncompiled on the server, one silently
|
|
22
|
-
* resolves to `undefined` - so `lib/auth.ts` declares the eight wrappers and
|
|
23
|
-
* hands them over at module scope. A bare import because there is nothing to
|
|
24
|
-
* name: the registration *is* the module's effect.
|
|
25
|
-
*
|
|
26
|
-
* Here because a router is the one module both entry points load, so the
|
|
27
|
-
* registration has happened before any route, loader or component can reach for
|
|
28
|
-
* it, in the browser bundle and on the server alike.
|
|
29
|
-
*/
|
|
30
17
|
import "./lib/auth";
|
|
31
|
-
|
|
32
|
-
* The admin transport, registered the same way and for the same reason.
|
|
33
|
-
*
|
|
34
|
-
* One server function rather than eight, reading the AdminCP's own session under
|
|
35
|
-
* its own cookie. It is a separate registration from the auth one on purpose:
|
|
36
|
-
* they are two sessions, two cookies and two cache entries, and nothing in
|
|
37
|
-
* VitNode may let the public session answer an admin question.
|
|
38
|
-
*/
|
|
18
|
+
|
|
39
19
|
import "./lib/admin-auth";
|
|
40
20
|
import { createLocaleRewrite, localeRouting } from "./lib/i18n/runtime";
|
|
41
21
|
import { pageHead } from "./lib/page-head";
|
|
@@ -44,160 +24,26 @@ import { Route as adminShellRoute } from "./routes/_admin";
|
|
|
44
24
|
import { Route as mainShellRoute } from "./routes/_main";
|
|
45
25
|
import { routeTree as fileRouteTree } from "./routeTree.gen";
|
|
46
26
|
|
|
47
|
-
/**
|
|
48
|
-
* The Content Engine registry, behind a literal dynamic import.
|
|
49
|
-
*
|
|
50
|
-
* Awaited by the one loader that needs it - `/admin/content/*` - rather than
|
|
51
|
-
* imported here. Building the registry reaches `@vitnode/core/content` and
|
|
52
|
-
* every configured plugin's admin form components, and this module is the one
|
|
53
|
-
* the client entry evaluates on every page: as a static import it put `zod`,
|
|
54
|
-
* every plugin's content registrations, the content form primitives and
|
|
55
|
-
* `react-hook-form` in front of the front page's first paint. See
|
|
56
|
-
* `CoreAdminRouteContext.loadContentRegistry`.
|
|
57
|
-
*/
|
|
58
27
|
const loadContentRegistry = async () =>
|
|
59
28
|
(await import("./lib/content-registry")).contentRegistry;
|
|
60
29
|
|
|
61
|
-
/**
|
|
62
|
-
* One route tree: this app's route files, plus the AdminCP screens `@vitnode/core`
|
|
63
|
-
* owns, plus the pages its plugins declare.
|
|
64
|
-
*
|
|
65
|
-
* At module scope rather than inside `getRouter`, because `getRouter` runs once
|
|
66
|
-
* per server request and mounting the plugin routes mutates the route tree - the
|
|
67
|
-
* generated tree is a module singleton. `withPluginRoutes` is idempotent anyway;
|
|
68
|
-
* doing it once is simply where it belongs.
|
|
69
|
-
*
|
|
70
|
-
* The plugin half comes from one generated file: a static import of each
|
|
71
|
-
* configured plugin's own route tree. No plugin page is copied into
|
|
72
|
-
* `src/routes`, no route path is written by hand, and nothing here knows which
|
|
73
|
-
* plugins are installed - see `@vitnode/core/tanstack/plugin-routes`.
|
|
74
|
-
*
|
|
75
|
-
* A page is reached only through the literal `lazy(() => import(...))` its route
|
|
76
|
-
* declared, so every one of them is a chunk of its own. The one part of a plugin
|
|
77
|
-
* route that is not lazy is a `search` schema: a router's `validateSearch` runs
|
|
78
|
-
* during path matching, before any chunk is fetched, so it lives in the tree
|
|
79
|
-
* rather than in the page.
|
|
80
|
-
*
|
|
81
|
-
* `mountUnder` names one route per shell, which is the whole of what "a plugin
|
|
82
|
-
* route renders in the application shell" amounts to here. A plugin declares
|
|
83
|
-
* `area: "main"` or `area: "admin"`; `_main` is the route that renders the
|
|
84
|
-
* public shell and `_admin` the one that renders the AdminCP, and being a child
|
|
85
|
-
* of one of them is what gives `/example` the header and the one `<main>` that
|
|
86
|
-
* `/discover` has, or gives `/admin/reports` the sidebar, the breadcrumb area
|
|
87
|
-
* and the admin session guard that `/admin/core` has. No new field, no per-route
|
|
88
|
-
* layout metadata and no second copy of either shell - route composition, which
|
|
89
|
-
* the area declaration already described.
|
|
90
|
-
*
|
|
91
|
-
* Neither shell changes a path: both are pathless, so `/example` stays
|
|
92
|
-
* `/example` and an admin plugin route's `/admin/…` is the path its own route
|
|
93
|
-
* spells out in full. An area VitNode knows and this app has not named here
|
|
94
|
-
* fails the composition rather than being mounted under the other one.
|
|
95
|
-
*
|
|
96
|
-
* `_main` and `_admin` are imported for their route objects, and they are the
|
|
97
|
-
* same objects the generated tree holds: `createFileRoute` produces one instance
|
|
98
|
-
* per module and `routeTree.gen.ts` mutates it in place.
|
|
99
|
-
*
|
|
100
|
-
* `withCoreMainRoutes`, `withCoreAdminRoutes` and `withCoreRootRoutes` mount
|
|
101
|
-
* core's own screens the same way, one per mount point: the public pages under
|
|
102
|
-
* the main shell, the AdminCP's under its own, and the shell-less ones - the auth
|
|
103
|
-
* cards and the AdminCP sign-in - straight under the root.
|
|
104
|
-
*
|
|
105
|
-
* They were twenty-nine route files in this application until
|
|
106
|
-
* `@vitnode/core/tanstack/routes` existed, every one of them pure wiring around
|
|
107
|
-
* something imported from the package - so an app carried a copy of VitNode's own
|
|
108
|
-
* routing table and core adding a screen meant an edit here. They are code-based
|
|
109
|
-
* rather than declared as plugin routes because they need the router's full
|
|
110
|
-
* option set: a real `validateSearch` that clamps `?page=999` before anything
|
|
111
|
-
* renders, a `beforeLoad` guard that runs before any chunk is fetched, and a
|
|
112
|
-
* splat path a plugin route path does not represent.
|
|
113
|
-
*
|
|
114
|
-
* `localeRouting` goes to the last of the three because a sign-in navigates to a
|
|
115
|
-
* path a *visitor* supplied: the route tree carries no locale, so the prefix has
|
|
116
|
-
* to be stripped before the router sees it, and which prefixes exist is this
|
|
117
|
-
* app's answer. It is the same object the `rewrite` below uses.
|
|
118
|
-
*
|
|
119
|
-
* `pageHead` is this app's own `createRouteHead(metadata)` binding, handed over
|
|
120
|
-
* because a package cannot know the site's name: a plugin page's `<title>` goes
|
|
121
|
-
* through the same `"<page> - <site>"` rule every other VitNode page's does,
|
|
122
|
-
* rather than through a second one the plugin invented.
|
|
123
|
-
*/
|
|
124
30
|
const routeTree = withCoreRootRoutes(
|
|
125
31
|
withCoreAdminRoutes(
|
|
126
32
|
withCoreMainRoutes(
|
|
127
|
-
withPluginRoutes(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
pageHead,
|
|
133
|
-
},
|
|
134
|
-
),
|
|
135
|
-
{ mountUnder: mainShellRoute, pageHead },
|
|
33
|
+
withPluginRoutes(fileRouteTree, pluginRouteSpecs(pluginRouteSources), {
|
|
34
|
+
mountUnder: { admin: adminShellRoute, main: mainShellRoute },
|
|
35
|
+
pageHead,
|
|
36
|
+
}),
|
|
37
|
+
{ localeRouting, mountUnder: mainShellRoute, pageHead },
|
|
136
38
|
),
|
|
137
39
|
{ loadContentRegistry, mountUnder: adminShellRoute, pageHead },
|
|
138
40
|
),
|
|
139
41
|
{ localeRouting, mountUnder: fileRouteTree, pageHead },
|
|
140
42
|
);
|
|
141
43
|
|
|
142
|
-
/**
|
|
143
|
-
* The app's router, and the QueryClient it owns.
|
|
144
|
-
*
|
|
145
|
-
* Start calls this once per server request and once in the browser, which is
|
|
146
|
-
* exactly the lifetime a QueryClient should have: created here, it is per
|
|
147
|
-
* request on the server - never a module-level client shared by every visitor
|
|
148
|
-
* being rendered at once - and a single long-lived one on the client.
|
|
149
|
-
*
|
|
150
|
-
* It goes into the router context, so a route loader reaches it as
|
|
151
|
-
* `context.queryClient` and can `ensureQueryData` before its component renders.
|
|
152
|
-
* That is the whole point of putting it here rather than in a provider: a
|
|
153
|
-
* loader runs before React does, so a client mounted by a component would be
|
|
154
|
-
* out of reach of the code that most wants it.
|
|
155
|
-
*
|
|
156
|
-
* `setupRouterSsrQueryIntegration` wires the two together: it dehydrates the
|
|
157
|
-
* cache into the SSR stream (including queries that resolve mid-render),
|
|
158
|
-
* hydrates it on the client before the first render, routes `redirect()` thrown
|
|
159
|
-
* inside a query or mutation through the router, and wraps the app in the one
|
|
160
|
-
* `QueryClientProvider` for this client. Nothing else in this app may create a
|
|
161
|
-
* `QueryClient` or a provider for one - two clients in a page means a query a
|
|
162
|
-
* loader cached is invisible to the component that reads it.
|
|
163
|
-
*
|
|
164
|
-
* `defaultPreloadStaleTime: 0` leaves caching to Query rather than having the
|
|
165
|
-
* router keep a second copy of the same data with its own expiry.
|
|
166
|
-
*
|
|
167
|
-
* `defaultStaleReloadMode: 'blocking'` is what makes a route's pending shape
|
|
168
|
-
* reachable at all once preloading is on. Router core's default is
|
|
169
|
-
* `'background'`, and a background reload never opens a pending window - but it
|
|
170
|
-
* still waits for the route's component chunk before it commits. So the common
|
|
171
|
-
* desktop path, hover a link and click it, took the one branch that renders
|
|
172
|
-
* nothing: the hover filled the loader, the click was therefore a background
|
|
173
|
-
* reload, and the chunk downloaded with the previous page still on screen and
|
|
174
|
-
* no skeleton in sight.
|
|
175
|
-
*
|
|
176
|
-
* It costs nothing here because a VitNode loader does not block on a warm cache:
|
|
177
|
-
* `ensureQueryData` with `revalidateIfStale` hands back the cached entry and
|
|
178
|
-
* refreshes behind it, so "blocking" describes a promise that resolves in a
|
|
179
|
-
* microtask. What changes is only that the router now marks the match pending
|
|
180
|
-
* while that happens, which is what `defaultPendingMs` is for.
|
|
181
|
-
*
|
|
182
|
-
* `defaultPendingMs: 150` is that threshold, and it is not zero for the same
|
|
183
|
-
* reason. At zero, every navigation opens a pending window, so
|
|
184
|
-
* `defaultPendingMinMs` holds a *fully cached* navigation behind a skeleton for
|
|
185
|
-
* 300ms - a page that could have been instant, made slow to look busy. At 150ms
|
|
186
|
-
* a cached navigation goes straight through with nothing shown and a slow one
|
|
187
|
-
* still gets its shape, in the content area and the breadcrumb together.
|
|
188
|
-
*
|
|
189
|
-
* `rewrite` is what makes one route tree serve two public URL shapes: `/pl/...`
|
|
190
|
-
* arrives, `/...` is matched, and every link the router builds gets the prefix
|
|
191
|
-
* back. No route file mentions a locale, so nothing here has to be duplicated
|
|
192
|
-
* per language - see `@vitnode/core/tanstack/i18n`.
|
|
193
|
-
*/
|
|
194
44
|
export function getRouter() {
|
|
195
45
|
const queryClient = createVitNodeQueryClient();
|
|
196
46
|
|
|
197
|
-
// The rewrite reads the locale off the router's own current location, and the
|
|
198
|
-
// router needs the rewrite to parse that location - so it is handed a getter
|
|
199
|
-
// rather than the router itself. `output` only ever runs once a link is built,
|
|
200
|
-
// which is long after the assignment below.
|
|
201
47
|
const holder: { current?: AnyRouter } = {};
|
|
202
48
|
|
|
203
49
|
const router = createTanStackRouter({
|
|
@@ -30,27 +30,11 @@ import appCss from "../styles.css?url";
|
|
|
30
30
|
|
|
31
31
|
const { debug, i18n, metadata, theme } = vitNodeConfig;
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* What the router itself provides, before any route has run.
|
|
35
|
-
*
|
|
36
|
-
* The QueryClient, and nothing else. `beforeLoad` below adds `locale` on top, so
|
|
37
|
-
* what a loader actually receives is `{ queryClient, locale }` - the language
|
|
38
|
-
* included, because a loader that fetches anything user-facing needs to know
|
|
39
|
-
* which one it is fetching.
|
|
40
|
-
*/
|
|
41
33
|
export interface RootRouterContext {
|
|
42
34
|
queryClient: QueryClient;
|
|
43
35
|
}
|
|
44
36
|
|
|
45
37
|
export const Route = createRootRouteWithContext<RootRouterContext>()({
|
|
46
|
-
/**
|
|
47
|
-
* The request's language, resolved once and handed to every loader below.
|
|
48
|
-
*
|
|
49
|
-
* The same function the rewrite and the components use, so there is one answer
|
|
50
|
-
* per request rather than one per consumer. Note that a language switch does
|
|
51
|
-
* not change the *internal* URL - only the public one - so the switcher
|
|
52
|
-
* invalidates the router to bring this back in step.
|
|
53
|
-
*/
|
|
54
38
|
beforeLoad: ({ location }) => ({
|
|
55
39
|
locale: resolveLocale<Locale>(publicPathnameOf(location)),
|
|
56
40
|
}),
|
|
@@ -58,16 +42,7 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({
|
|
|
58
42
|
head: () => ({
|
|
59
43
|
links: [
|
|
60
44
|
{ href: appCss, rel: "stylesheet" },
|
|
61
|
-
|
|
62
|
-
* The tab icon, from `public/favicon.ico`.
|
|
63
|
-
*
|
|
64
|
-
* Stated rather than left to the browser's automatic `/favicon.ico`
|
|
65
|
-
* request, because that request is a 404 until the file exists and a
|
|
66
|
-
* silently missing icon is easy to never notice. Drop your own 32px `.ico`
|
|
67
|
-
* at `public/favicon.ico`; it lives there rather than being imported so
|
|
68
|
-
* that the URL is stable and the file is still reachable at the well-known
|
|
69
|
-
* path browsers ask for unprompted.
|
|
70
|
-
*/
|
|
45
|
+
|
|
71
46
|
{
|
|
72
47
|
href: "/favicon.ico",
|
|
73
48
|
rel: "icon",
|
|
@@ -78,80 +53,22 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({
|
|
|
78
53
|
meta: [
|
|
79
54
|
{ charSet: "utf-8" },
|
|
80
55
|
{ content: "width=device-width, initial-scale=1", name: "viewport" },
|
|
81
|
-
// The default title, from the app's config. A route that names itself
|
|
82
|
-
// renders `"<page> - <shortTitle>"` instead, through `formatPageTitle` -
|
|
83
|
-
// the same rule Next.js applies through `title.template`.
|
|
84
56
|
{ title: metadata.title },
|
|
85
57
|
],
|
|
86
58
|
}),
|
|
87
|
-
/**
|
|
88
|
-
* Warm this language's shell strings before anything renders.
|
|
89
|
-
*
|
|
90
|
-
* `context.locale` rather than a default: `/pl` has to arrive with Polish
|
|
91
|
-
* already in the cache, or the first paint is English and the page flips after
|
|
92
|
-
* hydration.
|
|
93
|
-
*/
|
|
94
59
|
loader: async ({ context }) => {
|
|
95
60
|
await context.queryClient.ensureQueryData(
|
|
96
61
|
intlQueryOptions({ locale: context.locale }),
|
|
97
62
|
);
|
|
98
63
|
},
|
|
99
|
-
/**
|
|
100
|
-
* Every URL this application does not serve.
|
|
101
|
-
*
|
|
102
|
-
* The last resort, and until now there was none: a path that matched no route
|
|
103
|
-
* at all fell through to TanStack Router's own `<p>Not Found</p>` - no shell,
|
|
104
|
-
* no strings, no way back - and the router warned about the missing option on
|
|
105
|
-
* every such navigation. `/admin/contents` and a hand-typed `/blog/post-30`
|
|
106
|
-
* both landed there.
|
|
107
|
-
*
|
|
108
|
-
* ## What reaches it, and what does not
|
|
109
|
-
*
|
|
110
|
-
* Only a path **no route matched**. A route that matched and then answered
|
|
111
|
-
* `notFound()` from its own loader is caught by the nearest
|
|
112
|
-
* `notFoundComponent` above it, which is why `/admin/content/nope` and
|
|
113
|
-
* `/admin/content/blog/articles/999999/edit` render the AdminCP's 404 inside
|
|
114
|
-
* the panel rather than this - see `_admin`'s, which mounts the shell around
|
|
115
|
-
* the same message.
|
|
116
|
-
*
|
|
117
|
-
* ## It is a 404, and not a redirect anywhere
|
|
118
|
-
*
|
|
119
|
-
* The route tree is the whole application, so a URL that reaches here is one
|
|
120
|
-
* somebody typed or a stale bookmark, and saying so is the honest answer.
|
|
121
|
-
* Bouncing an unmatched path at some other origin would hide a genuinely
|
|
122
|
-
* missing page behind a hop to a server that 404s it anyway. Where a URL is
|
|
123
|
-
* served from is a deployment question, and a proxy in front of the app
|
|
124
|
-
* answers it better than this route can.
|
|
125
|
-
*/
|
|
126
64
|
notFoundComponent: RootNotFound,
|
|
127
65
|
shellComponent: RootDocument,
|
|
128
66
|
});
|
|
129
67
|
|
|
130
|
-
/**
|
|
131
|
-
* Rendered inside `RootComponent`, which is what makes the strings work: the
|
|
132
|
-
* providers it mounts include `RouteMessages` with `core.global`, and that is
|
|
133
|
-
* the namespace `NotFound` reads its two lines from. The root's loader has
|
|
134
|
-
* already warmed that entry, so nothing suspends.
|
|
135
|
-
*/
|
|
136
68
|
function RootNotFound() {
|
|
137
69
|
return <NotFound actions={<ErrorActions />} />;
|
|
138
70
|
}
|
|
139
71
|
|
|
140
|
-
/**
|
|
141
|
-
* The VitNode provider tree, mounted once above every route.
|
|
142
|
-
*
|
|
143
|
-
* Every provider in it is shared with the Next.js app - the theme, the toaster,
|
|
144
|
-
* the tooltip provider, the WebSocket - plus the pair of `use-intl` records that
|
|
145
|
-
* only a package can name. `VitNodeRootProviders` owns all of it, including the
|
|
146
|
-
* argument for why the realtime listeners are inside it rather than in the main
|
|
147
|
-
* shell.
|
|
148
|
-
*
|
|
149
|
-
* What this route contributes is the two things an application owns: which
|
|
150
|
-
* languages it serves, and its theme defaults.
|
|
151
|
-
*
|
|
152
|
-
* Because the locale comes from router state, changing language re-renders this:
|
|
153
|
-
* new locale, new query key, new messages, no page reload.
|
|
154
|
-
*/
|
|
155
72
|
function RootComponent() {
|
|
156
73
|
return (
|
|
157
74
|
<VitNodeRootProviders config={{ debug, locales: i18n.locales, theme }}>
|
|
@@ -160,20 +77,6 @@ function RootComponent() {
|
|
|
160
77
|
);
|
|
161
78
|
}
|
|
162
79
|
|
|
163
|
-
/**
|
|
164
|
-
* The document itself.
|
|
165
|
-
*
|
|
166
|
-
* `lang` is the language this request actually resolved to - `en` for `/`, `pl`
|
|
167
|
-
* for `/pl`, and on a route outside the localized URL space (`/admin`) whatever
|
|
168
|
-
* the visitor's cookie says. It comes from the same router state the provider
|
|
169
|
-
* reads, so the two cannot disagree and hydration has nothing to complain about.
|
|
170
|
-
*
|
|
171
|
-
* `ThemeScript` has to be in the head, and it has to be inline: it applies the
|
|
172
|
-
* stored theme to `<html>` before the browser paints, so the first frame is the
|
|
173
|
-
* theme the visitor chose rather than a flash of the default one.
|
|
174
|
-
* `suppressHydrationWarning` covers the attributes it writes, which by design
|
|
175
|
-
* differ from what the server rendered.
|
|
176
|
-
*/
|
|
177
80
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
|
178
81
|
const locale = useLocale();
|
|
179
82
|
|
|
@@ -5,60 +5,11 @@ import {
|
|
|
5
5
|
loadAdminDashboardRoute,
|
|
6
6
|
} from "@vitnode/core/tanstack/admin/dashboard";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
* `/admin/core` - the AdminCP dashboard, and the one route file `_admin` keeps.
|
|
10
|
-
*
|
|
11
|
-
* Topology only. The layout query, the widget catalogue, the drag-and-drop
|
|
12
|
-
* board, the settings dialogs and the four mutations behind them are
|
|
13
|
-
* `@vitnode/core/tanstack/admin/dashboard`.
|
|
14
|
-
*
|
|
15
|
-
* ## Why this one file is still here
|
|
16
|
-
*
|
|
17
|
-
* Every other AdminCP screen is `@vitnode/core`'s, mounted by
|
|
18
|
-
* `withCoreAdminRoutes` as a code-based route - see `src/router.tsx`. This one
|
|
19
|
-
* stays because the file-based generator requires it to, and the requirement is
|
|
20
|
-
* sharp rather than stylistic:
|
|
21
|
-
*
|
|
22
|
-
* - A pathless layout with no file children is **dropped from the generated
|
|
23
|
-
* tree**. `buildRouteTreeConfig` skips it outright, so `_admin` would not be
|
|
24
|
-
* in `routeTree.gen.ts` at all and the route object `src/router.tsx` mounts
|
|
25
|
-
* core's screens under would be an orphan.
|
|
26
|
-
* - It also collapses to a full path of `/`, which collides with `_main/index.tsx`
|
|
27
|
-
* and fails the generator's uniqueness check by name.
|
|
28
|
-
*
|
|
29
|
-
* So `_admin` needs one file-based child with a real path in order to exist, and
|
|
30
|
-
* this is it - which is the same job it has always had. It was the shell's first
|
|
31
|
-
* child for exactly this reason, and now it is the only one.
|
|
32
|
-
*
|
|
33
|
-
* Deleting it does not remove a screen; it removes the AdminCP.
|
|
34
|
-
*
|
|
35
|
-
* ## No `head`, deliberately
|
|
36
|
-
*
|
|
37
|
-
* The Next.js page exports no `generateMetadata`, so the tab keeps the site's
|
|
38
|
-
* own name. Declaring `title: 'VitNode'` here would render "VitNode - VitNode"
|
|
39
|
-
* through `formatPageTitle`; saying nothing inherits the root's title and
|
|
40
|
-
* `_admin`'s `noindex`, which is the parity-preserving answer.
|
|
41
|
-
*
|
|
42
|
-
* ## `pluginWidgets` is not passed
|
|
43
|
-
*
|
|
44
|
-
* A plugin declares widgets in its `admin.dashboard.widgets`, which reaches a
|
|
45
|
-
* Next.js board through `getVitNodeConfig()`. This route does not read the
|
|
46
|
-
* config - the AdminCP takes its plugin data from the generated projections, and
|
|
47
|
-
* neither of those carries widgets - so the board shows core's own, which is the
|
|
48
|
-
* complete set for this install because no configured plugin declares any. It is
|
|
49
|
-
* the same seam `AdminShell` leaves open for nav `declarations`.
|
|
50
|
-
*/
|
|
8
|
+
|
|
51
9
|
export const Route = createFileRoute("/_admin/admin/core/")({
|
|
52
10
|
loader: async ({ context }) => await loadAdminDashboardRoute(context),
|
|
53
11
|
component: AdminDashboardRoute,
|
|
54
|
-
|
|
55
|
-
* The whole of how an admin route contributes to the trail in the shell's
|
|
56
|
-
* header: the route declares its own crumb next to its own component, the
|
|
57
|
-
* shell renders whichever matched route declared the deepest one, and there is
|
|
58
|
-
* no map from pathname to breadcrumb anywhere. The label comes from the
|
|
59
|
-
* *visible* navigation, so this reads "Core" in whatever language the
|
|
60
|
-
* administrator is using without this file naming a string.
|
|
61
|
-
*/
|
|
12
|
+
|
|
62
13
|
staticData: {
|
|
63
14
|
breadcrumb: <AdminBreadcrumb segments={["core"]} />,
|
|
64
15
|
},
|
|
@@ -13,92 +13,7 @@ import { ErrorActions } from "@vitnode/core/tanstack/layout";
|
|
|
13
13
|
import { AdminShell } from "#/components/admin-shell";
|
|
14
14
|
import { pageHead } from "#/lib/page-head";
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
* The boundary every AdminCP page sits under - the admin session guard, and the
|
|
18
|
-
* shell.
|
|
19
|
-
*
|
|
20
|
-
* Pathless: the leading underscore means it contributes no URL segment, so
|
|
21
|
-
* `routes/_admin/core/index.tsx` is `/admin/core`, guarded, and the guard is
|
|
22
|
-
* this file. A page joins the AdminCP by *where its file lives*, not by
|
|
23
|
-
* remembering to check a session.
|
|
24
|
-
*
|
|
25
|
-
* ## It is not under `_authenticated`, and must not be
|
|
26
|
-
*
|
|
27
|
-
* The AdminCP has its own session, under its own cookie (`vitnode_auth_admin`),
|
|
28
|
-
* with its own model and its own endpoint. Stacking the public guard above this
|
|
29
|
-
* one would bounce an administrator to `/login` for a session the AdminCP does
|
|
30
|
-
* not use - and `AuthState.isAdmin`, which lives on the *public* session, means
|
|
31
|
-
* "may be offered the AdminCP", not "is inside it". Two cookies, two questions.
|
|
32
|
-
*
|
|
33
|
-
* ## Exactly one splat may live under here, and nothing wider
|
|
34
|
-
*
|
|
35
|
-
* `admin.content.$.tsx` claims `/admin/content/*` - the Content Engine's own
|
|
36
|
-
* namespace, which Stage 13 moved into this router. That one is deliberate and
|
|
37
|
-
* narrow; a second is how the AdminCP breaks.
|
|
38
|
-
*
|
|
39
|
-
* `$.tsx`, `admin.$.tsx` or any other catch-all beside it would consume every
|
|
40
|
-
* *remaining* admin URL, including the ones no route declares. The AdminCP's own
|
|
41
|
-
* not-found would stop being reachable, a plugin's admin route would be shadowed
|
|
42
|
-
* by whichever splat sat above it, and nothing would report either - silently,
|
|
43
|
-
* and all at once. `src/tests/admin-routes.test.ts` pins both halves: that the
|
|
44
|
-
* content splat sits at exactly that path, and that it is the only one.
|
|
45
|
-
*
|
|
46
|
-
* ## Why the check is in `beforeLoad`
|
|
47
|
-
*
|
|
48
|
-
* It runs before the loader and long before React, so a visitor without admin
|
|
49
|
-
* access never receives a byte of an AdminCP page - not a flash, not a
|
|
50
|
-
* hydration, not a `useEffect` that redirects afterwards. A component-level
|
|
51
|
-
* check would render the page first and then take it away, which on the server
|
|
52
|
-
* means admin markup already written into the stream.
|
|
53
|
-
*
|
|
54
|
-
* ## A hover is not a navigation
|
|
55
|
-
*
|
|
56
|
-
* `defaultPreload: 'intent'` means the router runs this whole chain when a
|
|
57
|
-
* pointer rests on an admin link, and router-core has no staleness gate on
|
|
58
|
-
* `beforeLoad` the way it has on a loader - it re-runs it from the top of the
|
|
59
|
-
* branch every time. With `ADMIN_SESSION_STALE_TIME` at zero that made crossing
|
|
60
|
-
* the sidebar one server-function POST and one Hono call *per link the mouse
|
|
61
|
-
* passed over*, for a question nobody had asked yet.
|
|
62
|
-
*
|
|
63
|
-
* So the read is chosen by the `preload` flag the router already hands in.
|
|
64
|
-
* `preloadAdminAccess` is the same query, the same key and the same rejection
|
|
65
|
-
* behaviour, trusted for thirty seconds - the identical window the public
|
|
66
|
-
* session has always used for the identical reason. `ensureAdminAccess` and its
|
|
67
|
-
* `staleTime: 0` are untouched on the path that matters, and that is the whole
|
|
68
|
-
* of the revocation guarantee: entering a screen is a real navigation, this runs
|
|
69
|
-
* again with `preload: false`, and the API is asked. Router-core never lets a
|
|
70
|
-
* preloaded `beforeLoad` result stand in for a navigation's.
|
|
71
|
-
*
|
|
72
|
-
* The redirect below is deliberately *not* conditional on `preload`. It is not a
|
|
73
|
-
* navigation - `preloadClientRoute` catches it and preloads the sign-in page
|
|
74
|
-
* instead of moving anybody - and skipping it would leave a denied preload
|
|
75
|
-
* walking into the screen loaders underneath, which would then send admin
|
|
76
|
-
* requests the API is going to refuse.
|
|
77
|
-
*
|
|
78
|
-
* ## A failed read is not a denial
|
|
79
|
-
*
|
|
80
|
-
* `ensureAdminAccess` resolves only for an answer the API actually gave - `200`
|
|
81
|
-
* or `403`. A `429` from the rate limiter, a `500`, an API that is not listening:
|
|
82
|
-
* all three reject, and that rejection is deliberately left to propagate as an
|
|
83
|
-
* ordinary route error.
|
|
84
|
-
*
|
|
85
|
-
* Only `canEnterAdmin` answering `false` sends anybody to `/admin`. Catching the
|
|
86
|
-
* rejection and redirecting instead would sign every administrator out of the
|
|
87
|
-
* AdminCP during an outage and present them with a sign-in form for a session
|
|
88
|
-
* they already hold - which is precisely what the Next.js `getSessionAdminApi()`
|
|
89
|
-
* does today, and precisely what this shape exists to stop.
|
|
90
|
-
*
|
|
91
|
-
* ## What it is not
|
|
92
|
-
*
|
|
93
|
-
* A navigation guard, and only that. `api/config.ts` puts
|
|
94
|
-
* `globalAdminMiddleware()` in front of every request whose path contains
|
|
95
|
-
* `/admin/`, each handler re-checks the staff tables, and
|
|
96
|
-
* `SessionAdminModel.getUser()` re-runs `checkIfUserIsAdmin` against the
|
|
97
|
-
* database on every request - deleting the session the moment the answer turns
|
|
98
|
-
* false. So an administrator who edits this app's cached permission set in
|
|
99
|
-
* devtools gets a visible button and an API that still refuses them. Nothing
|
|
100
|
-
* here is, or may become, the security boundary.
|
|
101
|
-
*/
|
|
16
|
+
|
|
102
17
|
export const Route = createFileRoute("/_admin")({
|
|
103
18
|
beforeLoad: async ({ context, location, preload }) => {
|
|
104
19
|
const access = preload
|
|
@@ -133,96 +48,20 @@ export const Route = createFileRoute("/_admin")({
|
|
|
133
48
|
// a page cannot disagree with the guard that let it render.
|
|
134
49
|
return { adminAccess: access };
|
|
135
50
|
},
|
|
136
|
-
|
|
137
|
-
* The shell's strings, warmed before React renders.
|
|
138
|
-
*
|
|
139
|
-
* `adminNav.namespaces` is the same array `AdminShell` hands the shell, and it
|
|
140
|
-
* has to be: the provider reads back the identical `intlQueryOptions` entry
|
|
141
|
-
* this fills, so warming a different namespace set would fill an entry nobody
|
|
142
|
-
* looks at and cost a round trip on the first paint anyway. It is not a fixed
|
|
143
|
-
* list because it cannot be - a plugin group's headings live under that
|
|
144
|
-
* plugin's own id, and which plugins this installation configured is decided
|
|
145
|
-
* in `src/admin-nav.gen.ts`.
|
|
146
|
-
*
|
|
147
|
-
* ## Imported inside the loader, not above it
|
|
148
|
-
*
|
|
149
|
-
* A route file's `loader` body runs only for a navigation into this shell; the
|
|
150
|
-
* file itself is evaluated in the client entry, on every page of the site. The
|
|
151
|
-
* generated navigation is not small - it carries every configured plugin's
|
|
152
|
-
* sidebar icons and its content type definitions, and through those the whole
|
|
153
|
-
* Content Engine and `zod` - so a static import here put ~45 KB of AdminCP
|
|
154
|
-
* data, plus `zod`, in front of the front page's first paint. `AdminShell`
|
|
155
|
-
* imports the same module, and `AdminShell` is this route's `component`, which
|
|
156
|
-
* is code-split: the two land in the same chunk, so an administrator pays for
|
|
157
|
-
* it once and everybody else not at all.
|
|
158
|
-
*/
|
|
51
|
+
|
|
159
52
|
loader: async ({ context }) => {
|
|
160
53
|
const { adminNav } = await import("#/lib/admin-nav");
|
|
161
54
|
|
|
162
55
|
await loadAdminMessages({ ...context, namespaces: adminNav.namespaces });
|
|
163
56
|
},
|
|
164
|
-
|
|
165
|
-
* Stated once for the whole panel rather than on each screen.
|
|
166
|
-
*
|
|
167
|
-
* The AdminCP is behind a session and must never be indexed. Router merges the
|
|
168
|
-
* `head` of every matched route and dedupes `meta` by `name`, preferring the
|
|
169
|
-
* deepest, so an admin screen inherits this by saying nothing - which is
|
|
170
|
-
* exactly what `RouteHeadOptions` describes. A screen's own `pageHead` adds
|
|
171
|
-
* only its title and description.
|
|
172
|
-
*/
|
|
57
|
+
|
|
173
58
|
head: () => pageHead({ robots: "noindex, nofollow" }),
|
|
174
|
-
|
|
175
|
-
* The AdminCP's 404, rendered inside the shell.
|
|
176
|
-
*
|
|
177
|
-
* What reaches it is a screen whose loader called `requireAdminPermission`
|
|
178
|
-
* and was refused - the same answer `app/[locale]/admin/(auth)/not-found.tsx`
|
|
179
|
-
* gives in the Next.js AdminCP. Declared here rather than per screen so every
|
|
180
|
-
* one of them answers a missing permission identically.
|
|
181
|
-
*
|
|
182
|
-
* A content URL that resolves to no content type reaches it too, and by the
|
|
183
|
-
* same route: `loadContentAdminRoute` throws `notFound()` for a splat its
|
|
184
|
-
* registry cannot name, which is the answer the Next.js catch-all's
|
|
185
|
-
* `notFound()` gives one navigation later.
|
|
186
|
-
*
|
|
187
|
-
* A URL under `/admin` that matches no route at all does *not* reach this one.
|
|
188
|
-
* Outside `/admin/content`, `_admin` has only declared children - see above -
|
|
189
|
-
* so such a path matches nothing in this subtree and the router falls back to
|
|
190
|
-
* its own not-found at the root. That is the correct trade, and the cost is
|
|
191
|
-
* paid deliberately: rendering an unmigrated admin URL inside this shell would
|
|
192
|
-
* mean claiming it. (The root has no `notFoundComponent` of its own yet, so
|
|
193
|
-
* that fallback is currently the router's bare one - for every unmatched URL
|
|
194
|
-
* in this application, not only admin ones. Giving the root a real 404 page is
|
|
195
|
-
* its own piece of work.)
|
|
196
|
-
*/
|
|
59
|
+
|
|
197
60
|
notFoundComponent: AdminNotFoundScreen,
|
|
198
61
|
component: AdminLayout,
|
|
199
62
|
});
|
|
200
63
|
|
|
201
|
-
|
|
202
|
-
* The refusal, wearing the panel it was refused inside.
|
|
203
|
-
*
|
|
204
|
-
* ## It mounts the shell itself, and has to
|
|
205
|
-
*
|
|
206
|
-
* A `notFoundComponent` renders *instead of* the component of the route that
|
|
207
|
-
* handles the error, not inside it - so this replaces `AdminLayout`, sidebar
|
|
208
|
-
* and header and palette included. An administrator who opened a screen they
|
|
209
|
-
* lack the permission for would otherwise be dropped onto a bare 404 page with
|
|
210
|
-
* no way back into the AdminCP but the browser's back button, which is not what
|
|
211
|
-
* the Next.js AdminCP does: its `not-found.tsx` sits *under*
|
|
212
|
-
* `admin/(auth)/layout.tsx` and keeps the panel around the message.
|
|
213
|
-
*
|
|
214
|
-
* Mounting `AdminShell` here restores that, and it costs nothing: `beforeLoad`
|
|
215
|
-
* has already resolved the admin session and the loader above has already
|
|
216
|
-
* warmed the shell's messages, so the providers inside read the same two cache
|
|
217
|
-
* entries the working screens read and nothing suspends or fetches again.
|
|
218
|
-
*
|
|
219
|
-
* ## What is bound rather than defaulted
|
|
220
|
-
*
|
|
221
|
-
* `ErrorActions` is this app's binding rather than core's default: `/` is served
|
|
222
|
-
* by the Next.js application on some installs and by this one on others, and
|
|
223
|
-
* only the route tree knows which. It is passed as an element from module scope
|
|
224
|
-
* so the type is stable across renders.
|
|
225
|
-
*/
|
|
64
|
+
|
|
226
65
|
function AdminNotFoundScreen() {
|
|
227
66
|
return (
|
|
228
67
|
<AdminShell>
|
|
@@ -231,24 +70,7 @@ function AdminNotFoundScreen() {
|
|
|
231
70
|
);
|
|
232
71
|
}
|
|
233
72
|
|
|
234
|
-
|
|
235
|
-
* The AdminCP shell.
|
|
236
|
-
*
|
|
237
|
-
* `AdminShell` is this app's binding of `AdminShellContent` - the sidebar, the
|
|
238
|
-
* command palette, the breadcrumb area, the user menu and the one `<main>` every
|
|
239
|
-
* admin page renders inside. It mounts `AdminPermissionsProvider` itself, from
|
|
240
|
-
* the same admin session query the guard above has already filled, so
|
|
241
|
-
* `AdminStaffPermissionGate` and `useAdminStaffPermission` work identically here
|
|
242
|
-
* and in the Next.js AdminCP. It cannot suspend in practice and nothing below it
|
|
243
|
-
* can suspend at all; see the note on the provider.
|
|
244
|
-
*
|
|
245
|
-
* What `#/components/admin-shell` adds on top is only what a package cannot
|
|
246
|
-
* answer for a particular installation: where the user lookup runs, which
|
|
247
|
-
* plugins this app configured, and how the command palette moves without a link.
|
|
248
|
-
*
|
|
249
|
-
* The guard, the loader and the route options above are the parts that must not
|
|
250
|
-
* move.
|
|
251
|
-
*/
|
|
73
|
+
|
|
252
74
|
function AdminLayout() {
|
|
253
75
|
return (
|
|
254
76
|
<AdminShell>
|
|
@@ -2,24 +2,6 @@ import { createFileRoute } from "@tanstack/react-router";
|
|
|
2
2
|
|
|
3
3
|
import { pageHead } from "#/lib/page-head";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The front page.
|
|
7
|
-
*
|
|
8
|
-
* One route file serving every public URL of the home page: `/` and `/pl` both
|
|
9
|
-
* match here, because the locale is stripped before matching and written back
|
|
10
|
-
* into every link the router builds. There is no `routes/pl/index.tsx` and there
|
|
11
|
-
* does not need to be one.
|
|
12
|
-
*
|
|
13
|
-
* **`head` must be written after `loader`** on routes that have one -
|
|
14
|
-
* `loaderData` is inferred from `loader` in the same object literal, and
|
|
15
|
-
* TypeScript reads a literal's members in order. This route has no loader: it
|
|
16
|
-
* fetches nothing, and the shell above it already warmed everything the header
|
|
17
|
-
* reads.
|
|
18
|
-
*
|
|
19
|
-
* `robots: 'index, follow'` is stated rather than assumed. `_main` says nothing
|
|
20
|
-
* about indexing, and a page that wants to be found should say so where somebody
|
|
21
|
-
* editing it will see it.
|
|
22
|
-
*/
|
|
23
5
|
export const Route = createFileRoute("/_main/")({
|
|
24
6
|
head: () =>
|
|
25
7
|
pageHead({
|
|
@@ -30,18 +12,6 @@ export const Route = createFileRoute("/_main/")({
|
|
|
30
12
|
component: HomeRoute,
|
|
31
13
|
});
|
|
32
14
|
|
|
33
|
-
/**
|
|
34
|
-
* Replace this with your own home page.
|
|
35
|
-
*
|
|
36
|
-
* No `<main>`: the shell owns the document's one `main` landmark, and a page
|
|
37
|
-
* that renders a second gives a screen reader two to choose between. A page owns
|
|
38
|
-
* its container - width, padding, vertical rhythm - and nothing above it.
|
|
39
|
-
*
|
|
40
|
-
* The copy is hard-coded because a new app has no strings of its own yet. To
|
|
41
|
-
* translate it, add the keys to `src/locales/` and read them with
|
|
42
|
-
* `useTranslations` from `use-intl` - the same hook every VitNode component
|
|
43
|
-
* uses, on every host.
|
|
44
|
-
*/
|
|
45
15
|
function HomeRoute() {
|
|
46
16
|
return (
|
|
47
17
|
<div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4">
|