@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.
- package/dist/add.d.ts +20 -0
- package/dist/add.js +121 -8
- package/dist/assemble.d.ts +4 -1
- package/dist/assemble.js +35 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +20 -4
- package/dist/inspect.d.ts +5 -0
- package/dist/inspect.js +5 -0
- package/dist/lockfile.d.ts +20 -1
- package/dist/lockfile.js +48 -6
- package/dist/remove.js +3 -2
- package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +3 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
- package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
- package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -680
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +30 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +23 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +137 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
- package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
- package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
- package/dist/templates/modules/auth/module.manifest.json +10 -0
- package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
- package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
- package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
- package/dist/templates/todo/dot-gitignore +6 -0
- package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
- package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
- package/dist/update.js +74 -8
- package/package.json +1 -1
- 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("/
|
|
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>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte
ADDED
|
@@ -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 {
|
|
1
|
+
import { page } from "$app/state";
|
|
2
2
|
import { site } from "$lib/site.svelte";
|
|
3
|
-
import type
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
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단계 인증을 사용합니다",
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts
ADDED
|
@@ -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
|
+
}
|