create-vitnode-app 2.0.0-canary.1 → 2.0.0-canary.11

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.
Files changed (61) hide show
  1. package/README.md +9 -2
  2. package/copy-of-vitnode-app/README.md +22 -0
  3. package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +10 -11
  4. package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +4 -26
  5. package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +1 -12
  6. package/copy-of-vitnode-app/api-single-app/src/server/vitnode-api.server.ts +13 -13
  7. package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +3 -12
  8. package/copy-of-vitnode-app/docker/docker-compose.yml +6 -4
  9. package/copy-of-vitnode-app/monorepo/apps/api/.env.example +1 -1
  10. package/copy-of-vitnode-app/monorepo/apps/web/.env.example +1 -12
  11. package/copy-of-vitnode-app/monorepo/turbo.json +8 -3
  12. package/copy-of-vitnode-app/root/.env.example +1 -8
  13. package/copy-of-vitnode-app/root/global.d.ts +1 -19
  14. package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +2 -53
  15. package/copy-of-vitnode-app/root/src/components/main-footer.tsx +44 -0
  16. package/copy-of-vitnode-app/root/src/components/main-header.tsx +1 -30
  17. package/copy-of-vitnode-app/root/src/lib/i18n.ts +37 -0
  18. package/copy-of-vitnode-app/root/src/locales/app.ts +1 -25
  19. package/copy-of-vitnode-app/root/src/locales/packages.ts +1 -34
  20. package/copy-of-vitnode-app/root/src/router.tsx +16 -165
  21. package/copy-of-vitnode-app/root/src/routes/__root.tsx +12 -115
  22. package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +4 -54
  23. package/copy-of-vitnode-app/root/src/routes/_admin.tsx +5 -198
  24. package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +182 -46
  25. package/copy-of-vitnode-app/root/src/routes/_main.tsx +12 -47
  26. package/copy-of-vitnode-app/root/src/server/messages.server.ts +2 -23
  27. package/copy-of-vitnode-app/root/src/start.ts +3 -86
  28. package/copy-of-vitnode-app/root/src/styles.css +70 -92
  29. package/copy-of-vitnode-app/root/src/vitnode.config.ts +15 -53
  30. package/copy-of-vitnode-app/root/src/vitnode.server.config.ts +12 -0
  31. package/copy-of-vitnode-app/root/tsconfig.json +8 -3
  32. package/copy-of-vitnode-app/root/vite.config.ts +2 -99
  33. package/copy-of-vitnode-plugin/root/global.d.ts +6 -6
  34. package/dist/src/create/create-package-json.js +5 -4
  35. package/dist/src/create/create-vitnode.js +15 -1
  36. package/dist/src/create/package-versions.js +22 -22
  37. package/dist/src/helpers/init-git.js +38 -0
  38. package/dist/src/helpers/init-vitnode.js +2 -3
  39. package/dist/src/helpers/install-dependencies.js +2 -3
  40. package/dist/src/helpers/spawn-command.js +7 -0
  41. package/dist/src/index.js +1 -0
  42. package/dist/src/plugin/create/add-plugin-to-config.js +231 -0
  43. package/dist/src/plugin/create/create-package-json.js +12 -10
  44. package/dist/src/plugin/create/create-plugin-vitnode.js +23 -2
  45. package/dist/src/plugin/create/route-templates.js +103 -61
  46. package/dist/src/questions.js +7 -0
  47. package/dist/tsconfig.build.tsbuildinfo +1 -1
  48. package/package.json +6 -6
  49. package/copy-of-vitnode-app/api/src/i18n.ts +0 -38
  50. package/copy-of-vitnode-app/root/src/i18n.ts +0 -36
  51. package/copy-of-vitnode-app/root/src/lib/admin-auth.ts +0 -11
  52. package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +0 -43
  53. package/copy-of-vitnode-app/root/src/lib/admin-search.ts +0 -37
  54. package/copy-of-vitnode-app/root/src/lib/auth.ts +0 -66
  55. package/copy-of-vitnode-app/root/src/lib/content-registry.ts +0 -51
  56. package/copy-of-vitnode-app/root/src/lib/document-headers.ts +0 -128
  57. package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +0 -75
  58. package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +0 -20
  59. package/copy-of-vitnode-app/root/src/lib/navigation.ts +0 -24
  60. package/copy-of-vitnode-app/root/src/lib/page-head.ts +0 -19
  61. package/copy-of-vitnode-app/root/src/vitnode.shell.config.ts +0 -35
