@podosoft/podokit 0.9.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 (66) 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 +32 -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)/admin/account/+page.svelte +8 -717
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +17 -9
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  39. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  40. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  46. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -3
  47. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +14 -7
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +12 -4
  49. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  50. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  51. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  52. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  53. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  54. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  55. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  56. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  57. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  58. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  59. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
  60. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  61. package/dist/templates/todo/dot-gitignore +6 -0
  62. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  63. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  64. package/dist/update.js +74 -8
  65. package/package.json +1 -1
  66. 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.
@@ -126,6 +126,22 @@ const en = {
126
126
  faviconHint: "SVG (recommended) or a square PNG 512×512, ≤ 1 MB. Applied to every page.",
127
127
  iconSaved: "Icon updated.", iconError: "Failed to upload the icon.", iconTooBig: "Icon must be 1 MB or smaller.",
128
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
+ },
129
145
  infoTitle: "Site info", infoDesc: "Optional details used in meta tags, the footer, and links.",
130
146
  description: "Description", descriptionPlaceholder: "A short tagline for your site",
131
147
  supportEmail: "Support email", footerText: "Footer text",
@@ -461,6 +477,22 @@ const ko: Messages = {
461
477
  faviconHint: "SVG(권장) 또는 정사각 PNG 512×512, 1 MB 이하. 모든 페이지에 적용됩니다.",
462
478
  iconSaved: "아이콘을 변경했습니다.", iconError: "아이콘 업로드에 실패했습니다.", iconTooBig: "아이콘은 1 MB 이하여야 합니다.",
463
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
+ },
464
496
  infoTitle: "사이트 정보", infoDesc: "메타 태그·푸터·링크에 쓰이는 선택 항목입니다.",
465
497
  description: "설명", descriptionPlaceholder: "사이트를 한 줄로 소개",
466
498
  supportEmail: "지원 이메일", footerText: "푸터 문구",
