@podosoft/podokit 0.5.1 → 0.7.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.
Files changed (98) hide show
  1. package/README.md +6 -0
  2. package/dist/add.d.ts +24 -2
  3. package/dist/add.js +77 -20
  4. package/dist/assemble.js +4 -1
  5. package/dist/create.d.ts +2 -0
  6. package/dist/create.js +6 -0
  7. package/dist/eject.js +9 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +7 -2
  10. package/dist/lib.d.ts +14 -0
  11. package/dist/lib.js +34 -0
  12. package/dist/lockfile.d.ts +7 -2
  13. package/dist/lockfile.js +37 -2
  14. package/dist/templates/base/AGENTS.md +30 -0
  15. package/dist/templates/base/CLAUDE.md +6 -0
  16. package/dist/templates/fullstack-nest-svelte/AGENTS.md +79 -0
  17. package/dist/templates/fullstack-nest-svelte/CLAUDE.md +6 -0
  18. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +25 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +4 -2
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.extensions.ts +21 -0
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +3 -0
  22. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/index.ts +1 -3
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-list.svelte +5 -25
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-trigger.svelte +1 -4
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs.svelte +1 -1
  28. package/dist/templates/fullstack-nest-svelte/apps/web/static/favicon.svg +13 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +12 -0
  30. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +150 -0
  31. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-module/SKILL.md +27 -0
  32. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-nest-endpoint/SKILL.md +23 -0
  33. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-sveltekit-route/SKILL.md +23 -0
  34. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-update/SKILL.md +26 -0
  35. package/dist/templates/fullstack-nest-svelte/dot-cursor/rules/podokit.mdc +11 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-devcontainer/devcontainer.json +20 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-dockerignore +8 -0
  38. package/dist/templates/fullstack-nest-svelte/dot-env.docker +26 -0
  39. package/dist/templates/fullstack-nest-svelte/dot-github/copilot-instructions.md +6 -0
  40. package/dist/templates/fullstack-nest-svelte/dot-mcp.json +8 -0
  41. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +13 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +87 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.module.ts +14 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +84 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +2 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +12 -1
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +37 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +25 -11
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +32 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +16 -3
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +52 -2
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +50 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +38 -13
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +203 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/+layout.svelte +7 -3
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +8 -3
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +34 -4
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +41 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +2 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/maintenance/+page.svelte +25 -0
  61. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +43 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +81 -0
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +42 -1
  64. package/dist/templates/modules/admin-dashboard/module.manifest.json +22 -4
  65. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +27 -0
  66. package/dist/templates/modules/auth/module.manifest.json +9 -8
  67. package/dist/templates/modules/bullmq/module.manifest.json +1 -1
  68. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.controller.ts +3 -0
  69. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +13 -0
  70. package/dist/templates/modules/{auth → mailer}/files/apps/api/src/mail/mailer.ts +41 -7
  71. package/dist/templates/modules/mailer/module.manifest.json +38 -0
  72. package/dist/templates/todo/AGENTS.md +80 -0
  73. package/dist/templates/todo/CLAUDE.md +6 -0
  74. package/dist/templates/todo/Dockerfile.dev +25 -0
  75. package/dist/templates/todo/apps/api/package.json +4 -2
  76. package/dist/templates/todo/apps/api/src/app.extensions.ts +21 -0
  77. package/dist/templates/todo/apps/api/src/app.module.ts +3 -0
  78. package/dist/templates/todo/apps/web/src/app.html +2 -0
  79. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/index.ts +1 -3
  80. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-content.svelte +1 -1
  81. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-list.svelte +5 -25
  82. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-trigger.svelte +1 -4
  83. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs.svelte +1 -1
  84. package/dist/templates/todo/apps/web/static/favicon.svg +13 -0
  85. package/dist/templates/todo/apps/web/vite.config.ts +12 -0
  86. package/dist/templates/todo/compose.dev.yaml +150 -0
  87. package/dist/templates/todo/dot-claude/skills/podokit-add-module/SKILL.md +27 -0
  88. package/dist/templates/todo/dot-claude/skills/podokit-nest-endpoint/SKILL.md +23 -0
  89. package/dist/templates/todo/dot-claude/skills/podokit-sveltekit-route/SKILL.md +23 -0
  90. package/dist/templates/todo/dot-claude/skills/podokit-update/SKILL.md +26 -0
  91. package/dist/templates/todo/dot-cursor/rules/podokit.mdc +11 -0
  92. package/dist/templates/todo/dot-devcontainer/devcontainer.json +20 -0
  93. package/dist/templates/todo/dot-dockerignore +8 -0
  94. package/dist/templates/todo/dot-env.docker +26 -0
  95. package/dist/templates/todo/dot-github/copilot-instructions.md +6 -0
  96. package/dist/templates/todo/dot-mcp.json +8 -0
  97. package/dist/templates/todo/infra/traefik/dynamic.yml +13 -0
  98. package/package.json +9 -1
