@podosoft/podokit 0.5.0 → 0.6.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 (89) hide show
  1. package/README.md +37 -0
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +6 -1
  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/index.d.ts +1 -0
  8. package/dist/index.js +6 -1
  9. package/dist/lib.d.ts +14 -0
  10. package/dist/lib.js +34 -0
  11. package/dist/lockfile.js +15 -0
  12. package/dist/templates/base/AGENTS.md +30 -0
  13. package/dist/templates/base/CLAUDE.md +6 -0
  14. package/dist/templates/fullstack-nest-svelte/AGENTS.md +79 -0
  15. package/dist/templates/fullstack-nest-svelte/CLAUDE.md +6 -0
  16. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +25 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +4 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/index.ts +1 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-content.svelte +1 -1
  21. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-list.svelte +5 -25
  22. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-trigger.svelte +1 -4
  23. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs.svelte +1 -1
  24. package/dist/templates/fullstack-nest-svelte/apps/web/static/favicon.svg +13 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +12 -0
  26. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +150 -0
  27. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-module/SKILL.md +27 -0
  28. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-nest-endpoint/SKILL.md +23 -0
  29. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-sveltekit-route/SKILL.md +23 -0
  30. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-update/SKILL.md +26 -0
  31. package/dist/templates/fullstack-nest-svelte/dot-cursor/rules/podokit.mdc +11 -0
  32. package/dist/templates/fullstack-nest-svelte/dot-devcontainer/devcontainer.json +20 -0
  33. package/dist/templates/fullstack-nest-svelte/dot-dockerignore +8 -0
  34. package/dist/templates/fullstack-nest-svelte/dot-env.docker +26 -0
  35. package/dist/templates/fullstack-nest-svelte/dot-github/copilot-instructions.md +6 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-mcp.json +8 -0
  37. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +13 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +87 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.module.ts +14 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +84 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +2 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +12 -1
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +13 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +32 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +16 -3
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +46 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +50 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +28 -13
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +203 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/+layout.svelte +7 -3
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +8 -3
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +24 -4
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +41 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +2 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/maintenance/+page.svelte +25 -0
  56. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +43 -0
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +81 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +8 -5
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +42 -1
  60. package/dist/templates/modules/admin-dashboard/module.manifest.json +20 -2
  61. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +27 -0
  62. package/dist/templates/modules/auth/module.manifest.json +6 -0
  63. package/dist/templates/modules/bullmq/module.manifest.json +1 -1
  64. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.controller.ts +3 -0
  65. package/dist/templates/todo/AGENTS.md +80 -0
  66. package/dist/templates/todo/CLAUDE.md +6 -0
  67. package/dist/templates/todo/Dockerfile.dev +25 -0
  68. package/dist/templates/todo/apps/api/package.json +4 -2
  69. package/dist/templates/todo/apps/web/src/app.html +2 -0
  70. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/index.ts +1 -3
  71. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-content.svelte +1 -1
  72. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-list.svelte +5 -25
  73. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-trigger.svelte +1 -4
  74. package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs.svelte +1 -1
  75. package/dist/templates/todo/apps/web/static/favicon.svg +13 -0
  76. package/dist/templates/todo/apps/web/vite.config.ts +12 -0
  77. package/dist/templates/todo/compose.dev.yaml +150 -0
  78. package/dist/templates/todo/dot-claude/skills/podokit-add-module/SKILL.md +27 -0
  79. package/dist/templates/todo/dot-claude/skills/podokit-nest-endpoint/SKILL.md +23 -0
  80. package/dist/templates/todo/dot-claude/skills/podokit-sveltekit-route/SKILL.md +23 -0
  81. package/dist/templates/todo/dot-claude/skills/podokit-update/SKILL.md +26 -0
  82. package/dist/templates/todo/dot-cursor/rules/podokit.mdc +11 -0
  83. package/dist/templates/todo/dot-devcontainer/devcontainer.json +20 -0
  84. package/dist/templates/todo/dot-dockerignore +8 -0
  85. package/dist/templates/todo/dot-env.docker +26 -0
  86. package/dist/templates/todo/dot-github/copilot-instructions.md +6 -0
  87. package/dist/templates/todo/dot-mcp.json +8 -0
  88. package/dist/templates/todo/infra/traefik/dynamic.yml +13 -0
  89. package/package.json +9 -1
