@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Containerized development environment.
|
|
2
|
+
#
|
|
3
|
+
# docker compose -f compose.dev.yaml watch # core stack
|
|
4
|
+
# docker compose -f compose.dev.yaml --profile cache \
|
|
5
|
+
# --profile storage --profile queue watch # with modules
|
|
6
|
+
#
|
|
7
|
+
# Only Traefik binds a host port (80). The app is at http://app.localhost.
|
|
8
|
+
# api/postgres/redis/minio have NO published ports — they talk by service name
|
|
9
|
+
# on the internal network, so this stack never collides with other projects.
|
|
10
|
+
#
|
|
11
|
+
# Core services (traefik, web, api, postgres) always run. Module-specific
|
|
12
|
+
# services are behind profiles — enable the ones your app uses:
|
|
13
|
+
# --profile cache redis (redis / rate-limit / bullmq modules)
|
|
14
|
+
# --profile storage minio (object-storage-s3 / file-upload modules)
|
|
15
|
+
# --profile queue worker (bullmq / job-progress modules)
|
|
16
|
+
#
|
|
17
|
+
# `watch` syncs your edits into the containers: the web has instant vite HMR;
|
|
18
|
+
# an API source change restarts the api service (~5s), which is the stable
|
|
19
|
+
# approach for nest inside a container.
|
|
20
|
+
name: {{projectName}}-dev
|
|
21
|
+
|
|
22
|
+
services:
|
|
23
|
+
traefik:
|
|
24
|
+
image: traefik:v3.5
|
|
25
|
+
command:
|
|
26
|
+
- --providers.file.filename=/etc/traefik/dynamic.yml
|
|
27
|
+
- --providers.file.watch=true
|
|
28
|
+
- --entrypoints.web.address=:80
|
|
29
|
+
- --api.dashboard=true
|
|
30
|
+
- --api.insecure=true
|
|
31
|
+
ports:
|
|
32
|
+
- "80:80"
|
|
33
|
+
- "127.0.0.1:8080:8080" # Traefik dashboard (dev only)
|
|
34
|
+
volumes:
|
|
35
|
+
- ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
|
36
|
+
networks: [podokit]
|
|
37
|
+
|
|
38
|
+
web:
|
|
39
|
+
build:
|
|
40
|
+
context: .
|
|
41
|
+
dockerfile: Dockerfile.dev
|
|
42
|
+
command: npm run dev -w {{projectName}}-web -- --host 0.0.0.0
|
|
43
|
+
working_dir: /app
|
|
44
|
+
env_file: .env.docker
|
|
45
|
+
environment:
|
|
46
|
+
- VITE_DOCKER=1
|
|
47
|
+
- BACKEND_INTERNAL_URL=http://api:5002
|
|
48
|
+
depends_on: [api]
|
|
49
|
+
networks: [podokit]
|
|
50
|
+
develop:
|
|
51
|
+
watch:
|
|
52
|
+
- { action: sync, path: ./apps/web/src, target: /app/apps/web/src }
|
|
53
|
+
- { action: sync, path: ./apps/web/static, target: /app/apps/web/static }
|
|
54
|
+
- { action: sync+restart, path: ./apps/web/vite.config.ts, target: /app/apps/web/vite.config.ts }
|
|
55
|
+
- { action: rebuild, path: ./apps/web/package.json }
|
|
56
|
+
|
|
57
|
+
api:
|
|
58
|
+
build:
|
|
59
|
+
context: .
|
|
60
|
+
dockerfile: Dockerfile.dev
|
|
61
|
+
init: true
|
|
62
|
+
command: npm run dev -w {{projectName}}-api
|
|
63
|
+
working_dir: /app
|
|
64
|
+
env_file: .env.docker
|
|
65
|
+
depends_on:
|
|
66
|
+
postgres: { condition: service_healthy }
|
|
67
|
+
networks: [podokit]
|
|
68
|
+
develop:
|
|
69
|
+
watch:
|
|
70
|
+
# nest-cli's in-container watch does not reliably respawn the app process,
|
|
71
|
+
# so restart the service on an API source change (~5s). The Docker docs'
|
|
72
|
+
# sync+restart is the stable, recommended approach; the web keeps instant
|
|
73
|
+
# HMR via plain sync.
|
|
74
|
+
- { action: sync+restart, path: ./apps/api/src, target: /app/apps/api/src }
|
|
75
|
+
- { action: rebuild, path: ./apps/api/package.json }
|
|
76
|
+
|
|
77
|
+
worker:
|
|
78
|
+
# started only with the bullmq / job-progress modules: --profile queue
|
|
79
|
+
profiles: [queue]
|
|
80
|
+
build:
|
|
81
|
+
context: .
|
|
82
|
+
dockerfile: Dockerfile.dev
|
|
83
|
+
init: true
|
|
84
|
+
command: npm run dev:worker -w {{projectName}}-api
|
|
85
|
+
working_dir: /app
|
|
86
|
+
env_file: .env.docker
|
|
87
|
+
depends_on:
|
|
88
|
+
redis: { condition: service_started }
|
|
89
|
+
networks: [podokit]
|
|
90
|
+
develop:
|
|
91
|
+
watch:
|
|
92
|
+
# nest-cli's in-container watch does not reliably respawn the app process,
|
|
93
|
+
# so restart the service on an API source change (~5s). The Docker docs'
|
|
94
|
+
# sync+restart is the stable, recommended approach; the web keeps instant
|
|
95
|
+
# HMR via plain sync.
|
|
96
|
+
- { action: sync+restart, path: ./apps/api/src, target: /app/apps/api/src }
|
|
97
|
+
- { action: rebuild, path: ./apps/api/package.json }
|
|
98
|
+
|
|
99
|
+
postgres:
|
|
100
|
+
image: postgres:16-alpine
|
|
101
|
+
environment:
|
|
102
|
+
POSTGRES_USER: podokit
|
|
103
|
+
POSTGRES_PASSWORD: podokit
|
|
104
|
+
POSTGRES_DB: podokit
|
|
105
|
+
healthcheck:
|
|
106
|
+
test: ["CMD-SHELL", "pg_isready -U podokit"]
|
|
107
|
+
interval: 3s
|
|
108
|
+
timeout: 3s
|
|
109
|
+
retries: 20
|
|
110
|
+
volumes:
|
|
111
|
+
- pgdata:/var/lib/postgresql/data
|
|
112
|
+
networks: [podokit] # no published ports — internal only
|
|
113
|
+
|
|
114
|
+
redis:
|
|
115
|
+
# started only with the redis / rate-limit / bullmq modules: --profile cache
|
|
116
|
+
profiles: [cache]
|
|
117
|
+
image: redis:7-alpine
|
|
118
|
+
networks: [podokit] # internal only
|
|
119
|
+
|
|
120
|
+
minio:
|
|
121
|
+
# started only with the object-storage-s3 / file-upload modules: --profile storage
|
|
122
|
+
profiles: [storage]
|
|
123
|
+
image: minio/minio:latest
|
|
124
|
+
command: server /data --console-address ":9001"
|
|
125
|
+
environment:
|
|
126
|
+
MINIO_ROOT_USER: podokit
|
|
127
|
+
MINIO_ROOT_PASSWORD: podokitsecret
|
|
128
|
+
volumes:
|
|
129
|
+
- miniodata:/data
|
|
130
|
+
networks: [podokit] # internal only
|
|
131
|
+
|
|
132
|
+
minio-init:
|
|
133
|
+
profiles: [storage]
|
|
134
|
+
image: minio/mc:latest
|
|
135
|
+
depends_on:
|
|
136
|
+
minio: { condition: service_started }
|
|
137
|
+
entrypoint:
|
|
138
|
+
- sh
|
|
139
|
+
- -c
|
|
140
|
+
- |
|
|
141
|
+
until mc alias set loc http://minio:9000 podokit podokitsecret; do sleep 1; done
|
|
142
|
+
mc mb -p loc/podokit || true
|
|
143
|
+
networks: [podokit]
|
|
144
|
+
|
|
145
|
+
networks:
|
|
146
|
+
podokit: {}
|
|
147
|
+
|
|
148
|
+
volumes:
|
|
149
|
+
pgdata: {}
|
|
150
|
+
miniodata: {}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-add-module
|
|
3
|
+
description: Use when the user wants a feature that a PodoKit module already provides (authentication, an admin dashboard, Redis, background jobs/queues, object storage, file uploads, SSE, rate limiting, logging). Prefer adding the module over hand-writing it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a PodoKit module
|
|
7
|
+
|
|
8
|
+
Before building auth, an admin panel, queues, storage, etc. by hand, check
|
|
9
|
+
whether a module provides it — modules wire themselves into the app correctly.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
podo add # list available modules
|
|
13
|
+
podo add <module> # e.g. auth, admin-dashboard, redis, bullmq, file-upload
|
|
14
|
+
npm install # install newly-added dependencies
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Common modules: `auth` (better-auth: email/password, sessions, OAuth, 2FA,
|
|
18
|
+
secure-by-default), `admin-dashboard` (user/session/org management + audit +
|
|
19
|
+
runtime settings, requires auth), `redis`, `bullmq` (queue + worker),
|
|
20
|
+
`job-progress` (SSE progress), `object-storage-s3`, `file-upload`, `sse`,
|
|
21
|
+
`rate-limit`, `logging`, `api-key-auth`.
|
|
22
|
+
|
|
23
|
+
Notes:
|
|
24
|
+
- A module auto-adds its required modules (e.g. `admin-dashboard` adds `auth`).
|
|
25
|
+
- After adding, follow the printed next steps (often DB migrations, e.g.
|
|
26
|
+
`npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts`).
|
|
27
|
+
- Module wiring lands inside `// podokit:` fenced regions — don't hand-edit those.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-nest-endpoint
|
|
3
|
+
description: Use when adding or changing a NestJS REST endpoint in apps/api — a controller/service with DTO validation, the standard error envelope, and a test. Follow this for any new backend route in a PodoKit project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a NestJS endpoint (PodoKit conventions)
|
|
7
|
+
|
|
8
|
+
1. **Module**: create/extend a Nest module under `apps/api/src/<feature>/`
|
|
9
|
+
(`<feature>.module.ts`, `.controller.ts`, `.service.ts`, `dto/`). Register the
|
|
10
|
+
module in `apps/api/src/app.module.ts` — add it **inside** the
|
|
11
|
+
`// podokit:begin:module-imports … end` fence, never outside it.
|
|
12
|
+
2. **DTOs**: validate every input with `class-validator` decorators on a DTO
|
|
13
|
+
class. No `any`; give explicit return types.
|
|
14
|
+
3. **Errors**: throw `AppException("STABLE_CODE", "message", statusCode)` (from
|
|
15
|
+
`@podosoft/podokit-contracts` / the app's `common/`). The global filter renders
|
|
16
|
+
it as `{ success: false, error: { code, message, statusCode, path, timestamp } }`.
|
|
17
|
+
Pick a stable, UPPER_SNAKE `code` — the frontend branches on it.
|
|
18
|
+
4. **Auth**: if the `auth` module is installed the API is secure by default; mark
|
|
19
|
+
public routes with `@Public()` and read the user with `@Session()`.
|
|
20
|
+
5. **Swagger**: annotate with `@ApiTags` so it shows under `/api-docs`.
|
|
21
|
+
6. **Test**: add an e2e/unit test under `apps/api` or the `tests/` workspace
|
|
22
|
+
(Nest e2e or Vitest + supertest). Verify with
|
|
23
|
+
`npm run build -w {{projectName}}-api` and the test.
|
package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-sveltekit-route/SKILL.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-sveltekit-route
|
|
3
|
+
description: Use when adding or changing a SvelteKit page/route in apps/web — pages that call the API, use shadcn-svelte components, and localize text. Follow this for any new frontend screen in a PodoKit project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a SvelteKit route (PodoKit conventions)
|
|
7
|
+
|
|
8
|
+
1. **Route**: add a folder under `apps/web/src/routes/`. Load data in
|
|
9
|
+
`+page.server.ts` / `+layout.server.ts` (SSR), not in the browser.
|
|
10
|
+
2. **Call the API only through the server proxy** — never a direct browser
|
|
11
|
+
`fetch` to `:5002`. Use the app's `ApiClient`: `$lib/api.ts` (browser,
|
|
12
|
+
same-origin `/api/*` proxy) and `$lib/server/api.ts` (SSR, internal URL +
|
|
13
|
+
cookie forwarding). It throws `ApiError` — branch on `err.code`.
|
|
14
|
+
3. **Svelte 5 runes only**: `$state`, `$derived`, `$effect`, `$props`. Never `$:`.
|
|
15
|
+
4. **UI**: use shadcn-svelte components (`$lib/components/ui/*`) over raw HTML.
|
|
16
|
+
Do **not** edit `ui/**` — wrap it if you need to customize. Semantic Tailwind
|
|
17
|
+
classes (`bg-background`, `text-foreground`); responsive (`sm:`/`md:`/`lg:`).
|
|
18
|
+
For any list/table, use the shared `DataTable` (see the podokit-data-table
|
|
19
|
+
skill, added with the admin-dashboard module).
|
|
20
|
+
5. **i18n**: no hardcoded user-facing strings — add keys to
|
|
21
|
+
`apps/web/src/lib/i18n/messages.ts` for **every** locale.
|
|
22
|
+
6. **Verify**: `npm run build -w {{projectName}}-web` (includes `svelte-check`)
|
|
23
|
+
and add/adjust a Playwright test in `tests/`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: podokit-update
|
|
3
|
+
description: Use when the user wants to update the project to a newer PodoKit version, pull in template/module improvements, check what they've changed vs the generated code, or take ownership of a managed file. Covers podo status/diff/doctor/update/eject.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Keep a PodoKit project up to date
|
|
7
|
+
|
|
8
|
+
The project records how it was assembled in `.podokit/` so the toolkit can update
|
|
9
|
+
its own files while keeping the user's changes.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
podo status # version, modules, file tiers, and how many managed files were edited
|
|
13
|
+
podo diff # list the PodoKit-managed files you've edited (owned files ignored)
|
|
14
|
+
podo doctor # check framework versions vs. the supported ranges
|
|
15
|
+
podo update # dry-run: preview what a newer version would change (writes nothing)
|
|
16
|
+
podo update --apply # apply it — clean updates are written; your edits are 3-way merged
|
|
17
|
+
podo eject <path> # take ownership of a managed file so update skips it
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Rules to respect:
|
|
21
|
+
- **owned** files (routes, your components, `AGENTS.md`, shadcn `ui/**`) are never
|
|
22
|
+
touched by `update`.
|
|
23
|
+
- **assembled** files are recomputed from the module set — keep edits **outside**
|
|
24
|
+
the `// podokit:begin…end` fences.
|
|
25
|
+
- Run `podo update` (dry-run) first and read the plan before `--apply`. Commit or
|
|
26
|
+
stash your work beforehand so a merge is easy to review.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: PodoKit project conventions (stack, commands, code style, tooling)
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The conventions for this project are documented in [`AGENTS.md`](../../AGENTS.md)
|
|
7
|
+
at the repository root. Follow them: web runs on :5001 and api on :5002; use
|
|
8
|
+
shadcn-svelte components and the shared `DataTable`/`TableToolbar` for lists;
|
|
9
|
+
Svelte 5 runes only; branch on the error `code`, not the message; keep strings in
|
|
10
|
+
i18n; and respect the `.podokit/` file-ownership tiers (never hand-edit
|
|
11
|
+
`// podokit:` fenced regions or `src/lib/components/ui/**`).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}} (containerized dev)",
|
|
3
|
+
"dockerComposeFile": ["../compose.dev.yaml"],
|
|
4
|
+
"service": "api",
|
|
5
|
+
"workspaceFolder": "/app",
|
|
6
|
+
"runServices": ["traefik", "web", "api", "worker", "postgres", "redis", "minio", "minio-init"],
|
|
7
|
+
"shutdownAction": "stopCompose",
|
|
8
|
+
"customizations": {
|
|
9
|
+
"vscode": {
|
|
10
|
+
"extensions": [
|
|
11
|
+
"svelte.svelte-vscode",
|
|
12
|
+
"dbaeumer.vscode-eslint",
|
|
13
|
+
"esbenp.prettier-vscode",
|
|
14
|
+
"bradlc.vscode-tailwindcss"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"forwardPorts": [],
|
|
19
|
+
"remoteUser": "node"
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
NODE_ENV=development
|
|
2
|
+
PORT=5002
|
|
3
|
+
POSTGRES_HOST=postgres
|
|
4
|
+
POSTGRES_PORT=5432
|
|
5
|
+
POSTGRES_USER=podokit
|
|
6
|
+
POSTGRES_PASSWORD=podokit
|
|
7
|
+
POSTGRES_DB=podokit
|
|
8
|
+
BACKEND_INTERNAL_URL=http://api:5002
|
|
9
|
+
CORS_ORIGIN=http://app.localhost
|
|
10
|
+
REDIS_HOST=redis
|
|
11
|
+
REDIS_PORT=6379
|
|
12
|
+
BETTER_AUTH_SECRET=change-me-in-container-min-32-characters
|
|
13
|
+
BETTER_AUTH_URL=http://app.localhost
|
|
14
|
+
ADMIN_EMAILS=admin@example.com
|
|
15
|
+
AUDIT_LOG_ENABLED=true
|
|
16
|
+
RATE_LIMIT_TTL=60
|
|
17
|
+
RATE_LIMIT_MAX=100
|
|
18
|
+
API_KEYS=dev-key-please-change
|
|
19
|
+
LOG_LEVEL=info
|
|
20
|
+
STORAGE_PROVIDER=minio
|
|
21
|
+
S3_ENDPOINT=http://minio:9000
|
|
22
|
+
S3_REGION=us-east-1
|
|
23
|
+
S3_BUCKET=podokit
|
|
24
|
+
S3_ACCESS_KEY_ID=podokit
|
|
25
|
+
S3_SECRET_ACCESS_KEY=podokitsecret
|
|
26
|
+
S3_FORCE_PATH_STYLE=true
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Copilot instructions
|
|
2
|
+
|
|
3
|
+
This project's coding conventions for AI agents live in [`AGENTS.md`](../AGENTS.md).
|
|
4
|
+
Read it before making changes — it covers the stack, commands (web :5001 / api
|
|
5
|
+
:5002), TypeScript/Svelte 5/shadcn-svelte rules, the error-code envelope, and the
|
|
6
|
+
`podo` tooling.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Traefik dynamic config (file provider). Routes the browser-facing web app;
|
|
2
|
+
# api/postgres/redis/minio stay internal (reached by service name).
|
|
3
|
+
http:
|
|
4
|
+
routers:
|
|
5
|
+
web:
|
|
6
|
+
rule: "PathPrefix(`/`)" # any host on this dedicated proxy -> web
|
|
7
|
+
entryPoints: [web]
|
|
8
|
+
service: web
|
|
9
|
+
services:
|
|
10
|
+
web:
|
|
11
|
+
loadBalancer:
|
|
12
|
+
servers:
|
|
13
|
+
- url: "http://web:5001"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/// <reference types="multer" />
|
|
2
|
+
import {
|
|
3
|
+
BadRequestException,
|
|
4
|
+
Body,
|
|
5
|
+
Controller,
|
|
6
|
+
ForbiddenException,
|
|
7
|
+
Get,
|
|
8
|
+
Post,
|
|
9
|
+
Put,
|
|
10
|
+
StreamableFile,
|
|
11
|
+
UploadedFile,
|
|
12
|
+
UseInterceptors,
|
|
13
|
+
} from "@nestjs/common";
|
|
14
|
+
import { FileInterceptor } from "@nestjs/platform-express";
|
|
15
|
+
import { ApiConsumes, ApiTags } from "@nestjs/swagger";
|
|
16
|
+
import { Public, Session, type UserSession } from "@thallesp/nestjs-better-auth";
|
|
17
|
+
import { PUBLIC_SITE_KEYS, SiteSettingsService } from "./site-settings.service";
|
|
18
|
+
|
|
19
|
+
function isAdmin(session: UserSession): boolean {
|
|
20
|
+
const role = session.user?.role;
|
|
21
|
+
const roles = Array.isArray(role) ? role : (role ?? "").split(",").map((r: string) => r.trim());
|
|
22
|
+
return roles.includes("admin");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ALLOWED = new Set<string>(PUBLIC_SITE_KEYS);
|
|
26
|
+
const MAX_ICON_BYTES = 1024 * 1024; // 1 MB
|
|
27
|
+
const ICON_TYPES = new Set(["image/svg+xml", "image/png", "image/x-icon", "image/vnd.microsoft.icon"]);
|
|
28
|
+
|
|
29
|
+
@ApiTags("site")
|
|
30
|
+
@Controller("site")
|
|
31
|
+
export class SiteSettingsController {
|
|
32
|
+
constructor(private readonly site: SiteSettingsService) {}
|
|
33
|
+
|
|
34
|
+
/** Public: the site branding every page needs (title, favicon presence, …). */
|
|
35
|
+
@Public()
|
|
36
|
+
@Get("settings")
|
|
37
|
+
async settings(): Promise<Record<string, string | boolean | null>> {
|
|
38
|
+
const all = await this.site.getAll();
|
|
39
|
+
const out: Record<string, string | boolean | null> = {};
|
|
40
|
+
for (const key of PUBLIC_SITE_KEYS) out[key] = all[key] ?? null;
|
|
41
|
+
out.hasFavicon = (await this.site.get("faviconContentType")) !== null;
|
|
42
|
+
out.faviconVersion = await this.site.faviconVersion();
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Admin: update one or more site settings. Unknown keys are rejected. */
|
|
47
|
+
@Put("settings")
|
|
48
|
+
async update(
|
|
49
|
+
@Session() session: UserSession,
|
|
50
|
+
@Body() body: Record<string, string>,
|
|
51
|
+
): Promise<Record<string, string>> {
|
|
52
|
+
if (!isAdmin(session)) throw new ForbiddenException("Admins only");
|
|
53
|
+
const update: Record<string, string> = {};
|
|
54
|
+
for (const [key, value] of Object.entries(body ?? {})) {
|
|
55
|
+
if (!ALLOWED.has(key)) throw new BadRequestException(`Unknown setting: ${key}`);
|
|
56
|
+
update[key] = String(value ?? "");
|
|
57
|
+
}
|
|
58
|
+
return this.site.setMany(update);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Admin: upload the browser icon. Stored in object storage; served below. */
|
|
62
|
+
@Post("favicon")
|
|
63
|
+
@ApiConsumes("multipart/form-data")
|
|
64
|
+
@UseInterceptors(FileInterceptor("file"))
|
|
65
|
+
async uploadFavicon(
|
|
66
|
+
@Session() session: UserSession,
|
|
67
|
+
@UploadedFile() file?: Express.Multer.File,
|
|
68
|
+
): Promise<{ version: string | null }> {
|
|
69
|
+
if (!isAdmin(session)) throw new ForbiddenException("Admins only");
|
|
70
|
+
if (!file) throw new BadRequestException("A file field is required (multipart/form-data)");
|
|
71
|
+
if (!ICON_TYPES.has(file.mimetype)) {
|
|
72
|
+
throw new BadRequestException("Icon must be SVG, PNG, or ICO");
|
|
73
|
+
}
|
|
74
|
+
if (file.size > MAX_ICON_BYTES) throw new BadRequestException("Icon must be 1 MB or smaller");
|
|
75
|
+
await this.site.setFavicon(file.buffer, file.mimetype);
|
|
76
|
+
return { version: await this.site.faviconVersion() };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Public: serve the current favicon from object storage (stable URL, no expiry). */
|
|
80
|
+
@Public()
|
|
81
|
+
@Get("favicon")
|
|
82
|
+
async favicon(): Promise<StreamableFile> {
|
|
83
|
+
const icon = await this.site.getFavicon();
|
|
84
|
+
if (!icon) throw new BadRequestException("No favicon set");
|
|
85
|
+
return new StreamableFile(icon.body, { type: icon.contentType });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
2
|
+
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
3
|
+
import { AppSetting } from "../settings/app-setting.entity";
|
|
4
|
+
import { SiteSettingsController } from "./site-settings.controller";
|
|
5
|
+
import { SiteSettingsService } from "./site-settings.service";
|
|
6
|
+
|
|
7
|
+
// StorageService comes from the global StorageModule (object-storage-s3).
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [TypeOrmModule.forFeature([AppSetting])],
|
|
10
|
+
controllers: [SiteSettingsController],
|
|
11
|
+
providers: [SiteSettingsService],
|
|
12
|
+
exports: [SiteSettingsService],
|
|
13
|
+
})
|
|
14
|
+
export class SiteSettingsModule {}
|
|
@@ -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/components/app-sidebar.svelte
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
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";
|
|
12
13
|
import type { SessionUser } from "../../app.d.ts";
|
|
13
14
|
import type { Messages } from "$lib/i18n/messages";
|
|
@@ -60,6 +61,18 @@
|
|
|
60
61
|
</Sidebar.Group>
|
|
61
62
|
</Sidebar.Content>
|
|
62
63
|
<Sidebar.Footer>
|
|
64
|
+
<Sidebar.Menu>
|
|
65
|
+
<Sidebar.MenuItem>
|
|
66
|
+
<Sidebar.MenuButton tooltipContent={i18n.t.nav.home}>
|
|
67
|
+
{#snippet child({ props })}
|
|
68
|
+
<a href="/" {...props}>
|
|
69
|
+
<HouseIcon />
|
|
70
|
+
<span>{i18n.t.nav.home}</span>
|
|
71
|
+
</a>
|
|
72
|
+
{/snippet}
|
|
73
|
+
</Sidebar.MenuButton>
|
|
74
|
+
</Sidebar.MenuItem>
|
|
75
|
+
</Sidebar.Menu>
|
|
63
76
|
<SidebarUserMenu {user} />
|
|
64
77
|
</Sidebar.Footer>
|
|
65
78
|
</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}
|