@podosoft/podokit 0.8.0 → 0.10.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 (84) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +3 -1
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  16. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  18. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +3 -1
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -680
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +30 -11
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +63 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +23 -7
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +137 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  64. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  65. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
  67. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
  68. package/dist/templates/modules/auth/module.manifest.json +10 -0
  69. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  70. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  71. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  72. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  73. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  74. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  75. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  76. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  77. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
  78. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  79. package/dist/templates/todo/dot-gitignore +6 -0
  80. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  81. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  82. package/dist/update.js +74 -8
  83. package/package.json +1 -1
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
@@ -0,0 +1,371 @@
1
+ <script lang="ts">
2
+ // Admin Appearance settings. Pending values stay inside the preview until
3
+ // the user saves, then apply across the app through the shared theme helper.
4
+ import Check from "@lucide/svelte/icons/check";
5
+ import ChevronDown from "@lucide/svelte/icons/chevron-down";
6
+ import Moon from "@lucide/svelte/icons/moon";
7
+ import RotateCcw from "@lucide/svelte/icons/rotate-ccw";
8
+ import Sun from "@lucide/svelte/icons/sun";
9
+ import X from "@lucide/svelte/icons/x";
10
+ import * as Card from "$lib/components/ui/card";
11
+ import { Button } from "$lib/components/ui/button";
12
+ import * as Collapsible from "$lib/components/ui/collapsible";
13
+ import { Input } from "$lib/components/ui/input";
14
+ import { Label } from "$lib/components/ui/label";
15
+ import { toast } from "svelte-sonner";
16
+ import { api } from "$lib/api";
17
+ import { getI18n } from "$lib/i18n";
18
+ import { site, type SiteSettings } from "$lib/site.svelte";
19
+ import {
20
+ FEATURED_THEME_PRESET_KEYS,
21
+ THEME_TOKEN_KEYS,
22
+ findPreset,
23
+ themePresets,
24
+ type ThemePreset,
25
+ type ThemeTokens,
26
+ } from "$lib/site/themes";
27
+ import {
28
+ applyTheme,
29
+ buildVarString,
30
+ effectiveTokens,
31
+ parseOverrides,
32
+ type ThemeConfig,
33
+ type ThemeMode,
34
+ type ThemeOverrides,
35
+ } from "$lib/site/apply-theme";
36
+
37
+ const i18n = getI18n();
38
+ const t = $derived(i18n.t.general);
39
+ const featuredKeys: readonly string[] = FEATURED_THEME_PRESET_KEYS;
40
+ const featuredPresets = featuredKeys
41
+ .map((key) => findPreset(key))
42
+ .filter((item): item is ThemePreset => item !== undefined);
43
+ const additionalPresets = themePresets.filter((item) => !featuredKeys.includes(item.key));
44
+
45
+ const s = $derived(site.value);
46
+ let preset = $state("default");
47
+ let brandColor = $state("");
48
+ let radius = $state("");
49
+ let overrides = $state<ThemeOverrides>({ light: {}, dark: {} });
50
+ let moreThemesOpen = $state(false);
51
+ let advancedOpen = $state(false);
52
+ let previewMode = $state<ThemeMode>("light");
53
+ let advancedMode = $state<ThemeMode>("light");
54
+ let seeded = $state(false);
55
+
56
+ $effect(() => {
57
+ if (seeded) return;
58
+ preset = s.themePreset || "default";
59
+ brandColor = s.brandColor ?? "";
60
+ radius = s.themeRadius ?? "";
61
+ const parsed = parseOverrides(s.themeOverrides);
62
+ overrides = { light: { ...(parsed?.light ?? {}) }, dark: { ...(parsed?.dark ?? {}) } };
63
+ seeded = true;
64
+ });
65
+
66
+ const config = $derived<ThemeConfig>({
67
+ preset,
68
+ brandColor: brandColor || null,
69
+ radius: radius || null,
70
+ overrides,
71
+ });
72
+ const lightVars = $derived(buildVarString(effectiveTokens(config, "light"), config.brandColor, config.radius));
73
+ const darkVars = $derived(buildVarString(effectiveTokens(config, "dark"), config.brandColor, config.radius));
74
+
75
+ function tokenValue(mode: ThemeMode, key: keyof ThemeTokens): string {
76
+ return overrides[mode]?.[key] ?? findPreset(preset)?.[mode]?.[key] ?? "#808080";
77
+ }
78
+
79
+ function setToken(mode: ThemeMode, key: keyof ThemeTokens, value: string): void {
80
+ overrides = { ...overrides, [mode]: { ...(overrides[mode] ?? {}), [key]: value } };
81
+ }
82
+
83
+ function selectPreset(key: string): void {
84
+ preset = key;
85
+ overrides = { light: {}, dark: {} };
86
+ }
87
+
88
+ function radiusLabel(value: string): string {
89
+ if (value === "") return t.radiusDefault;
90
+ if (value === "0") return t.radiusNone;
91
+ if (value === "0.25") return t.radiusSmall;
92
+ if (value === "0.5") return t.radiusMedium;
93
+ return t.radiusLarge;
94
+ }
95
+
96
+ function cleanOverrides(): ThemeOverrides {
97
+ const pick = (value?: Partial<ThemeTokens>): Partial<ThemeTokens> | undefined => {
98
+ if (!value) return undefined;
99
+ const entries = Object.entries(value).filter(([, token]) => Boolean(token));
100
+ return entries.length ? (Object.fromEntries(entries) as Partial<ThemeTokens>) : undefined;
101
+ };
102
+ const result: ThemeOverrides = {};
103
+ const light = pick(overrides.light);
104
+ const dark = pick(overrides.dark);
105
+ if (light) result.light = light;
106
+ if (dark) result.dark = dark;
107
+ return result;
108
+ }
109
+
110
+ let saving = $state(false);
111
+ async function save(): Promise<void> {
112
+ saving = true;
113
+ try {
114
+ const cleaned = cleanOverrides();
115
+ const payload: Record<string, string> = {
116
+ themePreset: preset === "default" ? "" : preset,
117
+ brandColor,
118
+ themeRadius: radius,
119
+ themeOverrides: cleaned.light || cleaned.dark ? JSON.stringify(cleaned) : "",
120
+ };
121
+ await api.put("/site/settings", payload);
122
+ site.patch(payload as Partial<SiteSettings>);
123
+ applyTheme(config);
124
+ toast.success(t.saved);
125
+ } catch {
126
+ toast.error(t.saveError);
127
+ } finally {
128
+ saving = false;
129
+ }
130
+ }
131
+
132
+ async function restoreDefaults(): Promise<void> {
133
+ preset = "default";
134
+ brandColor = "";
135
+ radius = "";
136
+ overrides = { light: {}, dark: {} };
137
+ await save();
138
+ }
139
+ </script>
140
+
141
+ <Card.Root>
142
+ <Card.Header class="gap-1.5 border-b border-border">
143
+ <Card.Title role="heading" aria-level={2}>{t.appearanceTitle}</Card.Title>
144
+ <Card.Description>{t.appearanceDesc}</Card.Description>
145
+ </Card.Header>
146
+
147
+ <Card.Content class="flex flex-col gap-8 pt-6">
148
+ <section class="flex flex-col gap-3" aria-labelledby="featured-themes-heading">
149
+ <div>
150
+ <h3 id="featured-themes-heading" class="text-sm font-medium">{t.featuredThemes}</h3>
151
+ <p class="text-muted-foreground mt-0.5 text-xs">{t.themePreset}</p>
152
+ </div>
153
+ <div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
154
+ {#each featuredPresets as item (item.key)}
155
+ {@render presetButton(item)}
156
+ {/each}
157
+ </div>
158
+
159
+ <Collapsible.Root bind:open={moreThemesOpen}>
160
+ <Collapsible.Trigger
161
+ class="border-input bg-background hover:bg-accent hover:text-accent-foreground flex min-h-9 items-center gap-2 rounded-md border px-3 text-sm font-medium transition-colors"
162
+ >
163
+ {moreThemesOpen ? t.hideThemes : t.moreThemes}
164
+ <ChevronDown class="size-4 transition-transform {moreThemesOpen ? 'rotate-180' : ''}" />
165
+ </Collapsible.Trigger>
166
+ <Collapsible.Content class="pt-3">
167
+ <div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
168
+ {#each additionalPresets as item (item.key)}
169
+ {@render presetButton(item)}
170
+ {/each}
171
+ </div>
172
+ </Collapsible.Content>
173
+ </Collapsible.Root>
174
+ </section>
175
+
176
+ <div class="grid gap-8 xl:grid-cols-[minmax(0,0.8fr)_minmax(22rem,1.2fr)]">
177
+ <section class="flex flex-col gap-5" aria-labelledby="quick-settings-heading">
178
+ <h3 id="quick-settings-heading" class="text-sm font-medium">{t.quickSettings}</h3>
179
+
180
+ <div class="flex flex-col gap-2">
181
+ <Label for="accent-color">{t.accentColor}</Label>
182
+ <div class="flex items-center gap-2">
183
+ <Input
184
+ type="color"
185
+ value={brandColor || "#6d28d9"}
186
+ oninput={(event) => (brandColor = event.currentTarget.value)}
187
+ aria-label={t.accentColor}
188
+ class="size-10 shrink-0 cursor-pointer p-1"
189
+ />
190
+ <Input id="accent-color" bind:value={brandColor} placeholder="#6d28d9" class="max-w-36 font-mono" />
191
+ {#if brandColor}
192
+ <Button type="button" variant="ghost" size="icon" onclick={() => (brandColor = "")} aria-label={t.clearColor}>
193
+ <X class="size-4" />
194
+ </Button>
195
+ {/if}
196
+ </div>
197
+ <p class="text-muted-foreground text-xs leading-relaxed">{t.accentColorHint}</p>
198
+ </div>
199
+
200
+ <fieldset class="flex flex-col gap-2">
201
+ <legend class="text-sm font-medium">{t.radius}</legend>
202
+ <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
203
+ {#each ["", "0", "0.25", "0.5", "1"] as value (value)}
204
+ <Button
205
+ type="button"
206
+ variant={radius === value ? "secondary" : "outline"}
207
+ class="justify-center"
208
+ aria-pressed={radius === value}
209
+ onclick={() => (radius = value)}
210
+ >
211
+ {radiusLabel(value)}
212
+ </Button>
213
+ {/each}
214
+ </div>
215
+ {#if radius && !["0", "0.25", "0.5", "1"].includes(radius)}
216
+ <p class="text-muted-foreground text-xs">{radius}rem</p>
217
+ {/if}
218
+ </fieldset>
219
+ </section>
220
+
221
+ <section class="flex flex-col gap-3" aria-labelledby="theme-preview-heading">
222
+ <div class="flex flex-wrap items-center justify-between gap-2">
223
+ <h3 id="theme-preview-heading" class="text-sm font-medium">{t.preview}</h3>
224
+ <div class="bg-muted flex rounded-lg p-1" role="group" aria-label={t.preview}>
225
+ <Button
226
+ type="button"
227
+ variant={previewMode === "light" ? "secondary" : "ghost"}
228
+ size="sm"
229
+ class="h-8 gap-1.5"
230
+ aria-pressed={previewMode === "light"}
231
+ onclick={() => (previewMode = "light")}
232
+ >
233
+ <Sun class="size-3.5" />{t.previewLight}
234
+ </Button>
235
+ <Button
236
+ type="button"
237
+ variant={previewMode === "dark" ? "secondary" : "ghost"}
238
+ size="sm"
239
+ class="h-8 gap-1.5"
240
+ aria-pressed={previewMode === "dark"}
241
+ onclick={() => (previewMode = "dark")}
242
+ >
243
+ <Moon class="size-3.5" />{t.previewDark}
244
+ </Button>
245
+ </div>
246
+ </div>
247
+ {@render previewPanel(previewMode === "light" ? lightVars : darkVars, previewMode === "dark")}
248
+ </section>
249
+ </div>
250
+
251
+ <Collapsible.Root bind:open={advancedOpen}>
252
+ <Collapsible.Trigger
253
+ class="border-border hover:bg-muted flex w-full items-center justify-between rounded-lg border px-4 py-3 text-left text-sm font-medium transition-colors"
254
+ >
255
+ <span>{advancedOpen ? t.advancedHide : t.advanced}</span>
256
+ <ChevronDown class="size-4 transition-transform {advancedOpen ? 'rotate-180' : ''}" />
257
+ </Collapsible.Trigger>
258
+ <Collapsible.Content class="pt-4">
259
+ <div class="bg-muted/40 flex flex-col gap-4 rounded-lg border border-border p-4">
260
+ <div class="flex w-fit rounded-md border border-border bg-background p-1" role="group" aria-label={t.advanced}>
261
+ {#each ["light", "dark"] as mode (mode)}
262
+ <Button
263
+ type="button"
264
+ variant={advancedMode === mode ? "secondary" : "ghost"}
265
+ size="sm"
266
+ class="h-8"
267
+ aria-pressed={advancedMode === mode}
268
+ onclick={() => (advancedMode = mode as ThemeMode)}
269
+ >
270
+ {mode === "light" ? t.previewLight : t.previewDark}
271
+ </Button>
272
+ {/each}
273
+ </div>
274
+
275
+ <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
276
+ {#each THEME_TOKEN_KEYS as key (key)}
277
+ <div class="flex flex-col gap-1.5">
278
+ <Label for={`theme-${advancedMode}-${key}`}>{t.themeTokens[key]}</Label>
279
+ <div class="flex items-center gap-2">
280
+ <Input
281
+ type="color"
282
+ value={tokenValue(advancedMode, key)}
283
+ oninput={(event) => setToken(advancedMode, key, event.currentTarget.value)}
284
+ aria-label={`${t.themeTokens[key]} ${advancedMode}`}
285
+ class="size-9 shrink-0 cursor-pointer p-1"
286
+ />
287
+ <Input
288
+ id={`theme-${advancedMode}-${key}`}
289
+ value={tokenValue(advancedMode, key)}
290
+ oninput={(event) => setToken(advancedMode, key, event.currentTarget.value)}
291
+ class="min-w-0 font-mono text-xs"
292
+ />
293
+ </div>
294
+ </div>
295
+ {/each}
296
+ </div>
297
+
298
+ <Button type="button" variant="outline" size="sm" class="w-fit gap-2" onclick={() => (overrides = { light: {}, dark: {} })}>
299
+ <RotateCcw class="size-3.5" />{t.resetTheme}
300
+ </Button>
301
+ </div>
302
+ </Collapsible.Content>
303
+ </Collapsible.Root>
304
+
305
+ <div class="flex flex-wrap items-center gap-2 border-t border-border pt-6">
306
+ <Button onclick={save} disabled={saving}>{saving ? t.saving : t.saveTheme}</Button>
307
+ <Button variant="outline" onclick={restoreDefaults} disabled={saving}>{t.restoreDefaults}</Button>
308
+ </div>
309
+ </Card.Content>
310
+ </Card.Root>
311
+
312
+ {#snippet presetButton(item: ThemePreset)}
313
+ <Button
314
+ type="button"
315
+ variant="outline"
316
+ class="h-auto min-h-16 justify-start gap-3 px-3 py-2.5 text-left {preset === item.key ? 'border-primary bg-primary/5 ring-1 ring-primary' : ''}"
317
+ aria-pressed={preset === item.key}
318
+ onclick={() => selectPreset(item.key)}
319
+ >
320
+ {@render presetSwatch(item)}
321
+ <span class="min-w-0 flex-1 truncate">{item.label}</span>
322
+ {#if preset === item.key}<Check class="text-primary size-4 shrink-0" />{/if}
323
+ </Button>
324
+ {/snippet}
325
+
326
+ {#snippet presetSwatch(item: ThemePreset)}
327
+ <span class="border-border flex size-9 shrink-0 overflow-hidden rounded-md border" aria-hidden="true">
328
+ {#if item.light}
329
+ <span class="h-full w-1/2" style={`background:${item.light.background}`}></span>
330
+ <span class="h-full w-1/2" style={`background:${item.light.primary}`}></span>
331
+ {:else}
332
+ <span class="bg-background h-full w-1/2"></span>
333
+ <span class="bg-primary h-full w-1/2"></span>
334
+ {/if}
335
+ </span>
336
+ {/snippet}
337
+
338
+ {#snippet previewPanel(vars: string, dark: boolean)}
339
+ <div class="border-border overflow-hidden rounded-xl border">
340
+ <div class="bg-background text-foreground grid min-h-72 grid-cols-[5.5rem_1fr] sm:grid-cols-[9rem_1fr] {dark ? 'dark' : ''}" style={vars}>
341
+ <aside class="bg-sidebar text-sidebar-foreground border-sidebar-border flex flex-col gap-4 border-r p-3">
342
+ <div class="flex items-center gap-2 text-xs font-semibold">
343
+ <span class="bg-primary size-5 rounded-md"></span>
344
+ <span class="hidden truncate sm:inline">{t.previewApp}</span>
345
+ </div>
346
+ <div class="flex flex-col gap-1 text-xs">
347
+ <span class="text-muted-foreground rounded-md px-2 py-1.5">{t.previewNav}</span>
348
+ <span class="bg-sidebar-accent text-sidebar-accent-foreground rounded-md px-2 py-1.5">{t.previewSettings}</span>
349
+ </div>
350
+ </aside>
351
+ <div class="flex flex-col gap-4 p-4 sm:p-6">
352
+ <div class="space-y-1">
353
+ <p class="text-base font-semibold">{t.previewHeading}</p>
354
+ <p class="text-muted-foreground max-w-md text-xs leading-relaxed">{t.previewBody}</p>
355
+ </div>
356
+ <div class="bg-card text-card-foreground border-border flex flex-col gap-4 rounded-lg border p-4">
357
+ <div class="space-y-2">
358
+ <div class="bg-muted h-2.5 w-2/3 rounded-full"></div>
359
+ <div class="bg-muted h-2.5 w-1/2 rounded-full"></div>
360
+ </div>
361
+ <div class="border-input bg-background h-9 rounded-md border"></div>
362
+ <div class="flex flex-wrap items-center gap-2">
363
+ <span class="bg-primary text-primary-foreground rounded-md px-3 py-2 text-xs font-medium">{t.previewPrimary}</span>
364
+ <span class="border-border rounded-md border px-3 py-2 text-xs">{t.previewOutline}</span>
365
+ <span class="bg-secondary text-secondary-foreground rounded-full px-2 py-1 text-[11px]">{t.previewBadge}</span>
366
+ </div>
367
+ </div>
368
+ </div>
369
+ </div>
370
+ </div>
371
+ {/snippet}
@@ -35,7 +35,7 @@
35
35
  {/snippet}
36
36
  </DropdownMenu.Trigger>
37
37
  <DropdownMenu.Content align="end" side="top" class="w-56">
38
- <DropdownMenu.Item onSelect={() => goto("/admin/account")}><UserIcon class="mr-2 size-4" /> {i18n.t.nav.account}</DropdownMenu.Item>
38
+ <DropdownMenu.Item onSelect={() => goto("/account")}><UserIcon class="mr-2 size-4" /> {i18n.t.nav.account}</DropdownMenu.Item>
39
39
  <DropdownMenu.Separator />
40
40
  <DropdownMenu.Item onSelect={signOut}><LogOutIcon class="mr-2 size-4" /> {i18n.t.nav.signOut}</DropdownMenu.Item>
41
41
  </DropdownMenu.Content>
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ import { page } from "$app/state";
3
+ import { ModeWatcher } from "mode-watcher";
4
+ import { site, type SiteSettings } from "$lib/site.svelte";
5
+ import { applyTheme, themeConfigFromSettings } from "$lib/site/apply-theme";
6
+
7
+ const loadedSite = $derived(
8
+ (page.data as { site?: Partial<SiteSettings> | null }).site,
9
+ );
10
+
11
+ // Seed the reactive site settings for every route without taking ownership of
12
+ // the application's root layout or public pages.
13
+ $effect.pre(() => site.init(loadedSite));
14
+
15
+ const branding = $derived({
16
+ name: site.value.name ?? loadedSite?.name ?? "",
17
+ description: site.value.description ?? loadedSite?.description ?? "",
18
+ hasFavicon: site.value.hasFavicon || Boolean(loadedSite?.hasFavicon),
19
+ faviconVersion: site.value.faviconVersion ?? loadedSite?.faviconVersion ?? "",
20
+ });
21
+
22
+ $effect(() => {
23
+ applyTheme(themeConfigFromSettings(site.value));
24
+ });
25
+ </script>
26
+
27
+ <svelte:head>
28
+ {#if branding.name}
29
+ <title>{branding.name}</title>
30
+ {/if}
31
+ {#if branding.description}
32
+ <meta name="description" content={branding.description} />
33
+ {/if}
34
+ {#if branding.hasFavicon}
35
+ <link rel="icon" href={`/api/site/favicon?v=${branding.faviconVersion}`} />
36
+ {/if}
37
+ </svelte:head>
38
+
39
+ <ModeWatcher />
@@ -1,17 +1,22 @@
1
- import { getContext, setContext } from "svelte";
1
+ import { page } from "$app/state";
2
2
  import { site } from "$lib/site.svelte";
3
- import type { Locale, Messages } from "./messages";
4
-
5
- const KEY = Symbol("i18n");
3
+ import { messages, resolveLocale, type Locale, type Messages } from "./messages";
6
4
 
7
5
  export type I18nContext = { readonly t: Messages; readonly locale: Locale };
8
6
 
9
- export function setI18nContext(ctx: I18nContext): void {
10
- setContext(KEY, ctx);
7
+ function currentLocale(): Locale {
8
+ return resolveLocale((page.data as { locale?: string }).locale);
11
9
  }
12
10
 
13
11
  export function getI18n(): I18nContext {
14
- return getContext(KEY) as I18nContext;
12
+ return {
13
+ get t() {
14
+ return messages[currentLocale()];
15
+ },
16
+ get locale() {
17
+ return currentLocale();
18
+ },
19
+ };
15
20
  }
16
21
 
17
22
  // Fill {name}-style placeholders in a message.
@@ -16,6 +16,20 @@ const en = {
16
16
  language: { label: "Language" },
17
17
  toolbar: { filter: "Filter", search: "Search", searchButton: "Search", all: "All" },
18
18
  theme: { toggle: "Toggle theme", light: "Light", dark: "Dark", system: "System" },
19
+ setup2fa: {
20
+ title: "Set up two-factor authentication",
21
+ subtitle: "This site requires two-factor authentication. Add an authenticator app to continue.",
22
+ passwordLabel: "Confirm your password",
23
+ begin: "Continue",
24
+ step1: "1. Scan with your authenticator app",
25
+ step2: "2. Save your backup codes",
26
+ step3: "3. Enter the 6-digit code",
27
+ savedConfirm: "I've saved my backup codes",
28
+ activate: "Activate and continue",
29
+ signOut: "Sign out",
30
+ wrongPassword: "That password is incorrect.",
31
+ enableFailed: "Couldn't start setup. Please try again.",
32
+ },
19
33
  auth: {
20
34
  signInTitle: "Sign in",
21
35
  signInDesc: "Enter your credentials to continue.",
@@ -30,6 +44,15 @@ const en = {
30
44
  magicLinkSent: "Check your email for a sign-in link.",
31
45
  magicLinkNeedsEmail: "Enter your email first.",
32
46
  otpButton: "Email me a code", otpCode: "One-time code", otpVerify: "Verify code",
47
+ verify: "Verify",
48
+ twoFactorPrompt: "Enter the 6-digit code from your authenticator app.",
49
+ twoFactorBackupPrompt: "Enter one of your backup codes.",
50
+ twoFactorCodeLabel: "Authentication code",
51
+ backupCodeLabel: "Backup code",
52
+ twoFactorUseBackup: "Use a backup code instead",
53
+ twoFactorUseAuthenticator: "Use your authenticator app instead",
54
+ twoFactorInvalidCode: "That code isn't valid. Check it and try again.",
55
+ twoFactorLocked: "Too many attempts. Please wait a moment and try again.",
33
56
  noAccount: "No account?",
34
57
  signUp: "Sign up",
35
58
  signInFailed: "Sign in failed",
@@ -103,6 +126,22 @@ const en = {
103
126
  faviconHint: "SVG (recommended) or a square PNG 512×512, ≤ 1 MB. Applied to every page.",
104
127
  iconSaved: "Icon updated.", iconError: "Failed to upload the icon.", iconTooBig: "Icon must be 1 MB or smaller.",
105
128
  brandColor: "Brand color",
129
+ appearanceTitle: "Appearance", appearanceDesc: "Choose the base colors and corner style for the app. Changes apply after you save.",
130
+ themePreset: "Base theme", featuredThemes: "Featured themes", moreThemes: "Show more themes", hideThemes: "Show fewer themes",
131
+ quickSettings: "Quick settings",
132
+ accentColor: "Brand color", accentColorHint: "Replaces the theme's primary color. Leave it empty to use the theme color.", clearColor: "Clear brand color",
133
+ radius: "Corner style", radiusDefault: "App default", radiusNone: "Square", radiusSmall: "Small", radiusMedium: "Medium", radiusLarge: "Large",
134
+ advanced: "Fine-tune colors", advancedHide: "Close color settings",
135
+ resetTheme: "Clear color adjustments", saveTheme: "Save changes", restoreDefaults: "Restore defaults",
136
+ preview: "Preview", previewLight: "Light", previewDark: "Dark",
137
+ previewApp: "Workspace", previewNav: "Overview", previewSettings: "Settings",
138
+ previewHeading: "Theme preview", previewBody: "Colors and corner styles are shown here before you save.",
139
+ previewPrimary: "Save", previewOutline: "Cancel", previewBadge: "Active",
140
+ themeTokens: {
141
+ background: "Background", card: "Card", foreground: "Foreground",
142
+ mutedForeground: "Muted text", border: "Border", secondary: "Secondary",
143
+ accent: "Accent", primary: "Primary", primaryForeground: "Primary text",
144
+ },
106
145
  infoTitle: "Site info", infoDesc: "Optional details used in meta tags, the footer, and links.",
107
146
  description: "Description", descriptionPlaceholder: "A short tagline for your site",
108
147
  supportEmail: "Support email", footerText: "Footer text",
@@ -132,6 +171,7 @@ const en = {
132
171
  organization: "Organizations", organizationDesc: "Multi-tenant organizations with members and email invitations.",
133
172
  oidcProvider: "OIDC provider", oidcProviderDesc: "Act as an identity provider so other apps can sign in with this one.",
134
173
  twoFactor: "Two-factor authentication", twoFactorDesc: "TOTP second factor with backup codes.",
174
+ require2fa: "Require two-factor", require2faDesc: "Every user must enrol in 2FA after signing up before using the app.",
135
175
  socialProviders: "Social providers", socialProvidersDesc: "OAuth sign-in (Google, GitHub).",
136
176
  breachCheck: "Breached-password check", breachCheckDesc: "Reject passwords found in known breaches (Have I Been Pwned).",
137
177
  accountDeletion: "Account deletion", accountDeletionDesc: "Let users delete their own account.",
@@ -281,6 +321,11 @@ const en = {
281
321
  scanHint: "Add this to your authenticator app, then enter the 6-digit code.",
282
322
  setupKey: "Setup key (otpauth URI)",
283
323
  backupCodes: "Backup codes — store them somewhere safe",
324
+ backupCodesHint: "Each code works once if you lose your authenticator. Save them now — they won't be shown again.",
325
+ downloadBackupCodes: "Download",
326
+ regenerateBackupCodes: "Regenerate backup codes",
327
+ regenerateBackupCodesHint: "Generating a new set invalidates your previous backup codes.",
328
+ backupCodesRegenerated: "New backup codes generated",
284
329
  code: "Verification code",
285
330
  verify: "Verify",
286
331
  twoFactorEnabled: "Two-factor enabled",
@@ -322,6 +367,20 @@ const ko: Messages = {
322
367
  language: { label: "언어" },
323
368
  toolbar: { filter: "필터", search: "검색", searchButton: "검색", all: "전체" },
324
369
  theme: { toggle: "테마 전환", light: "라이트", dark: "다크", system: "시스템" },
370
+ setup2fa: {
371
+ title: "2단계 인증 설정",
372
+ subtitle: "이 사이트는 2단계 인증이 필수입니다. 계속하려면 인증 앱을 등록하세요.",
373
+ passwordLabel: "비밀번호 확인",
374
+ begin: "계속",
375
+ step1: "1. 인증 앱으로 스캔",
376
+ step2: "2. 백업 코드 저장",
377
+ step3: "3. 6자리 코드 입력",
378
+ savedConfirm: "백업 코드를 저장했습니다",
379
+ activate: "활성화하고 계속",
380
+ signOut: "로그아웃",
381
+ wrongPassword: "비밀번호가 올바르지 않습니다.",
382
+ enableFailed: "설정을 시작할 수 없습니다. 다시 시도해 주세요.",
383
+ },
325
384
  auth: {
326
385
  signInTitle: "로그인",
327
386
  signInDesc: "계속하려면 로그인하세요.",
@@ -336,6 +395,15 @@ const ko: Messages = {
336
395
  magicLinkSent: "이메일의 로그인 링크를 확인하세요.",
337
396
  magicLinkNeedsEmail: "먼저 이메일을 입력하세요.",
338
397
  otpButton: "코드 이메일로 받기", otpCode: "일회용 코드", otpVerify: "코드 확인",
398
+ verify: "확인",
399
+ twoFactorPrompt: "인증 앱의 6자리 코드를 입력하세요.",
400
+ twoFactorBackupPrompt: "백업 코드 중 하나를 입력하세요.",
401
+ twoFactorCodeLabel: "인증 코드",
402
+ backupCodeLabel: "백업 코드",
403
+ twoFactorUseBackup: "백업 코드로 대신 인증",
404
+ twoFactorUseAuthenticator: "인증 앱으로 대신 인증",
405
+ twoFactorInvalidCode: "코드가 올바르지 않습니다. 다시 확인해 주세요.",
406
+ twoFactorLocked: "시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.",
339
407
  noAccount: "계정이 없으신가요?",
340
408
  signUp: "회원가입",
341
409
  signInFailed: "로그인에 실패했습니다",
@@ -409,6 +477,22 @@ const ko: Messages = {
409
477
  faviconHint: "SVG(권장) 또는 정사각 PNG 512×512, 1 MB 이하. 모든 페이지에 적용됩니다.",
410
478
  iconSaved: "아이콘을 변경했습니다.", iconError: "아이콘 업로드에 실패했습니다.", iconTooBig: "아이콘은 1 MB 이하여야 합니다.",
411
479
  brandColor: "브랜드 색상",
480
+ appearanceTitle: "화면 테마", appearanceDesc: "앱의 기본 색상과 모서리 형태를 설정합니다. 저장하면 모든 화면에 적용됩니다.",
481
+ themePreset: "기본 테마", featuredThemes: "주요 테마", moreThemes: "테마 더 보기", hideThemes: "테마 접기",
482
+ quickSettings: "빠른 설정",
483
+ accentColor: "브랜드 색상", accentColorHint: "선택한 테마의 주요 색상을 바꿉니다. 비우면 테마 색상을 사용합니다.", clearColor: "브랜드 색상 지우기",
484
+ radius: "모서리 형태", radiusDefault: "앱 기본값", radiusNone: "각지게", radiusSmall: "작게", radiusMedium: "보통", radiusLarge: "크게",
485
+ advanced: "세부 색상 설정", advancedHide: "세부 설정 닫기",
486
+ resetTheme: "세부 색상 지우기", saveTheme: "변경 저장", restoreDefaults: "기본값으로 복원",
487
+ preview: "미리보기", previewLight: "라이트", previewDark: "다크",
488
+ previewApp: "워크스페이스", previewNav: "대시보드", previewSettings: "설정",
489
+ previewHeading: "테마 미리보기", previewBody: "저장하기 전에 색상과 모서리 형태를 확인할 수 있습니다.",
490
+ previewPrimary: "저장", previewOutline: "취소", previewBadge: "사용 중",
491
+ themeTokens: {
492
+ background: "배경", card: "카드", foreground: "전경(글자)",
493
+ mutedForeground: "흐린 글자", border: "테두리", secondary: "보조",
494
+ accent: "강조", primary: "기본", primaryForeground: "기본 글자",
495
+ },
412
496
  infoTitle: "사이트 정보", infoDesc: "메타 태그·푸터·링크에 쓰이는 선택 항목입니다.",
413
497
  description: "설명", descriptionPlaceholder: "사이트를 한 줄로 소개",
414
498
  supportEmail: "지원 이메일", footerText: "푸터 문구",
@@ -438,6 +522,7 @@ const ko: Messages = {
438
522
  organization: "조직", organizationDesc: "멤버와 이메일 초대를 갖춘 멀티테넌트 조직.",
439
523
  oidcProvider: "OIDC 제공자", oidcProviderDesc: "다른 앱이 이 앱으로 로그인하도록 신원 제공자 역할.",
440
524
  twoFactor: "2단계 인증", twoFactorDesc: "TOTP 2차 인증 + 백업 코드.",
525
+ require2fa: "2FA 필수화", require2faDesc: "모든 사용자가 가입 후 2FA를 등록해야 앱을 사용할 수 있습니다.",
441
526
  socialProviders: "소셜 로그인", socialProvidersDesc: "OAuth 로그인 (Google, GitHub).",
442
527
  breachCheck: "유출 비밀번호 검사", breachCheckDesc: "유출된 비밀번호 거부 (Have I Been Pwned).",
443
528
  accountDeletion: "계정 삭제", accountDeletionDesc: "사용자가 자신의 계정을 삭제.",
@@ -587,6 +672,11 @@ const ko: Messages = {
587
672
  scanHint: "인증 앱에 등록한 뒤 6자리 코드를 입력하세요.",
588
673
  setupKey: "설정 키 (otpauth URI)",
589
674
  backupCodes: "백업 코드 — 안전한 곳에 보관하세요",
675
+ backupCodesHint: "인증 앱을 분실했을 때 각 코드는 한 번씩 사용할 수 있습니다. 지금 저장하세요 — 다시 표시되지 않습니다.",
676
+ downloadBackupCodes: "다운로드",
677
+ regenerateBackupCodes: "백업 코드 재발급",
678
+ regenerateBackupCodesHint: "새로 발급하면 이전 백업 코드는 무효화됩니다.",
679
+ backupCodesRegenerated: "새 백업 코드를 발급했습니다",
590
680
  code: "인증 코드",
591
681
  verify: "확인",
592
682
  twoFactorEnabled: "2단계 인증을 사용합니다",
@@ -0,0 +1,29 @@
1
+ const NON_INDEXABLE_PATH_PREFIXES = [
2
+ "/api",
3
+ "/admin",
4
+ "/account",
5
+ "/login",
6
+ "/signup",
7
+ "/forgot-password",
8
+ "/reset-password",
9
+ "/verify-email",
10
+ "/setup-2fa",
11
+ "/oauth2",
12
+ "/accept-invitation",
13
+ "/maintenance",
14
+ ] as const;
15
+
16
+ function matchesPathPrefix(pathname: string, prefix: string): boolean {
17
+ return pathname === prefix || pathname.startsWith(`${prefix}/`);
18
+ }
19
+
20
+ export function shouldPreventSearchIndexing(pathname: string): boolean {
21
+ return NON_INDEXABLE_PATH_PREFIXES.some((prefix) => matchesPathPrefix(pathname, prefix));
22
+ }
23
+
24
+ export function applySearchIndexingHeaders(response: Response, pathname: string): Response {
25
+ if (shouldPreventSearchIndexing(pathname)) {
26
+ response.headers.set("X-Robots-Tag", "noindex, nofollow");
27
+ }
28
+ return response;
29
+ }