# Content model & admin panel ## Collections Defined as code in `pocketbase/pb_migrations/*.js`, applied automatically the first time PocketBase boots against an empty data directory (and on every subsequent deploy that adds new migration files). No custom Go/JS hooks exist for either collection — schema and API rules only. | Collection | Fields | |---|---| | **articles** | `title` (text, required, max 160), `slug` (text, required, unique, kebab-case pattern), `content` (text — Markdown source, not the WYSIWYG `editor` field type), `cover` (single file, images only), `coverAlt` (text, alt text), `category` (relation to `categories`), `publishedAt` (date, nullable), `authorName` (text) | | **categories** | `name` (text, required, unique), `slug` (text, required, unique, kebab-case pattern) | Deliberately minimal: no tags, no separate SEO fields. Don't add these back without a concrete need — see `CLAUDE.md`: - Meta description is derived at request time from the article body (`summarise()` in `frontend/server/utils/pocketbase.ts`). - Publish date is `publishedAt`. - Social preview image is the cover. - The article byline is `authorName`, a plain field the editor fills in — PocketBase has no built-in "creator" metadata to auto-populate it from, unlike Strapi's `createdBy`. There is no native Draft & Publish in PocketBase. `publishedAt` reproduces its semantics explicitly: empty = draft (never returned by the public API), set and not in the future = published. This is enforced by the collection's `listRule`/`viewRule` (`publishedAt != '' && publishedAt <= @now`), not by application code — an unpublished or future-dated article is invisible to `GET /api/collections/articles/records` regardless of what Nitro asks for. Categories have no draft state (`slug` is unique so a category always exists or doesn't). Public URLs always use the slug, never the PocketBase record id. PocketBase file fields store only a filename, no width/height/alt metadata — that's why `coverAlt` is a real field rather than upload metadata, and why the frontend's `MediaImage` type carries no dimensions (see [frontend.md](./frontend.md)). ## Admin login & permissions - The admin panel lives at `/_/` (also reachable via `/admin`, which just redirects there), routed by Caddy straight to the `pocketbase` container ([architecture.md](./architecture.md#path-routing-caddy)). It's PocketBase's standard superuser email/password auth — no custom auth code in this repo. - Public visitors **never** authenticate. There is no visitor account system, no comments, no public write access of any kind. - Each collection's `createRule`/`updateRule`/`deleteRule` is `null` — writes are superuser-only, full stop. There is no separate "editor" role: the site owner is the only superuser, matching "no front-end user accounts should ever exist." - `listRule`/`viewRule` on `articles` are `publishedAt != '' && publishedAt <= @now` (public read of published content only); on `categories` they're `''` (public read, unconditional — categories aren't drafted). - `cover`'s field config restricts uploads to images (PNG/JPEG/WEBP/AVIF/GIF), 10MB max — the equivalent of Strapi's old upload-plugin MIME allowlist, now expressed per-field instead of globally. - Any rule change beyond this is a deliberate least-privilege boundary, not an oversight, and needs to be justified explicitly. ## Editorial workflow 1. Log into `PUBLIC_SITE_URL/admin` (redirects to `/_/`). 2. Create/edit a Category if needed. 3. Create/edit an Article: title, slug, Markdown content, cover image + alt text, category, author name. 4. Set `publishedAt` (to now, or a past/future date) to publish it. Leaving it empty keeps the article a draft, invisible to the public API. 5. The change is live immediately: the public site has no cache layer to invalidate (see [architecture.md](./architecture.md#request-flow-reading-an-article)). ## Why Markdown, not a rich-text/WYSIWYG field `content` is a plain PocketBase `text` field, not the `editor` field type (which stores HTML). PocketBase stores the raw Markdown; conversion to HTML happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never in PocketBase and never in the browser. This keeps `marked` out of the client bundle and keeps HTML generation in one place. There is no sanitization step: this is intentional, since the only author is the trusted superuser, not arbitrary users.