create-vitnode-app 0.0.1-canary.0
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/LICENSE.md +21 -0
- package/README.md +78 -0
- package/copy-of-vitnode-app/.vscode/settings.json +10 -0
- package/copy-of-vitnode-app/README.md +40 -0
- package/copy-of-vitnode-app/api/.gitignore_template +32 -0
- package/copy-of-vitnode-app/api/drizzle.config.ts +12 -0
- package/copy-of-vitnode-app/api/src/i18n.ts +38 -0
- package/copy-of-vitnode-app/api/src/index.ts +27 -0
- package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +40 -0
- package/copy-of-vitnode-app/api/tsconfig.json +17 -0
- package/copy-of-vitnode-app/api-bun/src/index.ts +16 -0
- package/copy-of-vitnode-app/api-single-app/drizzle.config.ts +12 -0
- package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +32 -0
- package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +22 -0
- package/copy-of-vitnode-app/api-single-app/src/server/vitnode-api.server.ts +29 -0
- package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +37 -0
- package/copy-of-vitnode-app/docker/docker-compose.yml +30 -0
- package/copy-of-vitnode-app/eslint/.prettierrc.mjs +11 -0
- package/copy-of-vitnode-app/eslint/eslint.config.mjs +19 -0
- package/copy-of-vitnode-app/eslint-react/.prettierrc.mjs +11 -0
- package/copy-of-vitnode-app/eslint-react/eslint.config.mjs +19 -0
- package/copy-of-vitnode-app/monorepo/.gitignore_template +41 -0
- package/copy-of-vitnode-app/monorepo/apps/api/.env.example +12 -0
- package/copy-of-vitnode-app/monorepo/apps/web/.env.example +12 -0
- package/copy-of-vitnode-app/monorepo/turbo.json +53 -0
- package/copy-of-vitnode-app/root/.env.example +22 -0
- package/copy-of-vitnode-app/root/.gitignore_template +41 -0
- package/copy-of-vitnode-app/root/global.d.ts +28 -0
- package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +72 -0
- package/copy-of-vitnode-app/root/src/components/main-header.tsx +33 -0
- package/copy-of-vitnode-app/root/src/i18n.ts +36 -0
- package/copy-of-vitnode-app/root/src/lib/admin-auth.ts +11 -0
- package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +43 -0
- package/copy-of-vitnode-app/root/src/lib/admin-search.ts +37 -0
- package/copy-of-vitnode-app/root/src/lib/auth.ts +66 -0
- package/copy-of-vitnode-app/root/src/lib/content-registry.ts +51 -0
- package/copy-of-vitnode-app/root/src/lib/document-headers.ts +128 -0
- package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +75 -0
- package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +20 -0
- package/copy-of-vitnode-app/root/src/lib/navigation.ts +24 -0
- package/copy-of-vitnode-app/root/src/lib/page-head.ts +19 -0
- package/copy-of-vitnode-app/root/src/locales/app.ts +28 -0
- package/copy-of-vitnode-app/root/src/locales/packages.ts +43 -0
- package/copy-of-vitnode-app/root/src/router.tsx +227 -0
- package/copy-of-vitnode-app/root/src/routes/__root.tsx +212 -0
- package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +70 -0
- package/copy-of-vitnode-app/root/src/routes/_admin.tsx +258 -0
- package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +60 -0
- package/copy-of-vitnode-app/root/src/routes/_main.tsx +64 -0
- package/copy-of-vitnode-app/root/src/server/messages.server.ts +29 -0
- package/copy-of-vitnode-app/root/src/start.ts +88 -0
- package/copy-of-vitnode-app/root/src/styles.css +171 -0
- package/copy-of-vitnode-app/root/src/vitnode.config.ts +58 -0
- package/copy-of-vitnode-app/root/src/vitnode.shell.config.ts +35 -0
- package/copy-of-vitnode-app/root/tsconfig.json +29 -0
- package/copy-of-vitnode-app/root/tsr.config.json +3 -0
- package/copy-of-vitnode-app/root/vite.config.ts +119 -0
- package/copy-of-vitnode-plugin/root/.swcrc +26 -0
- package/copy-of-vitnode-plugin/root/global.d.ts +21 -0
- package/copy-of-vitnode-plugin/root/npmignore.template +17 -0
- package/copy-of-vitnode-plugin/root/tsconfig.build.json +5 -0
- package/copy-of-vitnode-plugin/root/tsconfig.json +25 -0
- package/dist/src/create/create-package-json.js +276 -0
- package/dist/src/create/create-vitnode.js +204 -0
- package/dist/src/create/package-versions.js +49 -0
- package/dist/src/helpers/get-available-package-managers.js +24 -0
- package/dist/src/helpers/get-package-json.js +2 -0
- package/dist/src/helpers/get-package-manager-from-root.js +15 -0
- package/dist/src/helpers/get-vitnode-package-version.js +9 -0
- package/dist/src/helpers/init-vitnode.js +34 -0
- package/dist/src/helpers/install-dependencies.js +71 -0
- package/dist/src/helpers/is-folder-empty.js +48 -0
- package/dist/src/helpers/is-online.js +38 -0
- package/dist/src/helpers/is-writeable.js +11 -0
- package/dist/src/helpers/packages-json.js +1 -0
- package/dist/src/helpers/validate-pkg.js +14 -0
- package/dist/src/helpers/with-If.js +1 -0
- package/dist/src/index.js +68 -0
- package/dist/src/plugin/create/add-plugin-to-workspace.js +74 -0
- package/dist/src/plugin/create/create-package-json.js +54 -0
- package/dist/src/plugin/create/create-plugin-vitnode.js +80 -0
- package/dist/src/plugin/create/route-templates.js +144 -0
- package/dist/src/plugin/index.js +29 -0
- package/dist/src/plugin/questions.js +14 -0
- package/dist/src/plugin/validation.js +76 -0
- package/dist/src/prepare/prepare.js +19 -0
- package/dist/src/questions.js +83 -0
- package/dist/src/validation.js +40 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { AdminBreadcrumb } from "@vitnode/core/tanstack/admin";
|
|
3
|
+
import {
|
|
4
|
+
AdminDashboardRouteContent,
|
|
5
|
+
loadAdminDashboardRoute,
|
|
6
|
+
} from "@vitnode/core/tanstack/admin/dashboard";
|
|
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's dashboard widgets reach the Next.js board through
|
|
45
|
+
* `getVitNodeConfig()`, which is server-side config kept out of this app's
|
|
46
|
+
* browser bundle by `vitnode.shell.config.ts` - and this app registers its
|
|
47
|
+
* plugins by id and messages only (see `src/vitnode.config.ts`). So the board
|
|
48
|
+
* shows core's own widgets, which is the complete set for this install. It is
|
|
49
|
+
* the same seam `AdminShell` leaves open for nav `declarations`, and it changes
|
|
50
|
+
* here when plugin AdminCP registration moves over.
|
|
51
|
+
*/
|
|
52
|
+
export const Route = createFileRoute("/_admin/admin/core/")({
|
|
53
|
+
loader: async ({ context }) => await loadAdminDashboardRoute(context),
|
|
54
|
+
component: AdminDashboardRoute,
|
|
55
|
+
/**
|
|
56
|
+
* The whole of how an admin route contributes to the trail in the shell's
|
|
57
|
+
* header: the route declares its own crumb next to its own component, the
|
|
58
|
+
* shell renders whichever matched route declared the deepest one, and there is
|
|
59
|
+
* no map from pathname to breadcrumb anywhere. The label comes from the
|
|
60
|
+
* *visible* navigation, so this reads "Core" in whatever language the
|
|
61
|
+
* administrator is using without this file naming a string.
|
|
62
|
+
*/
|
|
63
|
+
staticData: {
|
|
64
|
+
breadcrumb: <AdminBreadcrumb segments={["core"]} />,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function AdminDashboardRoute() {
|
|
69
|
+
return <AdminDashboardRouteContent {...Route.useLoaderData()} />;
|
|
70
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_ENTRY_PATH,
|
|
4
|
+
AdminNotFound,
|
|
5
|
+
adminReturnToFor,
|
|
6
|
+
canEnterAdmin,
|
|
7
|
+
ensureAdminAccess,
|
|
8
|
+
loadAdminMessages,
|
|
9
|
+
preloadAdminAccess,
|
|
10
|
+
} from "@vitnode/core/tanstack/admin";
|
|
11
|
+
import { ErrorActions } from "@vitnode/core/tanstack/layout";
|
|
12
|
+
|
|
13
|
+
import { AdminShell } from "#/components/admin-shell";
|
|
14
|
+
import { pageHead } from "#/lib/page-head";
|
|
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
|
+
*/
|
|
102
|
+
export const Route = createFileRoute("/_admin")({
|
|
103
|
+
beforeLoad: async ({ context, location, preload }) => {
|
|
104
|
+
const access = preload
|
|
105
|
+
? await preloadAdminAccess(context.queryClient)
|
|
106
|
+
: await ensureAdminAccess(context.queryClient);
|
|
107
|
+
|
|
108
|
+
if (!canEnterAdmin(access)) {
|
|
109
|
+
// TanStack Router's own control-flow signal: `redirect()` returns a typed
|
|
110
|
+
// redirect object that the router catches and turns into a navigation
|
|
111
|
+
// (or, during SSR, a 302). Throwing it is what stops the guard.
|
|
112
|
+
//
|
|
113
|
+
// `/admin` is a sibling leaf, not a child of this route, so this cannot
|
|
114
|
+
// loop: the sign-in page's own guard only redirects *away* on a granted
|
|
115
|
+
// session, and `sanitizeAdminReturnTo` rejects `/admin` as a target.
|
|
116
|
+
//
|
|
117
|
+
// Cast because `/admin` is not in this router's type table: it is
|
|
118
|
+
// `@vitnode/core`'s code-based route now, mounted by `withCoreRootRoutes`,
|
|
119
|
+
// and code-based routes are outside the generated tree's types. The
|
|
120
|
+
// *runtime* is unaffected, and `ADMIN_ENTRY_PATH` is the package's own
|
|
121
|
+
// constant - so the path and the sign-in route that serves it are still one
|
|
122
|
+
// fact in one place.
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
124
|
+
throw redirect({
|
|
125
|
+
search: { returnTo: adminReturnToFor(location) },
|
|
126
|
+
to: ADMIN_ENTRY_PATH,
|
|
127
|
+
} as unknown as Parameters<typeof redirect>[0]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Merged into the context of everything below, so a child route reads
|
|
131
|
+
// `context.adminAccess` already narrowed to the granted half of the union.
|
|
132
|
+
// It is the same object the guard decided on, from the same cache entry, so
|
|
133
|
+
// a page cannot disagree with the guard that let it render.
|
|
134
|
+
return { adminAccess: access };
|
|
135
|
+
},
|
|
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
|
+
*/
|
|
159
|
+
loader: async ({ context }) => {
|
|
160
|
+
const { adminNav } = await import("#/lib/admin-nav");
|
|
161
|
+
|
|
162
|
+
await loadAdminMessages({ ...context, namespaces: adminNav.namespaces });
|
|
163
|
+
},
|
|
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
|
+
*/
|
|
173
|
+
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
|
+
*/
|
|
197
|
+
notFoundComponent: AdminNotFoundScreen,
|
|
198
|
+
component: AdminLayout,
|
|
199
|
+
});
|
|
200
|
+
|
|
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
|
+
*/
|
|
226
|
+
function AdminNotFoundScreen() {
|
|
227
|
+
return (
|
|
228
|
+
<AdminShell>
|
|
229
|
+
<AdminNotFound actions={<ErrorActions />} />
|
|
230
|
+
</AdminShell>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
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
|
+
*/
|
|
252
|
+
function AdminLayout() {
|
|
253
|
+
return (
|
|
254
|
+
<AdminShell>
|
|
255
|
+
<Outlet />
|
|
256
|
+
</AdminShell>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
import { pageHead } from "#/lib/page-head";
|
|
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
|
+
export const Route = createFileRoute("/_main/")({
|
|
24
|
+
head: () =>
|
|
25
|
+
pageHead({
|
|
26
|
+
description: "A VitNode application.",
|
|
27
|
+
robots: "index, follow",
|
|
28
|
+
title: "Home",
|
|
29
|
+
}),
|
|
30
|
+
component: HomeRoute,
|
|
31
|
+
});
|
|
32
|
+
|
|
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
|
+
function HomeRoute() {
|
|
46
|
+
return (
|
|
47
|
+
<div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4">
|
|
48
|
+
<h1 className="text-3xl font-semibold tracking-tight text-balance">
|
|
49
|
+
Welcome to VitNode
|
|
50
|
+
</h1>
|
|
51
|
+
|
|
52
|
+
<p className="text-muted-foreground leading-relaxed text-pretty">
|
|
53
|
+
Edit <code>src/routes/_main/index.tsx</code> to change this page. The
|
|
54
|
+
header, the footer and the shell around it are{" "}
|
|
55
|
+
<code>@vitnode/core</code>’s; everything under{" "}
|
|
56
|
+
<code>src/routes</code> is yours.
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
2
|
+
import { MainBreadcrumb } from "@vitnode/core/tanstack/breadcrumb";
|
|
3
|
+
import {
|
|
4
|
+
loadMainShell,
|
|
5
|
+
ThemeLayoutContent,
|
|
6
|
+
} from "@vitnode/core/tanstack/layout";
|
|
7
|
+
|
|
8
|
+
import { MainHeader } from "#/components/main-header";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The main application shell - the header, the breadcrumb area and the one
|
|
12
|
+
* `<main>` landmark that every public page renders inside.
|
|
13
|
+
*
|
|
14
|
+
* Pathless, so it contributes no URL segment: `/discover` is `/discover`, not
|
|
15
|
+
* `/_main/discover`. A page joins the shell by *where its file lives*, which is
|
|
16
|
+
* the same rule `_authenticated` uses for the session guard - and the reason
|
|
17
|
+
* `_authenticated` lives underneath this one: a signed-in page is still a page
|
|
18
|
+
* on the public site, so it wants the shell *and* the guard rather than a second
|
|
19
|
+
* copy of the shell.
|
|
20
|
+
*
|
|
21
|
+
* ## What is deliberately outside it
|
|
22
|
+
*
|
|
23
|
+
* The four auth screens: `/login`, `/login/sso/$providerId`, `/register` and
|
|
24
|
+
* `/login/reset-password`. An auth screen is a full-height card on an otherwise
|
|
25
|
+
* empty document, and the header it would render is a header whose only
|
|
26
|
+
* interesting control is "sign in". Keeping them out is what makes this a shell
|
|
27
|
+
* that routes opt into rather than one every route is subject to.
|
|
28
|
+
* `routes/api/$` is outside for a different reason: it is a server route and
|
|
29
|
+
* renders no document at all. Note this is a visual difference from the Next.js
|
|
30
|
+
* app, where all four sit inside `(main)` and do render the header.
|
|
31
|
+
*
|
|
32
|
+
* `src/tests/main-shell.test.ts` asserts both halves - the settings paths
|
|
33
|
+
* inside, the four auth screens outside.
|
|
34
|
+
*
|
|
35
|
+
* ## The slots
|
|
36
|
+
*
|
|
37
|
+
* `ThemeLayoutContent`'s, and two of the same three the Next.js `ThemeLayout`
|
|
38
|
+
* fills: `header` and `breadcrumb`.
|
|
39
|
+
*
|
|
40
|
+
* `listeners` is deliberately left empty here. The Next.js app puts the
|
|
41
|
+
* notification toasts and the WebSocket's sign-in resync in it because its
|
|
42
|
+
* `/login` is inside the main shell; this app's is not, so a sync mounted here
|
|
43
|
+
* would not exist during the sign-in it has to notice. They are mounted by
|
|
44
|
+
* `__root` instead, next to the connection whose lifetime they share.
|
|
45
|
+
*
|
|
46
|
+
* ## What it is not
|
|
47
|
+
*
|
|
48
|
+
* A provider. Every technical provider this app has - the QueryClient, the two
|
|
49
|
+
* intl records, the theme, the WebSocket - is mounted once by `__root`, above
|
|
50
|
+
* every route, because a login screen needs them just as much as a page under
|
|
51
|
+
* this shell does. What lives here is structure: markup, and where the slots go.
|
|
52
|
+
*/
|
|
53
|
+
export const Route = createFileRoute("/_main")({
|
|
54
|
+
loader: async ({ context }) => await loadMainShell(context),
|
|
55
|
+
component: MainLayout,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function MainLayout() {
|
|
59
|
+
return (
|
|
60
|
+
<ThemeLayoutContent breadcrumb={<MainBreadcrumb />} header={<MainHeader />}>
|
|
61
|
+
<Outlet />
|
|
62
|
+
</ThemeLayoutContent>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import "@tanstack/react-start/server-only";
|
|
2
|
+
import { createIntlMessagesLoader } from "@vitnode/core/tanstack/i18n/server";
|
|
3
|
+
|
|
4
|
+
import { packageMessages } from "#/locales/packages";
|
|
5
|
+
import { vitNodeConfig } from "#/vitnode.config";
|
|
6
|
+
|
|
7
|
+
export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The messages one page needs, in one language - this app's loader.
|
|
11
|
+
*
|
|
12
|
+
* Everything about *how* messages load is core's: the source order (core, then
|
|
13
|
+
* each plugin, then this app's overrides, later ones winning), the `web` scope
|
|
14
|
+
* stamped on each so the API's tree in this same process cannot be served in its
|
|
15
|
+
* place, the per-key fallback to the default locale, and the namespace pick that
|
|
16
|
+
* keeps every plugin's AdminCP copy out of a page that renders none of it.
|
|
17
|
+
*
|
|
18
|
+
* What is this app's, and can only be: the plugins it registered, the languages
|
|
19
|
+
* it declares, and `packageMessages` - the static loaders that stand in for each
|
|
20
|
+
* package's own locale barrel, because a barrel's
|
|
21
|
+
* `import("./en.json", { with: { type: "json" } })` is a specifier Rollup will
|
|
22
|
+
* not follow. See `src/locales/packages.ts` for the reproduction.
|
|
23
|
+
*/
|
|
24
|
+
export const loadIntlMessages = createIntlMessagesLoader({
|
|
25
|
+
appMessages: vitNodeConfig.i18n.messages,
|
|
26
|
+
defaultLocale: vitNodeConfig.i18n.defaultLocale,
|
|
27
|
+
packageMessages,
|
|
28
|
+
plugins: vitNodeConfig.plugins,
|
|
29
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCsrfMiddleware,
|
|
3
|
+
createMiddleware,
|
|
4
|
+
createStart,
|
|
5
|
+
} from "@tanstack/react-start";
|
|
6
|
+
import { handleLocaleRequest } from "@vitnode/core/tanstack/i18n/server";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
applyDocumentCacheControl,
|
|
10
|
+
applyRedirectCacheControl,
|
|
11
|
+
} from "#/lib/document-headers";
|
|
12
|
+
import { localeRouting } from "#/lib/i18n/runtime";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Locale routing, as the first thing that happens to a request.
|
|
16
|
+
*
|
|
17
|
+
* A global request middleware rather than a wrapper around the server entry:
|
|
18
|
+
* Start runs these before route matching and before SSR, which is exactly where
|
|
19
|
+
* a canonical redirect belongs - the alternative is rendering a page and then
|
|
20
|
+
* throwing it away.
|
|
21
|
+
*
|
|
22
|
+
* `handlerType` narrows it to page requests. Server function calls arrive on
|
|
23
|
+
* `/_serverFn/*` with `handlerType: "serverFn"`, and redirecting an RPC to a
|
|
24
|
+
* canonical URL would break it rather than tidy it.
|
|
25
|
+
*
|
|
26
|
+
* `/api/*` reaches here too and is deliberately ignored by
|
|
27
|
+
* `handleLocaleRequest`, so the Stage 1 Hono bridge sees the request exactly as
|
|
28
|
+
* the client sent it.
|
|
29
|
+
*
|
|
30
|
+
* `localeRouting` is handed in rather than read from the package's registered
|
|
31
|
+
* runtime: Start runs request middleware before route matching, so this is the
|
|
32
|
+
* one caller that cannot assume the router entry has been evaluated. Importing
|
|
33
|
+
* it from `#/lib/i18n/runtime` is also what guarantees this app's i18n is
|
|
34
|
+
* configured before the first request touches it.
|
|
35
|
+
*
|
|
36
|
+
* ## And what a document is allowed to say about itself
|
|
37
|
+
*
|
|
38
|
+
* The cache directive rides along here for one reason: this is already the only
|
|
39
|
+
* place in the application that holds every page response, before and after
|
|
40
|
+
* rendering, and a second middleware would be a second thing to remember. Every
|
|
41
|
+
* document this app produces carries a dehydrated Query cache containing the
|
|
42
|
+
* visitor's own session - and, under `/admin`, an administrator's whole
|
|
43
|
+
* permission set - so none of them may be stored by a shared cache. See
|
|
44
|
+
* `#/lib/document-headers`, which owns the rule and says why; this file only
|
|
45
|
+
* applies it.
|
|
46
|
+
*/
|
|
47
|
+
const localeMiddleware = createMiddleware().server(
|
|
48
|
+
async ({ handlerType, next, request }) => {
|
|
49
|
+
if (handlerType !== "router") return await next();
|
|
50
|
+
|
|
51
|
+
const { redirect, setCookie } = handleLocaleRequest(request, localeRouting);
|
|
52
|
+
if (redirect) {
|
|
53
|
+
applyRedirectCacheControl(redirect);
|
|
54
|
+
|
|
55
|
+
return redirect;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await next();
|
|
59
|
+
|
|
60
|
+
// `append`, not `set`: the API mounted at `/api/*` and the auth flow both
|
|
61
|
+
// mint their own cookies, and overwriting the header would sign people out.
|
|
62
|
+
if (setCookie) result.response.headers.append("set-cookie", setCookie);
|
|
63
|
+
|
|
64
|
+
// After the cookie, so a document that just wrote one is covered by the
|
|
65
|
+
// same directive as one that did not. Only an HTML response is touched -
|
|
66
|
+
// `/api/*` reaches here too, and the API's own caching is not this
|
|
67
|
+
// middleware's to decide.
|
|
68
|
+
applyDocumentCacheControl(result.response);
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* This app's Start instance.
|
|
76
|
+
*
|
|
77
|
+
* `createCsrfMiddleware` is not optional here. Start installs it *only* while an
|
|
78
|
+
* app declares no `requestMiddleware` of its own - the moment this file exists,
|
|
79
|
+
* the default is replaced by whatever it lists, and leaving CSRF out would
|
|
80
|
+
* expose every server function as an unauthenticated cross-site endpoint. It is
|
|
81
|
+
* declared first so it runs before anything else.
|
|
82
|
+
*/
|
|
83
|
+
export const startInstance = createStart(() => ({
|
|
84
|
+
requestMiddleware: [
|
|
85
|
+
createCsrfMiddleware({ filter: ctx => ctx.handlerType === "serverFn" }),
|
|
86
|
+
localeMiddleware,
|
|
87
|
+
],
|
|
88
|
+
}));
|