@@ -1,4 +1,5 @@
1
1
  import { getContext, setContext } from "svelte";
2
+ import { site } from "$lib/site.svelte";
2
3
  import type { Locale, Messages } from "./messages";
3
4
 
4
5
  const KEY = Symbol("i18n");
@@ -20,8 +21,20 @@ export function fmt(template: string, values: Record<string, string | number>):
20
21
  );
21
22
  }
22
23
 
23
- // Render a timestamp in the viewer's own locale and timezone (browser defaults),
24
- // so dates always match the user's regional settings. Used across all pages.
24
+ // Render a timestamp in the site's configured timezone when an admin has set one
25
+ // (Settings → General), otherwise in the viewer's own browser timezone. Used
26
+ // across all pages.
25
27
  export function formatDateTime(value: string | Date): string {
26
- return new Date(value).toLocaleString();
28
+ const date = new Date(value);
29
+ const timeZone = site.value.timezone || undefined;
30
+ if (timeZone) {
31
+ // A misconfigured timezone would throw a RangeError — fall back to the
32
+ // browser default rather than breaking the page.
33
+ try {
34
+ return date.toLocaleString(undefined, { timeZone });
35
+ } catch {
36
+ /* invalid timeZone — use the browser default below */
37
+ }
38
+ }
39
+ return date.toLocaleString();
27
40
  }