@@ -1,104 +1,16 @@
1
1
  import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
2
2
  import {
3
3
  ADMIN_ENTRY_PATH,
4
- AdminNotFound,
5
4
  adminReturnToFor,
6
5
  canEnterAdmin,
7
6
  ensureAdminAccess,
8
7
  loadAdminMessages,
9
8
  preloadAdminAccess,
10
9
  } from "@vitnode/core/tanstack/admin";
11
- import { ErrorActions } from "@vitnode/core/tanstack/layout";
10
+ import { pageHead } from "@vitnode/core/tanstack/metadata";
12
11
 
13
- import { AdminShell } from "#/components/admin-shell";
14
- import { pageHead } from "#/lib/page-head";
12
+ import { AdminShell } from "@/components/admin-shell";
15
13
 
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
14
  export const Route = createFileRoute("/_admin")({
103
15
  beforeLoad: async ({ context, location, preload }) => {
104
16
  const access = preload
@@ -133,122 +45,17 @@ export const Route = createFileRoute("/_admin")({
133
45
  // a page cannot disagree with the guard that let it render.
134
46
  return { adminAccess: access };
135
47
  },
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
- */
48
+
159
49
  loader: async ({ context }) => {
160
- const { adminNav } = await import("#/lib/admin-nav");
50
+ const { adminNav } = await import("@/admin-nav.gen");
161
51
 
162
52
  await loadAdminMessages({ ...context, namespaces: adminNav.namespaces });
163
53
  },
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
- */
54
+
173
55
  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
56
  component: AdminLayout,
199
57
  });
200
58
 
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
59
  function AdminLayout() {
253
60
  return (
254
61
  <AdminShell>
@@ -1,60 +1,196 @@
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
- */
1
+ import type { LucideIcon } from "lucide-react";
2
+
3
+ import type { RegisteredRouter } from "@tanstack/react-router";
4
+
5
+ import { createFileRoute, Link } from "@tanstack/react-router";
6
+ import { LogoVitNode } from "@vitnode/core/components/logo-vitnode";
7
+ import { buttonVariants } from "@vitnode/core/components/ui/button";
8
+ import {
9
+ VITNODE_DOCS_URL,
10
+ VITNODE_WEBSITE_URL,
11
+ } from "@vitnode/core/lib/docs-links";
12
+ import { useSessionQuery } from "@vitnode/core/tanstack/auth";
13
+ import { pageHead } from "@vitnode/core/tanstack/metadata";
14
+ import { cn } from "cn";
15
+ import {
16
+ ArrowUpRight,
17
+ BookOpen,
18
+ Globe,
19
+ ShieldCheck,
20
+ UserRoundPlus,
21
+ } from "lucide-react";
22
+
23
+ const REGISTER_HREF = "/register";
24
+ const ADMIN_HREF = "/admin";
25
+
23
26
  export const Route = createFileRoute("/_main/")({
24
27
  head: () =>
25
28
  pageHead({
26
- description: "A VitNode application.",
29
+ description: "Your VitNode app is up and running.",
27
30
  robots: "index, follow",
28
- title: "Home",
31
+ title: "Welcome",
29
32
  }),
30
33
  component: HomeRoute,
31
34
  });
32
35
 
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
- */
36
+ interface NextStep {
37
+ description: string;
38
+ external?: boolean;
39
+ href: string;
40
+ icon: LucideIcon;
41
+ title: string;
42
+ }
43
+
44
+ const registerStep: NextStep = {
45
+ description:
46
+ "Create a member account. On a fresh install the first one becomes the administrator.",
47
+ href: REGISTER_HREF,
48
+ icon: UserRoundPlus,
49
+ title: "Register",
50
+ };
51
+
52
+ const sharedSteps: NextStep[] = [
53
+ {
54
+ description: "Settings, members, roles, plugins and content live here.",
55
+ href: ADMIN_HREF,
56
+ icon: ShieldCheck,
57
+ title: "AdminCP",
58
+ },
59
+ {
60
+ description: "Configuration, routing, plugins and deployment guides.",
61
+ external: true,
62
+ href: VITNODE_DOCS_URL,
63
+ icon: BookOpen,
64
+ title: "Documentation",
65
+ },
66
+ {
67
+ description: "Project website, release notes and community links.",
68
+ external: true,
69
+ href: VITNODE_WEBSITE_URL,
70
+ icon: Globe,
71
+ title: "vitnode.com",
72
+ },
73
+ ];
74
+
45
75
  function HomeRoute() {
76
+ const { data } = useSessionQuery();
77
+ const user = data?.user ?? null;
78
+ const steps = user ? sharedSteps : [registerStep, ...sharedSteps];
79
+
46
80
  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>&rsquo;s; everything under{" "}
56
- <code>src/routes</code> is yours.
81
+ <div className="container mx-auto flex max-w-3xl flex-col gap-14 px-4 py-16 sm:py-24">
82
+ <section className="flex flex-col items-center gap-6 text-center">
83
+ <LogoVitNode className="size-14" idPrefix="welcome-logo" small />
84
+
85
+ <div className="flex flex-col gap-3">
86
+ <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
87
+ Your VitNode app is running
88
+ </h1>
89
+
90
+ <p className="text-muted-foreground mx-auto max-w-xl text-lg leading-relaxed text-pretty">
91
+ {user
92
+ ? `You are signed in as ${user.name}. Open the AdminCP to configure the site.`
93
+ : "Create an account or sign in, then open the AdminCP to configure the site. On a fresh install the first registered member becomes the administrator."}
94
+ </p>
95
+ </div>
96
+
97
+ <div className="flex flex-col gap-3 sm:flex-row">
98
+ {user ? null : (
99
+ <Link
100
+ className={cn(buttonVariants({ size: "lg" }), "px-5")}
101
+ to={REGISTER_HREF}
102
+ >
103
+ <UserRoundPlus />
104
+ Create an account
105
+ </Link>
106
+ )}
107
+
108
+ <Link
109
+ className={cn(
110
+ buttonVariants({
111
+ size: "lg",
112
+ variant: user ? "default" : "outline",
113
+ }),
114
+ "px-5",
115
+ )}
116
+ to={ADMIN_HREF}
117
+ >
118
+ <ShieldCheck />
119
+ Open AdminCP
120
+ </Link>
121
+ </div>
122
+ </section>
123
+
124
+ <section className="flex flex-col gap-4">
125
+ <h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
126
+ Next steps
127
+ </h2>
128
+
129
+ <div
130
+ className={cn(
131
+ "grid gap-3",
132
+ steps.length === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2",
133
+ )}
134
+ >
135
+ {steps.map(step => (
136
+ <NextStepCard key={step.href} {...step} />
137
+ ))}
138
+ </div>
139
+ </section>
140
+
141
+ <p className="text-muted-foreground text-center text-sm text-pretty">
142
+ This page lives in <code>src/routes/_main/index.tsx</code>. Replace it
143
+ whenever you are ready.
57
144
  </p>
58
145
  </div>
59
146
  );
60
147
  }
148
+
149
+ function NextStepCard({
150
+ description,
151
+ external = false,
152
+ href,
153
+ icon: Icon,
154
+ title,
155
+ }: NextStep) {
156
+ const className =
157
+ "group bg-card hover:bg-muted/50 focus-visible:border-ring focus-visible:ring-ring/50 flex flex-col gap-3 rounded-xl border p-5 transition-colors outline-none focus-visible:ring-3";
158
+
159
+ const content = (
160
+ <>
161
+ <div className="flex items-center justify-between">
162
+ <span className="bg-primary/10 text-primary flex size-9 items-center justify-center rounded-lg">
163
+ <Icon className="size-4.5" />
164
+ </span>
165
+
166
+ <ArrowUpRight className="text-muted-foreground size-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
167
+ </div>
168
+
169
+ <div className="flex flex-col gap-1">
170
+ <span className="font-medium">{title}</span>
171
+ <span className="text-muted-foreground text-sm leading-relaxed text-pretty">
172
+ {description}
173
+ </span>
174
+ </div>
175
+ </>
176
+ );
177
+
178
+ if (external) {
179
+ return (
180
+ <a
181
+ className={className}
182
+ href={href}
183
+ rel="noopener noreferrer"
184
+ target="_blank"
185
+ >
186
+ {content}
187
+ </a>
188
+ );
189
+ }
190
+
191
+ return (
192
+ <Link<RegisteredRouter, string, string> className={className} href={href}>
193
+ {content}
194
+ </Link>
195
+ );
196
+ }
@@ -5,51 +5,9 @@ import {
5
5
  ThemeLayoutContent,
6
6
  } from "@vitnode/core/tanstack/layout";
7
7
 
8
- import { MainHeader } from "#/components/main-header";
8
+ import { MainFooter } from "@/components/main-footer";
9
+ import { MainHeader } from "@/components/main-header";
9
10
 
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
11
  export const Route = createFileRoute("/_main")({
54
12
  loader: async ({ context }) => await loadMainShell(context),
55
13
  component: MainLayout,
@@ -57,8 +15,15 @@ export const Route = createFileRoute("/_main")({
57
15
 
58
16
  function MainLayout() {
59
17
  return (
60
- <ThemeLayoutContent breadcrumb={<MainBreadcrumb />} header={<MainHeader />}>
61
- <Outlet />
62
- </ThemeLayoutContent>
18
+ <div className="flex min-h-svh flex-col [&>main]:flex-1">
19
+ <ThemeLayoutContent
20
+ breadcrumb={<MainBreadcrumb />}
21
+ header={<MainHeader />}
22
+ >
23
+ <Outlet />
24
+ </ThemeLayoutContent>
25
+
26
+ <MainFooter />
27
+ </div>
63
28
  );
64
29
  }
@@ -1,29 +1,8 @@
1
1
  import "@tanstack/react-start/server-only";
2
2
  import { createIntlMessagesLoader } from "@vitnode/core/tanstack/i18n/server";
3
3
 
4
- import { packageMessages } from "#/locales/packages";
5
- import { vitNodeConfig } from "#/vitnode.config";
4
+ import { vitNodeServerConfig } from "@/vitnode.server.config";
6
5
 
7
6
  export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server";
8
7
 
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
- });
8
+ export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig);
@@ -1,88 +1,5 @@
1
- import {
2
- createCsrfMiddleware,
3
- createMiddleware,
4
- createStart,
5
- } from "@tanstack/react-start";
6
- import { handleLocaleRequest } from "@vitnode/core/tanstack/i18n/server";
1
+ import { createVitNodeStart } from "@vitnode/core/tanstack/start";
7
2
 
8
- import {
9
- applyDocumentCacheControl,
10
- applyRedirectCacheControl,
11
- } from "#/lib/document-headers";
12
- import { localeRouting } from "#/lib/i18n/runtime";
3
+ import { vitNodeConfig } from "@/vitnode.config";
13
4
 
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
- }));
5
+ export const startInstance = createVitNodeStart({ config: vitNodeConfig });