PocketBase gains optional, manually-authored English fields on articles and categories (same record, same slug), and the frontend serves an /en counterpart of every dynamic route via thin page wrappers around shared view components — no i18n library, consistent with the project's existing minimalism stance for a two-locale site with ~20 UI strings. An article/category with no translation 404s cleanly on its own /en detail page and is filtered out of /en listings, and the header's language-switch link falls back to the English blog index rather than a dead link; resolving that requires a global route middleware, since the layout's header renders before the page content in document order and so can't react to state a page component sets during its own async setup.
100 lines
6.2 KiB
Markdown
100 lines
6.2 KiB
Markdown
# 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, max 10000 — 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), `titleEn`/`contentEn`/`coverAltEn` (text, optional — see [English content](#english-content) below) |
|
|
| **categories** | `name` (text, required, unique), `slug` (text, required, unique, kebab-case pattern), `nameEn` (text, optional — see [English content](#english-content) below) |
|
|
|
|
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)).
|
|
|
|
## English content
|
|
|
|
The site is now bilingual (Italian, the source language, and English), but PocketBase has no
|
|
localization feature and no separate translation workflow was introduced for it: `titleEn`,
|
|
`contentEn`, `coverAltEn` on `articles` and `nameEn` on `categories` are plain, optional text
|
|
fields living on the **same record** as their Italian counterparts — same slug, same cover, same
|
|
`category` relation, same `publishedAt`. Added by migration
|
|
`pocketbase/pb_migrations/1757500002_add_english_fields.js`, which alters the two existing
|
|
collections (`app.findCollectionByNameOrId` + `collection.fields.add(...)` /
|
|
`.removeByName(...)` on the down-migration) rather than recreating them — the first example of
|
|
that pattern in this repo, since until now every migration only ever created a collection.
|
|
|
|
Translations are **written manually** by the editor in the admin UI, at their own pace — there is
|
|
no auto-translation step. Consequently:
|
|
|
|
- An article/category with empty `titleEn`/`contentEn`/`nameEn` simply has no English version yet.
|
|
This is normal, not an error state.
|
|
- `listRule`/`viewRule` are unchanged: whether a record is *published* is still governed solely by
|
|
`publishedAt`, independent of translation completeness. Whether it's *offered in English* is
|
|
decided by the Nitro layer (`frontend/server/routes/content/*`), not by a PocketBase rule — see
|
|
[frontend.md](./frontend.md#english-content).
|
|
- There's no `publishedAtEn` or similar: translation is all-or-nothing at the field level, not a
|
|
separate publish gate. If partial/staged English publishing is ever needed, that's the natural
|
|
next field to add — don't build it speculatively now.
|
|
|
|
## 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.
|