@podosoft/podokit 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/add.d.ts +2 -0
- package/dist/add.js +6 -1
- package/dist/assemble.js +4 -1
- package/dist/create.d.ts +2 -0
- package/dist/create.js +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/lib.d.ts +14 -0
- package/dist/lib.js +34 -0
- package/dist/lockfile.js +15 -0
- 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/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/components/app-sidebar.svelte +13 -0
- 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 +46 -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 +28 -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 +24 -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/organizations.ui.spec.ts +8 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +42 -1
- package/dist/templates/modules/admin-dashboard/module.manifest.json +20 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +27 -0
- package/dist/templates/modules/auth/module.manifest.json +6 -0
- 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/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/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
package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-data-table
|
|
3
|
+
description: Use when building ANY list or table view in this project (users, sessions, orders, any collection) — including inside dialogs. PodoKit requires the shared DataTable component; never assemble a table by hand.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a list with the shared DataTable
|
|
7
|
+
|
|
8
|
+
**Every** list/table in this project must use `$lib/components/data-table.svelte`
|
|
9
|
+
(`DataTable`) — it provides sortable headers and a footer pagination, consistently.
|
|
10
|
+
Do **not** assemble `Table.Root` / `Table.Header` / `Table.Body` by hand, and don't
|
|
11
|
+
put pagination outside the table.
|
|
12
|
+
|
|
13
|
+
```svelte
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import DataTable from "$lib/components/data-table.svelte";
|
|
16
|
+
const columns = [
|
|
17
|
+
{ key: "name", label: "Name", sortable: true },
|
|
18
|
+
{ key: "email", label: "Email", sortable: true },
|
|
19
|
+
{ key: "role", label: "Role", sortable: true }, // action/status columns: no `sortable`
|
|
20
|
+
];
|
|
21
|
+
let { rows } = $props(); // Svelte 5 runes only
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<DataTable {columns} {rows} label="{rows.length} users">
|
|
25
|
+
{#snippet row(item)}
|
|
26
|
+
<Table.Cell>{item.name}</Table.Cell>
|
|
27
|
+
<Table.Cell>{item.email}</Table.Cell>
|
|
28
|
+
<Table.Cell>{item.role}</Table.Cell>
|
|
29
|
+
{/snippet}
|
|
30
|
+
</DataTable>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **Client data** (bounded): pass `rows`; DataTable sorts/paginates internally.
|
|
34
|
+
- **Server data** (huge lists): use `manualSort` + `manualPagination` + `total`
|
|
35
|
+
+ `onChange` (re-fetch with `sortBy`/`sortDirection`/`page`).
|
|
36
|
+
- Nested/derived sort keys: give the column a `value` accessor.
|
|
37
|
+
- No footer needed: `perPage={0}`.
|
|
38
|
+
- **Search & filters**: put the shared `table-toolbar.svelte` (`TableToolbar`)
|
|
39
|
+
above the DataTable. Filters + search apply together on the Search button/Enter
|
|
40
|
+
(don't query on every keystroke); the search field is a `select` of columns.
|
|
41
|
+
|
|
42
|
+
The `/admin/users` page is the reference implementation. See `docs/development.md`
|
|
43
|
+
("Data tables", "Search & filters") on the PodoKit repo.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
+
import { anonState } from "../helpers/accounts";
|
|
3
|
+
import { ready } from "../helpers/hydration";
|
|
4
|
+
|
|
5
|
+
// Verifies that the General settings actually take effect (not just persist).
|
|
6
|
+
// The admin storageState (project default) drives the toggles; a fresh anonymous
|
|
7
|
+
// context checks the public-facing result. Serial workers (workers:1) make the
|
|
8
|
+
// toggle-then-restore pattern safe for the rest of the suite; restores run in a
|
|
9
|
+
// `finally` so a mid-test failure still leaves the shared site settings clean.
|
|
10
|
+
|
|
11
|
+
async function saveGeneral(page: Page): Promise<void> {
|
|
12
|
+
await page.getByRole("button", { name: "Save changes" }).click();
|
|
13
|
+
await expect(page.getByText("General settings saved.")).toBeVisible();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("general: footer text and links render on public pages @smoke", async ({ page }) => {
|
|
17
|
+
await ready(page, "/admin/settings");
|
|
18
|
+
const footer = page.getByLabel("Footer text");
|
|
19
|
+
const support = page.getByLabel("Support email");
|
|
20
|
+
const origFooter = await footer.inputValue();
|
|
21
|
+
const origSupport = await support.inputValue();
|
|
22
|
+
await footer.fill("© 2026 PodoKit Test");
|
|
23
|
+
await support.fill("help@example.com");
|
|
24
|
+
await saveGeneral(page);
|
|
25
|
+
try {
|
|
26
|
+
await page.goto("/");
|
|
27
|
+
await expect(page.getByText("© 2026 PodoKit Test")).toBeVisible();
|
|
28
|
+
await expect(page.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
|
|
29
|
+
} finally {
|
|
30
|
+
await ready(page, "/admin/settings");
|
|
31
|
+
await page.getByLabel("Footer text").fill(origFooter);
|
|
32
|
+
await page.getByLabel("Support email").fill(origSupport);
|
|
33
|
+
await saveGeneral(page);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("general: maintenance mode holds non-admins on the maintenance page @smoke", async ({ page, browser }) => {
|
|
38
|
+
await ready(page, "/admin/settings");
|
|
39
|
+
const origin = new URL(page.url()).origin;
|
|
40
|
+
await page.getByRole("switch", { name: "Maintenance mode" }).click();
|
|
41
|
+
await saveGeneral(page);
|
|
42
|
+
try {
|
|
43
|
+
const ctx = await browser.newContext({ storageState: anonState });
|
|
44
|
+
const anon = await ctx.newPage();
|
|
45
|
+
await anon.goto(`${origin}/`);
|
|
46
|
+
await expect(anon).toHaveURL(/\/maintenance$/);
|
|
47
|
+
await expect(anon.getByRole("heading", { name: "Under maintenance" })).toBeVisible();
|
|
48
|
+
await ctx.close();
|
|
49
|
+
// the admin is not held back
|
|
50
|
+
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
|
|
51
|
+
} finally {
|
|
52
|
+
await ready(page, "/admin/settings");
|
|
53
|
+
const t = page.getByRole("switch", { name: "Maintenance mode" });
|
|
54
|
+
if (await t.isChecked()) {
|
|
55
|
+
await t.click();
|
|
56
|
+
await saveGeneral(page);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("general: closing sign-up redirects /signup and hides the link @smoke", async ({ page, browser }) => {
|
|
62
|
+
await ready(page, "/admin/settings");
|
|
63
|
+
const origin = new URL(page.url()).origin;
|
|
64
|
+
await page.getByRole("switch", { name: "Allow public sign-up" }).click();
|
|
65
|
+
await saveGeneral(page);
|
|
66
|
+
try {
|
|
67
|
+
const ctx = await browser.newContext({ storageState: anonState });
|
|
68
|
+
const anon = await ctx.newPage();
|
|
69
|
+
await anon.goto(`${origin}/signup`);
|
|
70
|
+
await expect(anon).toHaveURL(/\/login/);
|
|
71
|
+
await expect(anon.getByRole("link", { name: "Sign up" })).toHaveCount(0);
|
|
72
|
+
await ctx.close();
|
|
73
|
+
} finally {
|
|
74
|
+
await ready(page, "/admin/settings");
|
|
75
|
+
const t = page.getByRole("switch", { name: "Allow public sign-up" });
|
|
76
|
+
if (!(await t.isChecked())) {
|
|
77
|
+
await t.click();
|
|
78
|
+
await saveGeneral(page);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -44,9 +44,12 @@ test("create an org with a non-ascii name, a parent, and a manager @smoke", asyn
|
|
|
44
44
|
// child row shows the parent org name
|
|
45
45
|
await expect(page.getByRole("row", { name: new RegExp(child) })).toContainText(parent);
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
// Clean up. Delete the child first (its row also shows the parent name, so a
|
|
48
|
+
// parent-name match isn't unique until the child is gone), and wait for the
|
|
49
|
+
// child row to disappear before removing the parent.
|
|
50
|
+
await page.getByRole("row", { name: new RegExp(child) }).getByRole("button", { name: "Delete" }).click();
|
|
51
|
+
await expect(page.getByText("Organization deleted").first()).toBeVisible();
|
|
52
|
+
await expect(page.getByRole("cell", { name: child, exact: true })).toHaveCount(0);
|
|
53
|
+
await page.getByRole("row", { name: new RegExp(parent) }).getByRole("button", { name: "Delete" }).first().click();
|
|
54
|
+
await expect(page.getByText("Organization deleted").first()).toBeVisible();
|
|
52
55
|
});
|
|
@@ -2,9 +2,40 @@ import { expect, test } from "@playwright/test";
|
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
3
|
|
|
4
4
|
// admin storageState (project default)
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
test("settings has General and Authentication tabs @smoke", async ({ page }) => {
|
|
6
7
|
await ready(page, "/admin/settings");
|
|
7
8
|
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
|
|
9
|
+
await expect(page.getByRole("tab", { name: "General" })).toBeVisible();
|
|
10
|
+
await expect(page.getByRole("tab", { name: "Authentication" })).toBeVisible();
|
|
11
|
+
// General is the default tab: the site name field is shown.
|
|
12
|
+
await expect(page.getByLabel("Site name")).toBeVisible();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("general settings: shows the favicon uploader @smoke", async ({ page }) => {
|
|
16
|
+
await ready(page, "/admin/settings");
|
|
17
|
+
await expect(page.getByText("Browser icon (favicon)")).toBeVisible();
|
|
18
|
+
await expect(page.locator('input[type="file"]')).toBeVisible();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("general settings: edit the site name, save, and apply live @smoke", async ({ page }) => {
|
|
22
|
+
await ready(page, "/admin/settings");
|
|
23
|
+
const name = page.getByLabel("Site name");
|
|
24
|
+
const original = await name.inputValue();
|
|
25
|
+
await name.fill("PodoKit Test Site");
|
|
26
|
+
await page.getByRole("button", { name: "Save changes" }).click();
|
|
27
|
+
await expect(page.getByText("General settings saved.")).toBeVisible();
|
|
28
|
+
// the browser tab title reflects the new name live (no reload)
|
|
29
|
+
await expect(page).toHaveTitle("PodoKit Test Site");
|
|
30
|
+
// restore so other specs start clean
|
|
31
|
+
await name.fill(original);
|
|
32
|
+
await page.getByRole("button", { name: "Save changes" }).click();
|
|
33
|
+
await expect(page.getByText("General settings saved.")).toBeVisible();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("settings lists the auth features under the Authentication tab @smoke", async ({ page }) => {
|
|
37
|
+
await ready(page, "/admin/settings");
|
|
38
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
8
39
|
await expect(page.getByText("Email & password")).toBeVisible();
|
|
9
40
|
await expect(page.getByText("Two-factor authentication")).toBeVisible();
|
|
10
41
|
await expect(page.getByText("Breached-password check")).toBeVisible();
|
|
@@ -13,6 +44,15 @@ test("settings lists the auth features and their status @smoke", async ({ page }
|
|
|
13
44
|
await expect(page.getByText("Enabled", { exact: true }).first()).toBeVisible();
|
|
14
45
|
});
|
|
15
46
|
|
|
47
|
+
test("sidebar: back-to-home link returns to the landing page @smoke", async ({ page }) => {
|
|
48
|
+
await ready(page, "/admin/settings");
|
|
49
|
+
// the home link lives in the sidebar footer, above the user menu
|
|
50
|
+
await page.getByRole("link", { name: "Back to home" }).click();
|
|
51
|
+
await expect(page).toHaveURL(/\/$/);
|
|
52
|
+
// the landing page shows the "Open admin" entry point
|
|
53
|
+
await expect(page.getByRole("link", { name: "Open admin" })).toBeVisible();
|
|
54
|
+
});
|
|
55
|
+
|
|
16
56
|
test("settings is reachable from the sidebar @smoke", async ({ page }) => {
|
|
17
57
|
await ready(page, "/admin");
|
|
18
58
|
await page.getByRole("link", { name: "Settings" }).click();
|
|
@@ -21,6 +61,7 @@ test("settings is reachable from the sidebar @smoke", async ({ page }) => {
|
|
|
21
61
|
|
|
22
62
|
test("admin can toggle a feature flag and it applies live @smoke", async ({ page }) => {
|
|
23
63
|
await ready(page, "/admin/settings");
|
|
64
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
24
65
|
const toggle = page.getByRole("switch", { name: "Magic link" });
|
|
25
66
|
test.skip((await toggle.count()) === 0, "magic link toggle not present");
|
|
26
67
|
// Serial workers (workers:1) make toggle-then-restore safe for other specs.
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
"name": "admin-dashboard",
|
|
3
3
|
"description": "Admin dashboard UI (SvelteKit + shadcn-svelte): login/signup/password-reset, a sidebar shell, and user + session management via the better-auth admin plugin. Requires auth.",
|
|
4
4
|
"requires": [
|
|
5
|
-
"auth"
|
|
5
|
+
"auth",
|
|
6
|
+
"object-storage-s3"
|
|
6
7
|
],
|
|
7
8
|
"targetApp": "web",
|
|
8
9
|
"dependencies": {
|
|
9
10
|
"qrcode": "^1.5.4"
|
|
10
11
|
},
|
|
11
12
|
"devDependencies": {
|
|
12
|
-
"@types/qrcode": "^1.5.5"
|
|
13
|
+
"@types/qrcode": "^1.5.5",
|
|
14
|
+
"@types/multer": "^1.4.12"
|
|
13
15
|
},
|
|
14
16
|
"env": [
|
|
15
17
|
"# admin-dashboard: emails auto-promoted to the admin role on sign-up (comma-separated)",
|
|
@@ -35,6 +37,22 @@
|
|
|
35
37
|
"file": "apps/api/src/auth/auth.ts",
|
|
36
38
|
"marker": "// podokit:end:auth-options",
|
|
37
39
|
"text": "trustedOrigins: (process.env.CORS_ORIGIN ?? \"http://localhost:5001\").split(\",\").map((origin) => origin.trim()),\n databaseHooks: {\n user: {\n create: {\n before: async (user) => ({\n data: {\n ...user,\n role: (process.env.ADMIN_EMAILS ?? \"\")\n .split(\",\")\n .map((email) => email.trim().toLowerCase())\n .includes(user.email.toLowerCase())\n ? \"admin\"\n : \"user\",\n },\n }),\n },\n },\n },"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"file": "AGENTS.md",
|
|
43
|
+
"marker": "<!-- podokit:end:agents-modules -->",
|
|
44
|
+
"optional": true,
|
|
45
|
+
"text": "### admin-dashboard\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Emails in `ADMIN_EMAILS` become admins on sign-up.\n- **Every list/table MUST use the shared `$lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `$lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand.\n"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"file": "apps/api/src/app.module.ts",
|
|
49
|
+
"marker": "// podokit:end:imports",
|
|
50
|
+
"text": "import { SiteSettingsModule } from \"./site-settings/site-settings.module\";"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"file": "apps/api/src/app.module.ts",
|
|
54
|
+
"marker": "// podokit:end:module-imports",
|
|
55
|
+
"text": "SiteSettingsModule,"
|
|
38
56
|
}
|
|
39
57
|
],
|
|
40
58
|
"instructions": [
|
|
@@ -45,7 +45,34 @@ export function createFeatureGate(pool: Pool) {
|
|
|
45
45
|
return cache;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Registration can be closed from the admin Settings ("Allow sign-up"). It is a
|
|
49
|
+
// site setting (`site.allowSignup`), so the row is absent until an admin turns
|
|
50
|
+
// it off — a missing/any-non-"false" value means open, matching the shipped
|
|
51
|
+
// default. Cached the same way as the feature flags.
|
|
52
|
+
let signupOpen = true;
|
|
53
|
+
let signupFetchedAt = 0;
|
|
54
|
+
async function isSignupOpen(): Promise<boolean> {
|
|
55
|
+
if (Date.now() - signupFetchedAt < CACHE_TTL_MS) return signupOpen;
|
|
56
|
+
try {
|
|
57
|
+
const res = await pool.query<{ value: string }>(
|
|
58
|
+
'SELECT "value" FROM "app_setting" WHERE "key" = $1',
|
|
59
|
+
["site.allowSignup"],
|
|
60
|
+
);
|
|
61
|
+
signupOpen = res.rows[0]?.value !== "false";
|
|
62
|
+
} catch {
|
|
63
|
+
signupOpen = true;
|
|
64
|
+
}
|
|
65
|
+
signupFetchedAt = Date.now();
|
|
66
|
+
return signupOpen;
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
return createAuthMiddleware(async (ctx) => {
|
|
70
|
+
// Block email/password registration when sign-up is closed. Social/invite
|
|
71
|
+
// flows are unaffected; admins create users from the dashboard.
|
|
72
|
+
if (ctx.path === "/sign-up/email" || ctx.path.startsWith("/sign-up/")) {
|
|
73
|
+
if (!(await isSignupOpen())) throw new APIError("FORBIDDEN", { message: "Sign-up is disabled." });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
49
76
|
const feature = FEATURE_PATHS.find((f) =>
|
|
50
77
|
f.prefixes.some((p) => ctx.path === p || ctx.path.startsWith(p)),
|
|
51
78
|
);
|
|
@@ -91,6 +91,12 @@
|
|
|
91
91
|
"file": "apps/api/src/app.module.ts",
|
|
92
92
|
"marker": "// podokit:end:providers",
|
|
93
93
|
"text": "{ provide: APP_GUARD, useClass: AuthGuard },"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"file": "AGENTS.md",
|
|
97
|
+
"marker": "<!-- podokit:end:agents-modules -->",
|
|
98
|
+
"optional": true,
|
|
99
|
+
"text": "### auth (better-auth)\n- The API is **secure by default**: every route needs a session except `/health` and `/api/auth/*`. Opt a route out with `@Public()`; read the user with `@Session()`.\n- Configure OAuth providers, SMTP, and the server toggles on the admin **Settings** page (DB-backed, applied live \u2014 not via env). Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required env vars.\n- Create the auth tables: `npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`.\n"
|
|
94
100
|
}
|
|
95
101
|
],
|
|
96
102
|
"instructions": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start:worker": "node dist/main-worker",
|
|
12
|
-
"dev:worker": "nest start --watch --entryFile main-worker"
|
|
12
|
+
"dev:worker": "nest start --watch --builder swc --entryFile main-worker"
|
|
13
13
|
},
|
|
14
14
|
"env": [
|
|
15
15
|
"# bullmq uses REDIS_HOST / REDIS_PORT (already defined above)"
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The API tsconfig restricts `types` to ["node"], which suppresses @types/multer's
|
|
2
|
+
// global augmentation of Express.Multer.File. Load it explicitly so the type resolves.
|
|
3
|
+
/// <reference types="multer" />
|
|
1
4
|
import { BadRequestException, Controller, Get, Param, Post, UploadedFile, UseInterceptors } from "@nestjs/common";
|
|
2
5
|
import { FileInterceptor } from "@nestjs/platform-express";
|
|
3
6
|
import { ApiConsumes, ApiTags } from "@nestjs/swagger";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# AGENTS.md — {{projectName}}
|
|
2
|
+
|
|
3
|
+
Guidance for AI coding agents (Claude Code, Codex, Cursor, Copilot, …) working in
|
|
4
|
+
this repository. It was generated by [PodoKit](https://github.com/podosoft-dev/podokit).
|
|
5
|
+
Follow it so your changes match the project's conventions.
|
|
6
|
+
|
|
7
|
+
## Project overview
|
|
8
|
+
|
|
9
|
+
A full-stack TypeScript monorepo (npm workspaces):
|
|
10
|
+
|
|
11
|
+
- `apps/api` — **NestJS** backend (`{{projectName}}-api`), TypeORM + PostgreSQL, Swagger at `/api-docs`.
|
|
12
|
+
- `apps/web` — **SvelteKit** frontend (`{{projectName}}-web`), Tailwind v4 + shadcn-svelte + typesafe-i18n.
|
|
13
|
+
- `infra/` — Docker Compose (PostgreSQL/Redis) and example k3s manifests.
|
|
14
|
+
- `.podokit/` — PodoKit's generation lockfile (see "PodoKit tooling" below). Do not edit by hand.
|
|
15
|
+
- **Todo example**: `apps/api/src/todos/` (controller/service/entity/DTO + migration) and the todo UI in `apps/web/src/routes/` show the end-to-end patterns to follow.
|
|
16
|
+
|
|
17
|
+
## Setup & commands
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install
|
|
21
|
+
cp .env.example .env
|
|
22
|
+
docker compose -f infra/docker/docker-compose.yml up -d # PostgreSQL (+ Redis)
|
|
23
|
+
npm run dev # api on http://localhost:5002, web on http://localhost:5001
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **Ports: web = 5001, api = 5002.** The web app reaches the API only through its
|
|
27
|
+
SvelteKit server proxy (`/api/*`), never with a direct browser `fetch`.
|
|
28
|
+
- Build: `npm run build` · Type-check/lint: `npm run lint` · Unit tests: `npm test`
|
|
29
|
+
- Per workspace: `npm run build -w {{projectName}}-api` / `-w {{projectName}}-web`.
|
|
30
|
+
- DB migrations (TypeORM): `npm run migration:run -w {{projectName}}-api`.
|
|
31
|
+
- e2e (Playwright, ships in `tests/`): `npm run test:e2e`.
|
|
32
|
+
|
|
33
|
+
**Always** verify a change by building the affected workspace (and `svelte-check`
|
|
34
|
+
for web) before considering it done. UI changes need a Playwright test; API
|
|
35
|
+
changes need an e2e/unit test.
|
|
36
|
+
|
|
37
|
+
## Code style
|
|
38
|
+
|
|
39
|
+
### TypeScript (both apps)
|
|
40
|
+
- `strict` mode. **No `any`** — use `unknown` + narrowing. **No `@ts-ignore`/`@ts-expect-error`** and no `eslint-disable`.
|
|
41
|
+
- Explicit function return types. DTOs use `class-validator` decorators.
|
|
42
|
+
- Keep `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noUncheckedIndexedAccess`.
|
|
43
|
+
|
|
44
|
+
### Svelte 5 (web)
|
|
45
|
+
- **Runes only**: `$state()`, `$derived()`, `$effect()`, `$props()`. **Never** use `$:` (deprecated).
|
|
46
|
+
- Reactive stores go in `.svelte.ts` files; don't destructure a getter-exported rune (it loses reactivity) — wrap in `$derived`.
|
|
47
|
+
|
|
48
|
+
### UI — Tailwind + shadcn-svelte (web)
|
|
49
|
+
- Prefer **shadcn-svelte components** over raw HTML (`<button>`, `<input>`, `<select>`, …).
|
|
50
|
+
- **Do not edit `src/lib/components/ui/**`** (the shadcn primitives). Customize in a separate wrapper.
|
|
51
|
+
- Tailwind v4 (CSS-first, no `tailwind.config`). Use semantic classes: `bg-background`, `text-foreground`, `border-border`. Dark mode via `mode-watcher`. Responsive (`sm:`/`md:`/`lg:`).
|
|
52
|
+
|
|
53
|
+
### i18n & errors
|
|
54
|
+
- **Every user-facing string goes through i18n** (`src/lib/i18n/messages.ts`) — no hardcoded text. Add keys to **all** locales at once.
|
|
55
|
+
- The backend returns a stable **error envelope**: `{ success: false, error: { code, message, statusCode, path, timestamp, details? } }`. Throw `AppException("SOME_CODE", ...)` on the backend; **branch on `error.code`, not the message,** on the frontend.
|
|
56
|
+
- Logging is structured; no emojis/icons. Start messages with an action verb.
|
|
57
|
+
|
|
58
|
+
## PodoKit tooling & file ownership
|
|
59
|
+
|
|
60
|
+
This project is managed by the `podo` CLI. Files have an ownership tier recorded
|
|
61
|
+
in `.podokit/files.lock` — respect it:
|
|
62
|
+
|
|
63
|
+
- **owned** (your code: `apps/web/src/routes/**`, your components/services, and this `AGENTS.md`) — edit freely.
|
|
64
|
+
- **assembled** (e.g. `apps/api/src/app.module.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
|
|
65
|
+
- **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
|
|
66
|
+
|
|
67
|
+
Useful commands:
|
|
68
|
+
|
|
69
|
+
- `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
|
|
70
|
+
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
71
|
+
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
72
|
+
|
|
73
|
+
## Commit conventions
|
|
74
|
+
|
|
75
|
+
Conventional Commits (`feat|fix|docs|refactor|test|chore`), imperative mood,
|
|
76
|
+
≤50-char subject, no trailing period, no emojis. One logical change per commit.
|
|
77
|
+
|
|
78
|
+
<!-- podokit:begin:agents-modules -->
|
|
79
|
+
<!-- Module-specific guidance is injected here by `podo add`. -->
|
|
80
|
+
<!-- podokit:end:agents-modules -->
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Development image for the PodoKit monorepo (web + api).
|
|
2
|
+
# Dependencies and source are baked in; `docker compose watch` syncs your
|
|
3
|
+
# edits into the running container so the native file watchers fire.
|
|
4
|
+
FROM node:22-bookworm-slim
|
|
5
|
+
|
|
6
|
+
# git for the toolchain (editors, AI agents); build tools for native modules.
|
|
7
|
+
RUN apt-get update \
|
|
8
|
+
&& apt-get install -y --no-install-recommends git python3 make g++ ca-certificates \
|
|
9
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
10
|
+
|
|
11
|
+
WORKDIR /app
|
|
12
|
+
|
|
13
|
+
# Install workspace dependencies (cached unless a package.json changes).
|
|
14
|
+
# NB: the host package-lock.json is intentionally NOT copied — a lockfile
|
|
15
|
+
# generated on macOS/Windows prunes platform-specific optional deps and can drop
|
|
16
|
+
# packages (e.g. @swc/cli) on linux. Resolving fresh in-container avoids that.
|
|
17
|
+
COPY package.json ./
|
|
18
|
+
COPY apps/api/package.json ./apps/api/package.json
|
|
19
|
+
COPY apps/web/package.json ./apps/web/package.json
|
|
20
|
+
RUN npm install
|
|
21
|
+
|
|
22
|
+
# Baked source (kept in sync during development by `docker compose watch`).
|
|
23
|
+
COPY . .
|
|
24
|
+
|
|
25
|
+
EXPOSE 5001 5002
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"version": "0.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
|
-
"dev": "nest start --watch",
|
|
6
|
+
"dev": "nest start --watch --builder swc",
|
|
7
7
|
"build": "nest build",
|
|
8
8
|
"start": "node dist/main",
|
|
9
9
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
"supertest": "^7.0.0",
|
|
41
41
|
"ts-jest": "^29.2.5",
|
|
42
42
|
"ts-node": "^10.9.2",
|
|
43
|
-
"typescript": "^6.0.3"
|
|
43
|
+
"typescript": "^6.0.3",
|
|
44
|
+
"@swc/core": "^1.7.0",
|
|
45
|
+
"@swc/cli": "^0.4.0"
|
|
44
46
|
},
|
|
45
47
|
"jest": {
|
|
46
48
|
"moduleFileExtensions": [
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{projectName}}</title>
|
|
7
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
6
8
|
%sveltekit.head%
|
|
7
9
|
</head>
|
|
8
10
|
<body data-sveltekit-preload-data="hover" class="h-full">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Root from "./tabs.svelte";
|
|
2
2
|
import Content from "./tabs-content.svelte";
|
|
3
|
-
import List
|
|
3
|
+
import List from "./tabs-list.svelte";
|
|
4
4
|
import Trigger from "./tabs-trigger.svelte";
|
|
5
5
|
|
|
6
6
|
export {
|
|
@@ -8,8 +8,6 @@ export {
|
|
|
8
8
|
Content,
|
|
9
9
|
List,
|
|
10
10
|
Trigger,
|
|
11
|
-
tabsListVariants,
|
|
12
|
-
type TabsListVariant,
|
|
13
11
|
//
|
|
14
12
|
Root as Tabs,
|
|
15
13
|
Content as TabsContent,
|
|
@@ -1,40 +1,20 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
import { tv, type VariantProps } from "tailwind-variants";
|
|
3
|
-
|
|
4
|
-
export const tabsListVariants = tv({
|
|
5
|
-
base: "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
|
6
|
-
variants: {
|
|
7
|
-
variant: {
|
|
8
|
-
default: "cn-tabs-list-variant-default bg-muted",
|
|
9
|
-
line: "cn-tabs-list-variant-line gap-1 bg-transparent",
|
|
10
|
-
},
|
|
11
|
-
},
|
|
12
|
-
defaultVariants: {
|
|
13
|
-
variant: "default",
|
|
14
|
-
},
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
export type TabsListVariant = VariantProps<typeof tabsListVariants>["variant"];
|
|
18
|
-
</script>
|
|
19
|
-
|
|
20
1
|
<script lang="ts">
|
|
21
2
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
|
22
3
|
import { cn } from "$lib/utils.js";
|
|
23
4
|
|
|
24
5
|
let {
|
|
25
6
|
ref = $bindable(null),
|
|
26
|
-
variant = "default",
|
|
27
7
|
class: className,
|
|
28
8
|
...restProps
|
|
29
|
-
}: TabsPrimitive.ListProps
|
|
30
|
-
variant?: TabsListVariant;
|
|
31
|
-
} = $props();
|
|
9
|
+
}: TabsPrimitive.ListProps = $props();
|
|
32
10
|
</script>
|
|
33
11
|
|
|
34
12
|
<TabsPrimitive.List
|
|
35
13
|
bind:ref
|
|
36
14
|
data-slot="tabs-list"
|
|
37
|
-
|
|
38
|
-
|
|
15
|
+
class={cn(
|
|
16
|
+
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
|
17
|
+
className
|
|
18
|
+
)}
|
|
39
19
|
{...restProps}
|
|
40
20
|
/>
|
|
@@ -13,10 +13,7 @@
|
|
|
13
13
|
bind:ref
|
|
14
14
|
data-slot="tabs-trigger"
|
|
15
15
|
class={cn(
|
|
16
|
-
"
|
|
17
|
-
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
|
18
|
-
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
|
19
|
-
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
|
16
|
+
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
20
17
|
className
|
|
21
18
|
)}
|
|
22
19
|
{...restProps}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="PodoKit">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="pk" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#8b5cf6"/>
|
|
5
|
+
<stop offset="1" stop-color="#4f46e5"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="64" height="64" rx="15" fill="url(#pk)"/>
|
|
9
|
+
<!-- layered stack: full-stack toolkit -->
|
|
10
|
+
<path d="M32 14 50 25 32 36 14 25Z" fill="#fff" fill-opacity="0.96"/>
|
|
11
|
+
<path d="M16 31 32 40.5 48 31" fill="none" stroke="#fff" stroke-opacity="0.78" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
<path d="M16 38 32 47.5 48 38" fill="none" stroke="#fff" stroke-opacity="0.5" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
13
|
+
</svg>
|
|
@@ -2,6 +2,18 @@ import { sveltekit } from "@sveltejs/kit/vite";
|
|
|
2
2
|
import tailwindcss from "@tailwindcss/vite";
|
|
3
3
|
import { defineConfig } from "vite";
|
|
4
4
|
|
|
5
|
+
// Inside the dev container (compose.dev.yaml sets VITE_DOCKER=1): bind to all
|
|
6
|
+
// interfaces, allow the Traefik hostname, and route HMR through Traefik on :80.
|
|
7
|
+
const inDocker = process.env.VITE_DOCKER === "1";
|
|
8
|
+
|
|
5
9
|
export default defineConfig({
|
|
6
10
|
plugins: [tailwindcss(), sveltekit()],
|
|
11
|
+
server: inDocker
|
|
12
|
+
? {
|
|
13
|
+
host: true,
|
|
14
|
+
port: 5001,
|
|
15
|
+
allowedHosts: true,
|
|
16
|
+
hmr: { clientPort: 80 },
|
|
17
|
+
}
|
|
18
|
+
: undefined,
|
|
7
19
|
});
|