@@ -11,6 +11,8 @@ export const localeNames: Record<Locale, string> = {
11
11
  const en = {
12
12
  common: { appName: "Admin" },
13
13
  landing: { title: "Welcome", subtitle: "This is your app's home page. Replace it with your own content — the admin area lives under /admin.", openAdmin: "Open admin" },
14
+ footer: { support: "Support", terms: "Terms", privacy: "Privacy" },
15
+ maintenance: { title: "Under maintenance", subtitle: "The site is temporarily unavailable. Please check back soon.", adminHint: "Administrators can sign in to manage the site.", signIn: "Admin sign in" },
14
16
  language: { label: "Language" },
15
17
  toolbar: { filter: "Filter", search: "Search", searchButton: "Search", all: "All" },
16
18
  theme: { toggle: "Toggle theme", light: "Light", dark: "Dark", system: "System" },
@@ -59,7 +61,7 @@ const en = {
59
61
  resendVerification: "Resend verification email",
60
62
  emailNotVerified: "Verify your email before signing in.",
61
63
  },
62
- nav: { overview: "Overview", users: "Users", sessions: "Sessions", organizations: "Organizations", audit: "Audit log", account: "Account", settings: "Settings", signOut: "Sign out" },
64
+ nav: { overview: "Overview", users: "Users", sessions: "Sessions", organizations: "Organizations", audit: "Audit log", account: "Account", settings: "Settings", home: "Back to home", signOut: "Sign out" },
63
65
  passkeys: {
64
66
  title: "Passkeys", subtitle: "Passwordless sign-in with your device (Face ID, fingerprint, security key).",
65
67
  name: "Name", namePlaceholder: "e.g. MacBook", add: "Add passkey", remove: "Remove", untitled: "Passkey",
@@ -92,7 +94,27 @@ const en = {
92
94
  createdTitle: "API key created", createdDesc: "Copy it now — you will not be able to see it again.",
93
95
  copy: "Copy", copied: "Copied to clipboard", done: "Done",
94
96
  },
97
+ general: {
98
+ tab: "General",
99
+ brandingTitle: "Branding", brandingDesc: "The site name and icon shown in the browser and across the app.",
100
+ siteName: "Site name", siteNamePlaceholder: "My App",
101
+ siteNameHint: "Used as the browser tab title and wherever the app name appears.",
102
+ favicon: "Browser icon (favicon)",
103
+ faviconHint: "SVG (recommended) or a square PNG 512×512, ≤ 1 MB. Applied to every page.",
104
+ iconSaved: "Icon updated.", iconError: "Failed to upload the icon.", iconTooBig: "Icon must be 1 MB or smaller.",
105
+ brandColor: "Brand color",
106
+ infoTitle: "Site info", infoDesc: "Optional details used in meta tags, the footer, and links.",
107
+ description: "Description", descriptionPlaceholder: "A short tagline for your site",
108
+ supportEmail: "Support email", footerText: "Footer text",
109
+ termsUrl: "Terms URL", privacyUrl: "Privacy URL",
110
+ locale: "Default language", timezone: "Default timezone",
111
+ opsTitle: "Operations",
112
+ maintenance: "Maintenance mode", maintenanceDesc: "Show a maintenance page to non-admins.",
113
+ allowSignup: "Allow public sign-up", allowSignupDesc: "Turn off to make the app invite-only.",
114
+ save: "Save changes", saving: "Saving…", saved: "General settings saved.", saveError: "Failed to save settings.",
115
+ },
95
116
  settings: {
117
+ tab: "Authentication",
96
118
  title: "Settings", subtitle: "Which authentication features are enabled, and how they are configured.",
97
119
  authFeaturesTitle: "Authentication features", authFeaturesSubtitle: "Toggle sign-in methods and security features. Changes apply immediately.",
98
120
  serverConfigTitle: "Server configuration", serverConfigSubtitle: "Set through environment variables and applied at startup (read-only).",
@@ -292,6 +314,8 @@ export type Messages = typeof en;
292
314
  const ko: Messages = {
293
315
  common: { appName: "관리자" },
294
316
  landing: { title: "환영합니다", subtitle: "앱의 홈 페이지입니다. 원하는 내용으로 교체하세요 — 관리자 기능은 /admin 에 있습니다.", openAdmin: "관리자 열기" },
317
+ footer: { support: "고객지원", terms: "이용약관", privacy: "개인정보처리방침" },
318
+ maintenance: { title: "점검 중", subtitle: "사이트를 일시적으로 사용할 수 없습니다. 잠시 후 다시 확인해 주세요.", adminHint: "관리자는 로그인하여 사이트를 관리할 수 있습니다.", signIn: "관리자 로그인" },
295
319
  language: { label: "언어" },
296
320
  toolbar: { filter: "필터", search: "검색", searchButton: "검색", all: "전체" },
297
321
  theme: { toggle: "테마 전환", light: "라이트", dark: "다크", system: "시스템" },
@@ -340,7 +364,7 @@ const ko: Messages = {
340
364
  emailNotVerified: "로그인 전에 이메일을 인증해 주세요.",
341
365
  resetFailed: "재설정에 실패했습니다",
342
366
  },
343
- nav: { overview: "개요", users: "회원", sessions: "세션", organizations: "조직", audit: "감사 로그", account: "계정", settings: "설정", signOut: "로그아웃" },
367
+ nav: { overview: "개요", users: "회원", sessions: "세션", organizations: "조직", audit: "감사 로그", account: "계정", settings: "설정", home: "홈으로 돌아가기", signOut: "로그아웃" },
344
368
  passkeys: {
345
369
  title: "패스키", subtitle: "기기로 비밀번호 없이 로그인(Face ID, 지문, 보안 키).",
346
370
  name: "이름", namePlaceholder: "예: MacBook", add: "패스키 추가", remove: "삭제", untitled: "패스키",
@@ -373,7 +397,27 @@ const ko: Messages = {
373
397
  createdTitle: "API 키 생성됨", createdDesc: "지금 복사하세요 — 다시 볼 수 없습니다.",
374
398
  copy: "복사", copied: "클립보드에 복사됨", done: "완료",
375
399
  },
400
+ general: {
401
+ tab: "일반",
402
+ brandingTitle: "브랜딩", brandingDesc: "브라우저와 앱 전반에 표시되는 사이트 이름과 아이콘입니다.",
403
+ siteName: "사이트 이름", siteNamePlaceholder: "My App",
404
+ siteNameHint: "브라우저 탭 제목과 앱 이름이 쓰이는 곳에 사용됩니다.",
405
+ favicon: "브라우저 아이콘 (favicon)",
406
+ faviconHint: "SVG(권장) 또는 정사각 PNG 512×512, 1 MB 이하. 모든 페이지에 적용됩니다.",
407
+ iconSaved: "아이콘을 변경했습니다.", iconError: "아이콘 업로드에 실패했습니다.", iconTooBig: "아이콘은 1 MB 이하여야 합니다.",
408
+ brandColor: "브랜드 색상",
409
+ infoTitle: "사이트 정보", infoDesc: "메타 태그·푸터·링크에 쓰이는 선택 항목입니다.",
410
+ description: "설명", descriptionPlaceholder: "사이트를 한 줄로 소개",
411
+ supportEmail: "지원 이메일", footerText: "푸터 문구",
412
+ termsUrl: "약관 URL", privacyUrl: "개인정보 URL",
413
+ locale: "기본 언어", timezone: "기본 시간대",
414
+ opsTitle: "운영",
415
+ maintenance: "유지보수 모드", maintenanceDesc: "관리자 외 사용자에게 점검 페이지를 표시합니다.",
416
+ allowSignup: "공개 가입 허용", allowSignupDesc: "끄면 초대 전용으로 전환됩니다.",
417
+ save: "변경 저장", saving: "저장 중…", saved: "일반 설정을 저장했습니다.", saveError: "설정 저장에 실패했습니다.",
418
+ },
376
419
  settings: {
420
+ tab: "인증",
377
421
  title: "설정", subtitle: "활성화된 인증 기능과 구성 방법입니다.",
378
422
  authFeaturesTitle: "인증 기능", authFeaturesSubtitle: "로그인 수단과 보안 기능을 켜고 끕니다. 변경은 즉시 적용됩니다.",
379
423
  serverConfigTitle: "서버 구성", serverConfigSubtitle: "환경변수로 설정되어 시작 시 적용됩니다(읽기 전용).",
@@ -0,0 +1,50 @@
1
+ // Reactive site branding (name, favicon, …) shared between the layout (which
2
+ // applies the browser title/favicon) and the admin general-settings form (which
3
+ // updates it live). Initialised from the server load; patched after a save.
4
+ export interface SiteSettings {
5
+ name: string | null;
6
+ description: string | null;
7
+ supportEmail: string | null;
8
+ footerText: string | null;
9
+ brandColor: string | null;
10
+ termsUrl: string | null;
11
+ privacyUrl: string | null;
12
+ locale: string | null;
13
+ timezone: string | null;
14
+ maintenanceMode: string | null;
15
+ allowSignup: string | null;
16
+ hasFavicon: boolean;
17
+ faviconVersion: string | null;
18
+ }
19
+
20
+ const empty: SiteSettings = {
21
+ name: null,
22
+ description: null,
23
+ supportEmail: null,
24
+ footerText: null,
25
+ brandColor: null,
26
+ termsUrl: null,
27
+ privacyUrl: null,
28
+ locale: null,
29
+ timezone: null,
30
+ maintenanceMode: null,
31
+ allowSignup: null,
32
+ hasFavicon: false,
33
+ faviconVersion: null,
34
+ };
35
+
36
+ let current = $state<SiteSettings>({ ...empty });
37
+
38
+ export const site = {
39
+ get value(): SiteSettings {
40
+ return current;
41
+ },
42
+ /** Seed from the server load (idempotent). */
43
+ init(v: Partial<SiteSettings> | null | undefined): void {
44
+ if (v) current = { ...empty, ...v };
45
+ },
46
+ /** Merge in changes so the layout re-applies title/favicon immediately. */
47
+ patch(v: Partial<SiteSettings>): void {
48
+ current = { ...current, ...v };
49
+ },
50
+ };
@@ -7,6 +7,8 @@
7
7
  import { Button } from "$lib/components/ui/button";
8
8
  import * as Card from "$lib/components/ui/card";
9
9
  import * as Dialog from "$lib/components/ui/dialog";
10
+ import * as Tabs from "$lib/components/ui/tabs";
11
+ import GeneralSettings from "./general-settings.svelte";
10
12
  import { toast } from "svelte-sonner";
11
13
  import { api } from "$lib/api";
12
14
  import { getI18n } from "$lib/i18n";
@@ -220,19 +222,32 @@
220
222
  <p class="text-muted-foreground text-sm">{i18n.t.settings.subtitle}</p>
221
223
  </div>
222
224
 
223
- {#each sections as section (section.title)}
224
- <section class="flex flex-col gap-3">
225
- <div>
226
- <h2 class="text-lg font-semibold">{section.title}</h2>
227
- <p class="text-muted-foreground text-sm">{section.subtitle}</p>
228
- </div>
229
- <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
230
- {#each section.items as item (item.key)}
231
- {@render card(item)}
232
- {/each}
233
- </div>
234
- </section>
235
- {/each}
225
+ <Tabs.Root value="general">
226
+ <Tabs.List>
227
+ <Tabs.Trigger value="general">{i18n.t.general.tab}</Tabs.Trigger>
228
+ <Tabs.Trigger value="auth">{i18n.t.settings.tab}</Tabs.Trigger>
229
+ </Tabs.List>
230
+
231
+ <Tabs.Content value="general" class="mt-6">
232
+ <GeneralSettings />
233
+ </Tabs.Content>
234
+
235
+ <Tabs.Content value="auth" class="mt-6 flex flex-col gap-6">
236
+ {#each sections as section (section.title)}
237
+ <section class="flex flex-col gap-3">
238
+ <div>
239
+ <h2 class="text-lg font-semibold">{section.title}</h2>
240
+ <p class="text-muted-foreground text-sm">{section.subtitle}</p>
241
+ </div>
242
+ <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
243
+ {#each section.items as item (item.key)}
244
+ {@render card(item)}
245
+ {/each}
246
+ </div>
247
+ </section>
248
+ {/each}
249
+ </Tabs.Content>
250
+ </Tabs.Root>
236
251
  </div>
237
252
 
238
253
  <!-- Social login: manage every provider (list ⇄ add/edit form) in one dialog -->
@@ -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,41 @@ 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 = ["/", "/login", "/signup", "/forgot-password", "/reset-password", "/maintenance"];
7
8
  const AUTH_PATHS = ["/login", "/signup", "/forgot-password", "/reset-password"];
8
9
 
9
10
  function isPublic(pathname: string): boolean {
10
11
  return PUBLIC_PATHS.some((path) => pathname === path || (path !== "/" && pathname.startsWith(`${path}/`)));
11
12
  }
12
13
 
13
- export const load: LayoutServerLoad = ({ locals, url }) => {
14
+ export const load: LayoutServerLoad = async (event) => {
15
+ const { locals, url } = event;
14
16
  const { pathname } = url;
17
+ // Site settings were loaded once in hooks.server.ts.
18
+ const site = locals.site;
19
+ const isAdmin = locals.user?.role === "admin";
20
+
21
+ // Maintenance mode holds everyone but admins on a maintenance page. /login and
22
+ // /maintenance stay reachable so an admin can still sign in and turn it off.
23
+ const maintenance = site?.maintenanceMode === "true";
24
+ if (maintenance && !isAdmin && pathname !== "/maintenance" && pathname !== "/login") {
25
+ redirect(303, "/maintenance");
26
+ }
27
+ if (!maintenance && pathname === "/maintenance") {
28
+ redirect(303, "/");
29
+ }
30
+ // Registration closed from the admin Settings: send /signup back to /login.
31
+ if (site?.allowSignup === "false" && pathname === "/signup") {
32
+ redirect(303, "/login");
33
+ }
34
+
15
35
  if (locals.user && AUTH_PATHS.includes(pathname)) {
16
36
  redirect(303, "/admin");
17
37
  }
18
38
  if (!locals.user && !isPublic(pathname)) {
19
39
  redirect(303, `/login?redirect=${encodeURIComponent(pathname)}`);
20
40
  }
21
- return { user: locals.user, locale: locals.locale };
41
+ return { user: locals.user, locale: locals.locale, site };
22
42
  };
@@ -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>