@podosoft/podokit 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/add.d.ts +24 -2
- package/dist/add.js +77 -20
- package/dist/assemble.js +4 -1
- package/dist/create.d.ts +2 -0
- package/dist/create.js +6 -0
- package/dist/eject.js +9 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -2
- package/dist/lib.d.ts +14 -0
- package/dist/lib.js +34 -0
- package/dist/lockfile.d.ts +7 -2
- package/dist/lockfile.js +37 -2
- package/dist/templates/base/AGENTS.md +30 -0
- package/dist/templates/base/CLAUDE.md +6 -0
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +79 -0
- package/dist/templates/fullstack-nest-svelte/CLAUDE.md +6 -0
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +25 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/package.json +4 -2
- package/dist/templates/fullstack-nest-svelte/apps/api/src/app.extensions.ts +21 -0
- package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +3 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/index.ts +1 -3
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-content.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-list.svelte +5 -25
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs-trigger.svelte +1 -4
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/tabs/tabs.svelte +1 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/static/favicon.svg +13 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +12 -0
- package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +150 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-module/SKILL.md +27 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-nest-endpoint/SKILL.md +23 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-sveltekit-route/SKILL.md +23 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-update/SKILL.md +26 -0
- package/dist/templates/fullstack-nest-svelte/dot-cursor/rules/podokit.mdc +11 -0
- package/dist/templates/fullstack-nest-svelte/dot-devcontainer/devcontainer.json +20 -0
- package/dist/templates/fullstack-nest-svelte/dot-dockerignore +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.docker +26 -0
- package/dist/templates/fullstack-nest-svelte/dot-github/copilot-instructions.md +6 -0
- package/dist/templates/fullstack-nest-svelte/dot-mcp.json +8 -0
- package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +13 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +87 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.module.ts +14 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +84 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +12 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +37 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +25 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +32 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +16 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +52 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +38 -13
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +203 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/+layout.svelte +7 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +8 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +34 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/maintenance/+page.svelte +25 -0
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +43 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +81 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +42 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +22 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +27 -0
- package/dist/templates/modules/auth/module.manifest.json +9 -8
- package/dist/templates/modules/bullmq/module.manifest.json +1 -1
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.controller.ts +3 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +13 -0
- package/dist/templates/modules/{auth → mailer}/files/apps/api/src/mail/mailer.ts +41 -7
- package/dist/templates/modules/mailer/module.manifest.json +38 -0
- package/dist/templates/todo/AGENTS.md +80 -0
- package/dist/templates/todo/CLAUDE.md +6 -0
- package/dist/templates/todo/Dockerfile.dev +25 -0
- package/dist/templates/todo/apps/api/package.json +4 -2
- package/dist/templates/todo/apps/api/src/app.extensions.ts +21 -0
- package/dist/templates/todo/apps/api/src/app.module.ts +3 -0
- package/dist/templates/todo/apps/web/src/app.html +2 -0
- package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/index.ts +1 -3
- package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-content.svelte +1 -1
- package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-list.svelte +5 -25
- package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs-trigger.svelte +1 -4
- package/dist/templates/todo/apps/web/src/lib/components/ui/tabs/tabs.svelte +1 -1
- package/dist/templates/todo/apps/web/static/favicon.svg +13 -0
- package/dist/templates/todo/apps/web/vite.config.ts +12 -0
- package/dist/templates/todo/compose.dev.yaml +150 -0
- package/dist/templates/todo/dot-claude/skills/podokit-add-module/SKILL.md +27 -0
- package/dist/templates/todo/dot-claude/skills/podokit-nest-endpoint/SKILL.md +23 -0
- package/dist/templates/todo/dot-claude/skills/podokit-sveltekit-route/SKILL.md +23 -0
- package/dist/templates/todo/dot-claude/skills/podokit-update/SKILL.md +26 -0
- package/dist/templates/todo/dot-cursor/rules/podokit.mdc +11 -0
- package/dist/templates/todo/dot-devcontainer/devcontainer.json +20 -0
- package/dist/templates/todo/dot-dockerignore +8 -0
- package/dist/templates/todo/dot-env.docker +26 -0
- package/dist/templates/todo/dot-github/copilot-instructions.md +6 -0
- package/dist/templates/todo/dot-mcp.json +8 -0
- package/dist/templates/todo/infra/traefik/dynamic.yml +13 -0
- package/package.json +9 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
import { InjectRepository } from "@nestjs/typeorm";
|
|
3
|
+
import { Repository } from "typeorm";
|
|
4
|
+
import { AppSetting } from "../settings/app-setting.entity";
|
|
5
|
+
import { StorageService } from "../storage/storage.service";
|
|
6
|
+
|
|
7
|
+
/** Public, admin-editable site settings (name, description, favicon, …). Stored
|
|
8
|
+
* as `site.*` string keys in the shared `app_setting` table; the favicon binary
|
|
9
|
+
* lives in object storage (MinIO/S3) and is served from a stable endpoint. */
|
|
10
|
+
|
|
11
|
+
const PREFIX = "site.";
|
|
12
|
+
const FAVICON_KEY = "site/favicon"; // object-storage key
|
|
13
|
+
|
|
14
|
+
/** Keys exposed publicly (read by every page for title/branding). */
|
|
15
|
+
export const PUBLIC_SITE_KEYS = [
|
|
16
|
+
"name",
|
|
17
|
+
"description",
|
|
18
|
+
"supportEmail",
|
|
19
|
+
"footerText",
|
|
20
|
+
"brandColor",
|
|
21
|
+
"termsUrl",
|
|
22
|
+
"privacyUrl",
|
|
23
|
+
"locale",
|
|
24
|
+
"timezone",
|
|
25
|
+
"maintenanceMode",
|
|
26
|
+
"allowSignup",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export interface SiteFavicon {
|
|
30
|
+
body: Buffer;
|
|
31
|
+
contentType: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Injectable()
|
|
35
|
+
export class SiteSettingsService {
|
|
36
|
+
constructor(
|
|
37
|
+
@InjectRepository(AppSetting) private readonly repo: Repository<AppSetting>,
|
|
38
|
+
private readonly storage: StorageService,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
/** All `site.*` values as a plain map (keys without the prefix). */
|
|
42
|
+
async getAll(): Promise<Record<string, string>> {
|
|
43
|
+
const rows = await this.repo.find();
|
|
44
|
+
const out: Record<string, string> = {};
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
if (row.key.startsWith(PREFIX)) out[row.key.slice(PREFIX.length)] = row.value;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async get(key: string): Promise<string | null> {
|
|
52
|
+
const row = await this.repo.findOne({ where: { key: PREFIX + key } });
|
|
53
|
+
return row?.value ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Upsert several site settings at once. Returns the full updated map. */
|
|
57
|
+
async setMany(update: Record<string, string>): Promise<Record<string, string>> {
|
|
58
|
+
for (const [key, value] of Object.entries(update)) {
|
|
59
|
+
await this.repo.upsert({ key: PREFIX + key, value }, ["key"]);
|
|
60
|
+
}
|
|
61
|
+
return this.getAll();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Store an uploaded favicon in object storage and record its metadata. */
|
|
65
|
+
async setFavicon(body: Buffer, contentType: string): Promise<void> {
|
|
66
|
+
await this.storage.put(FAVICON_KEY, body, contentType);
|
|
67
|
+
await this.setMany({ faviconContentType: contentType, faviconUpdatedAt: `${Date.now()}` });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getFavicon(): Promise<SiteFavicon | null> {
|
|
71
|
+
const contentType = await this.get("faviconContentType");
|
|
72
|
+
if (!contentType) return null;
|
|
73
|
+
try {
|
|
74
|
+
return { body: await this.storage.get(FAVICON_KEY), contentType };
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Cache-busting version token so the browser refetches the favicon after a change. */
|
|
81
|
+
async faviconVersion(): Promise<string | null> {
|
|
82
|
+
return this.get("faviconUpdatedAt");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Locale } from "$lib/i18n/messages";
|
|
2
|
+
import type { SiteSettings } from "$lib/site.svelte";
|
|
2
3
|
|
|
3
4
|
export type SessionUser = {
|
|
4
5
|
id: string;
|
|
@@ -19,6 +20,7 @@ declare global {
|
|
|
19
20
|
user: SessionUser | null;
|
|
20
21
|
session: { id: string; impersonatedBy?: string | null } | null;
|
|
21
22
|
locale: Locale;
|
|
23
|
+
site: SiteSettings | null;
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
}
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import type { Handle } from "@sveltejs/kit";
|
|
2
2
|
import { serverApiClient } from "$lib/server/api";
|
|
3
3
|
import { resolveLocale } from "$lib/i18n/messages";
|
|
4
|
+
import type { SiteSettings } from "$lib/site.svelte";
|
|
4
5
|
|
|
5
6
|
export const handle: Handle = async ({ event, resolve }) => {
|
|
6
|
-
|
|
7
|
+
// Public site settings (branding, locale/timezone defaults, maintenance, …),
|
|
8
|
+
// loaded once here and reused by the layout load. A visitor with no locale
|
|
9
|
+
// cookie falls back to the admin-configured site locale, then the app default.
|
|
10
|
+
let site: SiteSettings | null = null;
|
|
11
|
+
try {
|
|
12
|
+
site = await serverApiClient(event).get<SiteSettings>("/site/settings");
|
|
13
|
+
} catch {
|
|
14
|
+
site = null;
|
|
15
|
+
}
|
|
16
|
+
event.locals.site = site;
|
|
17
|
+
event.locals.locale = resolveLocale(event.cookies.get("locale") ?? site?.locale ?? undefined);
|
|
7
18
|
try {
|
|
8
19
|
const { data } = await serverApiClient(event).auth.getSession();
|
|
9
20
|
event.locals.user = (data?.user as App.Locals["user"]) ?? null;
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Component } from "svelte";
|
|
2
|
+
import type { Messages } from "$lib/i18n/messages";
|
|
3
|
+
// Modules import their nav-entry icons here.
|
|
4
|
+
// podokit:begin:admin-nav-imports
|
|
5
|
+
// podokit:end:admin-nav-imports
|
|
6
|
+
|
|
7
|
+
// Module-driven admin menu registry. Modules append their sidebar nav entries and
|
|
8
|
+
// settings tabs at the markers below via `podo add` injection, so the admin menu
|
|
9
|
+
// grows and shrinks with the installed module set — no edits to app-sidebar or
|
|
10
|
+
// the settings page. Empty until a module registers.
|
|
11
|
+
|
|
12
|
+
/** A sidebar nav entry contributed by a module: a link, a label resolved from the
|
|
13
|
+
* current locale (from the module's own i18n block), and an icon. */
|
|
14
|
+
export type AdminNavEntry = {
|
|
15
|
+
href: string;
|
|
16
|
+
label: (t: Messages) => string;
|
|
17
|
+
icon: Component;
|
|
18
|
+
adminOnly?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A settings tab contributed by a module: a tab value, a label resolved from the
|
|
22
|
+
* current locale, and the panel component. */
|
|
23
|
+
export type AdminSettingsSection = {
|
|
24
|
+
value: string;
|
|
25
|
+
label: (t: Messages) => string;
|
|
26
|
+
component: Component;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const moduleNavEntries: AdminNavEntry[] = [
|
|
30
|
+
// podokit:begin:admin-nav-entries
|
|
31
|
+
// podokit:end:admin-nav-entries
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const moduleSettingsSections: AdminSettingsSection[] = [
|
|
35
|
+
// podokit:begin:admin-settings
|
|
36
|
+
// podokit:end:admin-settings
|
|
37
|
+
];
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte
CHANGED
|
@@ -8,26 +8,28 @@
|
|
|
8
8
|
import ScrollTextIcon from "@lucide/svelte/icons/scroll-text";
|
|
9
9
|
import Building2Icon from "@lucide/svelte/icons/building-2";
|
|
10
10
|
import SettingsIcon from "@lucide/svelte/icons/settings";
|
|
11
|
+
import HouseIcon from "@lucide/svelte/icons/house";
|
|
11
12
|
import { getI18n } from "$lib/i18n";
|
|
13
|
+
import { moduleNavEntries, type AdminNavEntry } from "$lib/admin/registry.svelte";
|
|
12
14
|
import type { SessionUser } from "../../app.d.ts";
|
|
13
|
-
import type { Messages } from "$lib/i18n/messages";
|
|
14
|
-
import type { Component } from "svelte";
|
|
15
15
|
|
|
16
16
|
let { user, capabilities }: { user: SessionUser; capabilities?: { auditLog?: boolean; organization?: boolean } } = $props();
|
|
17
17
|
const i18n = getI18n();
|
|
18
18
|
|
|
19
|
-
type NavItem =
|
|
19
|
+
type NavItem = AdminNavEntry;
|
|
20
20
|
const items = $derived<NavItem[]>([
|
|
21
|
-
{ href: "/admin",
|
|
22
|
-
{ href: "/admin/users",
|
|
23
|
-
{ href: "/admin/sessions",
|
|
21
|
+
{ href: "/admin", label: (t) => t.nav.overview, icon: LayoutDashboardIcon },
|
|
22
|
+
{ href: "/admin/users", label: (t) => t.nav.users, icon: UsersIcon, adminOnly: true },
|
|
23
|
+
{ href: "/admin/sessions", label: (t) => t.nav.sessions, icon: MonitorSmartphoneIcon, adminOnly: true },
|
|
24
24
|
...(capabilities?.organization
|
|
25
|
-
? [{ href: "/admin/organizations",
|
|
25
|
+
? [{ href: "/admin/organizations", label: (t) => t.nav.organizations, icon: Building2Icon, adminOnly: true } as NavItem]
|
|
26
26
|
: []),
|
|
27
27
|
...(capabilities?.auditLog
|
|
28
|
-
? [{ href: "/admin/audit",
|
|
28
|
+
? [{ href: "/admin/audit", label: (t) => t.nav.audit, icon: ScrollTextIcon, adminOnly: true } as NavItem]
|
|
29
29
|
: []),
|
|
30
|
-
|
|
30
|
+
// Module-contributed entries (present only for installed modules).
|
|
31
|
+
...moduleNavEntries,
|
|
32
|
+
{ href: "/admin/settings", label: (t) => t.nav.settings, icon: SettingsIcon, adminOnly: true },
|
|
31
33
|
]);
|
|
32
34
|
const visible = $derived(items.filter((item) => !item.adminOnly || user.role === "admin"));
|
|
33
35
|
</script>
|
|
@@ -46,11 +48,11 @@
|
|
|
46
48
|
<Sidebar.Menu>
|
|
47
49
|
{#each visible as item (item.href)}
|
|
48
50
|
<Sidebar.MenuItem>
|
|
49
|
-
<Sidebar.MenuButton isActive={page.url.pathname === item.href} tooltipContent={i18n.t
|
|
51
|
+
<Sidebar.MenuButton isActive={page.url.pathname === item.href} tooltipContent={item.label(i18n.t)}>
|
|
50
52
|
{#snippet child({ props })}
|
|
51
53
|
<a href={item.href} {...props}>
|
|
52
54
|
<item.icon />
|
|
53
|
-
<span>{i18n.t
|
|
55
|
+
<span>{item.label(i18n.t)}</span>
|
|
54
56
|
</a>
|
|
55
57
|
{/snippet}
|
|
56
58
|
</Sidebar.MenuButton>
|
|
@@ -60,6 +62,18 @@
|
|
|
60
62
|
</Sidebar.Group>
|
|
61
63
|
</Sidebar.Content>
|
|
62
64
|
<Sidebar.Footer>
|
|
65
|
+
<Sidebar.Menu>
|
|
66
|
+
<Sidebar.MenuItem>
|
|
67
|
+
<Sidebar.MenuButton tooltipContent={i18n.t.nav.home}>
|
|
68
|
+
{#snippet child({ props })}
|
|
69
|
+
<a href="/" {...props}>
|
|
70
|
+
<HouseIcon />
|
|
71
|
+
<span>{i18n.t.nav.home}</span>
|
|
72
|
+
</a>
|
|
73
|
+
{/snippet}
|
|
74
|
+
</Sidebar.MenuButton>
|
|
75
|
+
</Sidebar.MenuItem>
|
|
76
|
+
</Sidebar.Menu>
|
|
63
77
|
<SidebarUserMenu {user} />
|
|
64
78
|
</Sidebar.Footer>
|
|
65
79
|
</Sidebar.Root>
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Public site footer: renders the admin-configured footer text and links
|
|
3
|
+
// (support email, terms, privacy). Each element only shows when its value is
|
|
4
|
+
// set in Settings, so an unconfigured site has no empty footer.
|
|
5
|
+
import { site } from "$lib/site.svelte";
|
|
6
|
+
import { getI18n } from "$lib/i18n";
|
|
7
|
+
|
|
8
|
+
const i18n = getI18n();
|
|
9
|
+
const s = $derived(site.value);
|
|
10
|
+
const hasLinks = $derived(Boolean(s.supportEmail || s.termsUrl || s.privacyUrl));
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
{#if s.footerText || hasLinks}
|
|
14
|
+
<footer class="text-muted-foreground flex flex-col items-center gap-1 px-4 py-6 text-center text-sm">
|
|
15
|
+
{#if s.footerText}
|
|
16
|
+
<p>{s.footerText}</p>
|
|
17
|
+
{/if}
|
|
18
|
+
{#if hasLinks}
|
|
19
|
+
<nav class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
|
20
|
+
{#if s.supportEmail}
|
|
21
|
+
<a href={`mailto:${s.supportEmail}`} class="hover:text-foreground hover:underline">{i18n.t.footer.support}</a>
|
|
22
|
+
{/if}
|
|
23
|
+
{#if s.termsUrl}
|
|
24
|
+
<a href={s.termsUrl} class="hover:text-foreground hover:underline">{i18n.t.footer.terms}</a>
|
|
25
|
+
{/if}
|
|
26
|
+
{#if s.privacyUrl}
|
|
27
|
+
<a href={s.privacyUrl} class="hover:text-foreground hover:underline">{i18n.t.footer.privacy}</a>
|
|
28
|
+
{/if}
|
|
29
|
+
</nav>
|
|
30
|
+
{/if}
|
|
31
|
+
</footer>
|
|
32
|
+
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getContext, setContext } from "svelte";
|
|
2
|
+
import { site } from "$lib/site.svelte";
|
|
2
3
|
import type { Locale, Messages } from "./messages";
|
|
3
4
|
|
|
4
5
|
const KEY = Symbol("i18n");
|
|
@@ -20,8 +21,20 @@ export function fmt(template: string, values: Record<string, string | number>):
|
|
|
20
21
|
);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
// Render a timestamp in the
|
|
24
|
-
//
|
|
24
|
+
// Render a timestamp in the site's configured timezone when an admin has set one
|
|
25
|
+
// (Settings → General), otherwise in the viewer's own browser timezone. Used
|
|
26
|
+
// across all pages.
|
|
25
27
|
export function formatDateTime(value: string | Date): string {
|
|
26
|
-
|
|
28
|
+
const date = new Date(value);
|
|
29
|
+
const timeZone = site.value.timezone || undefined;
|
|
30
|
+
if (timeZone) {
|
|
31
|
+
// A misconfigured timezone would throw a RangeError — fall back to the
|
|
32
|
+
// browser default rather than breaking the page.
|
|
33
|
+
try {
|
|
34
|
+
return date.toLocaleString(undefined, { timeZone });
|
|
35
|
+
} catch {
|
|
36
|
+
/* invalid timeZone — use the browser default below */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return date.toLocaleString();
|
|
27
40
|
}
|
|
@@ -11,6 +11,8 @@ export const localeNames: Record<Locale, string> = {
|
|
|
11
11
|
const en = {
|
|
12
12
|
common: { appName: "Admin" },
|
|
13
13
|
landing: { title: "Welcome", subtitle: "This is your app's home page. Replace it with your own content — the admin area lives under /admin.", openAdmin: "Open admin" },
|
|
14
|
+
footer: { support: "Support", terms: "Terms", privacy: "Privacy" },
|
|
15
|
+
maintenance: { title: "Under maintenance", subtitle: "The site is temporarily unavailable. Please check back soon.", adminHint: "Administrators can sign in to manage the site.", signIn: "Admin sign in" },
|
|
14
16
|
language: { label: "Language" },
|
|
15
17
|
toolbar: { filter: "Filter", search: "Search", searchButton: "Search", all: "All" },
|
|
16
18
|
theme: { toggle: "Toggle theme", light: "Light", dark: "Dark", system: "System" },
|
|
@@ -59,7 +61,7 @@ const en = {
|
|
|
59
61
|
resendVerification: "Resend verification email",
|
|
60
62
|
emailNotVerified: "Verify your email before signing in.",
|
|
61
63
|
},
|
|
62
|
-
nav: { overview: "Overview", users: "Users", sessions: "Sessions", organizations: "Organizations", audit: "Audit log", account: "Account", settings: "Settings", signOut: "Sign out" },
|
|
64
|
+
nav: { overview: "Overview", users: "Users", sessions: "Sessions", organizations: "Organizations", audit: "Audit log", account: "Account", settings: "Settings", home: "Back to home", signOut: "Sign out" },
|
|
63
65
|
passkeys: {
|
|
64
66
|
title: "Passkeys", subtitle: "Passwordless sign-in with your device (Face ID, fingerprint, security key).",
|
|
65
67
|
name: "Name", namePlaceholder: "e.g. MacBook", add: "Add passkey", remove: "Remove", untitled: "Passkey",
|
|
@@ -92,7 +94,27 @@ const en = {
|
|
|
92
94
|
createdTitle: "API key created", createdDesc: "Copy it now — you will not be able to see it again.",
|
|
93
95
|
copy: "Copy", copied: "Copied to clipboard", done: "Done",
|
|
94
96
|
},
|
|
97
|
+
general: {
|
|
98
|
+
tab: "General",
|
|
99
|
+
brandingTitle: "Branding", brandingDesc: "The site name and icon shown in the browser and across the app.",
|
|
100
|
+
siteName: "Site name", siteNamePlaceholder: "My App",
|
|
101
|
+
siteNameHint: "Used as the browser tab title and wherever the app name appears.",
|
|
102
|
+
favicon: "Browser icon (favicon)",
|
|
103
|
+
faviconHint: "SVG (recommended) or a square PNG 512×512, ≤ 1 MB. Applied to every page.",
|
|
104
|
+
iconSaved: "Icon updated.", iconError: "Failed to upload the icon.", iconTooBig: "Icon must be 1 MB or smaller.",
|
|
105
|
+
brandColor: "Brand color",
|
|
106
|
+
infoTitle: "Site info", infoDesc: "Optional details used in meta tags, the footer, and links.",
|
|
107
|
+
description: "Description", descriptionPlaceholder: "A short tagline for your site",
|
|
108
|
+
supportEmail: "Support email", footerText: "Footer text",
|
|
109
|
+
termsUrl: "Terms URL", privacyUrl: "Privacy URL",
|
|
110
|
+
locale: "Default language", timezone: "Default timezone",
|
|
111
|
+
opsTitle: "Operations",
|
|
112
|
+
maintenance: "Maintenance mode", maintenanceDesc: "Show a maintenance page to non-admins.",
|
|
113
|
+
allowSignup: "Allow public sign-up", allowSignupDesc: "Turn off to make the app invite-only.",
|
|
114
|
+
save: "Save changes", saving: "Saving…", saved: "General settings saved.", saveError: "Failed to save settings.",
|
|
115
|
+
},
|
|
95
116
|
settings: {
|
|
117
|
+
tab: "Authentication",
|
|
96
118
|
title: "Settings", subtitle: "Which authentication features are enabled, and how they are configured.",
|
|
97
119
|
authFeaturesTitle: "Authentication features", authFeaturesSubtitle: "Toggle sign-in methods and security features. Changes apply immediately.",
|
|
98
120
|
serverConfigTitle: "Server configuration", serverConfigSubtitle: "Set through environment variables and applied at startup (read-only).",
|
|
@@ -285,6 +307,9 @@ const en = {
|
|
|
285
307
|
verified: "Verified",
|
|
286
308
|
unverified: "Unverified",
|
|
287
309
|
},
|
|
310
|
+
// Modules add their i18n blocks here.
|
|
311
|
+
// podokit:begin:messages-en
|
|
312
|
+
// podokit:end:messages-en
|
|
288
313
|
};
|
|
289
314
|
|
|
290
315
|
export type Messages = typeof en;
|
|
@@ -292,6 +317,8 @@ export type Messages = typeof en;
|
|
|
292
317
|
const ko: Messages = {
|
|
293
318
|
common: { appName: "관리자" },
|
|
294
319
|
landing: { title: "환영합니다", subtitle: "앱의 홈 페이지입니다. 원하는 내용으로 교체하세요 — 관리자 기능은 /admin 에 있습니다.", openAdmin: "관리자 열기" },
|
|
320
|
+
footer: { support: "고객지원", terms: "이용약관", privacy: "개인정보처리방침" },
|
|
321
|
+
maintenance: { title: "점검 중", subtitle: "사이트를 일시적으로 사용할 수 없습니다. 잠시 후 다시 확인해 주세요.", adminHint: "관리자는 로그인하여 사이트를 관리할 수 있습니다.", signIn: "관리자 로그인" },
|
|
295
322
|
language: { label: "언어" },
|
|
296
323
|
toolbar: { filter: "필터", search: "검색", searchButton: "검색", all: "전체" },
|
|
297
324
|
theme: { toggle: "테마 전환", light: "라이트", dark: "다크", system: "시스템" },
|
|
@@ -340,7 +367,7 @@ const ko: Messages = {
|
|
|
340
367
|
emailNotVerified: "로그인 전에 이메일을 인증해 주세요.",
|
|
341
368
|
resetFailed: "재설정에 실패했습니다",
|
|
342
369
|
},
|
|
343
|
-
nav: { overview: "개요", users: "회원", sessions: "세션", organizations: "조직", audit: "감사 로그", account: "계정", settings: "설정", signOut: "로그아웃" },
|
|
370
|
+
nav: { overview: "개요", users: "회원", sessions: "세션", organizations: "조직", audit: "감사 로그", account: "계정", settings: "설정", home: "홈으로 돌아가기", signOut: "로그아웃" },
|
|
344
371
|
passkeys: {
|
|
345
372
|
title: "패스키", subtitle: "기기로 비밀번호 없이 로그인(Face ID, 지문, 보안 키).",
|
|
346
373
|
name: "이름", namePlaceholder: "예: MacBook", add: "패스키 추가", remove: "삭제", untitled: "패스키",
|
|
@@ -373,7 +400,27 @@ const ko: Messages = {
|
|
|
373
400
|
createdTitle: "API 키 생성됨", createdDesc: "지금 복사하세요 — 다시 볼 수 없습니다.",
|
|
374
401
|
copy: "복사", copied: "클립보드에 복사됨", done: "완료",
|
|
375
402
|
},
|
|
403
|
+
general: {
|
|
404
|
+
tab: "일반",
|
|
405
|
+
brandingTitle: "브랜딩", brandingDesc: "브라우저와 앱 전반에 표시되는 사이트 이름과 아이콘입니다.",
|
|
406
|
+
siteName: "사이트 이름", siteNamePlaceholder: "My App",
|
|
407
|
+
siteNameHint: "브라우저 탭 제목과 앱 이름이 쓰이는 곳에 사용됩니다.",
|
|
408
|
+
favicon: "브라우저 아이콘 (favicon)",
|
|
409
|
+
faviconHint: "SVG(권장) 또는 정사각 PNG 512×512, 1 MB 이하. 모든 페이지에 적용됩니다.",
|
|
410
|
+
iconSaved: "아이콘을 변경했습니다.", iconError: "아이콘 업로드에 실패했습니다.", iconTooBig: "아이콘은 1 MB 이하여야 합니다.",
|
|
411
|
+
brandColor: "브랜드 색상",
|
|
412
|
+
infoTitle: "사이트 정보", infoDesc: "메타 태그·푸터·링크에 쓰이는 선택 항목입니다.",
|
|
413
|
+
description: "설명", descriptionPlaceholder: "사이트를 한 줄로 소개",
|
|
414
|
+
supportEmail: "지원 이메일", footerText: "푸터 문구",
|
|
415
|
+
termsUrl: "약관 URL", privacyUrl: "개인정보 URL",
|
|
416
|
+
locale: "기본 언어", timezone: "기본 시간대",
|
|
417
|
+
opsTitle: "운영",
|
|
418
|
+
maintenance: "유지보수 모드", maintenanceDesc: "관리자 외 사용자에게 점검 페이지를 표시합니다.",
|
|
419
|
+
allowSignup: "공개 가입 허용", allowSignupDesc: "끄면 초대 전용으로 전환됩니다.",
|
|
420
|
+
save: "변경 저장", saving: "저장 중…", saved: "일반 설정을 저장했습니다.", saveError: "설정 저장에 실패했습니다.",
|
|
421
|
+
},
|
|
376
422
|
settings: {
|
|
423
|
+
tab: "인증",
|
|
377
424
|
title: "설정", subtitle: "활성화된 인증 기능과 구성 방법입니다.",
|
|
378
425
|
authFeaturesTitle: "인증 기능", authFeaturesSubtitle: "로그인 수단과 보안 기능을 켜고 끕니다. 변경은 즉시 적용됩니다.",
|
|
379
426
|
serverConfigTitle: "서버 구성", serverConfigSubtitle: "환경변수로 설정되어 시작 시 적용됩니다(읽기 전용).",
|
|
@@ -566,6 +613,9 @@ const ko: Messages = {
|
|
|
566
613
|
verified: "인증됨",
|
|
567
614
|
unverified: "미인증",
|
|
568
615
|
},
|
|
616
|
+
// Modules add their i18n blocks here.
|
|
617
|
+
// podokit:begin:messages-ko
|
|
618
|
+
// podokit:end:messages-ko
|
|
569
619
|
};
|
|
570
620
|
|
|
571
621
|
export const messages: Record<Locale, Messages> = { en, ko };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Reactive site branding (name, favicon, …) shared between the layout (which
|
|
2
|
+
// applies the browser title/favicon) and the admin general-settings form (which
|
|
3
|
+
// updates it live). Initialised from the server load; patched after a save.
|
|
4
|
+
export interface SiteSettings {
|
|
5
|
+
name: string | null;
|
|
6
|
+
description: string | null;
|
|
7
|
+
supportEmail: string | null;
|
|
8
|
+
footerText: string | null;
|
|
9
|
+
brandColor: string | null;
|
|
10
|
+
termsUrl: string | null;
|
|
11
|
+
privacyUrl: string | null;
|
|
12
|
+
locale: string | null;
|
|
13
|
+
timezone: string | null;
|
|
14
|
+
maintenanceMode: string | null;
|
|
15
|
+
allowSignup: string | null;
|
|
16
|
+
hasFavicon: boolean;
|
|
17
|
+
faviconVersion: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const empty: SiteSettings = {
|
|
21
|
+
name: null,
|
|
22
|
+
description: null,
|
|
23
|
+
supportEmail: null,
|
|
24
|
+
footerText: null,
|
|
25
|
+
brandColor: null,
|
|
26
|
+
termsUrl: null,
|
|
27
|
+
privacyUrl: null,
|
|
28
|
+
locale: null,
|
|
29
|
+
timezone: null,
|
|
30
|
+
maintenanceMode: null,
|
|
31
|
+
allowSignup: null,
|
|
32
|
+
hasFavicon: false,
|
|
33
|
+
faviconVersion: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let current = $state<SiteSettings>({ ...empty });
|
|
37
|
+
|
|
38
|
+
export const site = {
|
|
39
|
+
get value(): SiteSettings {
|
|
40
|
+
return current;
|
|
41
|
+
},
|
|
42
|
+
/** Seed from the server load (idempotent). */
|
|
43
|
+
init(v: Partial<SiteSettings> | null | undefined): void {
|
|
44
|
+
if (v) current = { ...empty, ...v };
|
|
45
|
+
},
|
|
46
|
+
/** Merge in changes so the layout re-applies title/favicon immediately. */
|
|
47
|
+
patch(v: Partial<SiteSettings>): void {
|
|
48
|
+
current = { ...current, ...v };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
import { Button } from "$lib/components/ui/button";
|
|
8
8
|
import * as Card from "$lib/components/ui/card";
|
|
9
9
|
import * as Dialog from "$lib/components/ui/dialog";
|
|
10
|
+
import * as Tabs from "$lib/components/ui/tabs";
|
|
11
|
+
import GeneralSettings from "./general-settings.svelte";
|
|
12
|
+
import { moduleSettingsSections } from "$lib/admin/registry.svelte";
|
|
10
13
|
import { toast } from "svelte-sonner";
|
|
11
14
|
import { api } from "$lib/api";
|
|
12
15
|
import { getI18n } from "$lib/i18n";
|
|
@@ -220,19 +223,41 @@
|
|
|
220
223
|
<p class="text-muted-foreground text-sm">{i18n.t.settings.subtitle}</p>
|
|
221
224
|
</div>
|
|
222
225
|
|
|
223
|
-
|
|
224
|
-
<
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
</
|
|
235
|
-
|
|
226
|
+
<Tabs.Root value="general">
|
|
227
|
+
<Tabs.List>
|
|
228
|
+
<Tabs.Trigger value="general">{i18n.t.general.tab}</Tabs.Trigger>
|
|
229
|
+
<Tabs.Trigger value="auth">{i18n.t.settings.tab}</Tabs.Trigger>
|
|
230
|
+
{#each moduleSettingsSections as s (s.value)}
|
|
231
|
+
<Tabs.Trigger value={s.value}>{s.label(i18n.t)}</Tabs.Trigger>
|
|
232
|
+
{/each}
|
|
233
|
+
</Tabs.List>
|
|
234
|
+
|
|
235
|
+
<Tabs.Content value="general" class="mt-6">
|
|
236
|
+
<GeneralSettings />
|
|
237
|
+
</Tabs.Content>
|
|
238
|
+
|
|
239
|
+
<Tabs.Content value="auth" class="mt-6 flex flex-col gap-6">
|
|
240
|
+
{#each sections as section (section.title)}
|
|
241
|
+
<section class="flex flex-col gap-3">
|
|
242
|
+
<div>
|
|
243
|
+
<h2 class="text-lg font-semibold">{section.title}</h2>
|
|
244
|
+
<p class="text-muted-foreground text-sm">{section.subtitle}</p>
|
|
245
|
+
</div>
|
|
246
|
+
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
247
|
+
{#each section.items as item (item.key)}
|
|
248
|
+
{@render card(item)}
|
|
249
|
+
{/each}
|
|
250
|
+
</div>
|
|
251
|
+
</section>
|
|
252
|
+
{/each}
|
|
253
|
+
</Tabs.Content>
|
|
254
|
+
|
|
255
|
+
{#each moduleSettingsSections as s (s.value)}
|
|
256
|
+
<Tabs.Content value={s.value} class="mt-6">
|
|
257
|
+
<s.component />
|
|
258
|
+
</Tabs.Content>
|
|
259
|
+
{/each}
|
|
260
|
+
</Tabs.Root>
|
|
236
261
|
</div>
|
|
237
262
|
|
|
238
263
|
<!-- Social login: manage every provider (list ⇄ add/edit form) in one dialog -->
|