Compare commits
11
Commits
temp
..
1247a2ca07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1247a2ca07 | ||
|
|
364fda3980 | ||
|
|
33b49c5dbb | ||
|
|
847edc7397 | ||
|
|
7ce073d2a9 | ||
|
|
91181f2170 | ||
|
|
6609e3ae28 | ||
|
|
49c42ecc96 | ||
|
|
91540224a9 | ||
|
|
47a8e7788f | ||
|
|
0d56769388 |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"claude-md-management@claude-plugins-official": true,
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
||||
}
|
||||
}
|
||||
+13
-18
@@ -4,25 +4,20 @@
|
||||
PUBLIC_DOMAIN=blog.localhost
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# --- Database ---
|
||||
POSTGRES_DB=blog
|
||||
POSTGRES_USER=blog
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
||||
# --- Strapi ---
|
||||
# Generate each secret with: openssl rand -base64 32
|
||||
APP_KEYS=change-me-1,change-me-2
|
||||
API_TOKEN_SALT=change-me
|
||||
ADMIN_JWT_SECRET=change-me
|
||||
TRANSFER_TOKEN_SALT=change-me
|
||||
JWT_SECRET=change-me
|
||||
ENCRYPTION_KEY=change-me
|
||||
# --- PocketBase ---
|
||||
# Bootstraps (or updates, on restart) the initial superuser account — the
|
||||
# only login the admin UI accepts. Pick a real password (10+ chars); never
|
||||
# committed as anything but this placeholder.
|
||||
POCKETBASE_ADMIN_EMAIL=admin@example.com
|
||||
POCKETBASE_ADMIN_PASSWORD=change-me-1234
|
||||
|
||||
# --- Frontend ---
|
||||
# Server-side only: internal Docker address of Strapi.
|
||||
STRAPI_URL=http://cms:1337
|
||||
# Server-side only: internal Docker address of PocketBase.
|
||||
POCKETBASE_URL=http://pocketbase:8090
|
||||
# Public base URL of the website, used for canonical URLs and Open Graph.
|
||||
PUBLIC_SITE_URL=https://blog.localhost
|
||||
# Public base URL of Strapi, used to build absolute media URLs in the browser.
|
||||
# Same origin as PUBLIC_SITE_URL: Caddy proxies /admin and /uploads to Strapi.
|
||||
PUBLIC_STRAPI_URL=https://blog.localhost
|
||||
# Public base URL of PocketBase, used to build absolute cover-image URLs.
|
||||
# Same origin as PUBLIC_SITE_URL: Caddy proxies /_/* and /api/* there (the
|
||||
# admin UI is at PUBLIC_POCKETBASE_URL/_/, reachable from PUBLIC_SITE_URL/admin
|
||||
# too — see caddy/Caddyfile).
|
||||
PUBLIC_POCKETBASE_URL=https://blog.localhost
|
||||
|
||||
+13
-8
@@ -2,6 +2,10 @@
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
# symlink to frontend/node_modules (created by `npm run pretest:e2e`); the
|
||||
# trailing-slash pattern above doesn't match a symlink even when it points to
|
||||
# a directory
|
||||
tests/node_modules
|
||||
|
||||
# env / secrets
|
||||
.env
|
||||
@@ -21,19 +25,20 @@ frontend/dist/
|
||||
.nitro/
|
||||
.cache/
|
||||
|
||||
# strapi
|
||||
cms/.strapi/
|
||||
cms/dist/
|
||||
cms/build/
|
||||
cms/.tmp/
|
||||
cms/public/uploads/*
|
||||
!cms/public/uploads/.gitkeep
|
||||
cms/.strapi-updater.json
|
||||
# playwright
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
|
||||
# nuxt module-setup marker (regenerated, not meaningful project config)
|
||||
frontend/.nuxtrc
|
||||
|
||||
# caddy runtime
|
||||
caddy/data/
|
||||
caddy/config/
|
||||
|
||||
# pocketbase local dev data (bind-mounted, docker-compose.dev.yml)
|
||||
pocketbase/pb_data/
|
||||
|
||||
# logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -10,45 +10,58 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Stato
|
||||
|
||||
Struttura, content type, pagine blog, Docker e Caddy sono in piedi. Restano da fare:
|
||||
sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora configurato).
|
||||
Struttura, content type, pagine blog, Docker, Caddy e i test (unit/integration/e2e) sono in
|
||||
piedi. Restano da fare: sitemap e `robots.txt` dinamici, ricerca.
|
||||
|
||||
## Architettura
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /admin e i path dei plugin Strapi → Strapi 5 → PostgreSQL
|
||||
└─ tutto il resto → Nuxt 4 (SSR) → REST Strapi
|
||||
Browser → Caddy ─┬─ /_/* e /api/* → PocketBase (admin UI + API)
|
||||
└─ tutto il resto → Nuxt 4 (SSR) → REST PocketBase (interno)
|
||||
("/admin" fa redirect a /_/, gestito da Nitro)
|
||||
```
|
||||
|
||||
Caddy instrada per **path**, non per sottodominio: `PUBLIC_DOMAIN` serve sia il sito che il
|
||||
pannello Strapi. Ogni plugin Strapi monta la propria API admin sul proprio path di primo
|
||||
livello, non tutto sotto `/admin` (es. `/content-manager`, `/upload`, `/i18n`...): l'elenco
|
||||
completo dei prefissi da instradare a Strapi vive nel `Caddyfile`. Se aggiungi un plugin
|
||||
Strapi, aggiungi il suo prefisso lì. Niente di questo tocca `/api`, riservato agli endpoint
|
||||
Nitro del frontend. Un solo dominio, un solo certificato TLS.
|
||||
Caddy instrada per **path**, non per sottodominio o porta: `PUBLIC_DOMAIN` serve sia il sito
|
||||
che il pannello PocketBase. `/_/*` (dashboard) e `/api/*` (REST/file API) vanno **senza prefisso**
|
||||
a PocketBase — la sua dashboard referenzia se stessa con quei path assoluti, quindi non si possono
|
||||
instradare con uno strip-prefix (es. `/admin/*` riscritto): romperebbe gli asset/le chiamate della
|
||||
dashboard. Per questo `/api` è riservato a PocketBase, non a Nitro: gli endpoint del frontend
|
||||
vivono sotto `/content/*` (`frontend/server/routes/content/`, non `server/api/`). `/admin` è una
|
||||
route Nitro (`frontend/server/routes/admin.get.ts`) che fa redirect a `/_/`, non una regola
|
||||
Caddy: così funziona identico anche in sviluppo, dove Caddy non fa parte dello stack. Un solo
|
||||
dominio, un solo certificato TLS. Se cambi questa scelta di routing, spiega il trade-off prima
|
||||
(vedi [Vincoli](#vincoli)).
|
||||
|
||||
- Strapi è la **sola** fonte di verità editoriale. Niente altro backend (no Express/Nest/Fastify):
|
||||
se serve logica server, sta in Nitro (`frontend/server/`) o in un controller Strapi.
|
||||
- I visitatori pubblici non si autenticano mai. Solo editor/admin usano l'auth Strapi.
|
||||
- **Il browser dei visitatori pubblici non parla mai con Strapi.** Le pagine chiamano gli
|
||||
endpoint Nitro in `frontend/server/api/`, che sono l'unico posto dove si costruiscono
|
||||
query Strapi. Così `NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e
|
||||
niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il
|
||||
ritorno in `shared/types/blog.ts`. Fanno eccezione, per costruzione: l'admin panel
|
||||
(`/admin`, uso editor/admin autenticato) e le immagini cover, che il browser carica
|
||||
direttamente da `PUBLIC_STRAPI_URL` (`/uploads/...`, sola lettura, nessun'autenticazione
|
||||
richiesta né concessa).
|
||||
- PocketBase è la **sola** fonte di verità editoriale (CMS + database SQLite in un solo processo).
|
||||
Niente altro backend (no Express/Nest/Fastify): se serve logica server, sta in Nitro
|
||||
(`frontend/server/`) o in una regola/migration PocketBase.
|
||||
- I visitatori pubblici non si autenticano mai. Solo il superuser usa l'auth PocketBase.
|
||||
- **Il browser dei visitatori pubblici non parla mai con PocketBase per i contenuti.** Le pagine
|
||||
chiamano gli endpoint Nitro in `frontend/server/routes/content/`, che sono l'unico posto dove si
|
||||
costruiscono query PocketBase. Così `NUXT_POCKETBASE_URL` resta l'indirizzo interno Docker,
|
||||
niente CORS e niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì (sotto
|
||||
`/content/*`, mai `/api/*`) e tipizza il ritorno in `shared/types/blog.ts`. Fanno eccezione, per
|
||||
costruzione: l'admin panel (`/_/`, uso superuser autenticato, raggiungibile anche da `/admin`) e
|
||||
le immagini cover, che il browser carica direttamente da `PUBLIC_POCKETBASE_URL`
|
||||
(`/api/files/...`, sola lettura, nessun'autenticazione richiesta né concessa).
|
||||
- Il Markdown dell'articolo è convertito in HTML **nell'endpoint**, non nel componente: il
|
||||
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
|
||||
- L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente
|
||||
`@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in
|
||||
`nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione
|
||||
Google Translate del browser, non è gestita dall'app. I **contenuti** restano
|
||||
monolingua — la localizzazione di Strapi è disattivata.
|
||||
- `cms/src/index.ts` (`bootstrap`) dà al ruolo Public solo `find`/`findOne` su Article e Category,
|
||||
e disattiva la registrazione pubblica: non esistono utenti front-end, solo amministratori.
|
||||
Qualsiasi permesso in più va motivato.
|
||||
- Postgres non è esposto pubblicamente. Media su volume persistente, mai binari nel DB.
|
||||
- Il sito è **bilingue** (italiano, sorgente, e inglese), fatto in casa senza `@nuxtjs/i18n` né
|
||||
file di traduzione runtime: due lingue, ~20 stringhe statiche, nessun plurale — la libreria
|
||||
costerebbe più codice di quanto risolva (il fallback su contenuti non tradotti, la
|
||||
localizzazione degli endpoint Nitro e gran parte della SEO restano comunque da scrivere a mano).
|
||||
`<html lang>` è dinamico (per pagina, via `useSeo`), non più fisso. Le pagine inglesi vivono sotto
|
||||
prefisso `/en/*`, come wrapper sottili attorno agli stessi componenti "view" delle pagine
|
||||
italiane — vedi `docs/frontend.md#english-content`. Le stringhe UI statiche vivono **solo** in
|
||||
`frontend/shared/utils/i18n.ts`. Gli **articoli** hanno campi paralleli opzionali
|
||||
(`titleEn`/`contentEn`/`coverAltEn`, `nameEn` su categorie), tradotti **a mano** dall'editor in
|
||||
admin — nessuna traduzione automatica. Vedi `docs/content-model.md#english-content` per lo
|
||||
schema e la politica di fallback (404 pulito sul dettaglio non tradotto, filtro nelle liste).
|
||||
- `pocketbase/pb_migrations/*.js` definisce collection e regole: `listRule`/`viewRule` pubblici
|
||||
solo su `articles` (solo pubblicati, via `publishedAt`) e `categories`; `createRule`/
|
||||
`updateRule`/`deleteRule` sempre `null` (solo superuser). Qualsiasi permesso in più va motivato.
|
||||
- SQLite (PocketBase) non è esposto pubblicamente sulla rete — solo tramite l'API PocketBase
|
||||
stessa. Media sul volume persistente `pocketbase-data`, mai binari fuori da lì.
|
||||
|
||||
## Comandi
|
||||
|
||||
@@ -61,49 +74,67 @@ npm run build # build produzione
|
||||
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
||||
npm run lint
|
||||
|
||||
# cms/
|
||||
npm run develop # Strapi con content-type builder attivo
|
||||
npm run build # admin panel
|
||||
npm run start # produzione
|
||||
npm run test:unit # Vitest, ambiente Nuxt: funzioni pure + componenti/composable
|
||||
npm run test:integration # Vitest, server Nitro reale + PocketBase effimero reale
|
||||
npm run test:e2e # Playwright, browser reale contro build + PocketBase effimero reale
|
||||
npm run test # i tre, in sequenza
|
||||
|
||||
# singolo test:
|
||||
npx vitest run --config vitest.unit.config.ts tests/unit/queries.utils.test.ts
|
||||
npx vitest run --config vitest.integration.config.ts -t "lists only published articles"
|
||||
npx playwright test tests/e2e/home.spec.ts
|
||||
|
||||
# pocketbase/ (nessun npm script: binario singolo, le migration si applicano da sole all'avvio)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||
|
||||
# stack completo (con Caddy e domini reali)
|
||||
docker compose up -d --build
|
||||
docker compose logs -f cms
|
||||
docker compose logs -f pocketbase
|
||||
|
||||
# stack locale senza domini né TLS: porte su localhost, niente Caddy
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||
```
|
||||
|
||||
`docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio
|
||||
per non finire per sbaglio in produzione esponendo le porte.
|
||||
|
||||
Nessun test framework è ancora configurato. Se ne aggiungi uno, documenta qui il comando per
|
||||
lanciare **un singolo test**.
|
||||
`tests/` sta nella root del repo (non sotto `frontend/`), perché l'e2e esercita frontend e
|
||||
PocketBase insieme — gli strumenti (config Vitest/Playwright, `node_modules`) restano comunque in
|
||||
`frontend/`, l'unico progetto npm del repo, e puntano a `../tests/`. Playwright risolve i pacchetti
|
||||
dal `node_modules` più vicino al file di test: per questo `npm run test:e2e` crea prima (script
|
||||
`pretest:e2e`, idempotente) il symlink `tests/node_modules → ../frontend/node_modules`.
|
||||
|
||||
I test di integration/e2e avviano un vero binario PocketBase effimero (scaricato una volta in
|
||||
`.cache/pocketbase/` nella root, versione allineata a `pocketbase/Dockerfile`) contro le vere
|
||||
migration in `pocketbase/pb_migrations/` — niente PocketBase mockato. `npm run test:e2e` richiede
|
||||
Chromium installato una tantum: `npx playwright install chromium` (senza `--with-deps`, che
|
||||
richiede `sudo`; se mancano librerie di sistema per il browser, installarle a parte).
|
||||
|
||||
## Modelli di contenuto
|
||||
|
||||
| Type | Campi |
|
||||
|---|---|
|
||||
| Article | title, slug (UID da title), content (Markdown), cover, category (rel) |
|
||||
| Category | name, slug |
|
||||
| Article | title, slug, content (Markdown), cover, coverAlt, category (rel), publishedAt, authorName, titleEn, contentEn, coverAltEn |
|
||||
| Category | name, slug, nameEn |
|
||||
|
||||
Deliberatamente minimale: **niente Author** (l'unico autore è l'admin), niente tag, nessun campo
|
||||
SEO separato. La meta description è ricavata dall'inizio del body (`summarise` in
|
||||
`frontend/server/utils/strapi.ts`), la data è il `publishedAt` di Draft & Publish, l'immagine
|
||||
social è la cover. Non reintrodurre questi campi senza che servano davvero.
|
||||
Deliberatamente minimale: niente tag, nessun campo SEO separato. La meta description è ricavata
|
||||
dall'inizio del body (`summarise` in `frontend/server/utils/pocketbase.ts`), la data è
|
||||
`publishedAt`, l'immagine social è la cover. Non reintrodurre campi senza che servano davvero.
|
||||
|
||||
Draft & Publish attivo su Article. Le URL pubbliche usano lo **slug**, mai l'id numerico.
|
||||
PocketBase non ha Draft & Publish nativo: `publishedAt` vuoto = bozza, valorizzato (e non nel
|
||||
futuro) = pubblicato, imposto dalla `listRule`/`viewRule` della collection `articles`, non da
|
||||
codice applicativo. Le URL pubbliche usano lo **slug**, mai l'id del record.
|
||||
|
||||
## Frontend
|
||||
|
||||
Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`.
|
||||
Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`, ciascuna con equivalente `/en/...`.
|
||||
|
||||
- SSR o prerender per tutto ciò che è indicizzabile. Mai pagine blog client-only senza motivo scritto.
|
||||
- `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`,
|
||||
`composables/`, `layouts/`, `server/`).
|
||||
- Un solo punto di accesso a Strapi: un composable/util tipizzato. Non sparpagliare `$fetch` nelle pagine.
|
||||
- Un solo punto di accesso a PocketBase: un composable/util tipizzato. Non sparpagliare `$fetch` nelle pagine.
|
||||
- Tipizza esplicitamente il confine API. Niente `any` — `unknown` + narrowing.
|
||||
- Query Strapi: richiedi solo i campi e le relazioni che servono (`fields`, `populate` mirati).
|
||||
- Query PocketBase: richiedi solo i campi e le relazioni che servono (`fields`, `expand` mirati).
|
||||
Gestisci sempre 404, lista vuota, errore API.
|
||||
- Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD
|
||||
`BlogPosting`, gerarchia heading semantica. Il contenuto deve esistere nell'HTML server-rendered.
|
||||
@@ -112,18 +143,27 @@ Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`.
|
||||
|
||||
Il riferimento Hostinger è solo ispirazione visiva. Non copiare codice o asset.
|
||||
|
||||
## Documentazione
|
||||
|
||||
`docs/` descrive struttura e comportamento del repo (architettura, content model, frontend) a un
|
||||
livello più approfondito di questo file. Se implementi nuove funzionalità, endpoint, content type
|
||||
o cambi l'architettura (nuovo servizio, nuova rotta Caddy, nuovo modo di scambiare dati tra
|
||||
frontend e CMS), **aggiorna il file `docs/*.md` pertinente nello stesso commit**, o creane uno
|
||||
nuovo se non esiste una sezione adatta. Non lasciare `docs/` disallineata col codice.
|
||||
|
||||
## Priorità
|
||||
|
||||
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
|
||||
|
||||
Nessuna dipendenza, astrazione o servizio senza un bisogno concreto e attuale. Preferisci i
|
||||
built-in Strapi al reimplementare funzioni CMS in Nuxt.
|
||||
built-in PocketBase (regole per-collection, migration) al reimplementare funzioni CMS in Nuxt.
|
||||
|
||||
## Vincoli
|
||||
|
||||
- Mai committare `.env`, segreti, token, credenziali, chiavi. Mantieni `.env.example` sanificato.
|
||||
- Mai hard-codare domini di produzione, URL privilegiati o credenziali. Vanno in env var.
|
||||
- Non indebolire auth, CORS, TLS o security header per comodità. Least privilege sui ruoli Strapi.
|
||||
- Non indebolire auth, CORS, TLS o security header per comodità. Least privilege sulle regole
|
||||
PocketBase.
|
||||
- Richiedono **approvazione esplicita**: operazioni distruttive, migrazioni irreversibili, modifiche
|
||||
a dati o configurazione di produzione, cambi di credenziali.
|
||||
- Non riformattare file non correlati, non fare refactor collaterali, non riscrivere la history,
|
||||
@@ -133,5 +173,6 @@ built-in Strapi al reimplementare funzioni CMS in Nuxt.
|
||||
## Prima di dichiarare completo
|
||||
|
||||
Lint → test → typecheck → build dell'app toccata, con gli script del `package.json` relativo.
|
||||
Non affermare che un check è passato se non l'hai eseguito. Chiudi riassumendo cosa è cambiato e
|
||||
quali rischi restano aperti.
|
||||
Non affermare che un check è passato se non l'hai eseguito. Se hai toccato architettura o
|
||||
funzionalità, verifica di aver aggiornato `docs/` (vedi [Documentazione](#documentazione)). Chiudi
|
||||
riassumendo cosa è cambiato e quali rischi restano aperti.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Blog
|
||||
|
||||
Blog platform: a public website built with Nuxt, and a private Strapi CMS where the
|
||||
Blog platform: a public website built with Nuxt, and a private PocketBase CMS where the
|
||||
articles are written. Everything runs behind Caddy via Docker Compose.
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL
|
||||
└─ everything else → Nuxt (website)
|
||||
Browser → Caddy ─┬─ /_/*, /api/* → PocketBase (CMS)
|
||||
└─ everything else → Nuxt (website), which also redirects /admin → /_/
|
||||
```
|
||||
|
||||
There are no front-end accounts: sign-up is disabled and only administrators write
|
||||
content. An article is a title, a Markdown body, a cover image and a category.
|
||||
There are no front-end accounts: only the superuser writes content. An article is a
|
||||
title, a Markdown body, a cover image and a category.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -22,37 +22,32 @@ through Caddy. Requires Docker.
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**2. Generate the secrets.** Every `change-me` must become a different random value —
|
||||
Strapi refuses to start otherwise. This fills them all:
|
||||
**2. Set the admin credentials.** Replace `POCKETBASE_ADMIN_EMAIL` and
|
||||
`POCKETBASE_ADMIN_PASSWORD` in `.env` with your own — this is the superuser account
|
||||
PocketBase creates (or updates) on every start. The domain and URL variables can stay as
|
||||
they are for local use.
|
||||
|
||||
```bash
|
||||
for var in POSTGRES_PASSWORD API_TOKEN_SALT ADMIN_JWT_SECRET TRANSFER_TOKEN_SALT JWT_SECRET ENCRYPTION_KEY; do
|
||||
sed -i "s|^$var=.*|$var=$(openssl rand -base64 32)|" .env
|
||||
done
|
||||
sed -i "s|^APP_KEYS=.*|APP_KEYS=$(openssl rand -base64 32),$(openssl rand -base64 32)|" .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
`grep change-me .env` must print nothing. The domain and URL variables can stay as they
|
||||
are for local use.
|
||||
|
||||
**3. Start the stack**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build database cms frontend
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||
```
|
||||
|
||||
The first build takes a few minutes. `docker-compose.dev.yml` publishes the ports on
|
||||
`127.0.0.1` and leaves Caddy out; it must always be passed explicitly, so it can never be
|
||||
picked up by accident in production.
|
||||
|
||||
**4. Create the administrator account** at http://localhost:1337/admin (dev bypasses Caddy,
|
||||
so the CMS is reached directly on its port). This is the first
|
||||
run, so the form creates the account — pick your own credentials.
|
||||
**4. Open the admin UI** at http://localhost:3000/admin (redirects to PocketBase's dashboard
|
||||
— this works in dev too, without Caddy, since the redirect is handled by the frontend itself)
|
||||
and log in with the credentials from `.env`.
|
||||
|
||||
**5. Write something.** In the admin panel: create a **Category**, then an **Article**
|
||||
(the body field is Markdown), then press **Publish** — the website only shows published
|
||||
content.
|
||||
**5. Write something.** In the admin UI: create a **categories** record, then an
|
||||
**articles** record (the `content` field is Markdown), then set `publishedAt` — the
|
||||
website only shows articles whose `publishedAt` is set and not in the future.
|
||||
|
||||
**6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/<slug>` and
|
||||
`/category/<slug>`.
|
||||
@@ -60,16 +55,15 @@ content.
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
docker compose logs -f cms # follow the CMS logs
|
||||
docker compose logs -f pocketbase # follow the CMS logs
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart frontend
|
||||
docker compose down # stop, keep the data
|
||||
docker compose down -v # stop and WIPE the database and media
|
||||
docker compose down -v # stop and WIPE the CMS database and media
|
||||
```
|
||||
|
||||
To iterate on the code without rebuilding an image every time, run a package directly —
|
||||
`cd frontend && npm run dev`, or `cd cms && npm run develop`. The frontend defaults to
|
||||
`http://localhost:1337` for Strapi, so it works against the containerised CMS as is;
|
||||
override it with `NUXT_STRAPI_URL` if needed. Strapi reads its own `cms/.env`.
|
||||
To iterate on the frontend without rebuilding an image every time: `cd frontend && npm run
|
||||
dev`. It defaults to `http://localhost:8090` for PocketBase, so it works against the
|
||||
containerised CMS as is; override it with `NUXT_POCKETBASE_URL` if needed.
|
||||
|
||||
## Production
|
||||
|
||||
@@ -78,14 +72,16 @@ public IP of the machine:
|
||||
|
||||
| Record | Purpose |
|
||||
|---|---|
|
||||
| `example.com` | the website and, at `/admin`, the Strapi admin panel |
|
||||
| `example.com` | the website, and, at `/admin`, the PocketBase admin UI |
|
||||
|
||||
Wait for the record to resolve before starting the stack — Caddy requests the
|
||||
certificate on the first boot and a failed challenge means a retry delay.
|
||||
|
||||
**2. Open the firewall** for ports `80` and `443` only. Port `80` is required: Caddy uses
|
||||
it for the ACME challenge and to redirect to HTTPS. PostgreSQL, Strapi and Nuxt are only
|
||||
reachable inside the Docker network — do not publish their ports.
|
||||
it for the ACME challenge and to redirect to HTTPS. The admin UI shares port 443 with the
|
||||
site (reachable by anyone who knows `/admin`, protected only by the superuser login, so
|
||||
keep the password strong); PocketBase itself is never published directly, only Caddy's
|
||||
proxy to it.
|
||||
|
||||
**3. Configure the environment.** Copy `.env.example` to `.env` on the server and set:
|
||||
|
||||
@@ -94,16 +90,15 @@ reachable inside the Docker network — do not publish their ports.
|
||||
| `PUBLIC_DOMAIN` | `example.com` |
|
||||
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
|
||||
| `PUBLIC_SITE_URL` | `https://example.com` |
|
||||
| `PUBLIC_STRAPI_URL` | `https://example.com` — same origin, Caddy proxies `/admin` and Strapi's other plugin paths there (see `caddy/Caddyfile`) |
|
||||
| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public |
|
||||
| `PUBLIC_POCKETBASE_URL` | `https://example.com` — same origin, Caddy proxies `/_/` and `/api/` there |
|
||||
| `POCKETBASE_URL` | leave it as `http://pocketbase:8090` — internal address, never public |
|
||||
| `POCKETBASE_ADMIN_EMAIL` / `POCKETBASE_ADMIN_PASSWORD` | your real superuser credentials |
|
||||
|
||||
Then generate **fresh** secrets on that machine with the same loop as in development —
|
||||
different values from the ones you use locally. Keep `.env` out of version control; it is
|
||||
already ignored.
|
||||
Keep `.env` out of version control; it is already ignored.
|
||||
|
||||
> Changing `APP_KEYS`, `ADMIN_JWT_SECRET` or `JWT_SECRET` later logs everyone out.
|
||||
> Changing `ENCRYPTION_KEY` after content exists makes already-encrypted values
|
||||
> unreadable. Set them once, then back up the file somewhere safe.
|
||||
> Changing `POCKETBASE_ADMIN_PASSWORD` later and restarting rotates the superuser
|
||||
> password immediately (the entrypoint upserts it on every boot) — a credential change,
|
||||
> so treat it with the same care as any production credential rotation.
|
||||
|
||||
**4. Start everything**
|
||||
|
||||
@@ -111,24 +106,22 @@ already ignored.
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
This time Caddy is included: it serves both the website and, under `/admin` and Strapi's
|
||||
other plugin paths, the CMS on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on
|
||||
its own, and adds HSTS and the other security headers.
|
||||
This time Caddy is included: it serves both the website and, under `/admin`, the CMS
|
||||
admin UI on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on its own, and adds
|
||||
HSTS and the other security headers.
|
||||
|
||||
**5. Create the administrator account** at `https://example.com/admin`, immediately,
|
||||
before anyone else finds the URL — the first visitor to that form is the one who gets the
|
||||
account. Then publish as in development.
|
||||
**5. Log into the admin UI** at `https://example.com/admin` with the credentials from
|
||||
`.env`, then publish as in development.
|
||||
|
||||
**6. Back up what is not in git**: the `postgres-data` volume (all content) and the
|
||||
`cms-uploads` volume (all images). Nothing else on the server holds state.
|
||||
**6. Back up what is not in git**: the `pocketbase-data` volume (database and uploaded
|
||||
media together). Nothing else on the server holds state.
|
||||
|
||||
```bash
|
||||
docker compose exec -T database pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
|
||||
docker run --rm -v blog_cms-uploads:/data -v "$PWD:/out" alpine tar czf /out/uploads.tar.gz -C /data .
|
||||
docker run --rm -v blog_pocketbase-data:/data -v "$PWD:/out" alpine tar czf /out/pocketbase-data.tar.gz -C /data .
|
||||
```
|
||||
|
||||
**Updating a running site**: pull the new code, then `docker compose up -d --build`.
|
||||
Strapi applies its own schema changes at startup; take a backup first.
|
||||
PocketBase applies new `pb_migrations/` files at startup; take a backup first.
|
||||
|
||||
Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
|
||||
+14
-15
@@ -16,24 +16,23 @@
|
||||
import security_headers
|
||||
encode zstd gzip
|
||||
|
||||
# Strapi mounts each of its (and its plugins') admin APIs at its own
|
||||
# top-level path, not all under /admin - the admin panel's dashboard
|
||||
# widgets, media library, i18n, etc each call their own plugin prefix.
|
||||
# This list is every @strapi/* package in cms/package.json plus the
|
||||
# core-bundled plugins (content-manager, content-type-builder, upload,
|
||||
# i18n, email, content-releases, review-workflows). Adding a new Strapi
|
||||
# plugin later means adding its prefix here too.
|
||||
# None of this touches /api, reserved for the frontend's own Nitro
|
||||
# endpoints.
|
||||
@cms path /admin* /content-manager* /content-type-builder* /upload*\
|
||||
/i18n* /email* /content-releases* /review-workflows*\
|
||||
/users-permissions* /cloud*
|
||||
handle @cms {
|
||||
# Uploaded media can be large; Strapi's own limit still applies.
|
||||
# /admin is a vanity redirect to /_/, PocketBase's own fixed dashboard
|
||||
# route — handled by Nitro (frontend/server/routes/admin.get.ts), not
|
||||
# here, so it also works in dev where Caddy isn't in the stack.
|
||||
|
||||
# PocketBase's admin UI (/_/) and its own REST/file API (/api/*) both
|
||||
# reference themselves with root-absolute paths, so they must be
|
||||
# reachable unprefixed at the domain root — a path like /admin/api/...
|
||||
# with a stripped prefix would break the dashboard's own asset and API
|
||||
# calls. This is why the frontend's Nitro endpoints live under
|
||||
# /content/*, not /api/*: /api is reserved for PocketBase here.
|
||||
@pocketbase path /_/* /api/*
|
||||
handle @pocketbase {
|
||||
# Cover images and admin uploads pass through here too.
|
||||
request_body {
|
||||
max_size 100MB
|
||||
}
|
||||
reverse_proxy cms:1337
|
||||
reverse_proxy pocketbase:8090
|
||||
}
|
||||
|
||||
handle {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.strapi
|
||||
.tmp
|
||||
.env
|
||||
.git
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
@@ -1,8 +0,0 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=1337
|
||||
APP_KEYS="toBeModified1,toBeModified2"
|
||||
API_TOKEN_SALT=tobemodified
|
||||
ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=tobemodified
|
||||
JWT_SECRET=tobemodified
|
||||
ENCRYPTION_KEY=tobemodified
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
############################
|
||||
# OS X
|
||||
############################
|
||||
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
._*
|
||||
|
||||
|
||||
############################
|
||||
# Linux
|
||||
############################
|
||||
|
||||
*~
|
||||
|
||||
|
||||
############################
|
||||
# Windows
|
||||
############################
|
||||
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.cab
|
||||
*.msi
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
|
||||
############################
|
||||
# Packages
|
||||
############################
|
||||
|
||||
*.7z
|
||||
*.csv
|
||||
*.dat
|
||||
*.dmg
|
||||
*.gz
|
||||
*.iso
|
||||
*.jar
|
||||
*.rar
|
||||
*.tar
|
||||
*.zip
|
||||
*.com
|
||||
*.class
|
||||
*.dll
|
||||
*.exe
|
||||
*.o
|
||||
*.seed
|
||||
*.so
|
||||
*.swo
|
||||
*.swp
|
||||
*.swn
|
||||
*.swm
|
||||
*.out
|
||||
*.pid
|
||||
|
||||
|
||||
############################
|
||||
# Logs and databases
|
||||
############################
|
||||
|
||||
.tmp
|
||||
*.log
|
||||
*.sql
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
|
||||
############################
|
||||
# Misc.
|
||||
############################
|
||||
|
||||
*#
|
||||
ssl
|
||||
.idea
|
||||
nbproject
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
.tsbuildinfo
|
||||
.eslintcache
|
||||
|
||||
############################
|
||||
# Node.js
|
||||
############################
|
||||
|
||||
lib-cov
|
||||
lcov.info
|
||||
pids
|
||||
logs
|
||||
results
|
||||
node_modules
|
||||
.node_history
|
||||
|
||||
############################
|
||||
# Package managers
|
||||
############################
|
||||
|
||||
.yarn/*
|
||||
!.yarn/cache
|
||||
!.yarn/unplugged
|
||||
!.yarn/patches
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
.pnp.*
|
||||
yarn-error.log
|
||||
|
||||
############################
|
||||
# Tests
|
||||
############################
|
||||
|
||||
coverage
|
||||
|
||||
############################
|
||||
# Strapi
|
||||
############################
|
||||
|
||||
.env
|
||||
license.txt
|
||||
exports
|
||||
.strapi
|
||||
dist
|
||||
build
|
||||
.strapi-updater.json
|
||||
.strapi-cloud.json
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
# Sharp (image processing) needs these at install time on Alpine.
|
||||
RUN apk add --no-cache build-base python3 vips-dev
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache vips
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app /app
|
||||
RUN mkdir -p public/uploads && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 1337
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -1,61 +0,0 @@
|
||||
# 🚀 Getting started with Strapi
|
||||
|
||||
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
|
||||
|
||||
### `develop`
|
||||
|
||||
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
|
||||
|
||||
```
|
||||
npm run develop
|
||||
# or
|
||||
yarn develop
|
||||
```
|
||||
|
||||
### `start`
|
||||
|
||||
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
|
||||
|
||||
```
|
||||
npm run start
|
||||
# or
|
||||
yarn start
|
||||
```
|
||||
|
||||
### `build`
|
||||
|
||||
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
|
||||
|
||||
```
|
||||
npm run build
|
||||
# or
|
||||
yarn build
|
||||
```
|
||||
|
||||
## ⚙️ Deployment
|
||||
|
||||
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
|
||||
|
||||
```
|
||||
yarn strapi deploy
|
||||
```
|
||||
|
||||
## 📚 Learn more
|
||||
|
||||
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
|
||||
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
|
||||
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
|
||||
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
|
||||
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
|
||||
|
||||
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
|
||||
|
||||
## ✨ Community
|
||||
|
||||
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
|
||||
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
|
||||
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
|
||||
|
||||
---
|
||||
|
||||
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Admin => ({
|
||||
auth: {
|
||||
secret: env('ADMIN_JWT_SECRET')!,
|
||||
},
|
||||
apiToken: {
|
||||
salt: env('API_TOKEN_SALT')!,
|
||||
},
|
||||
transfer: {
|
||||
token: {
|
||||
salt: env('TRANSFER_TOKEN_SALT')!,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
encryptionKey: env('ENCRYPTION_KEY')!,
|
||||
},
|
||||
flags: {
|
||||
nps: env.bool('FLAG_NPS', true),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||
docLinks: env.bool('FLAG_DOC_LINKS', true),
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
const config: Core.Config.Api = {
|
||||
rest: {
|
||||
defaultLimit: 25,
|
||||
maxLimit: 100,
|
||||
withCount: true,
|
||||
strictParams: true,
|
||||
},
|
||||
documents: {
|
||||
strictParams: true,
|
||||
strictRelations: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,72 +0,0 @@
|
||||
import path from 'path';
|
||||
import type { Core } from '@strapi/strapi';
|
||||
import { isDatabaseClientKind } from '@strapi/database';
|
||||
|
||||
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Database => {
|
||||
const client = env('DATABASE_CLIENT', 'sqlite');
|
||||
|
||||
if (!isDatabaseClientKind(client)) {
|
||||
throw new Error(
|
||||
`Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`
|
||||
);
|
||||
}
|
||||
|
||||
const connections: Record<Core.Config.Database.ClientKind, Core.Config.Database['connection']> = {
|
||||
mysql: {
|
||||
client: 'mysql',
|
||||
connection: {
|
||||
host: env('DATABASE_HOST', 'localhost'),
|
||||
port: env.int('DATABASE_PORT', 3306),
|
||||
database: env('DATABASE_NAME', 'strapi'),
|
||||
user: env('DATABASE_USERNAME', 'strapi'),
|
||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
||||
ssl: env.bool('DATABASE_SSL', false) && {
|
||||
key: env('DATABASE_SSL_KEY', undefined),
|
||||
cert: env('DATABASE_SSL_CERT', undefined),
|
||||
ca: env('DATABASE_SSL_CA', undefined),
|
||||
capath: env('DATABASE_SSL_CAPATH', undefined),
|
||||
cipher: env('DATABASE_SSL_CIPHER', undefined),
|
||||
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
|
||||
},
|
||||
},
|
||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
||||
},
|
||||
postgres: {
|
||||
client: 'postgres',
|
||||
connection: {
|
||||
connectionString: env('DATABASE_URL'),
|
||||
host: env('DATABASE_HOST', 'localhost'),
|
||||
port: env.int('DATABASE_PORT', 5432),
|
||||
database: env('DATABASE_NAME', 'strapi'),
|
||||
user: env('DATABASE_USERNAME', 'strapi'),
|
||||
password: env('DATABASE_PASSWORD', 'strapi'),
|
||||
ssl: env.bool('DATABASE_SSL', false) && {
|
||||
key: env('DATABASE_SSL_KEY', undefined),
|
||||
cert: env('DATABASE_SSL_CERT', undefined),
|
||||
ca: env('DATABASE_SSL_CA', undefined),
|
||||
capath: env('DATABASE_SSL_CAPATH', undefined),
|
||||
cipher: env('DATABASE_SSL_CIPHER', undefined),
|
||||
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
|
||||
},
|
||||
schema: env('DATABASE_SCHEMA', 'public'),
|
||||
},
|
||||
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
|
||||
},
|
||||
sqlite: {
|
||||
client: 'sqlite',
|
||||
connection: {
|
||||
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
|
||||
},
|
||||
useNullAsDefault: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
connection: {
|
||||
...connections[client],
|
||||
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
const config: Core.Config.Middlewares = [
|
||||
'strapi::logger',
|
||||
'strapi::errors',
|
||||
'strapi::security',
|
||||
'strapi::cors',
|
||||
'strapi::poweredBy',
|
||||
'strapi::query',
|
||||
'strapi::body',
|
||||
'strapi::session',
|
||||
'strapi::favicon',
|
||||
'strapi::public',
|
||||
];
|
||||
|
||||
export default config;
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
const allowedMediaTypes = [
|
||||
'image/*',
|
||||
'video/*',
|
||||
'audio/*',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.*',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
];
|
||||
|
||||
const deniedExecutableTypes = [
|
||||
'application/vnd.microsoft.portable-executable',
|
||||
'application/x-msdownload',
|
||||
'application/x-msdos-program',
|
||||
'application/x-executable',
|
||||
'application/x-dosexec',
|
||||
'application/x-sh',
|
||||
'text/x-shellscript',
|
||||
'application/x-mach-binary',
|
||||
];
|
||||
|
||||
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
|
||||
'users-permissions': {
|
||||
config: {
|
||||
jwtManagement: 'refresh',
|
||||
sessions: {
|
||||
httpOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
config: {
|
||||
security: {
|
||||
allowedTypes: allowedMediaTypes,
|
||||
deniedTypes: deniedExecutableTypes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
|
||||
host: env('HOST', '0.0.0.0'),
|
||||
port: env.int('PORT', 1337),
|
||||
app: {
|
||||
keys: env.array('APP_KEYS')!,
|
||||
},
|
||||
webhooks: {
|
||||
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 497 B |
Generated
-21575
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "cms",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "A Strapi application",
|
||||
"scripts": {
|
||||
"build": "strapi build",
|
||||
"console": "strapi console",
|
||||
"deploy": "strapi deploy",
|
||||
"dev": "strapi develop",
|
||||
"develop": "strapi develop",
|
||||
"start": "strapi start",
|
||||
"strapi": "strapi",
|
||||
"upgrade": "npx @strapi/upgrade latest",
|
||||
"upgrade:dry": "npx @strapi/upgrade latest --dry"
|
||||
},
|
||||
"dependencies": {
|
||||
"@strapi/database": "5.52.1",
|
||||
"@strapi/plugin-cloud": "5.52.1",
|
||||
"@strapi/plugin-users-permissions": "5.52.1",
|
||||
"@strapi/strapi": "5.52.1",
|
||||
"pg": "8.20.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"styled-components": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0 <=26.x.x",
|
||||
"npm": ">=6.0.0"
|
||||
},
|
||||
"strapi": {
|
||||
"uuid": "a5980fd5-c31f-4ef1-8525-3dc2beb7b474",
|
||||
"installId": "18a390c5c080d58c3b9f83ca5586b6d49a0dce670f8c2f91a34447fd019ecc15"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
|
||||
# User-Agent: *
|
||||
# Disallow: /
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { StrapiApp } from '@strapi/strapi/admin';
|
||||
|
||||
export default {
|
||||
config: {
|
||||
locales: [
|
||||
// 'ar',
|
||||
// 'fr',
|
||||
// 'cs',
|
||||
// 'de',
|
||||
// 'da',
|
||||
// 'es',
|
||||
// 'he',
|
||||
// 'id',
|
||||
// 'it',
|
||||
// 'ja',
|
||||
// 'ko',
|
||||
// 'ms',
|
||||
// 'nl',
|
||||
// 'no',
|
||||
// 'pl',
|
||||
// 'pt-BR',
|
||||
// 'pt',
|
||||
// 'ru',
|
||||
// 'sk',
|
||||
// 'sv',
|
||||
// 'th',
|
||||
// 'tr',
|
||||
// 'uk',
|
||||
// 'vi',
|
||||
// 'zh-Hans',
|
||||
// 'zh',
|
||||
],
|
||||
},
|
||||
bootstrap(app: StrapiApp) {
|
||||
console.log(app);
|
||||
},
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["../plugins/**/admin/src/**/*", "./"],
|
||||
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { mergeConfig, type UserConfig } from 'vite';
|
||||
|
||||
export default (config: UserConfig) => {
|
||||
// Important: always return the modified config
|
||||
return mergeConfig(config, {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "articles",
|
||||
"info": {
|
||||
"singularName": "article",
|
||||
"pluralName": "articles",
|
||||
"displayName": "Article",
|
||||
"description": "Blog article"
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": true,
|
||||
"populateCreatorFields": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 160,
|
||||
"pluginOptions": { "i18n": { "localized": true } }
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true,
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
},
|
||||
"content": {
|
||||
"type": "richtext",
|
||||
"required": true,
|
||||
"pluginOptions": { "i18n": { "localized": true } }
|
||||
},
|
||||
"cover": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"allowedTypes": ["images"],
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
},
|
||||
"category": {
|
||||
"type": "relation",
|
||||
"relation": "manyToOne",
|
||||
"target": "api::category.category",
|
||||
"inversedBy": "articles",
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::article.article');
|
||||
@@ -1,3 +0,0 @@
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::article.article');
|
||||
@@ -1,3 +0,0 @@
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::article.article');
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "categories",
|
||||
"info": {
|
||||
"singularName": "category",
|
||||
"pluralName": "categories",
|
||||
"displayName": "Category",
|
||||
"description": "Article category"
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"unique": true,
|
||||
"pluginOptions": { "i18n": { "localized": true } }
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "name",
|
||||
"required": true,
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
},
|
||||
"articles": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::article.article",
|
||||
"mappedBy": "category",
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreController('api::category.category');
|
||||
@@ -1,3 +0,0 @@
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::category.category');
|
||||
@@ -1,3 +0,0 @@
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::category.category');
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
/** Read-only endpoints the public website needs. Nothing else is granted. */
|
||||
const PUBLIC_READ_ACTIONS = ['article', 'category'].flatMap((type) => [
|
||||
`api::${type}.${type}.find`,
|
||||
`api::${type}.${type}.findOne`,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Grants the Public role read access to the blog content types, so a fresh
|
||||
* deployment serves content without anyone clicking through the admin panel.
|
||||
* Existing permissions are left untouched.
|
||||
*/
|
||||
async function grantPublicReadAccess(strapi: Core.Strapi) {
|
||||
const publicRole = await strapi
|
||||
.query('plugin::users-permissions.role')
|
||||
.findOne({ where: { type: 'public' } });
|
||||
|
||||
if (!publicRole) return;
|
||||
|
||||
for (const action of PUBLIC_READ_ACTIONS) {
|
||||
const existing = await strapi
|
||||
.query('plugin::users-permissions.permission')
|
||||
.findOne({ where: { action, role: publicRole.id } });
|
||||
|
||||
if (!existing) {
|
||||
await strapi
|
||||
.query('plugin::users-permissions.permission')
|
||||
.create({ data: { action, role: publicRole.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The site has no front-end accounts: only the administrators authoring content
|
||||
* in the admin panel. Self-registration is therefore closed, so nobody can
|
||||
* create an Authenticated user through the public API.
|
||||
*/
|
||||
async function disablePublicSignUp(strapi: Core.Strapi) {
|
||||
const store = strapi.store({ type: 'plugin', name: 'users-permissions', key: 'advanced' });
|
||||
const advanced = ((await store.get({ key: 'advanced' })) ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (advanced.allow_register !== false) {
|
||||
await store.set({ key: 'advanced', value: { ...advanced, allow_register: false } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The site content is authored in Italian and English. Strapi ships with only
|
||||
* the default `it` locale, so the `en` locale is created here if missing.
|
||||
*/
|
||||
async function ensureEnglishLocale(strapi: Core.Strapi) {
|
||||
const locales = strapi.plugin('i18n').service('locales');
|
||||
const existing = await locales.find({ where: { code: 'en' } });
|
||||
|
||||
if (existing.length === 0) {
|
||||
await locales.create({ code: 'en', name: 'English (en)', isDefault: false });
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
register() {},
|
||||
|
||||
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
||||
await grantPublicReadAccess(strapi);
|
||||
await disablePublicSignUp(strapi);
|
||||
await ensureEnglishLocale(strapi);
|
||||
},
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2020"],
|
||||
"target": "ES2019",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmitOnError": true,
|
||||
"noImplicitThis": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
// Include root files
|
||||
"./",
|
||||
// Include all ts files
|
||||
"./**/*.ts",
|
||||
// Include all js files
|
||||
"./**/*.js",
|
||||
// Force the JSON files in the src folder to be included
|
||||
"src/**/*.json"
|
||||
],
|
||||
|
||||
"exclude": [
|
||||
"node_modules/",
|
||||
"build/",
|
||||
"dist/",
|
||||
".cache/",
|
||||
".tmp/",
|
||||
".strapi/",
|
||||
|
||||
// Do not include admin files in the server compilation
|
||||
"src/admin/",
|
||||
// Do not include test files
|
||||
"**/*.test.*",
|
||||
// Do not include plugins in the server compilation
|
||||
"src/plugins/**"
|
||||
]
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
# Local testing without domains or TLS: publishes the app ports on localhost and
|
||||
# leaves Caddy out. Never used in production — it must be passed explicitly:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build cms frontend
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||
services:
|
||||
database:
|
||||
pocketbase:
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
|
||||
cms:
|
||||
ports:
|
||||
- "127.0.0.1:1337:1337"
|
||||
- "127.0.0.1:8090:8090"
|
||||
volumes:
|
||||
- ./pocketbase/pb_data:/pb/pb_data
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
NUXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
NUXT_PUBLIC_STRAPI_URL: http://localhost:1337
|
||||
NUXT_PUBLIC_POCKETBASE_URL: http://localhost:8090
|
||||
|
||||
+12
-38
@@ -1,63 +1,38 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres:17-alpine
|
||||
pocketbase:
|
||||
build: ./pocketbase
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POCKETBASE_ADMIN_EMAIL: ${POCKETBASE_ADMIN_EMAIL}
|
||||
POCKETBASE_ADMIN_PASSWORD: ${POCKETBASE_ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- pocketbase-data:/pb/pb_data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
cms:
|
||||
build: ./cms
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 1337
|
||||
DATABASE_CLIENT: postgres
|
||||
DATABASE_HOST: database
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_NAME: ${POSTGRES_DB}
|
||||
DATABASE_USERNAME: ${POSTGRES_USER}
|
||||
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
APP_KEYS: ${APP_KEYS}
|
||||
API_TOKEN_SALT: ${API_TOKEN_SALT}
|
||||
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
|
||||
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
volumes:
|
||||
- cms-uploads:/app/public/uploads
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- cms
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3000
|
||||
NUXT_STRAPI_URL: ${STRAPI_URL}
|
||||
NUXT_POCKETBASE_URL: ${POCKETBASE_URL}
|
||||
NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
||||
NUXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL}
|
||||
NUXT_PUBLIC_POCKETBASE_URL: ${PUBLIC_POCKETBASE_URL}
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
- cms
|
||||
- pocketbase
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -75,7 +50,6 @@ services:
|
||||
- caddy-config:/config
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
cms-uploads:
|
||||
pocketbase-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Documentation
|
||||
|
||||
Technical documentation for this repository. For setup, deployment and day-to-day commands, see
|
||||
the root [README.md](../README.md); for coding conventions and constraints, see
|
||||
[CLAUDE.md](../CLAUDE.md). These docs explain the *structure and behavior* of the system in more
|
||||
depth than either of those.
|
||||
|
||||
- [architecture.md](./architecture.md) — services, routing, request flow, environment variables.
|
||||
- [content-model.md](./content-model.md) — PocketBase collections, admin panel, permissions, editorial workflow.
|
||||
- [frontend.md](./frontend.md) — Nuxt routes, server (Nitro) endpoints, data flow, SEO.
|
||||
|
||||
## What this repository is
|
||||
|
||||
A blog website with two faces:
|
||||
|
||||
- **Public site** — anonymous visitors read articles and browse categories. Fully server-rendered,
|
||||
no login, no client-side calls to the CMS.
|
||||
- **Admin panel** — the site owner logs into PocketBase's admin UI (`/admin`, redirects to `/_/`)
|
||||
to write, edit and publish articles and categories. This is the only way content changes; there
|
||||
is no other CMS and no public user accounts.
|
||||
|
||||
One codebase, two runtime components (Nuxt frontend, PocketBase — CMS and database in one
|
||||
process), fronted by a single Caddy reverse proxy on one domain.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Architecture
|
||||
|
||||
## Services
|
||||
|
||||
Three containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /_/* and /api/* → PocketBase (pocketbase)
|
||||
└─ everything else → Nuxt 4 SSR (frontend) → PocketBase REST (internal)
|
||||
("/admin" redirects to /_/, handled by Nitro)
|
||||
```
|
||||
|
||||
- **pocketbase** — a single PocketBase binary, the sole source of editorial truth. Embedded
|
||||
SQLite, no separate database service. No other backend framework exists in this repo; any
|
||||
server-side logic that isn't content management belongs in Nuxt's Nitro server, not in a new
|
||||
service.
|
||||
- **frontend** — Nuxt 4 in SSR mode. Renders public pages and exposes its own REST-like endpoints
|
||||
under `/content/*` (Nitro), which are the only code in the repo allowed to call PocketBase.
|
||||
- **caddy** — single reverse proxy, single TLS certificate, single public domain
|
||||
(`PUBLIC_DOMAIN`). Routes by **path**, not subdomain or port.
|
||||
|
||||
`docker-compose.dev.yml` is a local-only override (publishes ports on `localhost`, drops Caddy)
|
||||
and must always be passed explicitly with `-f docker-compose.yml -f docker-compose.dev.yml` —
|
||||
it's not an auto-merged `override.yml`, precisely so it can't be picked up by accident in
|
||||
production.
|
||||
|
||||
## Path routing (Caddy)
|
||||
|
||||
See [caddy/Caddyfile](../caddy/Caddyfile). One site block on `{$PUBLIC_DOMAIN}`:
|
||||
|
||||
- `@pocketbase path /_/* /api/*` → `pocketbase:8090`, **unprefixed** (100MB body limit, for
|
||||
uploads).
|
||||
- everything else → `frontend:3000`.
|
||||
|
||||
This means the English routes under `/en/*` ([frontend.md](./frontend.md#english-content))
|
||||
need **no Caddy changes** at all: they're plain Nuxt pages like any other, so they already fall
|
||||
under "everything else."
|
||||
|
||||
`/admin` is **not** a Caddy rule: it's a Nitro route
|
||||
(`frontend/server/routes/admin.get.ts`) that redirects to `${pocketbaseUrl}/_/` (PocketBase's own
|
||||
fixed dashboard route, since it can't be told to serve elsewhere). Handling it in Nitro rather
|
||||
than Caddy means it works identically in dev, where Caddy isn't part of the stack — `/admin`
|
||||
redirects to `http://localhost:8090/_/` there — and in production, where it redirects to the same
|
||||
origin's `/_/`, which Caddy then proxies to PocketBase.
|
||||
|
||||
**Why unprefixed, not a stripped `/admin/*` prefix:** PocketBase's admin dashboard references its
|
||||
own assets and API with paths rooted at `/_/` and `/api/`. A reverse-proxy rule that rewrites
|
||||
`/admin/foo` → `/foo` before forwarding would serve the dashboard's HTML fine, but every asset and
|
||||
API call the dashboard's own JS makes afterwards targets `/_/...`/`/api/...` directly — those
|
||||
requests would then miss the `/admin` prefix and never reach the rewrite rule, landing on Nuxt
|
||||
instead and breaking the dashboard. Routing `/_/*` and `/api/*` at the domain root, unprefixed, is
|
||||
the only configuration PocketBase's own code is written to expect (confirmed against a live
|
||||
container: dashboard HTML, its JS/CSS assets under `/_/assets/...`, and REST calls under
|
||||
`/api/...` all resolve correctly this way). This is also PocketBase's own documented recommendation
|
||||
for reverse-proxy deployments.
|
||||
|
||||
**`/api/*` is reserved for PocketBase here — the inverse of the old Strapi setup.** Nuxt's own
|
||||
Nitro endpoints live under `/content/*` instead (`frontend/server/routes/content/`, not
|
||||
`frontend/server/api/`, since Nitro auto-prefixes anything under `server/api/` with `/api`).
|
||||
PocketBase's public REST API is reached two ways: from inside the Docker network by the Nitro
|
||||
server, over `POCKETBASE_URL=http://pocketbase:8090`; and directly by the browser for the two
|
||||
things that don't go through Nitro — the admin UI and cover images.
|
||||
|
||||
## Request flow: reading an article
|
||||
|
||||
1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
|
||||
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/content/articles/my-slug')` — a
|
||||
same-origin call to Nuxt's own Nitro endpoint, resolved server-side during SSR (no round trip
|
||||
over the network in production).
|
||||
3. `frontend/server/routes/content/articles/[slug].get.ts` calls `pbFetch()` (in
|
||||
`frontend/server/utils/pocketbase.ts`), which hits `POCKETBASE_URL` (internal Docker address)
|
||||
with a PocketBase filter/fields query built by `frontend/server/utils/queries.ts`. PocketBase's
|
||||
`listRule`/`viewRule` on the `articles` collection already exclude unpublished entries — the
|
||||
endpoint doesn't need to check that itself.
|
||||
4. The endpoint converts the article's Markdown `content` to HTML server-side (`marked`, via
|
||||
`renderMarkdown()`) and derives a meta description (`summarise()`). The response shape is
|
||||
`Article` from `frontend/shared/types/blog.ts`.
|
||||
5. Nuxt renders the page with the HTML already embedded (`v-html`) — no Markdown parser ships to
|
||||
the client, and the article body is present in the server-rendered HTML for SEO/crawlers.
|
||||
6. The cover image `<img>` tag points at `PUBLIC_POCKETBASE_URL/api/files/...` — the only asset
|
||||
the browser fetches straight from PocketBase.
|
||||
|
||||
## Request flow: publishing content
|
||||
|
||||
1. Editor goes to `PUBLIC_SITE_URL/admin` (redirects to `/_/`, PocketBase's admin UI —
|
||||
authenticated superuser only, no public sign-up, see [content-model.md](./content-model.md)).
|
||||
2. Editor writes/edits an Article (Markdown body) or Category, and sets `publishedAt` to publish
|
||||
it.
|
||||
3. PocketBase writes to its embedded SQLite database (on the `pocketbase-data` volume). No cache
|
||||
to invalidate: the next public request for that slug hits PocketBase live through the Nitro
|
||||
endpoint.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Defined in [.env.example](../.env.example) (root, drives docker-compose).
|
||||
|
||||
| Variable | Consumed by | Purpose |
|
||||
|---|---|---|
|
||||
| `PUBLIC_DOMAIN` | caddy | Domain Caddy serves and requests a TLS cert for. |
|
||||
| `ACME_EMAIL` | caddy | Contact email for Let's Encrypt. |
|
||||
| `POCKETBASE_ADMIN_EMAIL` | pocketbase | Bootstraps (and keeps up to date, on every restart) the initial superuser account. |
|
||||
| `POCKETBASE_ADMIN_PASSWORD` | pocketbase | Password for the superuser above. Rotating it is a credential change — see `CLAUDE.md`. |
|
||||
| `POCKETBASE_URL` | frontend (server-only) | Internal Docker address of PocketBase (`http://pocketbase:8090`); mapped to `NUXT_POCKETBASE_URL`. Never sent to the browser. |
|
||||
| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
|
||||
| `PUBLIC_POCKETBASE_URL` | frontend, browser | Public-facing PocketBase origin (same domain as `PUBLIC_SITE_URL`; Caddy proxies `/_/*` and `/api/*` there) for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_POCKETBASE_URL`. |
|
||||
|
||||
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
|
||||
only).
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Frontend (Nuxt)
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | English counterpart | File(s) | Behavior |
|
||||
|---|---|---|---|
|
||||
| `/` | `/en` | `app/pages/index.vue` + `app/pages/en/index.vue`, both thin wrappers around `app/components/views/HomeView.vue` | Static hero/intro copy plus the single latest article, fetched via `/content/articles` (page 1), shown as a featured block. |
|
||||
| `/blog` | `/en/blog` | `app/pages/blog/index.vue` + `app/pages/en/blog/index.vue` → `BlogIndexView.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
|
||||
| `/blog/[slug]` | `/en/blog/[slug]` | `app/pages/blog/[slug].vue` + `app/pages/en/blog/[slug].vue` → `ArticleView.vue` | Full article: fetches `/content/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
|
||||
| `/category/[slug]` | `/en/category/[slug]` | `app/pages/category/[slug].vue` + `app/pages/en/category/[slug].vue` → `CategoryView.vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
|
||||
| `/come-difendersi-dal-corralito` | *(none)* | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no PocketBase data, Italian only — see [English content](#english-content-1). |
|
||||
|
||||
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
|
||||
|
||||
### English content
|
||||
|
||||
Each dynamic route has a `/en/`-prefixed counterpart, implemented as a thin page file
|
||||
(`app/pages/en/**`) that renders the exact same view component as its Italian counterpart with
|
||||
`locale="en"` — no template is duplicated. `app/components/views/*.vue` hold the actual markup and
|
||||
logic; the two page files per route only differ in that one prop. This is deliberately hand-rolled
|
||||
(no `@nuxtjs/i18n` or similar): two locales, ~20 short UI strings, no plurals — a library's routing
|
||||
and message-catalog machinery would be more code than the plain approach below, and CLAUDE.md's
|
||||
existing minimalism rule ("no dependency without a concrete, current need") still applies once you
|
||||
account for what a library *wouldn't* solve for you here (translation-availability fallback logic,
|
||||
Nitro locale-awareness, and most of the SEO work are custom code either way).
|
||||
|
||||
- **Locale detection**: purely from the URL path (`useLocale()` composable — `/en` or `/en/...` is
|
||||
English, everything else Italian). No cookie, no `Accept-Language` negotiation.
|
||||
- **Static UI strings**: `frontend/shared/utils/i18n.ts` — a plain `{ it: {...}, en: {...} }`
|
||||
object (`STRINGS`), keyed by role, with an `interpolate()` helper for the handful of strings that
|
||||
take a parameter (e.g. `"Pagina {current} di {total}"`). This is the *only* place UI copy lives;
|
||||
don't hardcode a string in a component when adding a new one.
|
||||
- **Locale-aware paths**: `frontend/shared/utils/locale.ts` — `localePath(locale, path)` prefixes a
|
||||
bare path with `/en` when needed; `bareLocalePath(path)` does the inverse.
|
||||
- **Dates**: `formatDate`/`formatDateTime` (`shared/utils/format.ts`) take an optional second
|
||||
`Locale` argument (`'it' | 'en'`, default `'it'`) and format via `Intl.DateTimeFormat` with
|
||||
`it-IT`/`en-GB`. No new dependency, and every pre-existing call site keeps working unchanged.
|
||||
- **`/content/*` endpoints**: all four take an optional `?lang=en` query param
|
||||
(`langParam()` in `server/utils/queries.ts`, default `it`). See below for the fallback policy.
|
||||
- **The language-switch button** (`app/components/LanguageSwitch.vue`, rendered in the header by
|
||||
`default.vue`) needs to know, for the *current* page, whether an equivalent page exists in the
|
||||
other language — an article/category with no translation shouldn't link to a page that then
|
||||
404s. That information is resolved by `app/middleware/lang-switch.global.ts` **before** the page
|
||||
renders, into a shared `useLangSwitchState()` (`{ available: boolean; fallback: string }`).
|
||||
This has to be a route middleware, not a `ref` set from inside the page component itself: a
|
||||
layout renders its header (where the switch lives) before the page content in document order, so
|
||||
by the time a page component's own `<script setup>` would set that state, the header has already
|
||||
resolved and rendered with whatever the default was — middleware runs, and fully resolves,
|
||||
before the layout/page tree starts rendering at all, which is the only way to avoid that race.
|
||||
When unavailable, the switch falls back to the other language's blog index (articles/categories)
|
||||
or home page (the static corralito page, which has no English version at all).
|
||||
- **SEO**: `app/composables/useSeo.ts` centralises canonical URL, Open Graph, JSON-LD, and
|
||||
`hreflang` alternate links (`it`, `en`, `x-default`) for a given `{ locale, path, ... }` — `path`
|
||||
is always the bare, unprefixed path, so IT/EN pages for the same content always agree on each
|
||||
other's URL. `hasAlternate: false` omits the `hreflang="en"` link entirely for an untranslated
|
||||
article. Introduced now (rather than earlier) because doubling the pre-existing per-page
|
||||
canonical/OG/JSON-LD duplication across two locales would have made an already-duplicated
|
||||
pattern much worse.
|
||||
|
||||
#### `/content/*` fallback policy for missing translations
|
||||
|
||||
- **Detail endpoints** (`/content/articles/:slug`, `/content/categories/:slug`) with `?lang=en`:
|
||||
if the record has no translation, **404** — never silently fall back to Italian text on an
|
||||
`/en/...` URL. The language-switch button (above) avoids ever linking to this in the first
|
||||
place; the 404 is a safety net, e.g. for a bookmarked/shared link.
|
||||
- **Listing endpoints** (`/content/articles`, `/content/categories`) with `?lang=en`: **filter
|
||||
out** untranslated records (`titleEn != ''` / `nameEn != ''` in the PocketBase query) rather than
|
||||
404ing the whole list, so an `/en/blog` archive only ever links to articles that actually exist
|
||||
in English.
|
||||
- A category's English **name** shown inline within an article (nested `expand.category`) falls
|
||||
back to the Italian name if untranslated — that's secondary metadata alongside the primary
|
||||
content, not the resource being requested, so the stricter 404 policy doesn't apply there.
|
||||
|
||||
## Server (Nitro) endpoints — the only PocketBase client
|
||||
|
||||
`frontend/server/routes/content/` (not `server/api/` — `/api/*` is reserved for PocketBase itself,
|
||||
see [architecture.md](./architecture.md#path-routing-caddy); Nitro maps `server/routes/**` to the
|
||||
matching path with no added prefix, unlike `server/api/**`):
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /content/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter, optional `lang` (`it`\|`en`, default `it`). Queries PocketBase with `ARTICLE_SUMMARY_FIELDS`. Returns `Paginated<ArticleSummary>`. |
|
||||
| `GET /content/articles/:slug` | One article: queries PocketBase with `ARTICLE_DETAIL_FIELDS` (includes `content` + `authorName`), converts Markdown to HTML, builds the meta description. 400 without a slug, 404 if not found, unpublished, or (`lang=en`) untranslated. Returns `Article`, including `hasTranslation`. |
|
||||
| `GET /content/categories` | All categories (name + slug), sorted by name. Optional `lang`. |
|
||||
| `GET /content/categories/:slug` | One category by slug. 400/404 as above (including `lang=en` + untranslated). |
|
||||
|
||||
All four accept `?lang=en` — see [English content](#english-content) for the fallback policy.
|
||||
|
||||
This is the **single point of contact** with PocketBase (`CLAUDE.md` rule): pages never call
|
||||
`$fetch` against PocketBase directly, and `NUXT_POCKETBASE_URL` never reaches the client. If you
|
||||
add a view that needs new data, add the endpoint here (under `/content/*`) and type its return in
|
||||
`frontend/shared/types/blog.ts` — don't scatter PocketBase calls into components.
|
||||
|
||||
`GET /admin` (`frontend/server/routes/admin.get.ts`) is the one other top-level server route: a
|
||||
redirect to `${runtimeConfig.public.pocketbaseUrl}/_/`, PocketBase's own fixed dashboard path. It
|
||||
lives in Nitro rather than Caddy so it works the same in dev (no Caddy in that stack) and
|
||||
production — see [architecture.md](./architecture.md#path-routing-caddy).
|
||||
|
||||
## Supporting utilities
|
||||
|
||||
- `frontend/server/utils/pocketbase.ts`
|
||||
- `pbFetch<T>(path)` — server-only fetch against `runtimeConfig.pocketbaseUrl`; wraps failures
|
||||
as a 502 so internal details never leak to the client.
|
||||
- `toMediaImage(collection, id, filename, alt)` — builds a PocketBase file URL
|
||||
(`/api/files/{collection}/{id}/{filename}`) from a record; returns `null` when there's no
|
||||
file.
|
||||
- `renderMarkdown(source)` — `marked.parse()`, no sanitization (trusted, admin-only content).
|
||||
- `summarise(source, maxLength = 155)` — strips Markdown syntax to build a plain-text meta
|
||||
description, word-boundary clipped.
|
||||
- `frontend/server/utils/queries.ts` — PocketBase query-string builders kept intentionally minimal
|
||||
(only the fields each page actually renders): `ARTICLE_SUMMARY_FIELDS`, `ARTICLE_DETAIL_FIELDS`,
|
||||
`PAGE_SIZE`, `pagination()`, `pageParam()`, `quote()` (escapes a value for PocketBase's `filter`
|
||||
DSL).
|
||||
- `frontend/app/composables/useMediaUrl.ts` — turns a `MediaImage` into an absolute browser URL by
|
||||
prefixing `runtimeConfig.public.pocketbaseUrl` unless already absolute.
|
||||
- `frontend/app/composables/useLocale.ts` — derives the current `Locale` from the URL path.
|
||||
- `frontend/app/composables/useLangSwitch.ts` — `useLangSwitchState()`, the shared
|
||||
`{ available, fallback }` state the language-switch button reads (set by
|
||||
`app/middleware/lang-switch.global.ts` — see [English content](#english-content)).
|
||||
- `frontend/app/composables/useSeo.ts` — canonical/OG/JSON-LD/hreflang, see
|
||||
[English content](#english-content).
|
||||
- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
|
||||
- `frontend/shared/utils/format.ts` — date formatting (`formatDate`, `formatDateTime`, `isoDate`),
|
||||
locale-parametrized (`it-IT`/`en-GB`, default `it`).
|
||||
- `frontend/shared/utils/locale.ts` — `Locale` type, `localePath()`/`bareLocalePath()`.
|
||||
- `frontend/shared/utils/i18n.ts` — the `STRINGS` dictionary and `interpolate()` helper for static
|
||||
UI copy, see [English content](#english-content).
|
||||
|
||||
## Types (`frontend/shared/types/blog.ts`)
|
||||
|
||||
```
|
||||
MediaImage { url, alt }
|
||||
Category { name, slug }
|
||||
ArticleSummary{ title, slug, publishedAt, cover: MediaImage | null, category: Category | null }
|
||||
Article extends ArticleSummary { html, summary, author: string | null, hasTranslation }
|
||||
Paginated<T> { items: T[], page, pageCount, total }
|
||||
```
|
||||
|
||||
`hasTranslation` is true when the article has a non-empty English title *and* content, regardless
|
||||
of which `lang` was requested — it's what the language-switch button uses to decide whether to
|
||||
link to this article's English version or fall back to the English blog index.
|
||||
|
||||
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
|
||||
listing pages use. `MediaImage` carries no width/height — PocketBase file fields don't store
|
||||
dimensions, and the covers already reserve their aspect ratio via CSS (`aspect-ratio`), so no
|
||||
layout shift results.
|
||||
|
||||
## Layout & shared components
|
||||
|
||||
- `app/layouts/default.vue` — the only layout: header (logo, tagline, nav, language switch),
|
||||
`<main id="main">` slot, footer (contact email, nav, social links), with a skip-link for
|
||||
accessibility. All copy comes from `STRINGS[locale]` (`shared/utils/i18n.ts`).
|
||||
- `app/components/ArticleCard.vue` — listing-grid card: cover (lazy, omitted if none), category
|
||||
kicker, title link, formatted date. Takes an optional `locale` prop (default `it`) for the link
|
||||
prefix and date formatting.
|
||||
- `app/components/SocialIcon.vue` — inlines SVG brand marks from `simple-icons` at build time
|
||||
(`?raw` imports) rather than bundling the whole icon set.
|
||||
- `app/components/LanguageSwitch.vue` — the header's language toggle: both language names are
|
||||
always shown (`Italiano / English`), the current one as plain non-interactive text
|
||||
(`aria-current="true"`), the other as a link — reads `useLangSwitchState()` and `useLocale()` to
|
||||
compute the target path, per the fallback rules in [English content](#english-content).
|
||||
- `app/components/PaginationNav.vue` — numbered pagination (not just prev/next): first, last, the
|
||||
current page and its immediate neighbors, collapsing gaps into a single `…` via
|
||||
`shared/utils/pagination.ts`'s `paginationRange()`. Used by `BlogIndexView`/`CategoryView`.
|
||||
Renders page links via `NuxtLink`'s `custom`/`v-slot` API rather than letting the component
|
||||
render its own `<a>` — `RouterLink`'s built-in active-class/`aria-current` detection compares
|
||||
only the route's `path`, not its query string, so with plain `NuxtLink`s every `?page=N` link
|
||||
would end up (wrongly) marked as the current page.
|
||||
- `app/components/views/*.vue` (`HomeView`, `BlogIndexView`, `ArticleView`, `CategoryView`) — the
|
||||
actual page markup/logic, parametrized by a `locale` prop; the real `pages/**` and `pages/en/**`
|
||||
files are thin wrappers around these (see [English content](#english-content)). Registered
|
||||
without Nuxt's default nested-directory name prefix
|
||||
(`components: [{ path: '~/components', pathPrefix: false }]` in `nuxt.config.ts`), so pages
|
||||
reference them as `<HomeView>` etc., not `<ViewsHomeView>`.
|
||||
|
||||
## SEO & accessibility
|
||||
|
||||
Every article page ships: unique `<title>`, meta description, canonical URL, Open Graph tags, and
|
||||
JSON-LD `BlogPosting` structured data, with the article body already present in server-rendered
|
||||
HTML (no client-only content). Accessibility requirements (focus visibility, labeled inputs,
|
||||
meaningful alt text, descriptive links, full keyboard navigation) apply across all pages/components
|
||||
— see `CLAUDE.md`.
|
||||
|
||||
## Tests (`tests/`, repo root)
|
||||
|
||||
Kept at the repo root, not under `frontend/`, since e2e exercises the frontend *and* PocketBase
|
||||
together — but the tooling (Vitest/Playwright configs, `node_modules`) still lives in `frontend/`,
|
||||
the only npm project in the repo; the configs there just point `include`/`testDir` at `../tests/`.
|
||||
|
||||
Three layers, all against real code (no mocked PocketBase):
|
||||
|
||||
- `tests/unit/` — Vitest, `environment: 'nuxt'` (`frontend/vitest.unit.config.ts`). Pure functions
|
||||
in `frontend/server/utils/*` and `frontend/shared/utils/*` (imported directly, not via
|
||||
auto-import) plus components and composables via `@nuxt/test-utils/runtime`'s `mountSuspended`.
|
||||
- `tests/integration/` — Vitest, node environment (`frontend/vitest.integration.config.ts`).
|
||||
`@nuxt/test-utils/e2e`'s `setup()` builds and runs the real Nitro server, pointed (via
|
||||
`nuxtConfig.runtimeConfig` overrides) at a real ephemeral PocketBase instance from
|
||||
`tests/support/pocketbase.ts`. Exercises the actual `/content/*` and `/admin` routes.
|
||||
- `tests/e2e/` — Playwright (`frontend/playwright.config.ts`), a real browser against the built
|
||||
app (`node .output/server/index.mjs`) and another ephemeral PocketBase on a fixed port (needed
|
||||
so the Nuxt server's env and Playwright's `globalSetup` can reference each other without an
|
||||
async hand-off — see the comments in `playwright.config.ts`/`tests/e2e/global-setup.ts`).
|
||||
|
||||
`tests/support/pocketbase.ts` is the shared piece: it downloads the same pinned PocketBase binary
|
||||
`pocketbase/Dockerfile` uses (cached in `.cache/pocketbase/` at the repo root, gitignored), starts
|
||||
it against the real `pocketbase/pb_migrations/`, and seeds it from `tests/support/fixtures.ts` —
|
||||
so integration and e2e tests run against the exact schema/rules that ship to production, not a
|
||||
hand-maintained approximation of them.
|
||||
@@ -1,8 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
const { locale } = useLocale()
|
||||
useHead({ htmlAttrs: { lang: locale } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
|
||||
@@ -94,6 +94,19 @@ h3 {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Visually hidden but still announced by screen readers. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Small uppercase label used for categories, dates and section headings. */
|
||||
.kicker {
|
||||
margin: 0;
|
||||
@@ -223,16 +236,3 @@ h3 {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: var(--gap-section);
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ article: ArticleSummary }>()
|
||||
const props = withDefaults(defineProps<{ article: ArticleSummary; locale?: Locale }>(), {
|
||||
locale: 'it',
|
||||
})
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const cover = computed(() => mediaUrl(props.article.cover))
|
||||
const { locale } = useLocale()
|
||||
const href = computed(() => localePath(props.locale, `/blog/${props.article.slug}`))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,16 +19,14 @@ const { locale } = useLocale()
|
||||
<NuxtLink
|
||||
v-if="cover"
|
||||
class="cover-link"
|
||||
:to="`/blog/${article.slug}`"
|
||||
:to="href"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alternativeText ?? ''"
|
||||
:width="article.cover?.width ?? undefined"
|
||||
:height="article.cover?.height ?? undefined"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
loading="lazy"
|
||||
>
|
||||
</NuxtLink>
|
||||
@@ -32,7 +34,7 @@ const { locale } = useLocale()
|
||||
<p v-if="article.category" class="kicker">{{ article.category.name }}</p>
|
||||
|
||||
<h2>
|
||||
<NuxtLink :to="`/blog/${article.slug}`">{{ article.title }}</NuxtLink>
|
||||
<NuxtLink :to="href">{{ article.title }}</NuxtLink>
|
||||
</h2>
|
||||
|
||||
<p class="kicker date">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { bareLocalePath, localePath } from '#shared/utils/locale'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
const route = useRoute()
|
||||
const locale = useLocale()
|
||||
const langSwitch = useLangSwitchState()
|
||||
|
||||
const strings = computed(() => STRINGS[locale.value])
|
||||
|
||||
function targetFor(otherLocale: Locale): string {
|
||||
if (!langSwitch.value.available) return langSwitch.value.fallback
|
||||
return localePath(otherLocale, bareLocalePath(route.path))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lang-switch" :aria-label="strings.langSwitch.ariaLabel" role="group">
|
||||
<span v-if="locale === 'it'" class="lang-option is-current" aria-current="true">
|
||||
{{ strings.langSwitch.it }}
|
||||
</span>
|
||||
<NuxtLink v-else class="lang-option" :to="targetFor('it')">
|
||||
{{ strings.langSwitch.it }}
|
||||
</NuxtLink>
|
||||
|
||||
<span class="lang-divider" aria-hidden="true">/</span>
|
||||
|
||||
<span v-if="locale === 'en'" class="lang-option is-current" aria-current="true">
|
||||
{{ strings.langSwitch.en }}
|
||||
</span>
|
||||
<NuxtLink v-else class="lang-option" :to="targetFor('en')">
|
||||
{{ strings.langSwitch.en }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lang-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lang-option {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
color: var(--color-muted);
|
||||
padding-block: 0.25rem;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
a.lang-option:hover,
|
||||
a.lang-option:focus-visible {
|
||||
color: var(--color-ink);
|
||||
border-bottom-color: var(--color-ink);
|
||||
}
|
||||
|
||||
.lang-option.is-current {
|
||||
color: var(--color-ink);
|
||||
border-bottom-color: var(--color-ink);
|
||||
}
|
||||
|
||||
.lang-divider {
|
||||
color: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
import { paginationRange } from '#shared/utils/pagination'
|
||||
|
||||
const props = defineProps<{ page: number; pageCount: number; locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
const items = computed(() => paginationRange(props.page, props.pageCount))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="pageCount > 1" class="pagination" :aria-label="strings.blog.paginationAriaLabel">
|
||||
<!-- `custom` bypasses NuxtLink/RouterLink's own active-class and aria-current
|
||||
computation, which compares only the path, not the query string — every
|
||||
page link would otherwise be (wrongly) marked as the current page. -->
|
||||
<NuxtLink
|
||||
v-if="page > 1"
|
||||
v-slot="{ href, navigate }"
|
||||
:to="{ query: { page: page - 1 } }"
|
||||
custom
|
||||
>
|
||||
<a :href="href ?? undefined" class="pagination-arrow" rel="prev" :aria-label="strings.blog.prevPage" @click="navigate">
|
||||
<span aria-hidden="true">‹</span>
|
||||
</a>
|
||||
</NuxtLink>
|
||||
<span v-else class="pagination-arrow is-disabled" aria-hidden="true">‹</span>
|
||||
|
||||
<ul class="pagination-list">
|
||||
<li v-for="(item, index) in items" :key="index">
|
||||
<span v-if="item === 'ellipsis'" class="pagination-ellipsis" aria-hidden="true">…</span>
|
||||
<span v-else-if="item === page" class="pagination-link is-current" aria-current="page">
|
||||
{{ item }}
|
||||
</span>
|
||||
<NuxtLink v-else v-slot="{ href, navigate }" :to="{ query: { page: item } }" custom>
|
||||
<a
|
||||
:href="href ?? undefined"
|
||||
class="pagination-link"
|
||||
:aria-label="interpolate(strings.blog.goToPage, { page: item })"
|
||||
@click="navigate"
|
||||
>
|
||||
{{ item }}
|
||||
</a>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NuxtLink
|
||||
v-if="page < pageCount"
|
||||
v-slot="{ href, navigate }"
|
||||
:to="{ query: { page: page + 1 } }"
|
||||
custom
|
||||
>
|
||||
<a :href="href ?? undefined" class="pagination-arrow" rel="next" :aria-label="strings.blog.nextPage" @click="navigate">
|
||||
<span aria-hidden="true">›</span>
|
||||
</a>
|
||||
</NuxtLink>
|
||||
<span v-else class="pagination-arrow is-disabled" aria-hidden="true">›</span>
|
||||
|
||||
<p class="sr-only" role="status">
|
||||
{{ interpolate(strings.blog.pageOf, { current: page, total: pageCount }) }}
|
||||
</p>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: var(--gap-section);
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.pagination-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pagination-arrow,
|
||||
.pagination-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding-inline: 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.pagination-arrow {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.pagination-arrow.is-disabled {
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
.pagination-link:hover {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.pagination-arrow:not(.is-disabled):hover {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.pagination-link.is-current {
|
||||
background: var(--color-ink);
|
||||
color: var(--color-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { Article } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: article } = await useFetch<Article>(`/content/articles/${slug}`, {
|
||||
key: `article-${slug}-${props.locale}`,
|
||||
query: { lang: props.locale },
|
||||
})
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
|
||||
const blogHref = computed(() => localePath(props.locale, '/blog'))
|
||||
const categoryHref = computed(() =>
|
||||
article.value?.category ? localePath(props.locale, `/category/${article.value.category.slug}`) : ''
|
||||
)
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: `/blog/${slug}`,
|
||||
title: article.value.title,
|
||||
description: article.value.summary,
|
||||
type: 'article',
|
||||
image: cover.value,
|
||||
hasAlternate: props.locale === 'en' ? true : article.value.hasTranslation,
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: article.value.title,
|
||||
description: article.value.summary,
|
||||
datePublished: isoDate(article.value.publishedAt),
|
||||
author: article.value.author
|
||||
? { '@type': 'Person', name: article.value.author }
|
||||
: undefined,
|
||||
image: cover.value ? [cover.value] : undefined,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': `${useRuntimeConfig().public.siteUrl}${localePath(props.locale, `/blog/${slug}`)}` },
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="article">
|
||||
<header class="article-header">
|
||||
<nav class="kicker" :aria-label="strings.article.breadcrumbAriaLabel">
|
||||
<NuxtLink :to="blogHref">{{ strings.article.articlesCrumb }}</NuxtLink>
|
||||
<template v-if="article.category">
|
||||
<span aria-hidden="true"> / </span>
|
||||
<NuxtLink :to="categoryHref">
|
||||
{{ article.category.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<h1>{{ article.title }}</h1>
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDateTime(article.publishedAt, locale) }}
|
||||
</time>
|
||||
<template v-if="article.author">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>{{ interpolate(strings.article.byAuthor, { author: article.author }) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
<div class="prose" v-html="article.html" />
|
||||
|
||||
<p class="back">
|
||||
<NuxtLink :to="blogHref">{{ strings.article.backToArticles }}</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-header {
|
||||
max-width: 46rem;
|
||||
/* The gap below the header lives here, not on the cover: an article without
|
||||
a cover image would otherwise have its body butting against the byline. */
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.article-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.article-header nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-header nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0;
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.back {
|
||||
max-width: var(--width-prose);
|
||||
margin: 4rem auto 0;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.back a {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
margin-block: 0 3rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: () => `blog-${props.locale}-${page.value}`,
|
||||
query: { page, lang: props.locale },
|
||||
})
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: '/blog',
|
||||
title: page.value > 1 ? interpolate(strings.value.blog.titlePage, { page: page.value }) : strings.value.blog.title,
|
||||
description: strings.value.blog.metaDescription,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ strings.blog.archiveKicker }}</p>
|
||||
<h1>{{ strings.blog.title }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ strings.blog.loadError }}</p>
|
||||
|
||||
<p v-else-if="status === 'pending'">{{ strings.blog.loading }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ strings.blog.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" :locale="locale" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<PaginationNav :page="data.page" :page-count="data.pageCount" :locale="locale" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data: category } = await useFetch<Category>(`/content/categories/${slug}`, {
|
||||
key: `category-${slug}-${props.locale}`,
|
||||
query: { lang: props.locale },
|
||||
})
|
||||
|
||||
if (!category.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: () => `category-articles-${slug}-${props.locale}-${page.value}`,
|
||||
query: { page, category: slug, lang: props.locale },
|
||||
})
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: `/category/${slug}`,
|
||||
title: category.value.name,
|
||||
description: interpolate(strings.value.category.metaDescription, { name: category.value.name }),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ strings.category.kicker }}</p>
|
||||
<h1>{{ category?.name }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ strings.category.loadError }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ strings.category.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" :locale="locale" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<PaginationNav :page="data.page" :page-count="data.pageCount" :locale="locale" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: `home-articles-${props.locale}`,
|
||||
query: { page: 1, lang: props.locale },
|
||||
})
|
||||
|
||||
const featured = computed(() => data.value?.items[0] ?? null)
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
||||
const featuredHref = computed(() => (featured.value ? localePath(props.locale, `/blog/${featured.value.slug}`) : '/'))
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: '/',
|
||||
title: SITE_NAME,
|
||||
description: strings.value.home.metaDescription,
|
||||
image: `${useRuntimeConfig().public.siteUrl}/logo.png`,
|
||||
})
|
||||
useHead({ titleTemplate: '%s' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="landing">
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/protesta-bancaria.jpg"
|
||||
:alt="strings.home.img1Alt"
|
||||
width="770"
|
||||
height="420"
|
||||
>
|
||||
|
||||
<h1>{{ strings.home.heading }}</h1>
|
||||
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/eurozona-crisi.jpg"
|
||||
:alt="strings.home.img2Alt"
|
||||
width="1000"
|
||||
height="736"
|
||||
>
|
||||
|
||||
<div class="landing-text">
|
||||
<p>{{ strings.home.p1 }}</p>
|
||||
<p>{{ strings.home.p2 }}</p>
|
||||
<p>{{ strings.home.p3 }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error">{{ strings.blog.loadError }}</p>
|
||||
|
||||
<p v-else-if="!featured">{{ strings.blog.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||
<NuxtLink
|
||||
v-if="featuredCover"
|
||||
class="featured-cover"
|
||||
:to="featuredHref"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alt ?? ''"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="featured-text">
|
||||
<p class="kicker">
|
||||
{{ featured.category ? featured.category.name : strings.home.fallbackKicker }}
|
||||
</p>
|
||||
<h2>
|
||||
<NuxtLink :to="featuredHref">{{ featured.title }}</NuxtLink>
|
||||
</h2>
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(featured.publishedAt)">
|
||||
{{ formatDate(featured.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
<p class="read-more">
|
||||
<NuxtLink :to="featuredHref">
|
||||
{{ strings.home.readLatest }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing {
|
||||
max-width: var(--width-prose);
|
||||
margin-inline: auto;
|
||||
margin-bottom: var(--gap-section);
|
||||
padding-bottom: var(--gap-section);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
margin-block: 1.75rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.landing-text {
|
||||
margin-top: 1.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-text p {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.featured {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
||||
width leaves both the image and the headline too cramped to read. The split
|
||||
also needs a cover to sit in it, or one column would just be empty. */
|
||||
@media (min-width: 64rem) {
|
||||
.featured.has-cover {
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Without a cover the headline is the whole lead, so it gets the full width
|
||||
but stays inside a readable measure. */
|
||||
.featured:not(.has-cover) .featured-text {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.featured-cover {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-cover img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured h2 {
|
||||
margin-top: 0.6rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.featured h2 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured h2 a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
margin-top: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.read-more a {
|
||||
padding-bottom: 0.35rem;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared per-navigation state for the language-switch button in the layout.
|
||||
* Each page sets this on setup: `available` is false when there is no
|
||||
* equivalent page in the other language for the current content (e.g. an
|
||||
* untranslated article, or the Italian-only corralito page) — in that case
|
||||
* `fallback` is where the switch should go instead (typically the other
|
||||
* language's blog index).
|
||||
*/
|
||||
interface LangSwitchState {
|
||||
available: boolean
|
||||
fallback: string
|
||||
}
|
||||
|
||||
export function useLangSwitchState() {
|
||||
return useState<LangSwitchState>('lang-switch', () => ({ available: true, fallback: '/en' }))
|
||||
}
|
||||
@@ -1,21 +1,7 @@
|
||||
import { STRINGS, type Strings } from '../i18n/strings'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
export type Locale = 'it' | 'en'
|
||||
|
||||
/**
|
||||
* Site language, persisted in a cookie so SSR renders the right language on
|
||||
* the first request. `t(key)` reads the current locale's UI string.
|
||||
*/
|
||||
/** Derives the current locale from the URL path — `/en` or `/en/...` is English, everything else Italian. */
|
||||
export function useLocale() {
|
||||
const locale = useCookie<Locale>('lang', { default: () => 'it', sameSite: 'lax' })
|
||||
|
||||
function setLocale(value: Locale) {
|
||||
locale.value = value
|
||||
}
|
||||
|
||||
function t<K extends keyof Strings>(key: K): Strings[K] {
|
||||
return STRINGS[locale.value][key]
|
||||
}
|
||||
|
||||
return { locale, setLocale, t }
|
||||
const route = useRoute()
|
||||
return computed<Locale>(() => (route.path === '/en' || route.path.startsWith('/en/') ? 'en' : 'it'))
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { StrapiImage } from '#shared/types/blog'
|
||||
import type { MediaImage } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Strapi returns media paths relative to its own origin; the browser needs
|
||||
* absolute ones. Remote providers (S3) already return absolute URLs.
|
||||
* PocketBase returns file paths relative to its own origin; the browser
|
||||
* needs absolute ones.
|
||||
*/
|
||||
export function useMediaUrl() {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
|
||||
return (image: StrapiImage | null | undefined): string | null => {
|
||||
return (image: MediaImage | null | undefined): string | null => {
|
||||
if (!image?.url) return null
|
||||
return image.url.startsWith('http') ? image.url : `${config.strapiUrl}${image.url}`
|
||||
return image.url.startsWith('http') ? image.url : `${config.pocketbaseUrl}${image.url}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
interface SeoOptions {
|
||||
locale: Locale
|
||||
/** The bare, unprefixed path, e.g. `/blog/mio-slug` — never `/en/...`. */
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
type?: 'website' | 'article'
|
||||
image?: string
|
||||
/** False when no translation exists at all for this content — omits the alternate link. */
|
||||
hasAlternate?: boolean
|
||||
jsonLd?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralises canonical/hreflang/OG/JSON-LD head tags so IT and EN pages for
|
||||
* the same content always agree on each other's URL — duplicating this per
|
||||
* page, doubled for two locales, is exactly how hreflang correctness drifts.
|
||||
*/
|
||||
export function useSeo(options: SeoOptions) {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const bare = options.path === '/' ? '' : options.path
|
||||
const itUrl = `${config.siteUrl}${bare}`
|
||||
const enUrl = `${config.siteUrl}/en${bare}`
|
||||
const canonical = options.locale === 'en' ? enUrl : itUrl
|
||||
|
||||
useSeoMeta({
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
ogTitle: options.title,
|
||||
ogDescription: options.description,
|
||||
ogType: options.type ?? 'website',
|
||||
ogUrl: canonical,
|
||||
ogImage: options.image,
|
||||
twitterCard: options.image ? 'summary_large_image' : undefined,
|
||||
})
|
||||
|
||||
const link: Array<{ rel: 'canonical' | 'alternate'; href: string; hreflang?: string }> = [
|
||||
{ rel: 'canonical', href: canonical },
|
||||
]
|
||||
if (options.hasAlternate !== false) {
|
||||
link.push(
|
||||
{ rel: 'alternate', hreflang: 'it', href: itUrl },
|
||||
{ rel: 'alternate', hreflang: 'en', href: enUrl },
|
||||
{ rel: 'alternate', hreflang: 'x-default', href: itUrl }
|
||||
)
|
||||
}
|
||||
|
||||
useHead({
|
||||
htmlAttrs: { lang: options.locale },
|
||||
// unhead's Link type narrows required keys per literal `rel` value in a
|
||||
// way a plain canonical/alternate/hreflang link doesn't fit — the shape
|
||||
// itself is valid HTML, so this is a type-only escape hatch.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
link: link as any,
|
||||
script: options.jsonLd
|
||||
? [{ type: 'application/ld+json', innerHTML: JSON.stringify(options.jsonLd) }]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/** Static UI strings, keyed once and translated for each supported locale. */
|
||||
|
||||
export interface Strings {
|
||||
skipToContent: string
|
||||
tagline: string
|
||||
navHome: string
|
||||
navAllArticles: string
|
||||
navCorralito: string
|
||||
navPrimary: string
|
||||
footerNavigate: string
|
||||
footerFollow: string
|
||||
footerContact: string
|
||||
footerRights: string
|
||||
switchLanguage: string
|
||||
loadError: string
|
||||
noArticlesYet: string
|
||||
latestFallback: string
|
||||
readArticle: string
|
||||
seeAllArticles: string
|
||||
archiveKicker: string
|
||||
articlesTitle: string
|
||||
allArticlesDescription: string
|
||||
pagePrefix: string
|
||||
loading: string
|
||||
pagination: string
|
||||
prevPage: string
|
||||
nextPage: string
|
||||
pageOf: (page: number, pageCount: number) => string
|
||||
breadcrumbArticles: string
|
||||
byAuthor: (name: string) => string
|
||||
backToArticles: string
|
||||
categoryKicker: string
|
||||
categoryDescription: (name: string) => string
|
||||
noArticlesInCategory: string
|
||||
}
|
||||
|
||||
export const STRINGS: Record<'it' | 'en', Strings> = {
|
||||
it: {
|
||||
skipToContent: 'Vai al contenuto',
|
||||
tagline: 'Articoli, guide e analisi.',
|
||||
navHome: 'Home',
|
||||
navAllArticles: 'Tutti gli articoli',
|
||||
navCorralito: 'Come difendersi dal corralito',
|
||||
navPrimary: 'Navigazione principale',
|
||||
footerNavigate: 'Naviga',
|
||||
footerFollow: 'Seguici',
|
||||
footerContact: 'Contatti:',
|
||||
footerRights: 'Tutti i diritti riservati.',
|
||||
switchLanguage: 'English',
|
||||
loadError: 'Non è stato possibile caricare gli articoli. Riprova più tardi.',
|
||||
noArticlesYet: 'Non è stato ancora pubblicato nulla.',
|
||||
latestFallback: 'Ultimo',
|
||||
readArticle: "Leggi l'articolo",
|
||||
seeAllArticles: 'Vedi tutti gli articoli',
|
||||
archiveKicker: "L'archivio",
|
||||
articlesTitle: 'Articoli',
|
||||
allArticlesDescription: 'Tutti gli articoli pubblicati sul blog.',
|
||||
pagePrefix: 'pagina',
|
||||
loading: 'Caricamento…',
|
||||
pagination: 'Paginazione',
|
||||
prevPage: 'Pagina precedente',
|
||||
nextPage: 'Pagina successiva',
|
||||
pageOf: (page: number, pageCount: number) => `Pagina ${page} di ${pageCount}`,
|
||||
breadcrumbArticles: 'Articoli',
|
||||
byAuthor: (name: string) => `Di ${name}`,
|
||||
backToArticles: 'Torna a tutti gli articoli',
|
||||
categoryKicker: 'Categoria',
|
||||
categoryDescription: (name: string) => `Tutti gli articoli nella categoria ${name}.`,
|
||||
noArticlesInCategory: 'Non ci sono articoli in questa categoria.',
|
||||
},
|
||||
en: {
|
||||
skipToContent: 'Skip to content',
|
||||
tagline: 'Articles, guides and analysis.',
|
||||
navHome: 'Home',
|
||||
navAllArticles: 'All articles',
|
||||
navCorralito: 'Come difendersi dal corralito',
|
||||
navPrimary: 'Main navigation',
|
||||
footerNavigate: 'Navigate',
|
||||
footerFollow: 'Follow',
|
||||
footerContact: 'Contact:',
|
||||
footerRights: 'All rights reserved.',
|
||||
switchLanguage: 'Italiano',
|
||||
loadError: 'The articles could not be loaded. Please try again later.',
|
||||
noArticlesYet: 'Nothing has been published yet.',
|
||||
latestFallback: 'Latest',
|
||||
readArticle: 'Read the article',
|
||||
seeAllArticles: 'See all articles',
|
||||
archiveKicker: 'The archive',
|
||||
articlesTitle: 'Articles',
|
||||
allArticlesDescription: 'All articles published on the blog.',
|
||||
pagePrefix: 'page',
|
||||
loading: 'Loading…',
|
||||
pagination: 'Pagination',
|
||||
prevPage: 'Previous page',
|
||||
nextPage: 'Next page',
|
||||
pageOf: (page: number, pageCount: number) => `Page ${page} of ${pageCount}`,
|
||||
breadcrumbArticles: 'Articles',
|
||||
byAuthor: (name: string) => `By ${name}`,
|
||||
backToArticles: 'Back to all articles',
|
||||
categoryKicker: 'Category',
|
||||
categoryDescription: (name: string) => `All articles in the ${name} category.`,
|
||||
noArticlesInCategory: 'There are no articles in this category.',
|
||||
},
|
||||
}
|
||||
@@ -1,43 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
const { locale, setLocale, t } = useLocale()
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const otherLocale = computed(() => (locale.value === 'it' ? 'en' : 'it'))
|
||||
|
||||
/**
|
||||
* Switches the site language. On an article/category page, jumps to the
|
||||
* translated counterpart's slug if one exists; elsewhere reloads the current
|
||||
* route so it refetches its content in the new language.
|
||||
*/
|
||||
function switchLanguage() {
|
||||
setLocale(otherLocale.value)
|
||||
reloadNuxtApp({ path: useRoute().fullPath })
|
||||
}
|
||||
const locale = useLocale()
|
||||
const strings = computed(() => STRINGS[locale.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<a class="skip-link" href="#main">{{ t('skipToContent') }}</a>
|
||||
<a class="skip-link" href="#main">{{ strings.skipLink }}</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<div class="container header-inner">
|
||||
<LanguageSwitch class="lang-switch-header" />
|
||||
|
||||
<p class="wordmark">
|
||||
<NuxtLink to="/">
|
||||
<NuxtLink :to="localePath(locale, '/')">
|
||||
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<p class="tagline kicker">{{ t('tagline') }}</p>
|
||||
<p class="tagline kicker">{{ strings.tagline }}</p>
|
||||
</div>
|
||||
|
||||
<nav class="site-nav" :aria-label="t('navPrimary')">
|
||||
<nav class="site-nav" :aria-label="strings.navAriaLabel">
|
||||
<div class="container nav-inner">
|
||||
<ul>
|
||||
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||
<li v-if="locale === 'it'">
|
||||
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="lang-switch" @click="switchLanguage">
|
||||
{{ t('switchLanguage') }}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -50,24 +43,26 @@ function switchLanguage() {
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-block">
|
||||
<img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||
<p class="muted">{{ t('tagline') }}</p>
|
||||
<p class="muted">{{ strings.tagline }}</p>
|
||||
<address class="contact">
|
||||
{{ t('footerContact') }}
|
||||
{{ strings.contactLabel }}
|
||||
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<nav class="footer-block" :aria-label="t('footerNavigate')">
|
||||
<p class="kicker">{{ t('footerNavigate') }}</p>
|
||||
<nav class="footer-block" :aria-label="strings.footerNavAriaLabel">
|
||||
<p class="kicker">{{ strings.footerNavigaKicker }}</p>
|
||||
<ul>
|
||||
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||
<li v-if="locale === 'it'">
|
||||
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="footer-block" :aria-label="t('footerFollow')">
|
||||
<p class="kicker">{{ t('footerFollow') }}</p>
|
||||
<nav class="footer-block" :aria-label="strings.footerFollowAriaLabel">
|
||||
<p class="kicker">{{ strings.footerSeguiciKicker }}</p>
|
||||
<ul>
|
||||
<li v-for="social in SOCIAL_LINKS" :key="social.name">
|
||||
<a class="social" :href="social.url" target="_blank" rel="noopener me">
|
||||
@@ -80,7 +75,7 @@ function switchLanguage() {
|
||||
</div>
|
||||
|
||||
<p class="container copyright kicker">
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ t('footerRights') }}
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ strings.copyright }}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -106,6 +101,16 @@ function switchLanguage() {
|
||||
}
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lang-switch-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: var(--space);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -132,10 +137,7 @@ function switchLanguage() {
|
||||
|
||||
.nav-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.5rem;
|
||||
padding-block: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -153,24 +155,6 @@ function switchLanguage() {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.35rem 0.75rem;
|
||||
color: var(--color-body);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang-switch:hover {
|
||||
color: var(--color-ink);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.site-nav {
|
||||
margin-top: 2rem;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Article, Category } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Resolves the language-switch target *before* the layout (and its header,
|
||||
* rendered ahead of the page in document order) mounts — setting this from
|
||||
* inside the page component itself is too late: a parent renders its
|
||||
* children in document order, so the header's LanguageSwitch would already
|
||||
* be resolved before the page's own setup ever runs, translated content or
|
||||
* not. Running as global route middleware guarantees this resolves first.
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const langSwitch = useLangSwitchState()
|
||||
const locale = to.path === '/en' || to.path.startsWith('/en/') ? 'en' : 'it'
|
||||
const bare = locale === 'en' ? to.path.slice(3) || '/' : to.path
|
||||
|
||||
const articleMatch = bare.match(/^\/blog\/([^/]+)$/)
|
||||
const categoryMatch = bare.match(/^\/category\/([^/]+)$/)
|
||||
|
||||
if (articleMatch) {
|
||||
const slug = articleMatch[1]
|
||||
const { data } = await useFetch<Article>(`/content/articles/${slug}`, {
|
||||
key: `article-${slug}-${locale}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
langSwitch.value = { available: Boolean(data.value?.hasTranslation), fallback: '/en/blog' }
|
||||
return
|
||||
}
|
||||
|
||||
if (categoryMatch) {
|
||||
// A category's own translation only gates that specific label — the
|
||||
// switch always lands somewhere useful (a filtered, possibly empty,
|
||||
// article list), so it's always shown.
|
||||
void useFetch<Category>(`/content/categories/${categoryMatch[1]}`, {
|
||||
key: `category-${categoryMatch[1]}-${locale}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
langSwitch.value = { available: true, fallback: '/en/blog' }
|
||||
return
|
||||
}
|
||||
|
||||
if (bare === '/come-difendersi-dal-corralito') {
|
||||
langSwitch.value = { available: false, fallback: '/en' }
|
||||
return
|
||||
}
|
||||
|
||||
langSwitch.value = { available: true, fallback: '/en' }
|
||||
})
|
||||
@@ -1,165 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { Article } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { locale, t } = useLocale()
|
||||
|
||||
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`, {
|
||||
key: () => `article-${slug}-${locale.value}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const mediaUrl = useMediaUrl()
|
||||
|
||||
const canonical = `${config.siteUrl}${route.path}`
|
||||
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
|
||||
|
||||
useSeoMeta({
|
||||
title: article.value.title,
|
||||
description: article.value.summary,
|
||||
ogTitle: article.value.title,
|
||||
ogDescription: article.value.summary,
|
||||
ogType: 'article',
|
||||
ogUrl: canonical,
|
||||
ogImage: cover,
|
||||
twitterCard: 'summary_large_image',
|
||||
})
|
||||
|
||||
useHead({
|
||||
link: [{ rel: 'canonical', href: canonical }],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: article.value.title,
|
||||
description: article.value.summary,
|
||||
datePublished: isoDate(article.value.publishedAt),
|
||||
inLanguage: locale.value,
|
||||
author: article.value.author
|
||||
? { '@type': 'Person', name: article.value.author }
|
||||
: undefined,
|
||||
image: cover.value ? [cover.value] : undefined,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="article">
|
||||
<header class="article-header">
|
||||
<nav class="kicker" aria-label="Percorso di navigazione">
|
||||
<NuxtLink to="/blog">{{ t('breadcrumbArticles') }}</NuxtLink>
|
||||
<template v-if="article.category">
|
||||
<span aria-hidden="true"> / </span>
|
||||
<NuxtLink :to="`/category/${article.category.slug}`">
|
||||
{{ article.category.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<h1>{{ article.title }}</h1>
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDateTime(article.publishedAt, locale) }}
|
||||
</time>
|
||||
<template v-if="article.author">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>{{ t('byAuthor')(article.author) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alternativeText ?? ''"
|
||||
:width="article.cover?.width ?? undefined"
|
||||
:height="article.cover?.height ?? undefined"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
<div class="prose" v-html="article.html" />
|
||||
|
||||
<p class="back">
|
||||
<NuxtLink to="/blog">{{ t('backToArticles') }}</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
<ArticleView locale="it" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-header {
|
||||
max-width: 46rem;
|
||||
/* The gap below the header lives here, not on the cover: an article without
|
||||
a cover image would otherwise have its body butting against the byline. */
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.article-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.article-header nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-header nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0;
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.back {
|
||||
max-width: var(--width-prose);
|
||||
margin: 4rem auto 0;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.back a {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
margin-block: 0 3rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { locale, t } = useLocale()
|
||||
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
||||
key: () => `blog-articles-${locale.value}-${page.value}`,
|
||||
query: { page, lang: locale },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const canonical = computed(
|
||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => (page.value > 1 ? `${t('articlesTitle')} — ${t('pagePrefix')} ${page.value}` : t('articlesTitle')),
|
||||
description: () => t('allArticlesDescription'),
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ t('archiveKicker') }}</p>
|
||||
<h1>{{ t('articlesTitle') }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ t('loadError') }}</p>
|
||||
|
||||
<p v-else-if="status === 'pending'">{{ t('loading') }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ t('noArticlesYet') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
{{ t('prevPage') }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
{{ t('pageOf')(data.page, data.pageCount) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
{{ t('nextPage') }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
<BlogIndexView locale="it" />
|
||||
</template>
|
||||
|
||||
@@ -1,77 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated, Category } from '#shared/types/blog'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { locale, t } = useLocale()
|
||||
|
||||
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`, {
|
||||
key: () => `category-${slug}-${locale.value}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
|
||||
if (!category.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
||||
key: () => `category-articles-${slug}-${locale.value}-${page.value}`,
|
||||
query: { page, category: slug, lang: locale },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const canonical = computed(
|
||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => category.value?.name ?? '',
|
||||
description: () => t('categoryDescription')(category.value?.name ?? ''),
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ t('categoryKicker') }}</p>
|
||||
<h1>{{ category?.name }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ t('loadError') }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ t('noArticlesInCategory') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
{{ t('prevPage') }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
{{ t('pageOf')(data.page, data.pageCount) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
{{ t('nextPage') }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
<CategoryView locale="it" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<ArticleView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<BlogIndexView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<CategoryView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<HomeView locale="en" />
|
||||
</template>
|
||||
@@ -1,241 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
const { locale, t } = useLocale()
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
||||
key: () => `home-articles-${locale.value}`,
|
||||
query: { page: 1, lang: locale },
|
||||
})
|
||||
|
||||
const featured = computed(() => data.value?.items[0] ?? null)
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const canonical = computed(() => `${config.siteUrl}${route.path}`)
|
||||
|
||||
// The home page is the site itself, so it carries the bare name as its title.
|
||||
useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
|
||||
|
||||
useSeoMeta({
|
||||
title: SITE_NAME,
|
||||
description: () => t('tagline'),
|
||||
ogTitle: SITE_NAME,
|
||||
ogDescription: () => t('tagline'),
|
||||
ogType: 'website',
|
||||
ogUrl: canonical,
|
||||
ogImage: `${config.siteUrl}/logo.png`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="landing">
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/protesta-bancaria.jpg"
|
||||
alt="Manifesti e scritte contro le banche durante una crisi bancaria"
|
||||
width="770"
|
||||
height="420"
|
||||
>
|
||||
|
||||
<h1>La fine dell'Unione Europea per come l'abbiamo conosciuta.</h1>
|
||||
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/eurozona-crisi.jpg"
|
||||
alt="Bandiera dell'Unione Europea incrinata"
|
||||
width="1000"
|
||||
height="736"
|
||||
>
|
||||
|
||||
<div class="landing-text">
|
||||
<p>
|
||||
La fine della nostra moneta come moneta forte: la fine di quella moneta che ci ha
|
||||
dato la vera libertà di viaggiare, consumare e fare.
|
||||
</p>
|
||||
<p>
|
||||
Una fine che avverrà a seguito di un "incidente" nucleare, al quale farà seguito un
|
||||
corralito bancario, ossia l'impossibilità, da parte dei cittadini europei, di
|
||||
disporre liberamente dei propri depositi.
|
||||
</p>
|
||||
<p>
|
||||
Le monete fiduciarie sono per loro natura cannibalizzanti. Oggi tocca all'euro.
|
||||
Anche perché le altre hanno praticamente perso tutte tutto.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error">{{ t('loadError') }}</p>
|
||||
|
||||
<p v-else-if="!featured">{{ t('noArticlesYet') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||
<NuxtLink
|
||||
v-if="featuredCover"
|
||||
class="featured-cover"
|
||||
:to="`/blog/${featured.slug}`"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alternativeText ?? ''"
|
||||
:width="featured.cover?.width ?? undefined"
|
||||
:height="featured.cover?.height ?? undefined"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="featured-text">
|
||||
<p class="kicker">
|
||||
{{ featured.category ? featured.category.name : t('latestFallback') }}
|
||||
</p>
|
||||
<h2>
|
||||
<NuxtLink :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLink>
|
||||
</h2>
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(featured.publishedAt)">
|
||||
{{ formatDate(featured.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
<p class="read-more">
|
||||
<NuxtLink :to="`/blog/${featured.slug}`">
|
||||
{{ t('readArticle') }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p class="more">
|
||||
<NuxtLink to="/blog">{{ t('seeAllArticles') }}</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<HomeView locale="it" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing {
|
||||
max-width: var(--width-prose);
|
||||
margin-inline: auto;
|
||||
margin-bottom: var(--gap-section);
|
||||
padding-bottom: var(--gap-section);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
margin-block: 1.75rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.landing-text {
|
||||
margin-top: 1.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-text p {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.featured {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
||||
width leaves both the image and the headline too cramped to read. The split
|
||||
also needs a cover to sit in it, or one column would just be empty. */
|
||||
@media (min-width: 64rem) {
|
||||
.featured.has-cover {
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Without a cover the headline is the whole lead, so it gets the full width
|
||||
but stays inside a readable measure. */
|
||||
.featured:not(.has-cover) .featured-text {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.featured-cover {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-cover img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured h2 {
|
||||
margin-top: 0.6rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.featured h2 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured h2 a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
margin-top: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.read-more a {
|
||||
padding-bottom: 0.35rem;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
}
|
||||
|
||||
.more {
|
||||
margin-top: var(--gap-section);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.more a {
|
||||
padding: 0.85rem 2.5rem;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--color-ink);
|
||||
color: var(--color-ink);
|
||||
display: inline-block;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.more a:hover {
|
||||
background: var(--color-ink);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
+12
-6
@@ -7,6 +7,11 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
// Default auto-import prefixes nested component dirs with their path
|
||||
// (e.g. `components/views/HomeView.vue` -> `<ViewsHomeView>`) — disabled so
|
||||
// the it/en thin page wrappers can reference `<HomeView>` etc. directly.
|
||||
components: [{ path: '~/components', pathPrefix: false }],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'it' },
|
||||
@@ -20,18 +25,19 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
// Server-only: internal Strapi address, never exposed to the browser.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
strapiToken: '',
|
||||
// Server-only: internal PocketBase address, never exposed to the browser.
|
||||
pocketbaseUrl: 'http://localhost:8090',
|
||||
public: {
|
||||
// Canonical origin of the public website.
|
||||
siteUrl: 'http://localhost:3000',
|
||||
// Browser-reachable Strapi origin, used to build absolute media URLs.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
// Browser-reachable PocketBase origin, used to build absolute media URLs.
|
||||
pocketbaseUrl: 'http://localhost:8090',
|
||||
},
|
||||
},
|
||||
|
||||
typescript: {
|
||||
strict: true,
|
||||
},
|
||||
})
|
||||
|
||||
modules: ['@nuxt/eslint'],
|
||||
})
|
||||
Generated
+3875
-60
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -8,7 +8,13 @@
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck"
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "eslint .",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"pretest:e2e": "ln -sfn ../frontend/node_modules ../tests/node_modules",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "npm run test:unit && npm run test:integration && npm run test:e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.11",
|
||||
@@ -18,7 +24,15 @@
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.17.0",
|
||||
"@nuxt/test-utils": "^4.3.2",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"@vue/test-utils": "^2.5.0",
|
||||
"eslint": "^10.10.0",
|
||||
"happy-dom": "^20.14.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^5.0.0",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// Fixed ports so the PocketBase instance (started in global-setup.ts) and the
|
||||
// built Nuxt server (started by Playwright's webServer below) can reference
|
||||
// each other statically — see tests/e2e/global-setup.ts for why.
|
||||
export const E2E_POCKETBASE_PORT = 8095
|
||||
const APP_PORT = 3100
|
||||
const BASE_URL = `http://127.0.0.1:${APP_PORT}`
|
||||
const POCKETBASE_URL = `http://127.0.0.1:${E2E_POCKETBASE_PORT}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '../tests/e2e',
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
globalSetup: fileURLToPath(new URL('../tests/e2e/global-setup.ts', import.meta.url)),
|
||||
webServer: {
|
||||
command: 'npm run build && node .output/server/index.mjs',
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(APP_PORT),
|
||||
NUXT_POCKETBASE_URL: POCKETBASE_URL,
|
||||
NUXT_PUBLIC_POCKETBASE_URL: POCKETBASE_URL,
|
||||
NUXT_PUBLIC_SITE_URL: BASE_URL,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Article } from '#shared/types/blog'
|
||||
|
||||
interface AdminUser {
|
||||
firstname: string | null
|
||||
lastname: string | null
|
||||
}
|
||||
|
||||
type RawArticle = Omit<Article, 'html' | 'summary' | 'author'> & {
|
||||
content: string | null
|
||||
createdBy?: AdminUser | null
|
||||
}
|
||||
|
||||
/** Joins the admin user's name parts; returns null when neither is set. */
|
||||
function byline(user: AdminUser | null | undefined): string | null {
|
||||
const name = [user?.firstname, user?.lastname].filter(Boolean).join(' ').trim()
|
||||
return name || null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Article> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
const locale = localeParam(getQuery(event).lang)
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
||||
}
|
||||
|
||||
const response = await strapiFetch<StrapiList<RawArticle>>(
|
||||
`/api/articles?${ARTICLE_DETAIL_QUERY}&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
)
|
||||
|
||||
const article = response.data[0]
|
||||
|
||||
if (!article) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
const { content, createdBy, ...rest } = article
|
||||
return {
|
||||
...rest,
|
||||
html: renderMarkdown(content),
|
||||
summary: summarise(content),
|
||||
author: byline(createdBy),
|
||||
}
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
||||
const query = getQuery(event)
|
||||
const page = pageParam(query.page)
|
||||
const locale = localeParam(query.lang)
|
||||
const category = typeof query.category === 'string' ? query.category : null
|
||||
|
||||
const filter = category
|
||||
? `&filters[category][slug][$eq]=${encodeURIComponent(category)}`
|
||||
: ''
|
||||
|
||||
const response = await strapiFetch<StrapiList<ArticleSummary>>(
|
||||
`/api/articles?${ARTICLE_SUMMARY_QUERY}&locale=${locale}&${pagination(page)}${filter}`
|
||||
)
|
||||
|
||||
return {
|
||||
items: response.data,
|
||||
page: response.meta.pagination.page,
|
||||
pageCount: response.meta.pagination.pageCount,
|
||||
total: response.meta.pagination.total,
|
||||
}
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Category> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
const locale = localeParam(getQuery(event).lang)
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||
}
|
||||
|
||||
const response = await strapiFetch<StrapiList<Category>>(
|
||||
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
)
|
||||
|
||||
const category = response.data[0]
|
||||
|
||||
if (!category) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
}
|
||||
|
||||
return category
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Category[]> => {
|
||||
const locale = localeParam(getQuery(event).lang)
|
||||
|
||||
const response = await strapiFetch<StrapiList<Category>>(
|
||||
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&sort[0]=name:asc&pagination[pageSize]=100`
|
||||
)
|
||||
|
||||
return response.data
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Vanity redirect to the PocketBase admin UI, whose own route is fixed at
|
||||
* `/_/`. Handled here (not in Caddy) so it works the same in dev, where
|
||||
* Caddy isn't in the stack, and in production.
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
return sendRedirect(event, `${config.pocketbaseUrl}/_/`, 302)
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Article, Category } from '#shared/types/blog'
|
||||
|
||||
interface RawCategory extends Category {
|
||||
nameEn?: string | null
|
||||
}
|
||||
|
||||
interface RawArticle {
|
||||
id: string
|
||||
title: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
publishedAt: string
|
||||
content: string | null
|
||||
contentEn: string | null
|
||||
cover: string
|
||||
coverAlt: string | null
|
||||
coverAltEn: string | null
|
||||
authorName: string | null
|
||||
expand?: { category?: RawCategory }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Article> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
||||
}
|
||||
|
||||
const lang = langParam(getQuery(event).lang)
|
||||
|
||||
const response = await pbFetch<PbList<RawArticle>>(
|
||||
`/api/collections/articles/records?fields=${ARTICLE_DETAIL_FIELDS}&expand=category&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&${pagination(1, 1)}`
|
||||
)
|
||||
|
||||
const article = response.items[0]
|
||||
|
||||
if (!article) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
const hasTranslation = Boolean(article.titleEn && article.contentEn)
|
||||
|
||||
// An /en/ request for an article with no translation yet 404s cleanly,
|
||||
// rather than silently falling back to Italian text on an English URL —
|
||||
// see docs/content-model.md.
|
||||
if (lang === 'en' && !hasTranslation) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
const title = lang === 'en' ? article.titleEn! : article.title
|
||||
const content = lang === 'en' ? article.contentEn : article.content
|
||||
const coverAlt = lang === 'en' ? article.coverAltEn || article.coverAlt : article.coverAlt
|
||||
const category = article.expand?.category
|
||||
? {
|
||||
name:
|
||||
lang === 'en'
|
||||
? article.expand.category.nameEn || article.expand.category.name
|
||||
: article.expand.category.name,
|
||||
slug: article.expand.category.slug,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
title,
|
||||
slug: article.slug,
|
||||
publishedAt: article.publishedAt,
|
||||
cover: toMediaImage('articles', article.id, article.cover, coverAlt),
|
||||
category,
|
||||
html: renderMarkdown(content),
|
||||
summary: summarise(content),
|
||||
author: article.authorName || null,
|
||||
hasTranslation,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
interface RawCategory extends Category {
|
||||
nameEn?: string | null
|
||||
}
|
||||
|
||||
interface RawArticleSummary {
|
||||
id: string
|
||||
title: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
publishedAt: string
|
||||
cover: string
|
||||
coverAlt: string | null
|
||||
coverAltEn: string | null
|
||||
expand?: { category?: RawCategory }
|
||||
}
|
||||
|
||||
function toSummary(raw: RawArticleSummary, lang: Locale): ArticleSummary {
|
||||
const title = lang === 'en' ? raw.titleEn || raw.title : raw.title
|
||||
const coverAlt = lang === 'en' ? raw.coverAltEn || raw.coverAlt : raw.coverAlt
|
||||
const category = raw.expand?.category
|
||||
? {
|
||||
name: lang === 'en' ? raw.expand.category.nameEn || raw.expand.category.name : raw.expand.category.name,
|
||||
slug: raw.expand.category.slug,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
title,
|
||||
slug: raw.slug,
|
||||
publishedAt: raw.publishedAt,
|
||||
cover: toMediaImage('articles', raw.id, raw.cover, coverAlt),
|
||||
category,
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
||||
const query = getQuery(event)
|
||||
const page = pageParam(query.page)
|
||||
const lang = langParam(query.lang)
|
||||
const category = typeof query.category === 'string' ? query.category : null
|
||||
|
||||
const filters = []
|
||||
if (category) filters.push(`category.slug = ${quote(category)}`)
|
||||
// An English listing only ever shows articles that have actually been
|
||||
// translated — never a partial/fallback entry, see docs/content-model.md.
|
||||
if (lang === 'en') filters.push(`titleEn != ''`)
|
||||
const filter = filters.length ? `&filter=${encodeURIComponent(filters.join(' && '))}` : ''
|
||||
|
||||
const response = await pbFetch<PbList<RawArticleSummary>>(
|
||||
`/api/collections/articles/records?fields=${ARTICLE_SUMMARY_FIELDS}&expand=category&sort=-publishedAt&${pagination(page)}${filter}`
|
||||
)
|
||||
|
||||
return {
|
||||
items: response.items.map((raw) => toSummary(raw, lang)),
|
||||
page: response.page,
|
||||
pageCount: response.totalPages,
|
||||
total: response.totalItems,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
interface RawCategory extends Category {
|
||||
nameEn: string | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Category> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||
}
|
||||
|
||||
const lang = langParam(getQuery(event).lang)
|
||||
|
||||
const response = await pbFetch<PbList<RawCategory>>(
|
||||
`/api/collections/categories/records?fields=${CATEGORY_FIELDS}&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&perPage=1`
|
||||
)
|
||||
|
||||
const category = response.items[0]
|
||||
|
||||
if (!category) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
}
|
||||
|
||||
if (lang === 'en' && !category.nameEn) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
name: lang === 'en' ? category.nameEn! : category.name,
|
||||
slug: category.slug,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
interface RawCategory extends Category {
|
||||
nameEn: string | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Category[]> => {
|
||||
const lang = langParam(getQuery(event).lang)
|
||||
// English listing only ever shows categories that have actually been
|
||||
// translated — mirrors the articles listing policy.
|
||||
const filter = lang === 'en' ? `&filter=${encodeURIComponent(`nameEn != ''`)}` : ''
|
||||
|
||||
const response = await pbFetch<PbList<RawCategory>>(
|
||||
`/api/collections/categories/records?fields=${CATEGORY_FIELDS}&sort=name&perPage=100${filter}`
|
||||
)
|
||||
|
||||
return response.items.map((category) => ({
|
||||
name: lang === 'en' ? category.nameEn || category.name : category.name,
|
||||
slug: category.slug,
|
||||
}))
|
||||
})
|
||||
@@ -1,19 +1,17 @@
|
||||
import { marked } from 'marked'
|
||||
import type { MediaImage } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Calls the Strapi REST API from the server only, so the internal URL and any
|
||||
* future API token never reach the browser.
|
||||
* Calls the PocketBase REST API from the server only, so the internal URL
|
||||
* never reaches the browser.
|
||||
*/
|
||||
export async function strapiFetch<T>(path: string): Promise<T> {
|
||||
const { strapiUrl, strapiToken } = useRuntimeConfig()
|
||||
export async function pbFetch<T>(path: string): Promise<T> {
|
||||
const { pocketbaseUrl } = useRuntimeConfig()
|
||||
|
||||
try {
|
||||
return (await $fetch(path, {
|
||||
baseURL: strapiUrl,
|
||||
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
|
||||
})) as T
|
||||
return (await $fetch(path, { baseURL: pocketbaseUrl })) as T
|
||||
} catch (error) {
|
||||
console.error(`Strapi request failed: ${path}`, error)
|
||||
console.error(`PocketBase request failed: ${path}`, error)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
|
||||
}
|
||||
}
|
||||
@@ -48,7 +46,24 @@ export function summarise(source: string | null | undefined, maxLength = 155): s
|
||||
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}…`
|
||||
}
|
||||
|
||||
export interface StrapiList<T> {
|
||||
data: T[]
|
||||
meta: { pagination: { page: number; pageCount: number; total: number } }
|
||||
export interface PbList<T> {
|
||||
page: number
|
||||
perPage: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
items: T[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a PocketBase file URL, relative to its own origin — PocketBase file
|
||||
* fields store only a filename, not a full path or dimensions.
|
||||
*/
|
||||
export function toMediaImage(
|
||||
collection: string,
|
||||
id: string,
|
||||
filename: string | null | undefined,
|
||||
alt: string | null | undefined
|
||||
): MediaImage | null {
|
||||
if (!filename) return null
|
||||
return { url: `/api/files/${collection}/${id}/${filename}`, alt: alt || null }
|
||||
}
|
||||
@@ -1,30 +1,17 @@
|
||||
/** Strapi query fragments. Only the fields the pages actually render are requested. */
|
||||
/** PocketBase query fragments. Only the fields the pages actually render are requested. */
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
const list = (prefix: string, names: readonly string[]) =>
|
||||
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&')
|
||||
export const ARTICLE_SUMMARY_FIELDS =
|
||||
'id,title,titleEn,slug,publishedAt,cover,coverAlt,coverAltEn,expand.category.name,expand.category.nameEn,expand.category.slug'
|
||||
|
||||
const IMAGE_FIELDS = ['url', 'alternativeText', 'width', 'height'] as const
|
||||
export const ARTICLE_DETAIL_FIELDS =
|
||||
'id,title,titleEn,slug,content,contentEn,publishedAt,cover,coverAlt,coverAltEn,authorName,expand.category.name,expand.category.nameEn,expand.category.slug'
|
||||
|
||||
export const ARTICLE_SUMMARY_QUERY = [
|
||||
list('fields', ['title', 'slug', 'publishedAt']),
|
||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
||||
list('populate[category][fields]', ['name', 'slug']),
|
||||
'sort[0]=publishedAt:desc',
|
||||
].join('&')
|
||||
|
||||
export const ARTICLE_DETAIL_QUERY = [
|
||||
list('fields', ['title', 'slug', 'content', 'publishedAt']),
|
||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
||||
list('populate[category][fields]', ['name', 'slug']),
|
||||
// The byline is the admin user who created the entry; Strapi exposes the
|
||||
// name fields only because the content type sets populateCreatorFields.
|
||||
list('populate[createdBy][fields]', ['firstname', 'lastname']),
|
||||
].join('&')
|
||||
export const CATEGORY_FIELDS = 'name,nameEn,slug'
|
||||
|
||||
export const PAGE_SIZE = 12
|
||||
|
||||
export const pagination = (page: number, pageSize = PAGE_SIZE) =>
|
||||
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
|
||||
export const pagination = (page: number, perPage = PAGE_SIZE) => `page=${page}&perPage=${perPage}`
|
||||
|
||||
/** Reads a positive integer query param, falling back to 1. */
|
||||
export const pageParam = (value: unknown): number => {
|
||||
@@ -32,7 +19,8 @@ export const pageParam = (value: unknown): number => {
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
export type Locale = 'it' | 'en'
|
||||
/** Reads a `lang` query param, falling back to Italian for anything but `en`. */
|
||||
export const langParam = (value: unknown): Locale => (value === 'en' ? 'en' : 'it')
|
||||
|
||||
/** Reads the `lang` query param, falling back to the site default `it`. */
|
||||
export const localeParam = (value: unknown): Locale => (value === 'en' ? 'en' : 'it')
|
||||
/** Quotes a value for PocketBase's `filter` DSL, escaping embedded quotes. */
|
||||
export const quote = (value: string) => `"${value.replace(/"/g, '\\"')}"`
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/** Shape of the Strapi payloads, narrowed to what the site actually renders. */
|
||||
/** Shape of the PocketBase payloads, narrowed to what the site actually renders. */
|
||||
|
||||
export interface StrapiImage {
|
||||
export interface MediaImage {
|
||||
url: string
|
||||
alternativeText: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
alt: string | null
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -17,7 +15,7 @@ export interface ArticleSummary {
|
||||
title: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
cover: StrapiImage | null
|
||||
cover: MediaImage | null
|
||||
category: Category | null
|
||||
}
|
||||
|
||||
@@ -26,8 +24,10 @@ export interface Article extends ArticleSummary {
|
||||
html: string
|
||||
/** Plain-text opening of the body, used as the meta description. */
|
||||
summary: string
|
||||
/** Byline: the full name of the admin user who wrote the article. */
|
||||
/** Byline: the article's authorName field, filled in manually by the editor. */
|
||||
author: string | null
|
||||
/** Whether this article has a (manually authored) English translation. */
|
||||
hasTranslation: boolean
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
const INTL_LOCALE = { it: 'it-IT', en: 'en-GB' } as const
|
||||
import type { Locale } from './locale'
|
||||
|
||||
const INTL_LOCALE: Record<Locale, string> = { it: 'it-IT', en: 'en-GB' }
|
||||
|
||||
/** Human-readable date for display; returns an empty string when unset. */
|
||||
export function formatDate(value: string | null | undefined, locale: 'it' | 'en' = 'it'): string {
|
||||
export function formatDate(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long' }).format(new Date(value))
|
||||
}
|
||||
|
||||
/** Date and time of publication, shown in the article byline. */
|
||||
export function formatDateTime(
|
||||
value: string | null | undefined,
|
||||
locale: 'it' | 'en' = 'it'
|
||||
): string {
|
||||
export function formatDateTime(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long', timeStyle: 'short' })
|
||||
.format(new Date(value))
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Static UI copy, in both languages. Deliberately a plain object, not a
|
||||
* library: two locales, ~20 short strings, no plurals — see docs/frontend.md.
|
||||
* Keyed by role (not by the Italian text), so a wording tweak never touches
|
||||
* the key.
|
||||
*/
|
||||
import type { Locale } from './locale'
|
||||
|
||||
export const STRINGS = {
|
||||
it: {
|
||||
skipLink: 'Vai al contenuto',
|
||||
tagline: 'Articoli, guide e analisi.',
|
||||
navAriaLabel: 'Navigazione principale',
|
||||
navHome: 'Home',
|
||||
navBlog: 'Tutti gli articoli',
|
||||
navCorralito: 'Come difendersi dal corralito',
|
||||
footerNavAriaLabel: 'Naviga',
|
||||
footerNavigaKicker: 'Naviga',
|
||||
footerSeguiciKicker: 'Seguici',
|
||||
footerFollowAriaLabel: 'Seguici',
|
||||
contactLabel: 'Contatti:',
|
||||
copyright: 'Tutti i diritti riservati.',
|
||||
langSwitch: {
|
||||
ariaLabel: 'Lingua del sito',
|
||||
it: 'Italiano',
|
||||
en: 'English',
|
||||
},
|
||||
home: {
|
||||
heading: "La fine dell'Unione Europea per come l'abbiamo conosciuta.",
|
||||
img1Alt: 'Manifesti e scritte contro le banche durante una crisi bancaria',
|
||||
img2Alt: "Bandiera dell'Unione Europea incrinata",
|
||||
p1: "La fine della nostra moneta come moneta forte: la fine di quella moneta che ci ha dato la vera libertà di viaggiare, consumare e fare.",
|
||||
p2: "Una fine che avverrà a seguito di un \"incidente\" nucleare, al quale farà seguito un corralito bancario, ossia l'impossibilità, da parte dei cittadini europei, di disporre liberamente dei propri depositi.",
|
||||
p3: "Le monete fiduciarie sono per loro natura cannibalizzanti. Oggi tocca all'euro. Anche perché le altre hanno praticamente perso tutte tutto.",
|
||||
fallbackKicker: 'Ultimo',
|
||||
readLatest: "Leggi l'ultimo articolo",
|
||||
metaDescription: 'Articoli, guide e analisi.',
|
||||
},
|
||||
blog: {
|
||||
archiveKicker: "L'archivio",
|
||||
title: 'Articoli',
|
||||
titlePage: 'Articoli — pagina {page}',
|
||||
metaDescription: 'Tutti gli articoli pubblicati sul blog.',
|
||||
loadError: 'Non è stato possibile caricare gli articoli. Riprova più tardi.',
|
||||
loading: 'Caricamento…',
|
||||
empty: 'Non è stato ancora pubblicato nulla.',
|
||||
prevPage: 'Pagina precedente',
|
||||
nextPage: 'Pagina successiva',
|
||||
pageOf: 'Pagina {current} di {total}',
|
||||
goToPage: 'Vai a pagina {page}',
|
||||
paginationAriaLabel: 'Paginazione',
|
||||
},
|
||||
category: {
|
||||
kicker: 'Categoria',
|
||||
metaDescription: 'Tutti gli articoli nella categoria {name}.',
|
||||
loadError: 'Non è stato possibile caricare gli articoli. Riprova più tardi.',
|
||||
empty: 'Non ci sono articoli in questa categoria.',
|
||||
},
|
||||
article: {
|
||||
breadcrumbAriaLabel: 'Percorso di navigazione',
|
||||
articlesCrumb: 'Articoli',
|
||||
byAuthor: 'Di {author}',
|
||||
backToArticles: 'Torna a tutti gli articoli',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
skipLink: 'Skip to content',
|
||||
tagline: 'Articles, guides and analysis.',
|
||||
navAriaLabel: 'Main navigation',
|
||||
navHome: 'Home',
|
||||
navBlog: 'All articles',
|
||||
navCorralito: '',
|
||||
footerNavAriaLabel: 'Navigate',
|
||||
footerNavigaKicker: 'Navigate',
|
||||
footerSeguiciKicker: 'Follow us',
|
||||
footerFollowAriaLabel: 'Follow us',
|
||||
contactLabel: 'Contact:',
|
||||
copyright: 'All rights reserved.',
|
||||
langSwitch: {
|
||||
ariaLabel: 'Site language',
|
||||
it: 'Italiano',
|
||||
en: 'English',
|
||||
},
|
||||
home: {
|
||||
heading: "The end of the European Union as we've known it.",
|
||||
img1Alt: 'Banners and graffiti against banks during a banking crisis',
|
||||
img2Alt: 'Cracked European Union flag',
|
||||
p1: 'The end of our currency as a strong currency: the end of the currency that gave us the real freedom to travel, spend and act.',
|
||||
p2: 'An end that will follow a nuclear "incident", followed by a banking corralito — the impossibility, for European citizens, of freely accessing their own deposits.',
|
||||
p3: "Fiat currencies are cannibalizing by nature. Today it's the euro's turn. Especially since the others have already lost almost everything.",
|
||||
fallbackKicker: 'Latest',
|
||||
readLatest: 'Read the latest article',
|
||||
metaDescription: 'Articles, guides and analysis.',
|
||||
},
|
||||
blog: {
|
||||
archiveKicker: 'The archive',
|
||||
title: 'Articles',
|
||||
titlePage: 'Articles — page {page}',
|
||||
metaDescription: 'All the articles published on the blog.',
|
||||
loadError: 'Could not load the articles. Please try again later.',
|
||||
loading: 'Loading…',
|
||||
empty: 'Nothing has been published yet.',
|
||||
prevPage: 'Previous page',
|
||||
nextPage: 'Next page',
|
||||
pageOf: 'Page {current} of {total}',
|
||||
goToPage: 'Go to page {page}',
|
||||
paginationAriaLabel: 'Pagination',
|
||||
},
|
||||
category: {
|
||||
kicker: 'Category',
|
||||
metaDescription: 'All the articles in the {name} category.',
|
||||
loadError: 'Could not load the articles. Please try again later.',
|
||||
empty: 'There are no articles in this category.',
|
||||
},
|
||||
article: {
|
||||
breadcrumbAriaLabel: 'Breadcrumb',
|
||||
articlesCrumb: 'Articles',
|
||||
byAuthor: 'By {author}',
|
||||
backToArticles: 'Back to all articles',
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<Locale, unknown>
|
||||
|
||||
/** Fills `{placeholder}` tokens in a translated string. */
|
||||
export function interpolate(template: string, params: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
|
||||
key in params ? String(params[key]) : match
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type Locale = 'it' | 'en'
|
||||
|
||||
export const LOCALES: Locale[] = ['it', 'en']
|
||||
|
||||
/** Prefixes a bare, unprefixed path (e.g. `/blog/mio-slug`) for the given locale. */
|
||||
export function localePath(locale: Locale, path: string): string {
|
||||
if (locale !== 'en') return path
|
||||
return path === '/' ? '/en' : `/en${path}`
|
||||
}
|
||||
|
||||
/** Strips the `/en` prefix, if any, returning the bare Italian-equivalent path. */
|
||||
export function bareLocalePath(path: string): string {
|
||||
if (path === '/en') return '/'
|
||||
if (path.startsWith('/en/')) return path.slice(3)
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type PaginationItem = number | 'ellipsis'
|
||||
|
||||
function range(start: number, end: number): number[] {
|
||||
const length = end - start + 1
|
||||
return Array.from({ length }, (_, i) => start + i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed page list for numbered pagination: always shows the first and
|
||||
* last page, the current page and its immediate siblings, collapsing any
|
||||
* gap into a single 'ellipsis' marker. Returns every page when the total
|
||||
* is small enough that no collapsing is needed.
|
||||
*/
|
||||
export function paginationRange(current: number, total: number, siblingCount = 1): PaginationItem[] {
|
||||
if (total <= 0) return []
|
||||
|
||||
const totalVisible = siblingCount * 2 + 5 // first + last + current + 2 siblings + 2 ellipses
|
||||
if (totalVisible >= total) return range(1, total)
|
||||
|
||||
const leftSibling = Math.max(current - siblingCount, 1)
|
||||
const rightSibling = Math.min(current + siblingCount, total)
|
||||
|
||||
const showLeftEllipsis = leftSibling > 2
|
||||
const showRightEllipsis = rightSibling < total - 1
|
||||
|
||||
if (!showLeftEllipsis && showRightEllipsis) {
|
||||
const leftRange = range(1, 3 + siblingCount * 2)
|
||||
return [...leftRange, 'ellipsis', total]
|
||||
}
|
||||
|
||||
if (showLeftEllipsis && !showRightEllipsis) {
|
||||
const rightRange = range(total - (3 + siblingCount * 2) + 1, total)
|
||||
return [1, 'ellipsis', ...rightRange]
|
||||
}
|
||||
|
||||
return [1, 'ellipsis', ...range(leftSibling, rightSibling), 'ellipsis', total]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['../tests/integration/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 60000,
|
||||
// Integration tests boot a real Nuxt server + PocketBase instance; running
|
||||
// several of those concurrently is wasteful, not parallel-safe by design.
|
||||
fileParallelism: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineVitestConfig } from '@nuxt/test-utils/config'
|
||||
|
||||
export default defineVitestConfig({
|
||||
test: {
|
||||
environment: 'nuxt',
|
||||
include: ['../tests/unit/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM alpine:3.20 AS download
|
||||
ARG PB_VERSION=0.40.3
|
||||
RUN apk add --no-cache unzip curl ca-certificates
|
||||
RUN curl -Lo /tmp/pb.zip \
|
||||
https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
|
||||
&& unzip /tmp/pb.zip -d /pb
|
||||
|
||||
FROM alpine:3.20 AS runtime
|
||||
RUN apk add --no-cache ca-certificates wget
|
||||
WORKDIR /pb
|
||||
COPY --from=download /pb/pocketbase /usr/local/bin/pocketbase
|
||||
COPY pb_migrations ./pb_migrations
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["pocketbase", "serve", "--http=0.0.0.0:8090"]
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Idempotent: safe to run on every start, including restarts of an existing
|
||||
# pb_data volume (upsert, not create).
|
||||
if [ -n "$POCKETBASE_ADMIN_EMAIL" ] && [ -n "$POCKETBASE_ADMIN_PASSWORD" ]; then
|
||||
pocketbase superuser upsert "$POCKETBASE_ADMIN_EMAIL" "$POCKETBASE_ADMIN_PASSWORD"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,26 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
type: 'base',
|
||||
name: 'categories',
|
||||
listRule: '',
|
||||
viewRule: '',
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: 'text', name: 'name', required: true, max: 160 },
|
||||
{ type: 'text', name: 'slug', required: true, max: 160, pattern: '^[a-z0-9]+(-[a-z0-9]+)*$' },
|
||||
{ type: 'autodate', name: 'created', onCreate: true },
|
||||
{ type: 'autodate', name: 'updated', onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_categories_slug ON categories (slug)',
|
||||
'CREATE UNIQUE INDEX idx_categories_name ON categories (name)',
|
||||
],
|
||||
})
|
||||
app.save(collection)
|
||||
}, (app) => {
|
||||
app.delete(app.findCollectionByNameOrId('categories'))
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// publishedAt mirrors Strapi's Draft & Publish semantics: empty = draft, set
|
||||
// (and not in the future) = published. listRule/viewRule enforce this, so an
|
||||
// unpublished or scheduled article is invisible to the public API by
|
||||
// construction, not by a check in application code.
|
||||
migrate((app) => {
|
||||
const categories = app.findCollectionByNameOrId('categories')
|
||||
|
||||
const collection = new Collection({
|
||||
type: 'base',
|
||||
name: 'articles',
|
||||
listRule: "publishedAt != '' && publishedAt <= @now",
|
||||
viewRule: "publishedAt != '' && publishedAt <= @now",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: 'text', name: 'title', required: true, max: 160 },
|
||||
{ type: 'text', name: 'slug', required: true, max: 160, pattern: '^[a-z0-9]+(-[a-z0-9]+)*$' },
|
||||
{ type: 'text', name: 'content', required: true, max: 10000 },
|
||||
{
|
||||
type: 'file',
|
||||
name: 'cover',
|
||||
maxSelect: 1,
|
||||
maxSize: 10485760,
|
||||
mimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif'],
|
||||
},
|
||||
{ type: 'text', name: 'coverAlt', max: 200 },
|
||||
{ type: 'relation', name: 'category', collectionId: categories.id, maxSelect: 1 },
|
||||
{ type: 'date', name: 'publishedAt' },
|
||||
{ type: 'text', name: 'authorName', max: 160 },
|
||||
{ type: 'autodate', name: 'created', onCreate: true },
|
||||
{ type: 'autodate', name: 'updated', onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX idx_articles_slug ON articles (slug)',
|
||||
'CREATE INDEX idx_articles_category ON articles (category)',
|
||||
'CREATE INDEX idx_articles_published ON articles (publishedAt)',
|
||||
],
|
||||
})
|
||||
app.save(collection)
|
||||
}, (app) => {
|
||||
app.delete(app.findCollectionByNameOrId('articles'))
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Adds optional English counterparts to the manually-authored Italian fields,
|
||||
// on the same record (same slug, same cover, same category, same
|
||||
// publishedAt) — see docs/content-model.md. An article/category with no
|
||||
// translation yet simply leaves these blank; nothing else changes.
|
||||
migrate((app) => {
|
||||
const articles = app.findCollectionByNameOrId('articles')
|
||||
articles.fields.add(
|
||||
new TextField({ name: 'titleEn', max: 160 }),
|
||||
new TextField({ name: 'contentEn', max: 10000 }),
|
||||
new TextField({ name: 'coverAltEn', max: 200 })
|
||||
)
|
||||
app.save(articles)
|
||||
|
||||
const categories = app.findCollectionByNameOrId('categories')
|
||||
categories.fields.add(new TextField({ name: 'nameEn', max: 160 }))
|
||||
app.save(categories)
|
||||
}, (app) => {
|
||||
const articles = app.findCollectionByNameOrId('articles')
|
||||
articles.fields.removeByName('titleEn')
|
||||
articles.fields.removeByName('contentEn')
|
||||
articles.fields.removeByName('coverAltEn')
|
||||
app.save(articles)
|
||||
|
||||
const categories = app.findCollectionByNameOrId('categories')
|
||||
categories.fields.removeByName('nameEn')
|
||||
app.save(categories)
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('/admin redirects to the real PocketBase dashboard', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
|
||||
await expect(page).toHaveURL(/\/_\/(#.*)?$/)
|
||||
// The static <title> is "PocketBase"; the dashboard SPA then renders its
|
||||
// login view and retitles the page — asserting the live title also proves
|
||||
// the dashboard's own JS bundle loaded and ran correctly through the proxy.
|
||||
await expect(page).toHaveTitle('Superuser login')
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('article detail renders body, breadcrumb and structured data', async ({ page }) => {
|
||||
await page.goto('/blog/articolo-pubblicato')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Articolo pubblicato' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Economia' })).toHaveAttribute(
|
||||
'href',
|
||||
'/category/economia'
|
||||
)
|
||||
await expect(page.locator('.prose strong')).toHaveText('markdown')
|
||||
|
||||
const jsonLd = await page.locator('script[type="application/ld+json"]').textContent()
|
||||
const data = JSON.parse(jsonLd ?? '{}')
|
||||
expect(data['@type']).toBe('BlogPosting')
|
||||
expect(data.headline).toBe('Articolo pubblicato')
|
||||
})
|
||||
|
||||
test('unknown article slug renders the 404 page', async ({ page }) => {
|
||||
const response = await page.goto('/blog/non-esiste')
|
||||
|
||||
expect(response?.status()).toBe(404)
|
||||
await expect(page.getByRole('heading', { name: 'Pagina non trovata' })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('blog archive lists only published articles', async ({ page }) => {
|
||||
await page.goto('/blog')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Secondo articolo pubblicato' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Articolo pubblicato', exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Articolo in bozza')).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('category page filters articles to that category only', async ({ page }) => {
|
||||
await page.goto('/category/attualita')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Secondo articolo pubblicato' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Articolo pubblicato', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('unknown category slug 404s', async ({ page }) => {
|
||||
const response = await page.goto('/category/non-esiste')
|
||||
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user