Compare commits
10
Commits
v1.0.0
..
1247a2ca07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1247a2ca07 | ||
|
|
364fda3980 | ||
|
|
33b49c5dbb | ||
|
|
847edc7397 | ||
|
|
7ce073d2a9 | ||
|
|
91181f2170 | ||
|
|
6609e3ae28 | ||
|
|
49c42ecc96 | ||
|
|
91540224a9 | ||
|
|
47a8e7788f |
@@ -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
|
PUBLIC_DOMAIN=blog.localhost
|
||||||
ACME_EMAIL=admin@example.com
|
ACME_EMAIL=admin@example.com
|
||||||
|
|
||||||
# --- Database ---
|
# --- PocketBase ---
|
||||||
POSTGRES_DB=blog
|
# Bootstraps (or updates, on restart) the initial superuser account — the
|
||||||
POSTGRES_USER=blog
|
# only login the admin UI accepts. Pick a real password (10+ chars); never
|
||||||
POSTGRES_PASSWORD=change-me
|
# committed as anything but this placeholder.
|
||||||
|
POCKETBASE_ADMIN_EMAIL=admin@example.com
|
||||||
# --- Strapi ---
|
POCKETBASE_ADMIN_PASSWORD=change-me-1234
|
||||||
# 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
|
|
||||||
|
|
||||||
# --- Frontend ---
|
# --- Frontend ---
|
||||||
# Server-side only: internal Docker address of Strapi.
|
# Server-side only: internal Docker address of PocketBase.
|
||||||
STRAPI_URL=http://cms:1337
|
POCKETBASE_URL=http://pocketbase:8090
|
||||||
# Public base URL of the website, used for canonical URLs and Open Graph.
|
# Public base URL of the website, used for canonical URLs and Open Graph.
|
||||||
PUBLIC_SITE_URL=https://blog.localhost
|
PUBLIC_SITE_URL=https://blog.localhost
|
||||||
# Public base URL of Strapi, used to build absolute media URLs in the browser.
|
# Public base URL of PocketBase, used to build absolute cover-image URLs.
|
||||||
# Same origin as PUBLIC_SITE_URL: Caddy proxies /admin and /uploads to Strapi.
|
# Same origin as PUBLIC_SITE_URL: Caddy proxies /_/* and /api/* there (the
|
||||||
PUBLIC_STRAPI_URL=https://blog.localhost
|
# 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/
|
node_modules/
|
||||||
.pnp/
|
.pnp/
|
||||||
.pnp.js
|
.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 / secrets
|
||||||
.env
|
.env
|
||||||
@@ -21,19 +25,20 @@ frontend/dist/
|
|||||||
.nitro/
|
.nitro/
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
# strapi
|
# playwright
|
||||||
cms/.strapi/
|
frontend/test-results/
|
||||||
cms/dist/
|
frontend/playwright-report/
|
||||||
cms/build/
|
|
||||||
cms/.tmp/
|
# nuxt module-setup marker (regenerated, not meaningful project config)
|
||||||
cms/public/uploads/*
|
frontend/.nuxtrc
|
||||||
!cms/public/uploads/.gitkeep
|
|
||||||
cms/.strapi-updater.json
|
|
||||||
|
|
||||||
# caddy runtime
|
# caddy runtime
|
||||||
caddy/data/
|
caddy/data/
|
||||||
caddy/config/
|
caddy/config/
|
||||||
|
|
||||||
|
# pocketbase local dev data (bind-mounted, docker-compose.dev.yml)
|
||||||
|
pocketbase/pb_data/
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -10,45 +10,58 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Stato
|
## Stato
|
||||||
|
|
||||||
Struttura, content type, pagine blog, Docker e Caddy sono in piedi. Restano da fare:
|
Struttura, content type, pagine blog, Docker, Caddy e i test (unit/integration/e2e) sono in
|
||||||
sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora configurato).
|
piedi. Restano da fare: sitemap e `robots.txt` dinamici, ricerca.
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser → Caddy ─┬─ /admin e i path dei plugin Strapi → Strapi 5 → PostgreSQL
|
Browser → Caddy ─┬─ /_/* e /api/* → PocketBase (admin UI + API)
|
||||||
└─ tutto il resto → Nuxt 4 (SSR) → REST Strapi
|
└─ 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
|
Caddy instrada per **path**, non per sottodominio o porta: `PUBLIC_DOMAIN` serve sia il sito
|
||||||
pannello Strapi. Ogni plugin Strapi monta la propria API admin sul proprio path di primo
|
che il pannello PocketBase. `/_/*` (dashboard) e `/api/*` (REST/file API) vanno **senza prefisso**
|
||||||
livello, non tutto sotto `/admin` (es. `/content-manager`, `/upload`, `/i18n`...): l'elenco
|
a PocketBase — la sua dashboard referenzia se stessa con quei path assoluti, quindi non si possono
|
||||||
completo dei prefissi da instradare a Strapi vive nel `Caddyfile`. Se aggiungi un plugin
|
instradare con uno strip-prefix (es. `/admin/*` riscritto): romperebbe gli asset/le chiamate della
|
||||||
Strapi, aggiungi il suo prefisso lì. Niente di questo tocca `/api`, riservato agli endpoint
|
dashboard. Per questo `/api` è riservato a PocketBase, non a Nitro: gli endpoint del frontend
|
||||||
Nitro del frontend. Un solo dominio, un solo certificato TLS.
|
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):
|
- PocketBase è la **sola** fonte di verità editoriale (CMS + database SQLite in un solo processo).
|
||||||
se serve logica server, sta in Nitro (`frontend/server/`) o in un controller Strapi.
|
Niente altro backend (no Express/Nest/Fastify): se serve logica server, sta in Nitro
|
||||||
- I visitatori pubblici non si autenticano mai. Solo editor/admin usano l'auth Strapi.
|
(`frontend/server/`) o in una regola/migration PocketBase.
|
||||||
- **Il browser dei visitatori pubblici non parla mai con Strapi.** Le pagine chiamano gli
|
- I visitatori pubblici non si autenticano mai. Solo il superuser usa l'auth PocketBase.
|
||||||
endpoint Nitro in `frontend/server/api/`, che sono l'unico posto dove si costruiscono
|
- **Il browser dei visitatori pubblici non parla mai con PocketBase per i contenuti.** Le pagine
|
||||||
query Strapi. Così `NUXT_STRAPI_URL` resta l'indirizzo interno Docker, niente CORS e
|
chiamano gli endpoint Nitro in `frontend/server/routes/content/`, che sono l'unico posto dove si
|
||||||
niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì e tipizza il
|
costruiscono query PocketBase. Così `NUXT_POCKETBASE_URL` resta l'indirizzo interno Docker,
|
||||||
ritorno in `shared/types/blog.ts`. Fanno eccezione, per costruzione: l'admin panel
|
niente CORS e niente token nel client. Se aggiungi una vista, aggiungi l'endpoint lì (sotto
|
||||||
(`/admin`, uso editor/admin autenticato) e le immagini cover, che il browser carica
|
`/content/*`, mai `/api/*`) e tipizza il ritorno in `shared/types/blog.ts`. Fanno eccezione, per
|
||||||
direttamente da `PUBLIC_STRAPI_URL` (`/uploads/...`, sola lettura, nessun'autenticazione
|
costruzione: l'admin panel (`/_/`, uso superuser autenticato, raggiungibile anche da `/admin`) e
|
||||||
richiesta né concessa).
|
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
|
- 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.
|
contenuto è già nell'HTML SSR e `marked` resta fuori dal bundle client.
|
||||||
- L'**interfaccia** è solo in italiano, stringhe statiche nei componenti (niente
|
- Il sito è **bilingue** (italiano, sorgente, e inglese), fatto in casa senza `@nuxtjs/i18n` né
|
||||||
`@nuxtjs/i18n`, niente file di traduzione): `<html lang="it">` è fisso in
|
file di traduzione runtime: due lingue, ~20 stringhe statiche, nessun plurale — la libreria
|
||||||
`nuxt.config.ts`. La traduzione per i visitatori stranieri è delegata all'estensione
|
costerebbe più codice di quanto risolva (il fallback su contenuti non tradotti, la
|
||||||
Google Translate del browser, non è gestita dall'app. I **contenuti** restano
|
localizzazione degli endpoint Nitro e gran parte della SEO restano comunque da scrivere a mano).
|
||||||
monolingua — la localizzazione di Strapi è disattivata.
|
`<html lang>` è dinamico (per pagina, via `useSeo`), non più fisso. Le pagine inglesi vivono sotto
|
||||||
- `cms/src/index.ts` (`bootstrap`) dà al ruolo Public solo `find`/`findOne` su Article e Category,
|
prefisso `/en/*`, come wrapper sottili attorno agli stessi componenti "view" delle pagine
|
||||||
e disattiva la registrazione pubblica: non esistono utenti front-end, solo amministratori.
|
italiane — vedi `docs/frontend.md#english-content`. Le stringhe UI statiche vivono **solo** in
|
||||||
Qualsiasi permesso in più va motivato.
|
`frontend/shared/utils/i18n.ts`. Gli **articoli** hanno campi paralleli opzionali
|
||||||
- Postgres non è esposto pubblicamente. Media su volume persistente, mai binari nel DB.
|
(`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
|
## Comandi
|
||||||
|
|
||||||
@@ -61,49 +74,67 @@ npm run build # build produzione
|
|||||||
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
||||||
npm run lint
|
npm run lint
|
||||||
|
|
||||||
# cms/
|
npm run test:unit # Vitest, ambiente Nuxt: funzioni pure + componenti/composable
|
||||||
npm run develop # Strapi con content-type builder attivo
|
npm run test:integration # Vitest, server Nitro reale + PocketBase effimero reale
|
||||||
npm run build # admin panel
|
npm run test:e2e # Playwright, browser reale contro build + PocketBase effimero reale
|
||||||
npm run start # produzione
|
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)
|
# stack completo (con Caddy e domini reali)
|
||||||
docker compose up -d --build
|
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
|
# 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
|
`docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio
|
||||||
per non finire per sbaglio in produzione esponendo le porte.
|
per non finire per sbaglio in produzione esponendo le porte.
|
||||||
|
|
||||||
Nessun test framework è ancora configurato. Se ne aggiungi uno, documenta qui il comando per
|
`tests/` sta nella root del repo (non sotto `frontend/`), perché l'e2e esercita frontend e
|
||||||
lanciare **un singolo test**.
|
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
|
## Modelli di contenuto
|
||||||
|
|
||||||
| Type | Campi |
|
| Type | Campi |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Article | title, slug (UID da title), content (Markdown), cover, category (rel) |
|
| Article | title, slug, content (Markdown), cover, coverAlt, category (rel), publishedAt, authorName, titleEn, contentEn, coverAltEn |
|
||||||
| Category | name, slug |
|
| Category | name, slug, nameEn |
|
||||||
|
|
||||||
Deliberatamente minimale: **niente Author** (l'unico autore è l'admin), niente tag, nessun campo
|
Deliberatamente minimale: niente tag, nessun campo SEO separato. La meta description è ricavata
|
||||||
SEO separato. La meta description è ricavata dall'inizio del body (`summarise` in
|
dall'inizio del body (`summarise` in `frontend/server/utils/pocketbase.ts`), la data è
|
||||||
`frontend/server/utils/strapi.ts`), la data è il `publishedAt` di Draft & Publish, l'immagine
|
`publishedAt`, l'immagine social è la cover. Non reintrodurre campi senza che servano davvero.
|
||||||
social è la cover. Non reintrodurre questi 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
|
## 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.
|
- 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/`,
|
- `<script setup lang="ts">`, Composition API. Convenzioni Nuxt standard (`pages/`, `components/`,
|
||||||
`composables/`, `layouts/`, `server/`).
|
`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.
|
- 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.
|
Gestisci sempre 404, lista vuota, errore API.
|
||||||
- Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD
|
- Ogni articolo indicizzabile: title unico, meta description, canonical, Open Graph, JSON-LD
|
||||||
`BlogPosting`, gerarchia heading semantica. Il contenuto deve esistere nell'HTML server-rendered.
|
`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.
|
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à
|
## Priorità
|
||||||
|
|
||||||
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
|
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
|
||||||
|
|
||||||
Nessuna dipendenza, astrazione o servizio senza un bisogno concreto e attuale. Preferisci i
|
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
|
## Vincoli
|
||||||
|
|
||||||
- Mai committare `.env`, segreti, token, credenziali, chiavi. Mantieni `.env.example` sanificato.
|
- 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.
|
- 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
|
- Richiedono **approvazione esplicita**: operazioni distruttive, migrazioni irreversibili, modifiche
|
||||||
a dati o configurazione di produzione, cambi di credenziali.
|
a dati o configurazione di produzione, cambi di credenziali.
|
||||||
- Non riformattare file non correlati, non fare refactor collaterali, non riscrivere la history,
|
- 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
|
## Prima di dichiarare completo
|
||||||
|
|
||||||
Lint → test → typecheck → build dell'app toccata, con gli script del `package.json` relativo.
|
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
|
Non affermare che un check è passato se non l'hai eseguito. Se hai toccato architettura o
|
||||||
quali rischi restano aperti.
|
funzionalità, verifica di aver aggiornato `docs/` (vedi [Documentazione](#documentazione)). Chiudi
|
||||||
|
riassumendo cosa è cambiato e quali rischi restano aperti.
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Blog
|
# 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.
|
articles are written. Everything runs behind Caddy via Docker Compose.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser → Caddy ─┬─ /admin and Strapi's plugin paths → Strapi (CMS) → PostgreSQL
|
Browser → Caddy ─┬─ /_/*, /api/* → PocketBase (CMS)
|
||||||
└─ everything else → Nuxt (website)
|
└─ everything else → Nuxt (website), which also redirects /admin → /_/
|
||||||
```
|
```
|
||||||
|
|
||||||
There are no front-end accounts: sign-up is disabled and only administrators write
|
There are no front-end accounts: only the superuser writes content. An article is a
|
||||||
content. An article is a title, a Markdown body, a cover image and a category.
|
title, a Markdown body, a cover image and a category.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -22,37 +22,32 @@ through Caddy. Requires Docker.
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Generate the secrets.** Every `change-me` must become a different random value —
|
**2. Set the admin credentials.** Replace `POCKETBASE_ADMIN_EMAIL` and
|
||||||
Strapi refuses to start otherwise. This fills them all:
|
`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
|
```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
|
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**
|
**3. Start the stack**
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
`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.
|
picked up by accident in production.
|
||||||
|
|
||||||
**4. Create the administrator account** at http://localhost:1337/admin (dev bypasses Caddy,
|
**4. Open the admin UI** at http://localhost:3000/admin (redirects to PocketBase's dashboard
|
||||||
so the CMS is reached directly on its port). This is the first
|
— this works in dev too, without Caddy, since the redirect is handled by the frontend itself)
|
||||||
run, so the form creates the account — pick your own credentials.
|
and log in with the credentials from `.env`.
|
||||||
|
|
||||||
**5. Write something.** In the admin panel: create a **Category**, then an **Article**
|
**5. Write something.** In the admin UI: create a **categories** record, then an
|
||||||
(the body field is Markdown), then press **Publish** — the website only shows published
|
**articles** record (the `content` field is Markdown), then set `publishedAt` — the
|
||||||
content.
|
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
|
**6. Open the website** at http://localhost:3000 — home, `/blog`, `/blog/<slug>` and
|
||||||
`/category/<slug>`.
|
`/category/<slug>`.
|
||||||
@@ -60,16 +55,15 @@ content.
|
|||||||
Useful commands:
|
Useful commands:
|
||||||
|
|
||||||
```bash
|
```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 -f docker-compose.yml -f docker-compose.dev.yml restart frontend
|
||||||
docker compose down # stop, keep the data
|
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 —
|
To iterate on the frontend without rebuilding an image every time: `cd frontend && npm run
|
||||||
`cd frontend && npm run dev`, or `cd cms && npm run develop`. The frontend defaults to
|
dev`. It defaults to `http://localhost:8090` for PocketBase, so it works against the
|
||||||
`http://localhost:1337` for Strapi, so it works against the containerised CMS as is;
|
containerised CMS as is; override it with `NUXT_POCKETBASE_URL` if needed.
|
||||||
override it with `NUXT_STRAPI_URL` if needed. Strapi reads its own `cms/.env`.
|
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
@@ -78,14 +72,16 @@ public IP of the machine:
|
|||||||
|
|
||||||
| Record | Purpose |
|
| 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
|
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.
|
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
|
**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
|
it for the ACME challenge and to redirect to HTTPS. The admin UI shares port 443 with the
|
||||||
reachable inside the Docker network — do not publish their ports.
|
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:
|
**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` |
|
| `PUBLIC_DOMAIN` | `example.com` |
|
||||||
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
|
| `ACME_EMAIL` | a mailbox you read — Let's Encrypt sends expiry warnings there |
|
||||||
| `PUBLIC_SITE_URL` | `https://example.com` |
|
| `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`) |
|
| `PUBLIC_POCKETBASE_URL` | `https://example.com` — same origin, Caddy proxies `/_/` and `/api/` there |
|
||||||
| `STRAPI_URL` | leave it as `http://cms:1337` — internal address, never public |
|
| `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 —
|
Keep `.env` out of version control; it is already ignored.
|
||||||
different values from the ones you use locally. Keep `.env` out of version control; it is
|
|
||||||
already ignored.
|
|
||||||
|
|
||||||
> Changing `APP_KEYS`, `ADMIN_JWT_SECRET` or `JWT_SECRET` later logs everyone out.
|
> Changing `POCKETBASE_ADMIN_PASSWORD` later and restarting rotates the superuser
|
||||||
> Changing `ENCRYPTION_KEY` after content exists makes already-encrypted values
|
> password immediately (the entrypoint upserts it on every boot) — a credential change,
|
||||||
> unreadable. Set them once, then back up the file somewhere safe.
|
> so treat it with the same care as any production credential rotation.
|
||||||
|
|
||||||
**4. Start everything**
|
**4. Start everything**
|
||||||
|
|
||||||
@@ -111,24 +106,22 @@ already ignored.
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
This time Caddy is included: it serves both the website and, under `/admin` and Strapi's
|
This time Caddy is included: it serves both the website and, under `/admin`, the CMS
|
||||||
other plugin paths, the CMS on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on
|
admin UI on `PUBLIC_DOMAIN`, obtains and renews the TLS certificate on its own, and adds
|
||||||
its own, and adds HSTS and the other security headers.
|
HSTS and the other security headers.
|
||||||
|
|
||||||
**5. Create the administrator account** at `https://example.com/admin`, immediately,
|
**5. Log into the admin UI** at `https://example.com/admin` with the credentials from
|
||||||
before anyone else finds the URL — the first visitor to that form is the one who gets the
|
`.env`, then publish as in development.
|
||||||
account. Then publish as in development.
|
|
||||||
|
|
||||||
**6. Back up what is not in git**: the `postgres-data` volume (all content) and the
|
**6. Back up what is not in git**: the `pocketbase-data` volume (database and uploaded
|
||||||
`cms-uploads` volume (all images). Nothing else on the server holds state.
|
media together). Nothing else on the server holds state.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T database pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
|
docker run --rm -v blog_pocketbase-data:/data -v "$PWD:/out" alpine tar czf /out/pocketbase-data.tar.gz -C /data .
|
||||||
docker run --rm -v blog_cms-uploads:/data -v "$PWD:/out" alpine tar czf /out/uploads.tar.gz -C /data .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Updating a running site**: pull the new code, then `docker compose up -d --build`.
|
**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).
|
Architecture, conventions and constraints are documented in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
|||||||
+14
-15
@@ -16,24 +16,23 @@
|
|||||||
import security_headers
|
import security_headers
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Strapi mounts each of its (and its plugins') admin APIs at its own
|
# /admin is a vanity redirect to /_/, PocketBase's own fixed dashboard
|
||||||
# top-level path, not all under /admin - the admin panel's dashboard
|
# route — handled by Nitro (frontend/server/routes/admin.get.ts), not
|
||||||
# widgets, media library, i18n, etc each call their own plugin prefix.
|
# here, so it also works in dev where Caddy isn't in the stack.
|
||||||
# This list is every @strapi/* package in cms/package.json plus the
|
|
||||||
# core-bundled plugins (content-manager, content-type-builder, upload,
|
# PocketBase's admin UI (/_/) and its own REST/file API (/api/*) both
|
||||||
# i18n, email, content-releases, review-workflows). Adding a new Strapi
|
# reference themselves with root-absolute paths, so they must be
|
||||||
# plugin later means adding its prefix here too.
|
# reachable unprefixed at the domain root — a path like /admin/api/...
|
||||||
# None of this touches /api, reserved for the frontend's own Nitro
|
# with a stripped prefix would break the dashboard's own asset and API
|
||||||
# endpoints.
|
# calls. This is why the frontend's Nitro endpoints live under
|
||||||
@cms path /admin* /content-manager* /content-type-builder* /upload*\
|
# /content/*, not /api/*: /api is reserved for PocketBase here.
|
||||||
/i18n* /email* /content-releases* /review-workflows*\
|
@pocketbase path /_/* /api/*
|
||||||
/users-permissions* /cloud*
|
handle @pocketbase {
|
||||||
handle @cms {
|
# Cover images and admin uploads pass through here too.
|
||||||
# Uploaded media can be large; Strapi's own limit still applies.
|
|
||||||
request_body {
|
request_body {
|
||||||
max_size 100MB
|
max_size 100MB
|
||||||
}
|
}
|
||||||
reverse_proxy cms:1337
|
reverse_proxy pocketbase:8090
|
||||||
}
|
}
|
||||||
|
|
||||||
handle {
|
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,41 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "articles",
|
|
||||||
"info": {
|
|
||||||
"singularName": "article",
|
|
||||||
"pluralName": "articles",
|
|
||||||
"displayName": "Article",
|
|
||||||
"description": "Blog article"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": true,
|
|
||||||
"populateCreatorFields": true
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"maxLength": 160
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"type": "uid",
|
|
||||||
"targetField": "title",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"type": "richtext",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"cover": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": false,
|
|
||||||
"allowedTypes": ["images"]
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "manyToOne",
|
|
||||||
"target": "api::category.category",
|
|
||||||
"inversedBy": "articles"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,31 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "categories",
|
|
||||||
"info": {
|
|
||||||
"singularName": "category",
|
|
||||||
"pluralName": "categories",
|
|
||||||
"displayName": "Category",
|
|
||||||
"description": "Article category"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": false
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"unique": true
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"type": "uid",
|
|
||||||
"targetField": "name",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"articles": {
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "oneToMany",
|
|
||||||
"target": "api::article.article",
|
|
||||||
"mappedBy": "category"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,55 +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 } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
register() {},
|
|
||||||
|
|
||||||
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
|
||||||
await grantPublicReadAccess(strapi);
|
|
||||||
await disablePublicSignUp(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
|
# 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:
|
# 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:
|
services:
|
||||||
database:
|
pocketbase:
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5432:5432"
|
- "127.0.0.1:8090:8090"
|
||||||
|
volumes:
|
||||||
cms:
|
- ./pocketbase/pb_data:/pb/pb_data
|
||||||
ports:
|
|
||||||
- "127.0.0.1:1337:1337"
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
environment:
|
environment:
|
||||||
NUXT_PUBLIC_SITE_URL: http://localhost:3000
|
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:
|
services:
|
||||||
database:
|
pocketbase:
|
||||||
image: postgres:17-alpine
|
build: ./pocketbase
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POCKETBASE_ADMIN_EMAIL: ${POCKETBASE_ADMIN_EMAIL}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POCKETBASE_ADMIN_PASSWORD: ${POCKETBASE_ADMIN_PASSWORD}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- pocketbase-data:/pb/pb_data
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- cms
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
NUXT_STRAPI_URL: ${STRAPI_URL}
|
NUXT_POCKETBASE_URL: ${POCKETBASE_URL}
|
||||||
NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL}
|
||||||
NUXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL}
|
NUXT_PUBLIC_POCKETBASE_URL: ${PUBLIC_POCKETBASE_URL}
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontend
|
- frontend
|
||||||
- cms
|
- pocketbase
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@@ -75,7 +50,6 @@ services:
|
|||||||
- caddy-config:/config
|
- caddy-config:/config
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
pocketbase-data:
|
||||||
cms-uploads:
|
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-config:
|
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.
|
||||||
@@ -94,6 +94,19 @@ h3 {
|
|||||||
color: var(--color-muted);
|
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. */
|
/* Small uppercase label used for categories, dates and section headings. */
|
||||||
.kicker {
|
.kicker {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -223,16 +236,3 @@ h3 {
|
|||||||
font-size: 0.9em;
|
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,10 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ArticleSummary } from '#shared/types/blog'
|
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 mediaUrl = useMediaUrl()
|
||||||
const cover = computed(() => mediaUrl(props.article.cover))
|
const cover = computed(() => mediaUrl(props.article.cover))
|
||||||
|
const href = computed(() => localePath(props.locale, `/blog/${props.article.slug}`))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -14,16 +19,14 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="cover"
|
v-if="cover"
|
||||||
class="cover-link"
|
class="cover-link"
|
||||||
:to="`/blog/${article.slug}`"
|
:to="href"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="cover"
|
class="cover"
|
||||||
:src="cover"
|
:src="cover"
|
||||||
:alt="article.cover?.alternativeText ?? ''"
|
:alt="article.cover?.alt ?? ''"
|
||||||
:width="article.cover?.width ?? undefined"
|
|
||||||
:height="article.cover?.height ?? undefined"
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
>
|
>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -31,12 +34,12 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
|||||||
<p v-if="article.category" class="kicker">{{ article.category.name }}</p>
|
<p v-if="article.category" class="kicker">{{ article.category.name }}</p>
|
||||||
|
|
||||||
<h2>
|
<h2>
|
||||||
<NuxtLink :to="`/blog/${article.slug}`">{{ article.title }}</NuxtLink>
|
<NuxtLink :to="href">{{ article.title }}</NuxtLink>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="kicker date">
|
<p class="kicker date">
|
||||||
<time :datetime="isoDate(article.publishedAt)">
|
<time :datetime="isoDate(article.publishedAt)">
|
||||||
{{ formatDate(article.publishedAt) }}
|
{{ formatDate(article.publishedAt, locale) }}
|
||||||
</time>
|
</time>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -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' }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Locale } from '#shared/utils/locale'
|
||||||
|
|
||||||
|
/** Derives the current locale from the URL path — `/en` or `/en/...` is English, everything else Italian. */
|
||||||
|
export function useLocale() {
|
||||||
|
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
|
* PocketBase returns file paths relative to its own origin; the browser
|
||||||
* absolute ones. Remote providers (S3) already return absolute URLs.
|
* needs absolute ones.
|
||||||
*/
|
*/
|
||||||
export function useMediaUrl() {
|
export function useMediaUrl() {
|
||||||
const { public: config } = useRuntimeConfig()
|
const { public: config } = useRuntimeConfig()
|
||||||
|
|
||||||
return (image: StrapiImage | null | undefined): string | null => {
|
return (image: MediaImage | null | undefined): string | null => {
|
||||||
if (!image?.url) return 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,26 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { STRINGS } from '#shared/utils/i18n'
|
||||||
|
import { localePath } from '#shared/utils/locale'
|
||||||
|
|
||||||
|
const locale = useLocale()
|
||||||
|
const strings = computed(() => STRINGS[locale.value])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a class="skip-link" href="#main">Vai al contenuto</a>
|
<a class="skip-link" href="#main">{{ strings.skipLink }}</a>
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container">
|
<div class="container header-inner">
|
||||||
|
<LanguageSwitch class="lang-switch-header" />
|
||||||
|
|
||||||
<p class="wordmark">
|
<p class="wordmark">
|
||||||
<NuxtLink to="/">
|
<NuxtLink :to="localePath(locale, '/')">
|
||||||
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
<p class="tagline kicker">Articoli, guide e analisi.</p>
|
<p class="tagline kicker">{{ strings.tagline }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="site-nav" aria-label="Navigazione principale">
|
<nav class="site-nav" :aria-label="strings.navAriaLabel">
|
||||||
<div class="container nav-inner">
|
<div class="container nav-inner">
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
<li v-if="locale === 'it'">
|
||||||
|
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -34,24 +43,26 @@
|
|||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<div class="footer-block">
|
<div class="footer-block">
|
||||||
<img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
<img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||||
<p class="muted">Articoli, guide e analisi.</p>
|
<p class="muted">{{ strings.tagline }}</p>
|
||||||
<address class="contact">
|
<address class="contact">
|
||||||
Contatti:
|
{{ strings.contactLabel }}
|
||||||
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
|
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
|
||||||
</address>
|
</address>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="footer-block" aria-label="Naviga">
|
<nav class="footer-block" :aria-label="strings.footerNavAriaLabel">
|
||||||
<p class="kicker">Naviga</p>
|
<p class="kicker">{{ strings.footerNavigaKicker }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
<li v-if="locale === 'it'">
|
||||||
|
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav class="footer-block" aria-label="Seguici">
|
<nav class="footer-block" :aria-label="strings.footerFollowAriaLabel">
|
||||||
<p class="kicker">Seguici</p>
|
<p class="kicker">{{ strings.footerSeguiciKicker }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="social in SOCIAL_LINKS" :key="social.name">
|
<li v-for="social in SOCIAL_LINKS" :key="social.name">
|
||||||
<a class="social" :href="social.url" target="_blank" rel="noopener me">
|
<a class="social" :href="social.url" target="_blank" rel="noopener me">
|
||||||
@@ -64,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="container copyright kicker">
|
<p class="container copyright kicker">
|
||||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. Tutti i diritti riservati.
|
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ strings.copyright }}
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,6 +101,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: var(--space);
|
||||||
|
}
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,159 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Article } from '#shared/types/blog'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const slug = route.params.slug as string
|
|
||||||
|
|
||||||
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
|
|
||||||
|
|
||||||
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),
|
|
||||||
author: article.value.author
|
|
||||||
? { '@type': 'Person', name: article.value.author }
|
|
||||||
: undefined,
|
|
||||||
image: cover.value ? [cover.value] : undefined,
|
|
||||||
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article v-if="article">
|
<ArticleView locale="it" />
|
||||||
<header class="article-header">
|
|
||||||
<nav class="kicker" aria-label="Percorso di navigazione">
|
|
||||||
<NuxtLink to="/blog">Articoli</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) }}
|
|
||||||
</time>
|
|
||||||
<template v-if="article.author">
|
|
||||||
<span aria-hidden="true"> · </span>
|
|
||||||
<span>Di {{ 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">Torna a tutti gli articoli</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</template>
|
</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,66 +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 { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
|
||||||
query: { page },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
|
||||||
const canonical = computed(
|
|
||||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
|
||||||
)
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: () => (page.value > 1 ? `Articoli — pagina ${page.value}` : 'Articoli'),
|
|
||||||
description: 'Tutti gli articoli pubblicati sul blog.',
|
|
||||||
ogType: 'website',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<BlogIndexView locale="it" />
|
||||||
<header class="page-header">
|
|
||||||
<p class="kicker">L'archivio</p>
|
|
||||||
<h1>Articoli</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
|
||||||
|
|
||||||
<p v-else-if="status === 'pending'">Caricamento…</p>
|
|
||||||
|
|
||||||
<p v-else-if="!data?.items.length">Non è stato ancora pubblicato nulla.</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="Paginazione">
|
|
||||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
|
||||||
Pagina precedente
|
|
||||||
</NuxtLink>
|
|
||||||
<span class="muted">
|
|
||||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
|
||||||
</span>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="data.page < data.pageCount"
|
|
||||||
:to="{ query: { page: data.page + 1 } }"
|
|
||||||
rel="next"
|
|
||||||
>
|
|
||||||
Pagina successiva
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,71 +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 { data: category } = await useFetch<Category>(`/api/categories/${slug}`)
|
|
||||||
|
|
||||||
if (!category.value) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
|
||||||
query: { page, category: slug },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
|
||||||
const canonical = computed(
|
|
||||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
|
||||||
)
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: category.value.name,
|
|
||||||
description: () => `Tutti gli articoli nella categoria ${category.value?.name ?? ''}.`,
|
|
||||||
ogType: 'website',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<CategoryView locale="it" />
|
||||||
<header class="page-header">
|
|
||||||
<p class="kicker">Categoria</p>
|
|
||||||
<h1>{{ category?.name }}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
|
||||||
|
|
||||||
<p v-else-if="!data?.items.length">Non ci sono articoli in questa categoria.</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="Paginazione">
|
|
||||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
|
||||||
Pagina precedente
|
|
||||||
</NuxtLink>
|
|
||||||
<span class="muted">
|
|
||||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
|
||||||
</span>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="data.page < data.pageCount"
|
|
||||||
:to="{ query: { page: data.page + 1 } }"
|
|
||||||
rel="next"
|
|
||||||
>
|
|
||||||
Pagina successiva
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</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,212 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
|
||||||
|
|
||||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
|
||||||
key: 'home-articles',
|
|
||||||
query: { page: 1 },
|
|
||||||
})
|
|
||||||
|
|
||||||
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: 'Articoli, guide e analisi.',
|
|
||||||
ogTitle: SITE_NAME,
|
|
||||||
ogDescription: 'Articoli, guide e analisi.',
|
|
||||||
ogType: 'website',
|
|
||||||
ogUrl: canonical,
|
|
||||||
ogImage: `${config.siteUrl}/logo.png`,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<HomeView locale="it" />
|
||||||
<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">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
|
||||||
|
|
||||||
<p v-else-if="!featured">Non è stato ancora pubblicato nulla.</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 : 'Ultimo' }}
|
|
||||||
</p>
|
|
||||||
<h2>
|
|
||||||
<NuxtLink :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLink>
|
|
||||||
</h2>
|
|
||||||
<p class="kicker date">
|
|
||||||
<time :datetime="isoDate(featured.publishedAt)">
|
|
||||||
{{ formatDate(featured.publishedAt) }}
|
|
||||||
</time>
|
|
||||||
</p>
|
|
||||||
<p class="read-more">
|
|
||||||
<NuxtLink :to="`/blog/${featured.slug}`">
|
|
||||||
Leggi l'ultimo articolo
|
|
||||||
</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</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,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'],
|
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: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
htmlAttrs: { lang: 'it' },
|
htmlAttrs: { lang: 'it' },
|
||||||
@@ -20,18 +25,19 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
// Server-only: internal Strapi address, never exposed to the browser.
|
// Server-only: internal PocketBase address, never exposed to the browser.
|
||||||
strapiUrl: 'http://localhost:1337',
|
pocketbaseUrl: 'http://localhost:8090',
|
||||||
strapiToken: '',
|
|
||||||
public: {
|
public: {
|
||||||
// Canonical origin of the public website.
|
// Canonical origin of the public website.
|
||||||
siteUrl: 'http://localhost:3000',
|
siteUrl: 'http://localhost:3000',
|
||||||
// Browser-reachable Strapi origin, used to build absolute media URLs.
|
// Browser-reachable PocketBase origin, used to build absolute media URLs.
|
||||||
strapiUrl: 'http://localhost:1337',
|
pocketbaseUrl: 'http://localhost:8090',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
strict: true,
|
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",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare",
|
"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": {
|
"dependencies": {
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
@@ -18,7 +24,15 @@
|
|||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^5.0.0",
|
||||||
"vue-tsc": "^3.3.11"
|
"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,43 +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')
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await strapiFetch<StrapiList<RawArticle>>(
|
|
||||||
`/api/articles?${ARTICLE_DETAIL_QUERY}&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,22 +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 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}&${pagination(page)}${filter}`
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: response.data,
|
|
||||||
page: response.meta.pagination.page,
|
|
||||||
pageCount: response.meta.pagination.pageCount,
|
|
||||||
total: response.meta.pagination.total,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { Category } from '#shared/types/blog'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<Category> => {
|
|
||||||
const slug = getRouterParam(event, 'slug')
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await strapiFetch<StrapiList<Category>>(
|
|
||||||
`/api/categories?fields[0]=name&fields[1]=slug&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,9 +0,0 @@
|
|||||||
import type { Category } from '#shared/types/blog'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (): Promise<Category[]> => {
|
|
||||||
const response = await strapiFetch<StrapiList<Category>>(
|
|
||||||
'/api/categories?fields[0]=name&fields[1]=slug&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 { 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
|
* Calls the PocketBase REST API from the server only, so the internal URL
|
||||||
* future API token never reach the browser.
|
* never reaches the browser.
|
||||||
*/
|
*/
|
||||||
export async function strapiFetch<T>(path: string): Promise<T> {
|
export async function pbFetch<T>(path: string): Promise<T> {
|
||||||
const { strapiUrl, strapiToken } = useRuntimeConfig()
|
const { pocketbaseUrl } = useRuntimeConfig()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return (await $fetch(path, {
|
return (await $fetch(path, { baseURL: pocketbaseUrl })) as T
|
||||||
baseURL: strapiUrl,
|
|
||||||
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
|
|
||||||
})) as T
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Strapi request failed: ${path}`, error)
|
console.error(`PocketBase request failed: ${path}`, error)
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
|
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()}…`
|
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}…`
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrapiList<T> {
|
export interface PbList<T> {
|
||||||
data: T[]
|
page: number
|
||||||
meta: { pagination: { page: number; pageCount: number; total: 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,33 +1,26 @@
|
|||||||
/** 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[]) =>
|
export const ARTICLE_SUMMARY_FIELDS =
|
||||||
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&')
|
'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 = [
|
export const CATEGORY_FIELDS = 'name,nameEn,slug'
|
||||||
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 PAGE_SIZE = 12
|
export const PAGE_SIZE = 12
|
||||||
|
|
||||||
export const pagination = (page: number, pageSize = PAGE_SIZE) =>
|
export const pagination = (page: number, perPage = PAGE_SIZE) => `page=${page}&perPage=${perPage}`
|
||||||
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
|
|
||||||
|
|
||||||
/** Reads a positive integer query param, falling back to 1. */
|
/** Reads a positive integer query param, falling back to 1. */
|
||||||
export const pageParam = (value: unknown): number => {
|
export const pageParam = (value: unknown): number => {
|
||||||
const page = Number(value)
|
const page = Number(value)
|
||||||
return Number.isInteger(page) && page > 0 ? page : 1
|
return Number.isInteger(page) && page > 0 ? page : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reads a `lang` query param, falling back to Italian for anything but `en`. */
|
||||||
|
export const langParam = (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
|
url: string
|
||||||
alternativeText: string | null
|
alt: string | null
|
||||||
width: number | null
|
|
||||||
height: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@@ -17,7 +15,7 @@ export interface ArticleSummary {
|
|||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
publishedAt: string
|
publishedAt: string
|
||||||
cover: StrapiImage | null
|
cover: MediaImage | null
|
||||||
category: Category | null
|
category: Category | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,8 +24,10 @@ export interface Article extends ArticleSummary {
|
|||||||
html: string
|
html: string
|
||||||
/** Plain-text opening of the body, used as the meta description. */
|
/** Plain-text opening of the body, used as the meta description. */
|
||||||
summary: string
|
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
|
author: string | null
|
||||||
|
/** Whether this article has a (manually authored) English translation. */
|
||||||
|
hasTranslation: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Paginated<T> {
|
export interface Paginated<T> {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
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. */
|
/** Human-readable date for display; returns an empty string when unset. */
|
||||||
export function formatDate(value: string | null | undefined): string {
|
export function formatDate(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(value))
|
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long' }).format(new Date(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Date and time of publication, shown in the article byline. */
|
/** Date and time of publication, shown in the article byline. */
|
||||||
export function formatDateTime(value: string | null | undefined): string {
|
export function formatDateTime(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long', timeStyle: 'short' })
|
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long', timeStyle: 'short' })
|
||||||
.format(new Date(value))
|
.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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { startTestPocketbase } from '../support/pocketbase'
|
||||||
|
import { E2E_POCKETBASE_PORT } from '../../frontend/playwright.config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts one PocketBase instance, shared by the whole e2e run, on the fixed
|
||||||
|
* port `playwright.config.ts` also bakes into the Nuxt server's env — so both
|
||||||
|
* sides can reference it without needing an async hand-off between them.
|
||||||
|
*
|
||||||
|
* Playwright keeps this module's closure alive and calls the returned
|
||||||
|
* function as teardown once all tests finish (no separate globalTeardown
|
||||||
|
* file needed).
|
||||||
|
*/
|
||||||
|
export default async function globalSetup() {
|
||||||
|
const pb = await startTestPocketbase({ port: E2E_POCKETBASE_PORT })
|
||||||
|
return async () => {
|
||||||
|
await pb.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
test('home page shows the latest published article as featured', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Secondo articolo pubblicato' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: "Leggi l'ultimo articolo" })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/blog/secondo-articolo'
|
||||||
|
)
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user