@@ -0,0 +1,203 @@
1
+ <script lang="ts">
2
+ import { Input } from "$lib/components/ui/input";
3
+ import { Label } from "$lib/components/ui/label";
4
+ import { Button } from "$lib/components/ui/button";
5
+ import { Switch } from "$lib/components/ui/switch";
6
+ import * as Card from "$lib/components/ui/card";
7
+ import { toast } from "svelte-sonner";
8
+ import { api } from "$lib/api";
9
+ import { getI18n } from "$lib/i18n";
10
+ import { site, type SiteSettings } from "$lib/site.svelte";
11
+
12
+ const i18n = getI18n();
13
+ const t = $derived(i18n.t.general);
14
+
15
+ // Text fields, seeded from the current site settings.
16
+ const s = $derived(site.value);
17
+ let form = $state({
18
+ name: "",
19
+ description: "",
20
+ supportEmail: "",
21
+ footerText: "",
22
+ brandColor: "",
23
+ termsUrl: "",
24
+ privacyUrl: "",
25
+ locale: "",
26
+ timezone: "",
27
+ });
28
+ let maintenanceMode = $state(false);
29
+ let allowSignup = $state(true);
30
+ let seeded = $state(false);
31
+ $effect(() => {
32
+ if (seeded) return;
33
+ form = {
34
+ name: s.name ?? "",
35
+ description: s.description ?? "",
36
+ supportEmail: s.supportEmail ?? "",
37
+ footerText: s.footerText ?? "",
38
+ brandColor: s.brandColor ?? "",
39
+ termsUrl: s.termsUrl ?? "",
40
+ privacyUrl: s.privacyUrl ?? "",
41
+ locale: s.locale ?? "",
42
+ timezone: s.timezone ?? "",
43
+ };
44
+ maintenanceMode = s.maintenanceMode === "true";
45
+ allowSignup = s.allowSignup !== "false";
46
+ seeded = true;
47
+ });
48
+
49
+ let saving = $state(false);
50
+ async function save(): Promise<void> {
51
+ saving = true;
52
+ try {
53
+ const payload: Record<string, string> = {
54
+ ...form,
55
+ maintenanceMode: String(maintenanceMode),
56
+ allowSignup: String(allowSignup),
57
+ };
58
+ await api.put("/site/settings", payload);
59
+ site.patch(payload as Partial<SiteSettings>); // live: browser title updates now
60
+ toast.success(t.saved);
61
+ } catch {
62
+ toast.error(t.saveError);
63
+ } finally {
64
+ saving = false;
65
+ }
66
+ }
67
+
68
+ const ICON_TYPES = ".svg,.png,.ico";
69
+ let uploading = $state(false);
70
+ const faviconSrc = $derived(
71
+ s.hasFavicon ? `/api/site/favicon?v=${s.faviconVersion ?? ""}` : "/favicon.svg",
72
+ );
73
+ async function uploadFavicon(e: Event): Promise<void> {
74
+ const input = e.currentTarget as HTMLInputElement;
75
+ const file = input.files?.[0];
76
+ if (!file) return;
77
+ if (file.size > 1024 * 1024) {
78
+ toast.error(t.iconTooBig);
79
+ input.value = "";
80
+ return;
81
+ }
82
+ uploading = true;
83
+ try {
84
+ const body = new FormData();
85
+ body.append("file", file);
86
+ const res = await fetch("/api/site/favicon", { method: "POST", body });
87
+ if (!res.ok) throw new Error(String(res.status));
88
+ const { version } = (await res.json()) as { version: string | null };
89
+ site.patch({ hasFavicon: true, faviconVersion: version }); // live: favicon refetches
90
+ toast.success(t.iconSaved);
91
+ } catch {
92
+ toast.error(t.iconError);
93
+ } finally {
94
+ uploading = false;
95
+ input.value = "";
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <div class="flex flex-col gap-6">
101
+ <div class="grid items-start gap-6 lg:grid-cols-2">
102
+ <Card.Root>
103
+ <Card.Header>
104
+ <Card.Title>{t.brandingTitle}</Card.Title>
105
+ <Card.Description>{t.brandingDesc}</Card.Description>
106
+ </Card.Header>
107
+ <Card.Content class="flex flex-col gap-4">
108
+ <div class="flex flex-col gap-1.5">
109
+ <Label for="site-name">{t.siteName}</Label>
110
+ <Input id="site-name" bind:value={form.name} placeholder={t.siteNamePlaceholder} />
111
+ <p class="text-muted-foreground text-xs">{t.siteNameHint}</p>
112
+ </div>
113
+
114
+ <div class="flex flex-col gap-1.5">
115
+ <Label>{t.favicon}</Label>
116
+ <div class="flex items-center gap-3">
117
+ <img src={faviconSrc} alt="favicon" class="bg-muted size-12 rounded-md border p-1" />
118
+ <div class="flex flex-col gap-1.5">
119
+ <input
120
+ type="file"
121
+ accept={ICON_TYPES}
122
+ disabled={uploading}
123
+ onchange={uploadFavicon}
124
+ class="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-1.5 file:text-sm"
125
+ />
126
+ <p class="text-muted-foreground text-xs">{t.faviconHint}</p>
127
+ </div>
128
+ </div>
129
+ </div>
130
+
131
+ <div class="flex flex-col gap-1.5">
132
+ <Label for="brand-color">{t.brandColor}</Label>
133
+ <Input id="brand-color" bind:value={form.brandColor} placeholder="#6d28d9" />
134
+ </div>
135
+ </Card.Content>
136
+ </Card.Root>
137
+
138
+ <Card.Root class="lg:row-span-2">
139
+ <Card.Header>
140
+ <Card.Title>{t.infoTitle}</Card.Title>
141
+ <Card.Description>{t.infoDesc}</Card.Description>
142
+ </Card.Header>
143
+ <Card.Content class="flex flex-col gap-4">
144
+ <div class="flex flex-col gap-1.5">
145
+ <Label for="site-desc">{t.description}</Label>
146
+ <Input id="site-desc" bind:value={form.description} placeholder={t.descriptionPlaceholder} />
147
+ </div>
148
+ <div class="grid gap-4 sm:grid-cols-2">
149
+ <div class="flex flex-col gap-1.5">
150
+ <Label for="support-email">{t.supportEmail}</Label>
151
+ <Input id="support-email" type="email" bind:value={form.supportEmail} placeholder="support@example.com" />
152
+ </div>
153
+ <div class="flex flex-col gap-1.5">
154
+ <Label for="footer">{t.footerText}</Label>
155
+ <Input id="footer" bind:value={form.footerText} placeholder="© 2026 Example" />
156
+ </div>
157
+ <div class="flex flex-col gap-1.5">
158
+ <Label for="terms">{t.termsUrl}</Label>
159
+ <Input id="terms" bind:value={form.termsUrl} placeholder="/terms" />
160
+ </div>
161
+ <div class="flex flex-col gap-1.5">
162
+ <Label for="privacy">{t.privacyUrl}</Label>
163
+ <Input id="privacy" bind:value={form.privacyUrl} placeholder="/privacy" />
164
+ </div>
165
+ <div class="flex flex-col gap-1.5">
166
+ <Label for="locale">{t.locale}</Label>
167
+ <Input id="locale" bind:value={form.locale} placeholder="en" />
168
+ </div>
169
+ <div class="flex flex-col gap-1.5">
170
+ <Label for="timezone">{t.timezone}</Label>
171
+ <Input id="timezone" bind:value={form.timezone} placeholder="UTC" />
172
+ </div>
173
+ </div>
174
+ </Card.Content>
175
+ </Card.Root>
176
+
177
+ <Card.Root>
178
+ <Card.Header>
179
+ <Card.Title>{t.opsTitle}</Card.Title>
180
+ </Card.Header>
181
+ <Card.Content class="flex flex-col gap-4">
182
+ <div class="flex items-center justify-between gap-4">
183
+ <div class="flex flex-col">
184
+ <Label>{t.maintenance}</Label>
185
+ <p class="text-muted-foreground text-sm">{t.maintenanceDesc}</p>
186
+ </div>
187
+ <Switch bind:checked={maintenanceMode} aria-label={t.maintenance} />
188
+ </div>
189
+ <div class="flex items-center justify-between gap-4">
190
+ <div class="flex flex-col">
191
+ <Label>{t.allowSignup}</Label>
192
+ <p class="text-muted-foreground text-sm">{t.allowSignupDesc}</p>
193
+ </div>
194
+ <Switch bind:checked={allowSignup} aria-label={t.allowSignup} />
195
+ </div>
196
+ </Card.Content>
197
+ </Card.Root>
198
+ </div>
199
+
200
+ <div>
201
+ <Button onclick={save} disabled={saving}>{saving ? t.saving : t.save}</Button>
202
+ </div>
203
+ </div>
@@ -1,13 +1,17 @@
1
1
  <script lang="ts">
2
2
  import LanguageSwitch from "$lib/components/language-switch.svelte";
3
+ import SiteFooter from "$lib/components/site-footer.svelte";
3
4
  let { children } = $props();
4
5
  </script>
5
6
 
6
- <div class="bg-muted/30 relative flex min-h-svh items-center justify-center p-6">
7
+ <div class="bg-muted/30 relative flex min-h-svh flex-col p-6">
7
8
  <div class="absolute right-4 top-4">
8
9
  <LanguageSwitch />
9
10
  </div>
10
- <div class="w-full max-w-sm">
11
- {@render children()}
11
+ <div class="flex flex-1 items-center justify-center">
12
+ <div class="w-full max-w-sm">
13
+ {@render children()}
14
+ </div>
12
15
  </div>
16
+ <SiteFooter />
13
17
  </div>
@@ -8,11 +8,14 @@
8
8
  import * as Alert from "$lib/components/ui/alert";
9
9
  import { api } from "$lib/api";
10
10
  import { getI18n } from "$lib/i18n";
11
+ import { site } from "$lib/site.svelte";
11
12
 
12
13
  import type { PageData } from "./$types";
13
14
 
14
15
  let { data }: { data: PageData } = $props();
15
16
  const i18n = getI18n();
17
+ // Hide the sign-up link when registration is closed from admin Settings.
18
+ const signupOpen = $derived(site.value.allowSignup !== "false");
16
19
  let email = $state("");
17
20
  let password = $state("");
18
21
  let error = $state<string | null>(null);
@@ -175,7 +178,9 @@
175
178
  </div>
176
179
  {/if}
177
180
  </Card.Content>
178
- <Card.Footer class="justify-center">
179
- <p class="text-muted-foreground text-sm">{i18n.t.auth.noAccount} <a href="/signup" class="text-foreground hover:underline">{i18n.t.auth.signUp}</a></p>
180
- </Card.Footer>
181
+ {#if signupOpen}
182
+ <Card.Footer class="justify-center">
183
+ <p class="text-muted-foreground text-sm">{i18n.t.auth.noAccount} <a href="/signup" class="text-foreground hover:underline">{i18n.t.auth.signUp}</a></p>
184
+ </Card.Footer>
185
+ {/if}
181
186
  </Card.Root>
@@ -2,21 +2,51 @@ import type { LayoutServerLoad } from "./$types";
2
2
  import { redirect } from "@sveltejs/kit";
3
3
 
4
4
  // "/" is a public landing page. Auth pages send signed-in users to /admin, and
5
- // the admin area requires a session.
6
- const PUBLIC_PATHS = ["/", "/login", "/signup", "/forgot-password", "/reset-password"];
5
+ // the admin area requires a session. /maintenance is public so it renders while
6
+ // the rest of the site is held back.
7
+ const PUBLIC_PATHS = [
8
+ "/",
9
+ "/login",
10
+ "/signup",
11
+ "/forgot-password",
12
+ "/reset-password",
13
+ "/maintenance",
14
+ // Modules add their public (no-session) page prefixes here.
15
+ // podokit:begin:public-paths
16
+ // podokit:end:public-paths
17
+ ];
7
18
  const AUTH_PATHS = ["/login", "/signup", "/forgot-password", "/reset-password"];
8
19
 
9
20
  function isPublic(pathname: string): boolean {
10
21
  return PUBLIC_PATHS.some((path) => pathname === path || (path !== "/" && pathname.startsWith(`${path}/`)));
11
22
  }
12
23
 
13
- export const load: LayoutServerLoad = ({ locals, url }) => {
24
+ export const load: LayoutServerLoad = async (event) => {
25
+ const { locals, url } = event;
14
26
  const { pathname } = url;
27
+ // Site settings were loaded once in hooks.server.ts.
28
+ const site = locals.site;
29
+ const isAdmin = locals.user?.role === "admin";
30
+
31
+ // Maintenance mode holds everyone but admins on a maintenance page. /login and
32
+ // /maintenance stay reachable so an admin can still sign in and turn it off.
33
+ const maintenance = site?.maintenanceMode === "true";
34
+ if (maintenance && !isAdmin && pathname !== "/maintenance" && pathname !== "/login") {
35
+ redirect(303, "/maintenance");
36
+ }
37
+ if (!maintenance && pathname === "/maintenance") {
38
+ redirect(303, "/");
39
+ }
40
+ // Registration closed from the admin Settings: send /signup back to /login.
41
+ if (site?.allowSignup === "false" && pathname === "/signup") {
42
+ redirect(303, "/login");
43
+ }
44
+
15
45
  if (locals.user && AUTH_PATHS.includes(pathname)) {
16
46
  redirect(303, "/admin");
17
47
  }
18
48
  if (!locals.user && !isPublic(pathname)) {
19
49
  redirect(303, `/login?redirect=${encodeURIComponent(pathname)}`);
20
50
  }
21
- return { user: locals.user, locale: locals.locale };
51
+ return { user: locals.user, locale: locals.locale, site };
22
52
  };
@@ -3,6 +3,7 @@
3
3
  import { ModeWatcher } from "mode-watcher";
4
4
  import { setI18nContext } from "$lib/i18n";
5
5
  import { messages, resolveLocale } from "$lib/i18n/messages";
6
+ import { site } from "$lib/site.svelte";
6
7
 
7
8
  let { children, data } = $props();
8
9
  const locale = $derived(resolveLocale(data.locale));
@@ -14,7 +15,47 @@
14
15
  return locale;
15
16
  },
16
17
  });
18
+
19
+ // Seed the reactive site branding; the general-settings form patches it live.
20
+ $effect.pre(() => site.init(data.site));
21
+ // Prefer live store values (patched after a save); fall back to the server load
22
+ // so SSR renders the saved branding too. Empty falls back to the app.html default.
23
+ const branding = $derived({
24
+ name: site.value.name ?? data.site?.name ?? "",
25
+ description: site.value.description ?? data.site?.description ?? "",
26
+ brandColor: site.value.brandColor ?? data.site?.brandColor ?? "",
27
+ hasFavicon: site.value.hasFavicon || Boolean(data.site?.hasFavicon),
28
+ faviconVersion: site.value.faviconVersion ?? data.site?.faviconVersion ?? "",
29
+ });
30
+ // Apply the site name to the browser tab live (avoids a duplicate <title> vs
31
+ // the app.html default; the default project name stays as the SSR fallback).
32
+ $effect(() => {
33
+ if (branding.name) document.title = branding.name;
34
+ });
35
+ // Apply the brand color as the theme's primary accent live. Clearing it in
36
+ // Settings restores the stylesheet default (remove the inline override).
37
+ $effect(() => {
38
+ const root = document.documentElement;
39
+ if (branding.brandColor) {
40
+ root.style.setProperty("--primary", branding.brandColor);
41
+ root.style.setProperty("--sidebar-primary", branding.brandColor);
42
+ root.style.setProperty("--ring", branding.brandColor);
43
+ } else {
44
+ root.style.removeProperty("--primary");
45
+ root.style.removeProperty("--sidebar-primary");
46
+ root.style.removeProperty("--ring");
47
+ }
48
+ });
17
49
  </script>
18
50
 
51
+ <svelte:head>
52
+ {#if branding.description}
53
+ <meta name="description" content={branding.description} />
54
+ {/if}
55
+ {#if branding.hasFavicon}
56
+ <link rel="icon" href={`/api/site/favicon?v=${branding.faviconVersion}`} />
57
+ {/if}
58
+ </svelte:head>
59
+
19
60
  <ModeWatcher />
20
61
  {@render children()}
@@ -2,6 +2,7 @@
2
2
  import { Button } from "$lib/components/ui/button";
3
3
  import LanguageSwitch from "$lib/components/language-switch.svelte";
4
4
  import ThemeToggle from "$lib/components/theme-toggle.svelte";
5
+ import SiteFooter from "$lib/components/site-footer.svelte";
5
6
  import { getI18n } from "$lib/i18n";
6
7
 
7
8
  const i18n = getI18n();
@@ -19,4 +20,5 @@
19
20
  </div>
20
21
  <Button href="/admin">{i18n.t.landing.openAdmin}</Button>
21
22
  </main>
23
+ <SiteFooter />
22
24
  </div>
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ // Shown to non-admin visitors while maintenance mode is on (see +layout.server.ts).
3
+ // Admins are never redirected here and can sign in to turn it off.
4
+ import { Button } from "$lib/components/ui/button";
5
+ import LanguageSwitch from "$lib/components/language-switch.svelte";
6
+ import ThemeToggle from "$lib/components/theme-toggle.svelte";
7
+ import { getI18n } from "$lib/i18n";
8
+
9
+ const i18n = getI18n();
10
+ </script>
11
+
12
+ <div class="flex min-h-svh flex-col">
13
+ <header class="flex items-center justify-end gap-2 p-4">
14
+ <LanguageSwitch />
15
+ <ThemeToggle />
16
+ </header>
17
+ <main class="flex flex-1 flex-col items-center justify-center gap-6 px-4 text-center">
18
+ <div class="flex flex-col gap-3">
19
+ <h1 class="text-3xl font-semibold">{i18n.t.maintenance.title}</h1>
20
+ <p class="text-muted-foreground max-w-md">{i18n.t.maintenance.subtitle}</p>
21
+ <p class="text-muted-foreground text-sm">{i18n.t.maintenance.adminHint}</p>
22
+ </div>
23
+ <Button href="/login" variant="outline">{i18n.t.maintenance.signIn}</Button>
24
+ </main>
25
+ </div>
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: podokit-data-table
3
+ description: Use when building ANY list or table view in this project (users, sessions, orders, any collection) — including inside dialogs. PodoKit requires the shared DataTable component; never assemble a table by hand.
4
+ ---
5
+
6
+ # Build a list with the shared DataTable
7
+
8
+ **Every** list/table in this project must use `$lib/components/data-table.svelte`
9
+ (`DataTable`) — it provides sortable headers and a footer pagination, consistently.
10
+ Do **not** assemble `Table.Root` / `Table.Header` / `Table.Body` by hand, and don't
11
+ put pagination outside the table.
12
+
13
+ ```svelte
14
+ <script lang="ts">
15
+ import DataTable from "$lib/components/data-table.svelte";
16
+ const columns = [
17
+ { key: "name", label: "Name", sortable: true },
18
+ { key: "email", label: "Email", sortable: true },
19
+ { key: "role", label: "Role", sortable: true }, // action/status columns: no `sortable`
20
+ ];
21
+ let { rows } = $props(); // Svelte 5 runes only
22
+ </script>
23
+
24
+ <DataTable {columns} {rows} label="{rows.length} users">
25
+ {#snippet row(item)}
26
+ <Table.Cell>{item.name}</Table.Cell>
27
+ <Table.Cell>{item.email}</Table.Cell>
28
+ <Table.Cell>{item.role}</Table.Cell>
29
+ {/snippet}
30
+ </DataTable>
31
+ ```
32
+
33
+ - **Client data** (bounded): pass `rows`; DataTable sorts/paginates internally.
34
+ - **Server data** (huge lists): use `manualSort` + `manualPagination` + `total`
35
+ + `onChange` (re-fetch with `sortBy`/`sortDirection`/`page`).
36
+ - Nested/derived sort keys: give the column a `value` accessor.
37
+ - No footer needed: `perPage={0}`.
38
+ - **Search & filters**: put the shared `table-toolbar.svelte` (`TableToolbar`)
39
+ above the DataTable. Filters + search apply together on the Search button/Enter
40
+ (don't query on every keystroke); the search field is a `select` of columns.
41
+
42
+ The `/admin/users` page is the reference implementation. See `docs/development.md`
43
+ ("Data tables", "Search & filters") on the PodoKit repo.
@@ -0,0 +1,81 @@
1
+ import { expect, test, type Page } from "@playwright/test";
2
+ import { anonState } from "../helpers/accounts";
3
+ import { ready } from "../helpers/hydration";
4
+
5
+ // Verifies that the General settings actually take effect (not just persist).
6
+ // The admin storageState (project default) drives the toggles; a fresh anonymous
7
+ // context checks the public-facing result. Serial workers (workers:1) make the
8
+ // toggle-then-restore pattern safe for the rest of the suite; restores run in a
9
+ // `finally` so a mid-test failure still leaves the shared site settings clean.
10
+
11
+ async function saveGeneral(page: Page): Promise<void> {
12
+ await page.getByRole("button", { name: "Save changes" }).click();
13
+ await expect(page.getByText("General settings saved.")).toBeVisible();
14
+ }
15
+
16
+ test("general: footer text and links render on public pages @smoke", async ({ page }) => {
17
+ await ready(page, "/admin/settings");
18
+ const footer = page.getByLabel("Footer text");
19
+ const support = page.getByLabel("Support email");
20
+ const origFooter = await footer.inputValue();
21
+ const origSupport = await support.inputValue();
22
+ await footer.fill("© 2026 PodoKit Test");
23
+ await support.fill("help@example.com");
24
+ await saveGeneral(page);
25
+ try {
26
+ await page.goto("/");
27
+ await expect(page.getByText("© 2026 PodoKit Test")).toBeVisible();
28
+ await expect(page.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
29
+ } finally {
30
+ await ready(page, "/admin/settings");
31
+ await page.getByLabel("Footer text").fill(origFooter);
32
+ await page.getByLabel("Support email").fill(origSupport);
33
+ await saveGeneral(page);
34
+ }
35
+ });
36
+
37
+ test("general: maintenance mode holds non-admins on the maintenance page @smoke", async ({ page, browser }) => {
38
+ await ready(page, "/admin/settings");
39
+ const origin = new URL(page.url()).origin;
40
+ await page.getByRole("switch", { name: "Maintenance mode" }).click();
41
+ await saveGeneral(page);
42
+ try {
43
+ const ctx = await browser.newContext({ storageState: anonState });
44
+ const anon = await ctx.newPage();
45
+ await anon.goto(`${origin}/`);
46
+ await expect(anon).toHaveURL(/\/maintenance$/);
47
+ await expect(anon.getByRole("heading", { name: "Under maintenance" })).toBeVisible();
48
+ await ctx.close();
49
+ // the admin is not held back
50
+ await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
51
+ } finally {
52
+ await ready(page, "/admin/settings");
53
+ const t = page.getByRole("switch", { name: "Maintenance mode" });
54
+ if (await t.isChecked()) {
55
+ await t.click();
56
+ await saveGeneral(page);
57
+ }
58
+ }
59
+ });
60
+
61
+ test("general: closing sign-up redirects /signup and hides the link @smoke", async ({ page, browser }) => {
62
+ await ready(page, "/admin/settings");
63
+ const origin = new URL(page.url()).origin;
64
+ await page.getByRole("switch", { name: "Allow public sign-up" }).click();
65
+ await saveGeneral(page);
66
+ try {
67
+ const ctx = await browser.newContext({ storageState: anonState });
68
+ const anon = await ctx.newPage();
69
+ await anon.goto(`${origin}/signup`);
70
+ await expect(anon).toHaveURL(/\/login/);
71
+ await expect(anon.getByRole("link", { name: "Sign up" })).toHaveCount(0);
72
+ await ctx.close();
73
+ } finally {
74
+ await ready(page, "/admin/settings");
75
+ const t = page.getByRole("switch", { name: "Allow public sign-up" });
76
+ if (!(await t.isChecked())) {
77
+ await t.click();
78
+ await saveGeneral(page);
79
+ }
80
+ }
81
+ });
@@ -2,9 +2,40 @@ import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
4
  // admin storageState (project default)
5
- test("settings lists the auth features and their status @smoke", async ({ page }) => {
5
+
6
+ test("settings has General and Authentication tabs @smoke", async ({ page }) => {
6
7
  await ready(page, "/admin/settings");
7
8
  await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
9
+ await expect(page.getByRole("tab", { name: "General" })).toBeVisible();
10
+ await expect(page.getByRole("tab", { name: "Authentication" })).toBeVisible();
11
+ // General is the default tab: the site name field is shown.
12
+ await expect(page.getByLabel("Site name")).toBeVisible();
13
+ });
14
+
15
+ test("general settings: shows the favicon uploader @smoke", async ({ page }) => {
16
+ await ready(page, "/admin/settings");
17
+ await expect(page.getByText("Browser icon (favicon)")).toBeVisible();
18
+ await expect(page.locator('input[type="file"]')).toBeVisible();
19
+ });
20
+
21
+ test("general settings: edit the site name, save, and apply live @smoke", async ({ page }) => {
22
+ await ready(page, "/admin/settings");
23
+ const name = page.getByLabel("Site name");
24
+ const original = await name.inputValue();
25
+ await name.fill("PodoKit Test Site");
26
+ await page.getByRole("button", { name: "Save changes" }).click();
27
+ await expect(page.getByText("General settings saved.")).toBeVisible();
28
+ // the browser tab title reflects the new name live (no reload)
29
+ await expect(page).toHaveTitle("PodoKit Test Site");
30
+ // restore so other specs start clean
31
+ await name.fill(original);
32
+ await page.getByRole("button", { name: "Save changes" }).click();
33
+ await expect(page.getByText("General settings saved.")).toBeVisible();
34
+ });
35
+
36
+ test("settings lists the auth features under the Authentication tab @smoke", async ({ page }) => {
37
+ await ready(page, "/admin/settings");
38
+ await page.getByRole("tab", { name: "Authentication" }).click();
8
39
  await expect(page.getByText("Email & password")).toBeVisible();
9
40
  await expect(page.getByText("Two-factor authentication")).toBeVisible();
10
41
  await expect(page.getByText("Breached-password check")).toBeVisible();
@@ -13,6 +44,15 @@ test("settings lists the auth features and their status @smoke", async ({ page }
13
44
  await expect(page.getByText("Enabled", { exact: true }).first()).toBeVisible();
14
45
  });
15
46
 
47
+ test("sidebar: back-to-home link returns to the landing page @smoke", async ({ page }) => {
48
+ await ready(page, "/admin/settings");
49
+ // the home link lives in the sidebar footer, above the user menu
50
+ await page.getByRole("link", { name: "Back to home" }).click();
51
+ await expect(page).toHaveURL(/\/$/);
52
+ // the landing page shows the "Open admin" entry point
53
+ await expect(page.getByRole("link", { name: "Open admin" })).toBeVisible();
54
+ });
55
+
16
56
  test("settings is reachable from the sidebar @smoke", async ({ page }) => {
17
57
  await ready(page, "/admin");
18
58
  await page.getByRole("link", { name: "Settings" }).click();
@@ -21,6 +61,7 @@ test("settings is reachable from the sidebar @smoke", async ({ page }) => {
21
61
 
22
62
  test("admin can toggle a feature flag and it applies live @smoke", async ({ page }) => {
23
63
  await ready(page, "/admin/settings");
64
+ await page.getByRole("tab", { name: "Authentication" }).click();
24
65
  const toggle = page.getByRole("switch", { name: "Magic link" });
25
66
  test.skip((await toggle.count()) === 0, "magic link toggle not present");
26
67
  // Serial workers (workers:1) make toggle-then-restore safe for other specs.
@@ -2,14 +2,16 @@
2
2
  "name": "admin-dashboard",
3
3
  "description": "Admin dashboard UI (SvelteKit + shadcn-svelte): login/signup/password-reset, a sidebar shell, and user + session management via the better-auth admin plugin. Requires auth.",
4
4
  "requires": [
5
- "auth"
5
+ "auth",
6
+ "object-storage-s3"
6
7
  ],
7
8
  "targetApp": "web",
8
9
  "dependencies": {
9
10
  "qrcode": "^1.5.4"
10
11
  },
11
12
  "devDependencies": {
12
- "@types/qrcode": "^1.5.5"
13
+ "@types/qrcode": "^1.5.5",
14
+ "@types/multer": "^1.4.12"
13
15
  },
14
16
  "env": [
15
17
  "# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
@@ -35,13 +37,29 @@
35
37
  "file": "apps/api/src/auth/auth.ts",
36
38
  "marker": "// podokit:end:auth-options",
37
39
  "text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5001\").split(\",\").map((origin) => origin.trim()),\n databaseHooks: {\n user: {\n create: {\n before: async (user) => ({\n data: {\n ...user,\n role: (process.env.ADMIN_EMAILS ?? \"\")\n .split(\",\")\n .map((email) => email.trim().toLowerCase())\n .includes(user.email.toLowerCase())\n ? \"admin\"\n : \"user\",\n },\n }),\n },\n },\n },"
40
+ },
41
+ {
42
+ "file": "AGENTS.md",
43
+ "marker": "<!-- podokit:end:agents-modules -->",
44
+ "optional": true,
45
+ "text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Emails in `ADMIN_EMAILS` become admins on sign-up.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
46
+ },
47
+ {
48
+ "file": "apps/api/src/app.module.ts",
49
+ "marker": "// podokit:end:imports",
50
+ "text": "import { SiteSettingsModule } from \"./site-settings/site-settings.module\";"
51
+ },
52
+ {
53
+ "file": "apps/api/src/app.module.ts",
54
+ "marker": "// podokit:end:module-imports",
55
+ "text": "SiteSettingsModule,"
38
56
  }
39
57
  ],
40
58
  "instructions": [
41
59
  "Set ADMIN_EMAILS in .env (comma-separated). Those emails become admins on sign-up.",
42
60
  "Re-run the auth migration to add the admin columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
43
61
  "Start the app (npm run dev), open /signup, register an ADMIN_EMAILS address, then sign in.",
44
- "Admin pages: /dashboard/users (list, ban, set role, revoke sessions). Everyone: /dashboard/sessions, /dashboard/account.",
45
- "Password reset: the reset link is logged to the API console in dev; wire a real mailer via emailAndPassword.sendResetPassword for production."
62
+ "Admin pages: /admin/users (list, ban, set role, revoke sessions). Everyone: /admin/sessions, /admin/account.",
63
+ "Password reset emails go through the mailer module (installed automatically with auth); in dev without SMTP they are logged to the API console. Configure SMTP in admin settings or via SMTP_* env for production."
46
64
  ]
47
65
  }