@@ -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
+ }
@@ -0,0 +1,124 @@
1
+ // Computes the effective theme (preset neutrals ⊕ brand-color accent ⊕ radius ⊕
2
+ // per-token overrides) and applies it. Two outputs share one var builder:
3
+ // - applyTheme(): injects a mode-scoped <style> (`:root:not(.dark)` / `:root.dark`)
4
+ // so light edits never touch dark and vice-versa. Removes it when nothing is set.
5
+ // - buildVarString(): the same CSS var text, for an inline preview container.
6
+ import { findPreset, type ThemeTokens } from "./themes";
7
+ import type { SiteSettings } from "../site.svelte";
8
+
9
+ export type ThemeMode = "light" | "dark";
10
+ export interface ThemeOverrides {
11
+ light?: Partial<ThemeTokens>;
12
+ dark?: Partial<ThemeTokens>;
13
+ }
14
+ export interface ThemeConfig {
15
+ preset: string | null;
16
+ brandColor: string | null;
17
+ radius: string | null;
18
+ overrides: ThemeOverrides | null;
19
+ }
20
+
21
+ const STYLE_ID = "podokit-theme";
22
+
23
+ /** Merge preset base with per-token overrides for one mode. */
24
+ export function effectiveTokens(config: ThemeConfig, mode: ThemeMode): Partial<ThemeTokens> {
25
+ const preset = findPreset(config.preset);
26
+ const base = (preset ? preset[mode] : null) ?? {};
27
+ const over = config.overrides?.[mode] ?? {};
28
+ return { ...base, ...over };
29
+ }
30
+
31
+ /** Build the CSS custom-property text for a mode. brandColor overrides primary;
32
+ * radius (a plain rem number) is appended with its unit. Only present tokens emit. */
33
+ export function buildVarString(tokens: Partial<ThemeTokens>, brandColor?: string | null, radius?: string | null): string {
34
+ const lines: string[] = [];
35
+ const set = (name: string, value?: string): void => {
36
+ if (value) lines.push(`--${name}: ${value};`);
37
+ };
38
+ set("background", tokens.background);
39
+ set("foreground", tokens.foreground);
40
+ set("card", tokens.card);
41
+ set("card-foreground", tokens.foreground);
42
+ set("popover", tokens.card);
43
+ set("popover-foreground", tokens.foreground);
44
+ set("muted-foreground", tokens.mutedForeground);
45
+ set("border", tokens.border);
46
+ set("input", tokens.border);
47
+ set("secondary", tokens.secondary);
48
+ set("secondary-foreground", tokens.foreground);
49
+ set("muted", tokens.secondary);
50
+ set("accent", tokens.accent);
51
+ set("accent-foreground", tokens.foreground);
52
+ const primary = (brandColor ?? "").trim() || tokens.primary;
53
+ set("primary", primary);
54
+ set("ring", primary);
55
+ set("primary-foreground", tokens.primaryForeground);
56
+ // Sidebar tokens (admin dashboard) — derive the whole set so the sidebar
57
+ // re-themes with the rest of the app, not just its accent.
58
+ set("sidebar", tokens.card);
59
+ set("sidebar-foreground", tokens.foreground);
60
+ set("sidebar-primary", primary);
61
+ set("sidebar-primary-foreground", tokens.primaryForeground);
62
+ set("sidebar-accent", tokens.secondary);
63
+ set("sidebar-accent-foreground", tokens.foreground);
64
+ set("sidebar-border", tokens.border);
65
+ set("sidebar-ring", primary);
66
+ const r = (radius ?? "").trim();
67
+ if (r) lines.push(`--radius: ${r}rem;`);
68
+ return lines.join(" ");
69
+ }
70
+
71
+ /** True when the config would set no custom properties (falls back to app.css). */
72
+ export function isEmptyTheme(config: ThemeConfig): boolean {
73
+ return (
74
+ !buildVarString(effectiveTokens(config, "light"), config.brandColor, config.radius) &&
75
+ !buildVarString(effectiveTokens(config, "dark"), config.brandColor, config.radius)
76
+ );
77
+ }
78
+
79
+ /** Apply the theme globally via a mode-scoped stylesheet (client only). */
80
+ export function applyTheme(config: ThemeConfig): void {
81
+ if (typeof document === "undefined") return;
82
+ const lightVars = buildVarString(effectiveTokens(config, "light"), config.brandColor, config.radius);
83
+ const darkVars = buildVarString(effectiveTokens(config, "dark"), config.brandColor, config.radius);
84
+ const existing = document.getElementById(STYLE_ID);
85
+ if (!lightVars && !darkVars) {
86
+ existing?.remove();
87
+ return;
88
+ }
89
+ const css =
90
+ (lightVars ? `:root:not(.dark) { ${lightVars} }\n` : "") + (darkVars ? `:root.dark { ${darkVars} }` : "");
91
+ const el = existing ?? document.head.appendChild(Object.assign(document.createElement("style"), { id: STYLE_ID }));
92
+ el.textContent = css;
93
+ }
94
+
95
+ /** Safely parse the stored themeOverrides JSON string into a typed object. */
96
+ export function parseOverrides(json: string | null | undefined): ThemeOverrides | null {
97
+ if (!json) return null;
98
+ try {
99
+ const parsed: unknown = JSON.parse(json);
100
+ if (!parsed || typeof parsed !== "object") return null;
101
+ const pick = (v: unknown): Partial<ThemeTokens> | undefined => {
102
+ if (!v || typeof v !== "object") return undefined;
103
+ const out: Record<string, string> = {};
104
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
105
+ if (typeof val === "string") out[k] = val;
106
+ }
107
+ return out as Partial<ThemeTokens>;
108
+ };
109
+ const rec = parsed as Record<string, unknown>;
110
+ return { light: pick(rec.light), dark: pick(rec.dark) };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /** Build a ThemeConfig from persisted site settings. */
117
+ export function themeConfigFromSettings(s: SiteSettings): ThemeConfig {
118
+ return {
119
+ preset: s.themePreset,
120
+ brandColor: s.brandColor,
121
+ radius: s.themeRadius,
122
+ overrides: parseOverrides(s.themeOverrides),
123
+ };
124
+ }