Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd920627fa | ||
|
|
ac536bd152 | ||
|
|
1247a2ca07 | ||
|
|
364fda3980 | ||
|
|
33b49c5dbb | ||
|
|
847edc7397 | ||
|
|
7ce073d2a9 | ||
|
|
91181f2170 | ||
|
|
6609e3ae28 | ||
|
|
49c42ecc96 | ||
|
|
91540224a9 | ||
|
|
47a8e7788f | ||
|
|
0d56769388 |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"claude-md-management@claude-plugins-official": true,
|
||||||
|
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-18
@@ -4,25 +4,20 @@
|
|||||||
PUBLIC_DOMAIN=blog.localhost
|
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*
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-09-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- English version of the site (`/en/*`) alongside the Italian original, hand-rolled without an
|
||||||
|
i18n library: `/en`, `/en/blog`, `/en/blog/[slug]`, `/en/category/[slug]` and the corralito page
|
||||||
|
all get an English counterpart, sharing markup with their Italian page via a `locale` prop.
|
||||||
|
- Manual, per-record English fields on content: `titleEn`/`contentEn`/`coverAltEn` on articles,
|
||||||
|
`nameEn` on categories — translated by the editor in the admin UI, no auto-translation.
|
||||||
|
- English translation of the corralito training-courses page specifically (it was left Italian-only
|
||||||
|
when `/en` support first landed, then translated in a follow-up pass).
|
||||||
|
- Numbered pagination on the blog archive and category pages (first, last, current page and its
|
||||||
|
neighbors, collapsing gaps into `…`), replacing plain previous/next links.
|
||||||
|
- `claude-md-management` and `ui-ux-pro-max` Claude Code plugins, enabled repo-wide via
|
||||||
|
`.claude/settings.json`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Redesigned the language switch: both languages are shown at once ("Italiano / English"), the
|
||||||
|
current one as plain non-interactive text, moved to the header's top-right corner instead of
|
||||||
|
stacked under the logo.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- An article/category with no English translation now 404s cleanly on its own `/en` page and is
|
||||||
|
filtered out of `/en` listings, instead of silently showing Italian text on an English URL.
|
||||||
|
- Pagination links no longer report every page as "current" to assistive technology — caused by
|
||||||
|
`NuxtLink`/`RouterLink`'s built-in active-state detection comparing only the route path, not its
|
||||||
|
query string.
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-09-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `docs/` — architecture, content model, and frontend reference documentation.
|
||||||
|
- ESLint, via the official `@nuxt/eslint` module.
|
||||||
|
- Full test suite: unit (Vitest), integration (a real Nitro server against an ephemeral
|
||||||
|
PocketBase), and end-to-end (Playwright) — under `tests/` at the repo root.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Lightened the (still Strapi-based, at the time) CMS: dropped the unused cloud plugin, disabled
|
||||||
|
telemetry and promotional UI — a stopgap before the PocketBase migration below.
|
||||||
|
- Replaced the Strapi + PostgreSQL CMS with PocketBase: a single binary with embedded SQLite and
|
||||||
|
per-collection API rules. Frontend endpoints moved to `/content/*`; `/api/*` is now reserved for
|
||||||
|
PocketBase's own REST/file API.
|
||||||
|
- Local development now bind-mounts PocketBase's data directory (`pocketbase/pb_data/`) instead of
|
||||||
|
using a named Docker volume, so test data can be inspected or swapped directly on the host.
|
||||||
|
- `articles.content` now has an explicit 10000-character cap, instead of relying on an undocumented
|
||||||
|
PocketBase default that silently rejected longer articles.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-08-28
|
||||||
|
|
||||||
|
Initial release.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Strapi CMS with `Article` and `Category` content types.
|
||||||
|
- Nuxt frontend: blog index, article detail, and category pages.
|
||||||
|
- Docker Compose stack: PostgreSQL, Caddy reverse proxy, and the app services.
|
||||||
|
- Project documentation, license, and `.gitignore`; docs for the stack, dev workflow, production
|
||||||
|
deployment, and generating `.env` secrets.
|
||||||
|
- Editorial-magazine restyle of the frontend, with the CorralitoComing seal as site logo and
|
||||||
|
favicon (every size).
|
||||||
|
- Contact address and official social-brand links in the footer.
|
||||||
|
- Author name and publication time shown on articles.
|
||||||
|
- Responsive layout for phone, tablet, and desktop.
|
||||||
|
- Landing page content and a dedicated courses page.
|
||||||
|
- A language switcher supporting English, Italian, Spanish, and French (later removed — see
|
||||||
|
Removed, below).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the site to CorralitoComing and switched the interface to English (later reverted to
|
||||||
|
Italian-only, see Removed).
|
||||||
|
- Simplified the home page and navigation down to a single "read the latest article" link.
|
||||||
|
- The CMS admin panel is served under `/admin` on the public domain; every Strapi plugin path is
|
||||||
|
routed there, not just `/admin`.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The English/Italian/Spanish/French language switcher — reverted to an Italian-only interface,
|
||||||
|
relying on the visitor's browser to translate the page instead.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ACME DNS resolution failures in the Caddy container.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- `ops/` (holds VPS credentials) added to `.gitignore` — never committed.
|
||||||
@@ -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,51 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "articles",
|
|
||||||
"info": {
|
|
||||||
"singularName": "article",
|
|
||||||
"pluralName": "articles",
|
|
||||||
"displayName": "Article",
|
|
||||||
"description": "Blog article"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": true,
|
|
||||||
"populateCreatorFields": true
|
|
||||||
},
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"maxLength": 160,
|
|
||||||
"pluginOptions": { "i18n": { "localized": true } }
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"type": "uid",
|
|
||||||
"targetField": "title",
|
|
||||||
"required": true,
|
|
||||||
"pluginOptions": { "i18n": { "localized": false } }
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"type": "richtext",
|
|
||||||
"required": true,
|
|
||||||
"pluginOptions": { "i18n": { "localized": true } }
|
|
||||||
},
|
|
||||||
"cover": {
|
|
||||||
"type": "media",
|
|
||||||
"multiple": false,
|
|
||||||
"allowedTypes": ["images"],
|
|
||||||
"pluginOptions": { "i18n": { "localized": false } }
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "manyToOne",
|
|
||||||
"target": "api::category.category",
|
|
||||||
"inversedBy": "articles",
|
|
||||||
"pluginOptions": { "i18n": { "localized": false } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreController('api::article.article');
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreRouter('api::article.article');
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreService('api::article.article');
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"kind": "collectionType",
|
|
||||||
"collectionName": "categories",
|
|
||||||
"info": {
|
|
||||||
"singularName": "category",
|
|
||||||
"pluralName": "categories",
|
|
||||||
"displayName": "Category",
|
|
||||||
"description": "Article category"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"draftAndPublish": false
|
|
||||||
},
|
|
||||||
"pluginOptions": {
|
|
||||||
"i18n": {
|
|
||||||
"localized": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"attributes": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true,
|
|
||||||
"unique": true,
|
|
||||||
"pluginOptions": { "i18n": { "localized": true } }
|
|
||||||
},
|
|
||||||
"slug": {
|
|
||||||
"type": "uid",
|
|
||||||
"targetField": "name",
|
|
||||||
"required": true,
|
|
||||||
"pluginOptions": { "i18n": { "localized": false } }
|
|
||||||
},
|
|
||||||
"articles": {
|
|
||||||
"type": "relation",
|
|
||||||
"relation": "oneToMany",
|
|
||||||
"target": "api::article.article",
|
|
||||||
"mappedBy": "category",
|
|
||||||
"pluginOptions": { "i18n": { "localized": false } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreController('api::category.category');
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreRouter('api::category.category');
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { factories } from '@strapi/strapi';
|
|
||||||
|
|
||||||
export default factories.createCoreService('api::category.category');
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { Core } from '@strapi/strapi';
|
|
||||||
|
|
||||||
/** Read-only endpoints the public website needs. Nothing else is granted. */
|
|
||||||
const PUBLIC_READ_ACTIONS = ['article', 'category'].flatMap((type) => [
|
|
||||||
`api::${type}.${type}.find`,
|
|
||||||
`api::${type}.${type}.findOne`,
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grants the Public role read access to the blog content types, so a fresh
|
|
||||||
* deployment serves content without anyone clicking through the admin panel.
|
|
||||||
* Existing permissions are left untouched.
|
|
||||||
*/
|
|
||||||
async function grantPublicReadAccess(strapi: Core.Strapi) {
|
|
||||||
const publicRole = await strapi
|
|
||||||
.query('plugin::users-permissions.role')
|
|
||||||
.findOne({ where: { type: 'public' } });
|
|
||||||
|
|
||||||
if (!publicRole) return;
|
|
||||||
|
|
||||||
for (const action of PUBLIC_READ_ACTIONS) {
|
|
||||||
const existing = await strapi
|
|
||||||
.query('plugin::users-permissions.permission')
|
|
||||||
.findOne({ where: { action, role: publicRole.id } });
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
await strapi
|
|
||||||
.query('plugin::users-permissions.permission')
|
|
||||||
.create({ data: { action, role: publicRole.id } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The site has no front-end accounts: only the administrators authoring content
|
|
||||||
* in the admin panel. Self-registration is therefore closed, so nobody can
|
|
||||||
* create an Authenticated user through the public API.
|
|
||||||
*/
|
|
||||||
async function disablePublicSignUp(strapi: Core.Strapi) {
|
|
||||||
const store = strapi.store({ type: 'plugin', name: 'users-permissions', key: 'advanced' });
|
|
||||||
const advanced = ((await store.get({ key: 'advanced' })) ?? {}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (advanced.allow_register !== false) {
|
|
||||||
await store.set({ key: 'advanced', value: { ...advanced, allow_register: false } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The site content is authored in Italian and English. Strapi ships with only
|
|
||||||
* the default `it` locale, so the `en` locale is created here if missing.
|
|
||||||
*/
|
|
||||||
async function ensureEnglishLocale(strapi: Core.Strapi) {
|
|
||||||
const locales = strapi.plugin('i18n').service('locales');
|
|
||||||
const existing = await locales.find({ where: { code: 'en' } });
|
|
||||||
|
|
||||||
if (existing.length === 0) {
|
|
||||||
await locales.create({ code: 'en', name: 'English (en)', isDefault: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
register() {},
|
|
||||||
|
|
||||||
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
|
||||||
await grantPublicReadAccess(strapi);
|
|
||||||
await disablePublicSignUp(strapi);
|
|
||||||
await ensureEnglishLocale(strapi);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "CommonJS",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"lib": ["ES2020"],
|
|
||||||
"target": "ES2019",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"incremental": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"noEmitOnError": true,
|
|
||||||
"noImplicitThis": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "."
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
// Include root files
|
|
||||||
"./",
|
|
||||||
// Include all ts files
|
|
||||||
"./**/*.ts",
|
|
||||||
// Include all js files
|
|
||||||
"./**/*.js",
|
|
||||||
// Force the JSON files in the src folder to be included
|
|
||||||
"src/**/*.json"
|
|
||||||
],
|
|
||||||
|
|
||||||
"exclude": [
|
|
||||||
"node_modules/",
|
|
||||||
"build/",
|
|
||||||
"dist/",
|
|
||||||
".cache/",
|
|
||||||
".tmp/",
|
|
||||||
".strapi/",
|
|
||||||
|
|
||||||
// Do not include admin files in the server compilation
|
|
||||||
"src/admin/",
|
|
||||||
// Do not include test files
|
|
||||||
"**/*.test.*",
|
|
||||||
// Do not include plugins in the server compilation
|
|
||||||
"src/plugins/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
# Local testing without domains or TLS: publishes the app ports on localhost and
|
# 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,209 @@
|
|||||||
|
# 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` | `/en/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` + `app/pages/en/come-difendersi-dal-corralito.vue` → `CorralitoView.vue` | Fully static marketing page, no PocketBase data — copy lives in `STRINGS[locale].corralito` (`shared/utils/i18n.ts`), same pattern as every other view. |
|
||||||
|
|
||||||
|
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/category list; every
|
||||||
|
other route (including the static corralito page) exists in both languages unconditionally, so
|
||||||
|
the mechanical `/en` prefix swap is always valid there.
|
||||||
|
- **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`, `CorralitoView`) — the
|
||||||
|
actual page markup/logic, parametrized by a `locale` prop; the real `pages/**` and `pages/en/**`
|
||||||
|
files are thin wrappers around these (see [English content](#english-content)). Registered
|
||||||
|
without Nuxt's default nested-directory name prefix
|
||||||
|
(`components: [{ path: '~/components', pathPrefix: false }]` in `nuxt.config.ts`), so pages
|
||||||
|
reference them as `<HomeView>` etc., not `<ViewsHomeView>`.
|
||||||
|
|
||||||
|
## SEO & accessibility
|
||||||
|
|
||||||
|
Every article page ships: unique `<title>`, meta description, canonical URL, Open Graph tags, and
|
||||||
|
JSON-LD `BlogPosting` structured data, with the article body already present in server-rendered
|
||||||
|
HTML (no client-only content). Accessibility requirements (focus visibility, labeled inputs,
|
||||||
|
meaningful alt text, descriptive links, full keyboard navigation) apply across all pages/components
|
||||||
|
— see `CLAUDE.md`.
|
||||||
|
|
||||||
|
## Tests (`tests/`, repo root)
|
||||||
|
|
||||||
|
Kept at the repo root, not under `frontend/`, since e2e exercises the frontend *and* PocketBase
|
||||||
|
together — but the tooling (Vitest/Playwright configs, `node_modules`) still lives in `frontend/`,
|
||||||
|
the only npm project in the repo; the configs there just point `include`/`testDir` at `../tests/`.
|
||||||
|
|
||||||
|
Three layers, all against real code (no mocked PocketBase):
|
||||||
|
|
||||||
|
- `tests/unit/` — Vitest, `environment: 'nuxt'` (`frontend/vitest.unit.config.ts`). Pure functions
|
||||||
|
in `frontend/server/utils/*` and `frontend/shared/utils/*` (imported directly, not via
|
||||||
|
auto-import) plus components and composables via `@nuxt/test-utils/runtime`'s `mountSuspended`.
|
||||||
|
- `tests/integration/` — Vitest, node environment (`frontend/vitest.integration.config.ts`).
|
||||||
|
`@nuxt/test-utils/e2e`'s `setup()` builds and runs the real Nitro server, pointed (via
|
||||||
|
`nuxtConfig.runtimeConfig` overrides) at a real ephemeral PocketBase instance from
|
||||||
|
`tests/support/pocketbase.ts`. Exercises the actual `/content/*` and `/admin` routes.
|
||||||
|
- `tests/e2e/` — Playwright (`frontend/playwright.config.ts`), a real browser against the built
|
||||||
|
app (`node .output/server/index.mjs`) and another ephemeral PocketBase on a fixed port (needed
|
||||||
|
so the Nuxt server's env and Playwright's `globalSetup` can reference each other without an
|
||||||
|
async hand-off — see the comments in `playwright.config.ts`/`tests/e2e/global-setup.ts`).
|
||||||
|
|
||||||
|
`tests/support/pocketbase.ts` is the shared piece: it downloads the same pinned PocketBase binary
|
||||||
|
`pocketbase/Dockerfile` uses (cached in `.cache/pocketbase/` at the repo root, gitignored), starts
|
||||||
|
it against the real `pocketbase/pb_migrations/`, and seeds it from `tests/support/fixtures.ts` —
|
||||||
|
so integration and e2e tests run against the exact schema/rules that ship to production, not a
|
||||||
|
hand-maintained approximation of them.
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { locale } = useLocale()
|
|
||||||
useHead({ htmlAttrs: { lang: locale } })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
|
|||||||
@@ -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,11 +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 { locale } = useLocale()
|
const href = computed(() => localePath(props.locale, `/blog/${props.article.slug}`))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -15,16 +19,14 @@ const { locale } = useLocale()
|
|||||||
<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>
|
||||||
@@ -32,7 +34,7 @@ const { locale } = useLocale()
|
|||||||
<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">
|
||||||
|
|||||||
@@ -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,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Locale } from '#shared/utils/locale'
|
||||||
|
import { STRINGS } from '#shared/utils/i18n'
|
||||||
|
|
||||||
|
const props = defineProps<{ locale: Locale }>()
|
||||||
|
|
||||||
|
const strings = computed(() => STRINGS[props.locale])
|
||||||
|
const { public: config } = useRuntimeConfig()
|
||||||
|
|
||||||
|
useSeo({
|
||||||
|
locale: props.locale,
|
||||||
|
path: '/come-difendersi-dal-corralito',
|
||||||
|
title: strings.value.corralito.metaTitle,
|
||||||
|
description: strings.value.corralito.metaDescription,
|
||||||
|
image: `${config.siteUrl}/protesta-bancaria.jpg`,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article>
|
||||||
|
<header class="article-header">
|
||||||
|
<h1>{{ strings.navCorralito }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<img
|
||||||
|
class="cover"
|
||||||
|
src="/protesta-bancaria.jpg"
|
||||||
|
:alt="strings.corralito.coverAlt"
|
||||||
|
width="770"
|
||||||
|
height="420"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="prose">
|
||||||
|
<p>{{ strings.corralito.p1 }}</p>
|
||||||
|
<p>{{ strings.corralito.p2 }}</p>
|
||||||
|
<p>{{ strings.corralito.topicsIntro }}</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li v-for="topic in strings.corralito.topics" :key="topic">{{ topic }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>{{ strings.corralito.disclaimer }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-cta">
|
||||||
|
<p class="kicker">{{ strings.corralito.contactKicker }}</p>
|
||||||
|
<p class="contact-links">
|
||||||
|
<a :href="`mailto:${SITE_EMAIL}`">{{ strings.corralito.emailLink }}</a>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<a href="https://t.me/Ito1505" target="_blank" rel="noopener">{{ strings.corralito.telegramLink }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.article-header {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.article-header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-block: 0 3rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-cta {
|
||||||
|
max-width: var(--width-prose);
|
||||||
|
margin: 3rem auto 0;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-links {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-links a {
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-links a:hover {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||||
|
import type { Locale } from '#shared/utils/locale'
|
||||||
|
import { STRINGS } from '#shared/utils/i18n'
|
||||||
|
import { localePath } from '#shared/utils/locale'
|
||||||
|
|
||||||
|
const props = defineProps<{ locale: Locale }>()
|
||||||
|
|
||||||
|
const strings = computed(() => STRINGS[props.locale])
|
||||||
|
|
||||||
|
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||||
|
key: `home-articles-${props.locale}`,
|
||||||
|
query: { page: 1, lang: props.locale },
|
||||||
|
})
|
||||||
|
|
||||||
|
const featured = computed(() => data.value?.items[0] ?? null)
|
||||||
|
|
||||||
|
const mediaUrl = useMediaUrl()
|
||||||
|
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
||||||
|
const featuredHref = computed(() => (featured.value ? localePath(props.locale, `/blog/${featured.value.slug}`) : '/'))
|
||||||
|
|
||||||
|
useSeo({
|
||||||
|
locale: props.locale,
|
||||||
|
path: '/',
|
||||||
|
title: SITE_NAME,
|
||||||
|
description: strings.value.home.metaDescription,
|
||||||
|
image: `${useRuntimeConfig().public.siteUrl}/logo.png`,
|
||||||
|
})
|
||||||
|
useHead({ titleTemplate: '%s' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="landing">
|
||||||
|
<img
|
||||||
|
class="landing-image"
|
||||||
|
src="/protesta-bancaria.jpg"
|
||||||
|
:alt="strings.home.img1Alt"
|
||||||
|
width="770"
|
||||||
|
height="420"
|
||||||
|
>
|
||||||
|
|
||||||
|
<h1>{{ strings.home.heading }}</h1>
|
||||||
|
|
||||||
|
<img
|
||||||
|
class="landing-image"
|
||||||
|
src="/eurozona-crisi.jpg"
|
||||||
|
:alt="strings.home.img2Alt"
|
||||||
|
width="1000"
|
||||||
|
height="736"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="landing-text">
|
||||||
|
<p>{{ strings.home.p1 }}</p>
|
||||||
|
<p>{{ strings.home.p2 }}</p>
|
||||||
|
<p>{{ strings.home.p3 }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="error">{{ strings.blog.loadError }}</p>
|
||||||
|
|
||||||
|
<p v-else-if="!featured">{{ strings.blog.empty }}</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="featuredCover"
|
||||||
|
class="featured-cover"
|
||||||
|
:to="featuredHref"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="featuredCover"
|
||||||
|
:alt="featured.cover?.alt ?? ''"
|
||||||
|
>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<div class="featured-text">
|
||||||
|
<p class="kicker">
|
||||||
|
{{ featured.category ? featured.category.name : strings.home.fallbackKicker }}
|
||||||
|
</p>
|
||||||
|
<h2>
|
||||||
|
<NuxtLink :to="featuredHref">{{ featured.title }}</NuxtLink>
|
||||||
|
</h2>
|
||||||
|
<p class="kicker date">
|
||||||
|
<time :datetime="isoDate(featured.publishedAt)">
|
||||||
|
{{ formatDate(featured.publishedAt, locale) }}
|
||||||
|
</time>
|
||||||
|
</p>
|
||||||
|
<p class="read-more">
|
||||||
|
<NuxtLink :to="featuredHref">
|
||||||
|
{{ strings.home.readLatest }}
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.landing {
|
||||||
|
max-width: var(--width-prose);
|
||||||
|
margin-inline: auto;
|
||||||
|
margin-bottom: var(--gap-section);
|
||||||
|
padding-bottom: var(--gap-section);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing h1 {
|
||||||
|
margin-block: 1.75rem;
|
||||||
|
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-text {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-text p {
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
||||||
|
width leaves both the image and the headline too cramped to read. The split
|
||||||
|
also needs a cover to sit in it, or one column would just be empty. */
|
||||||
|
@media (min-width: 64rem) {
|
||||||
|
.featured.has-cover {
|
||||||
|
grid-template-columns: 1.35fr 1fr;
|
||||||
|
gap: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without a cover the headline is the whole lead, so it gets the full width
|
||||||
|
but stays inside a readable measure. */
|
||||||
|
.featured:not(.has-cover) .featured-text {
|
||||||
|
max-width: 46rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-cover {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-cover img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured h2 {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured h2 a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured h2 a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-more {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-more a {
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--color-ink);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Shared per-navigation state for the language-switch button in the layout.
|
||||||
|
* Each page sets this on setup: `available` is false when there is no
|
||||||
|
* equivalent page in the other language for the current content (e.g. an
|
||||||
|
* untranslated article, or the Italian-only corralito page) — in that case
|
||||||
|
* `fallback` is where the switch should go instead (typically the other
|
||||||
|
* language's blog index).
|
||||||
|
*/
|
||||||
|
interface LangSwitchState {
|
||||||
|
available: boolean
|
||||||
|
fallback: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLangSwitchState() {
|
||||||
|
return useState<LangSwitchState>('lang-switch', () => ({ available: true, fallback: '/en' }))
|
||||||
|
}
|
||||||
@@ -1,21 +1,7 @@
|
|||||||
import { STRINGS, type Strings } from '../i18n/strings'
|
import type { Locale } from '#shared/utils/locale'
|
||||||
|
|
||||||
export type Locale = 'it' | 'en'
|
/** Derives the current locale from the URL path — `/en` or `/en/...` is English, everything else Italian. */
|
||||||
|
|
||||||
/**
|
|
||||||
* Site language, persisted in a cookie so SSR renders the right language on
|
|
||||||
* the first request. `t(key)` reads the current locale's UI string.
|
|
||||||
*/
|
|
||||||
export function useLocale() {
|
export function useLocale() {
|
||||||
const locale = useCookie<Locale>('lang', { default: () => 'it', sameSite: 'lax' })
|
const route = useRoute()
|
||||||
|
return computed<Locale>(() => (route.path === '/en' || route.path.startsWith('/en/') ? 'en' : 'it'))
|
||||||
function setLocale(value: Locale) {
|
|
||||||
locale.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
function t<K extends keyof Strings>(key: K): Strings[K] {
|
|
||||||
return STRINGS[locale.value][key]
|
|
||||||
}
|
|
||||||
|
|
||||||
return { locale, setLocale, t }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,104 +0,0 @@
|
|||||||
/** Static UI strings, keyed once and translated for each supported locale. */
|
|
||||||
|
|
||||||
export interface Strings {
|
|
||||||
skipToContent: string
|
|
||||||
tagline: string
|
|
||||||
navHome: string
|
|
||||||
navAllArticles: string
|
|
||||||
navCorralito: string
|
|
||||||
navPrimary: string
|
|
||||||
footerNavigate: string
|
|
||||||
footerFollow: string
|
|
||||||
footerContact: string
|
|
||||||
footerRights: string
|
|
||||||
switchLanguage: string
|
|
||||||
loadError: string
|
|
||||||
noArticlesYet: string
|
|
||||||
latestFallback: string
|
|
||||||
readArticle: string
|
|
||||||
seeAllArticles: string
|
|
||||||
archiveKicker: string
|
|
||||||
articlesTitle: string
|
|
||||||
allArticlesDescription: string
|
|
||||||
pagePrefix: string
|
|
||||||
loading: string
|
|
||||||
pagination: string
|
|
||||||
prevPage: string
|
|
||||||
nextPage: string
|
|
||||||
pageOf: (page: number, pageCount: number) => string
|
|
||||||
breadcrumbArticles: string
|
|
||||||
byAuthor: (name: string) => string
|
|
||||||
backToArticles: string
|
|
||||||
categoryKicker: string
|
|
||||||
categoryDescription: (name: string) => string
|
|
||||||
noArticlesInCategory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STRINGS: Record<'it' | 'en', Strings> = {
|
|
||||||
it: {
|
|
||||||
skipToContent: 'Vai al contenuto',
|
|
||||||
tagline: 'Articoli, guide e analisi.',
|
|
||||||
navHome: 'Home',
|
|
||||||
navAllArticles: 'Tutti gli articoli',
|
|
||||||
navCorralito: 'Come difendersi dal corralito',
|
|
||||||
navPrimary: 'Navigazione principale',
|
|
||||||
footerNavigate: 'Naviga',
|
|
||||||
footerFollow: 'Seguici',
|
|
||||||
footerContact: 'Contatti:',
|
|
||||||
footerRights: 'Tutti i diritti riservati.',
|
|
||||||
switchLanguage: 'English',
|
|
||||||
loadError: 'Non è stato possibile caricare gli articoli. Riprova più tardi.',
|
|
||||||
noArticlesYet: 'Non è stato ancora pubblicato nulla.',
|
|
||||||
latestFallback: 'Ultimo',
|
|
||||||
readArticle: "Leggi l'articolo",
|
|
||||||
seeAllArticles: 'Vedi tutti gli articoli',
|
|
||||||
archiveKicker: "L'archivio",
|
|
||||||
articlesTitle: 'Articoli',
|
|
||||||
allArticlesDescription: 'Tutti gli articoli pubblicati sul blog.',
|
|
||||||
pagePrefix: 'pagina',
|
|
||||||
loading: 'Caricamento…',
|
|
||||||
pagination: 'Paginazione',
|
|
||||||
prevPage: 'Pagina precedente',
|
|
||||||
nextPage: 'Pagina successiva',
|
|
||||||
pageOf: (page: number, pageCount: number) => `Pagina ${page} di ${pageCount}`,
|
|
||||||
breadcrumbArticles: 'Articoli',
|
|
||||||
byAuthor: (name: string) => `Di ${name}`,
|
|
||||||
backToArticles: 'Torna a tutti gli articoli',
|
|
||||||
categoryKicker: 'Categoria',
|
|
||||||
categoryDescription: (name: string) => `Tutti gli articoli nella categoria ${name}.`,
|
|
||||||
noArticlesInCategory: 'Non ci sono articoli in questa categoria.',
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
skipToContent: 'Skip to content',
|
|
||||||
tagline: 'Articles, guides and analysis.',
|
|
||||||
navHome: 'Home',
|
|
||||||
navAllArticles: 'All articles',
|
|
||||||
navCorralito: 'Come difendersi dal corralito',
|
|
||||||
navPrimary: 'Main navigation',
|
|
||||||
footerNavigate: 'Navigate',
|
|
||||||
footerFollow: 'Follow',
|
|
||||||
footerContact: 'Contact:',
|
|
||||||
footerRights: 'All rights reserved.',
|
|
||||||
switchLanguage: 'Italiano',
|
|
||||||
loadError: 'The articles could not be loaded. Please try again later.',
|
|
||||||
noArticlesYet: 'Nothing has been published yet.',
|
|
||||||
latestFallback: 'Latest',
|
|
||||||
readArticle: 'Read the article',
|
|
||||||
seeAllArticles: 'See all articles',
|
|
||||||
archiveKicker: 'The archive',
|
|
||||||
articlesTitle: 'Articles',
|
|
||||||
allArticlesDescription: 'All articles published on the blog.',
|
|
||||||
pagePrefix: 'page',
|
|
||||||
loading: 'Loading…',
|
|
||||||
pagination: 'Pagination',
|
|
||||||
prevPage: 'Previous page',
|
|
||||||
nextPage: 'Next page',
|
|
||||||
pageOf: (page: number, pageCount: number) => `Page ${page} of ${pageCount}`,
|
|
||||||
breadcrumbArticles: 'Articles',
|
|
||||||
byAuthor: (name: string) => `By ${name}`,
|
|
||||||
backToArticles: 'Back to all articles',
|
|
||||||
categoryKicker: 'Category',
|
|
||||||
categoryDescription: (name: string) => `All articles in the ${name} category.`,
|
|
||||||
noArticlesInCategory: 'There are no articles in this category.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { locale, setLocale, t } = useLocale()
|
import { STRINGS } from '#shared/utils/i18n'
|
||||||
|
import { localePath } from '#shared/utils/locale'
|
||||||
|
|
||||||
const otherLocale = computed(() => (locale.value === 'it' ? 'en' : 'it'))
|
const locale = useLocale()
|
||||||
|
const strings = computed(() => STRINGS[locale.value])
|
||||||
/**
|
|
||||||
* Switches the site language. On an article/category page, jumps to the
|
|
||||||
* translated counterpart's slug if one exists; elsewhere reloads the current
|
|
||||||
* route so it refetches its content in the new language.
|
|
||||||
*/
|
|
||||||
function switchLanguage() {
|
|
||||||
setLocale(otherLocale.value)
|
|
||||||
reloadNuxtApp({ path: useRoute().fullPath })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a class="skip-link" href="#main">{{ t('skipToContent') }}</a>
|
<a class="skip-link" href="#main">{{ strings.skipLink }}</a>
|
||||||
|
|
||||||
<header class="site-header">
|
<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">{{ t('tagline') }}</p>
|
<p class="tagline kicker">{{ strings.tagline }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="site-nav" :aria-label="t('navPrimary')">
|
<nav class="site-nav" :aria-label="strings.navAriaLabel">
|
||||||
<div class="container nav-inner">
|
<div class="container nav-inner">
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
<li>
|
||||||
|
<NuxtLink :to="localePath(locale, '/come-difendersi-dal-corralito')">{{ strings.navCorralito }}</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button type="button" class="lang-switch" @click="switchLanguage">
|
|
||||||
{{ t('switchLanguage') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -50,24 +43,26 @@ function switchLanguage() {
|
|||||||
<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">{{ t('tagline') }}</p>
|
<p class="muted">{{ strings.tagline }}</p>
|
||||||
<address class="contact">
|
<address class="contact">
|
||||||
{{ t('footerContact') }}
|
{{ 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="t('footerNavigate')">
|
<nav class="footer-block" :aria-label="strings.footerNavAriaLabel">
|
||||||
<p class="kicker">{{ t('footerNavigate') }}</p>
|
<p class="kicker">{{ strings.footerNavigaKicker }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
<li>
|
||||||
|
<NuxtLink :to="localePath(locale, '/come-difendersi-dal-corralito')">{{ strings.navCorralito }}</NuxtLink>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav class="footer-block" :aria-label="t('footerFollow')">
|
<nav class="footer-block" :aria-label="strings.footerFollowAriaLabel">
|
||||||
<p class="kicker">{{ t('footerFollow') }}</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">
|
||||||
@@ -80,7 +75,7 @@ function switchLanguage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="container copyright kicker">
|
<p class="container copyright kicker">
|
||||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ t('footerRights') }}
|
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ strings.copyright }}
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,6 +101,16 @@ function switchLanguage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: var(--space);
|
||||||
|
}
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -132,10 +137,7 @@ function switchLanguage() {
|
|||||||
|
|
||||||
.nav-inner {
|
.nav-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem 1.5rem;
|
|
||||||
padding-block: 0.6rem;
|
padding-block: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,24 +155,6 @@ function switchLanguage() {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-switch {
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
color: var(--color-body);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-switch:hover {
|
|
||||||
color: var(--color-ink);
|
|
||||||
border-color: var(--color-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 48rem) {
|
@media (min-width: 48rem) {
|
||||||
.site-nav {
|
.site-nav {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
langSwitch.value = { available: true, fallback: '/en' }
|
||||||
|
})
|
||||||
@@ -1,165 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Article } from '#shared/types/blog'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const slug = route.params.slug as string
|
|
||||||
|
|
||||||
const { locale, t } = useLocale()
|
|
||||||
|
|
||||||
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`, {
|
|
||||||
key: () => `article-${slug}-${locale.value}`,
|
|
||||||
query: { lang: locale },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!article.value) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
|
||||||
const mediaUrl = useMediaUrl()
|
|
||||||
|
|
||||||
const canonical = `${config.siteUrl}${route.path}`
|
|
||||||
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: article.value.title,
|
|
||||||
description: article.value.summary,
|
|
||||||
ogTitle: article.value.title,
|
|
||||||
ogDescription: article.value.summary,
|
|
||||||
ogType: 'article',
|
|
||||||
ogUrl: canonical,
|
|
||||||
ogImage: cover,
|
|
||||||
twitterCard: 'summary_large_image',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
link: [{ rel: 'canonical', href: canonical }],
|
|
||||||
script: [
|
|
||||||
{
|
|
||||||
type: 'application/ld+json',
|
|
||||||
innerHTML: JSON.stringify({
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'BlogPosting',
|
|
||||||
headline: article.value.title,
|
|
||||||
description: article.value.summary,
|
|
||||||
datePublished: isoDate(article.value.publishedAt),
|
|
||||||
inLanguage: locale.value,
|
|
||||||
author: article.value.author
|
|
||||||
? { '@type': 'Person', name: article.value.author }
|
|
||||||
: undefined,
|
|
||||||
image: cover.value ? [cover.value] : undefined,
|
|
||||||
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article v-if="article">
|
<ArticleView locale="it" />
|
||||||
<header class="article-header">
|
|
||||||
<nav class="kicker" aria-label="Percorso di navigazione">
|
|
||||||
<NuxtLink to="/blog">{{ t('breadcrumbArticles') }}</NuxtLink>
|
|
||||||
<template v-if="article.category">
|
|
||||||
<span aria-hidden="true"> / </span>
|
|
||||||
<NuxtLink :to="`/category/${article.category.slug}`">
|
|
||||||
{{ article.category.name }}
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<h1>{{ article.title }}</h1>
|
|
||||||
|
|
||||||
<p class="kicker date">
|
|
||||||
<time :datetime="isoDate(article.publishedAt)">
|
|
||||||
{{ formatDateTime(article.publishedAt, locale) }}
|
|
||||||
</time>
|
|
||||||
<template v-if="article.author">
|
|
||||||
<span aria-hidden="true"> · </span>
|
|
||||||
<span>{{ t('byAuthor')(article.author) }}</span>
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<img
|
|
||||||
v-if="cover"
|
|
||||||
class="cover"
|
|
||||||
:src="cover"
|
|
||||||
:alt="article.cover?.alternativeText ?? ''"
|
|
||||||
:width="article.cover?.width ?? undefined"
|
|
||||||
:height="article.cover?.height ?? undefined"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
|
||||||
<div class="prose" v-html="article.html" />
|
|
||||||
|
|
||||||
<p class="back">
|
|
||||||
<NuxtLink to="/blog">{{ t('backToArticles') }}</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</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,69 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const page = computed(() => {
|
|
||||||
const value = Number(route.query.page)
|
|
||||||
return Number.isInteger(value) && value > 0 ? value : 1
|
|
||||||
})
|
|
||||||
|
|
||||||
const { locale, t } = useLocale()
|
|
||||||
|
|
||||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
|
||||||
key: () => `blog-articles-${locale.value}-${page.value}`,
|
|
||||||
query: { page, lang: locale },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
|
||||||
const canonical = computed(
|
|
||||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
|
||||||
)
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: () => (page.value > 1 ? `${t('articlesTitle')} — ${t('pagePrefix')} ${page.value}` : t('articlesTitle')),
|
|
||||||
description: () => t('allArticlesDescription'),
|
|
||||||
ogType: 'website',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<BlogIndexView locale="it" />
|
||||||
<header class="page-header">
|
|
||||||
<p class="kicker">{{ t('archiveKicker') }}</p>
|
|
||||||
<h1>{{ t('articlesTitle') }}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="error">{{ t('loadError') }}</p>
|
|
||||||
|
|
||||||
<p v-else-if="status === 'pending'">{{ t('loading') }}</p>
|
|
||||||
|
|
||||||
<p v-else-if="!data?.items.length">{{ t('noArticlesYet') }}</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<ul class="card-grid">
|
|
||||||
<li v-for="article in data.items" :key="article.slug">
|
|
||||||
<ArticleCard :article="article" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')">
|
|
||||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
|
||||||
{{ t('prevPage') }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span class="muted">
|
|
||||||
{{ t('pageOf')(data.page, data.pageCount) }}
|
|
||||||
</span>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="data.page < data.pageCount"
|
|
||||||
:to="{ query: { page: data.page + 1 } }"
|
|
||||||
rel="next"
|
|
||||||
>
|
|
||||||
{{ t('nextPage') }}
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,77 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ArticleSummary, Paginated, Category } from '#shared/types/blog'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const slug = route.params.slug as string
|
|
||||||
|
|
||||||
const page = computed(() => {
|
|
||||||
const value = Number(route.query.page)
|
|
||||||
return Number.isInteger(value) && value > 0 ? value : 1
|
|
||||||
})
|
|
||||||
|
|
||||||
const { locale, t } = useLocale()
|
|
||||||
|
|
||||||
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`, {
|
|
||||||
key: () => `category-${slug}-${locale.value}`,
|
|
||||||
query: { lang: locale },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!category.value) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
|
||||||
key: () => `category-articles-${slug}-${locale.value}-${page.value}`,
|
|
||||||
query: { page, category: slug, lang: locale },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
|
||||||
const canonical = computed(
|
|
||||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
|
||||||
)
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: () => category.value?.name ?? '',
|
|
||||||
description: () => t('categoryDescription')(category.value?.name ?? ''),
|
|
||||||
ogType: 'website',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<CategoryView locale="it" />
|
||||||
<header class="page-header">
|
|
||||||
<p class="kicker">{{ t('categoryKicker') }}</p>
|
|
||||||
<h1>{{ category?.name }}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="error">{{ t('loadError') }}</p>
|
|
||||||
|
|
||||||
<p v-else-if="!data?.items.length">{{ t('noArticlesInCategory') }}</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<ul class="card-grid">
|
|
||||||
<li v-for="article in data.items" :key="article.slug">
|
|
||||||
<ArticleCard :article="article" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')">
|
|
||||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
|
||||||
{{ t('prevPage') }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span class="muted">
|
|
||||||
{{ t('pageOf')(data.page, data.pageCount) }}
|
|
||||||
</span>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="data.page < data.pageCount"
|
|
||||||
:to="{ query: { page: data.page + 1 } }"
|
|
||||||
rel="next"
|
|
||||||
>
|
|
||||||
{{ t('nextPage') }}
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,132 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { public: config } = useRuntimeConfig()
|
|
||||||
const route = useRoute()
|
|
||||||
const canonical = computed(() => `${config.siteUrl}${route.path}`)
|
|
||||||
|
|
||||||
const title = 'Corsi di formazione sulle criptovalute'
|
|
||||||
const description = 'Corsi pratici, individuali o di gruppo, per capire come funzionano le criptovalute e difendersi dal corralito.'
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
ogTitle: title,
|
|
||||||
ogDescription: description,
|
|
||||||
ogType: 'website',
|
|
||||||
ogUrl: canonical,
|
|
||||||
ogImage: `${config.siteUrl}/protesta-bancaria.jpg`,
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article>
|
<CorralitoView locale="it" />
|
||||||
<header class="article-header">
|
|
||||||
<h1>Come difendersi dal corralito</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<img
|
|
||||||
class="cover"
|
|
||||||
src="/protesta-bancaria.jpg"
|
|
||||||
alt="Manifesti e scritte contro le banche durante una crisi bancaria"
|
|
||||||
width="770"
|
|
||||||
height="420"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div class="prose">
|
|
||||||
<p>
|
|
||||||
Stai pensando di avvicinarti al mondo delle criptovalute (un mercato parallelo al
|
|
||||||
mercato tradizionale finanziario), ma non sai da dove cominciare?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
È possibile organizzare corsi individuali o di gruppo dedicati alla conoscenza e al
|
|
||||||
funzionamento delle criptovalute, con un approccio prevalentemente pratico e senza
|
|
||||||
fornire consigli o raccomandazioni finanziarie.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>Durante il corso possiamo affrontare, tra gli altri, questi argomenti:</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>quali sono i principali tipi di criptovalute e quali differenze esistono tra loro;</li>
|
|
||||||
<li>come funziona una blockchain e cosa significa realmente effettuare una transazione;</li>
|
|
||||||
<li>dove e come vengono conservate le criptovalute: wallet, exchange e sistemi di custodia;</li>
|
|
||||||
<li>come acquistare e vendere criptovalute e quali sono le principali modalità disponibili;</li>
|
|
||||||
<li>come utilizzare un wallet e gestire correttamente le proprie chiavi;</li>
|
|
||||||
<li>quali sono i principali rischi legati alla sicurezza e alla custodia;</li>
|
|
||||||
<li>come orientarsi nel vastissimo universo delle criptovalute e dei token.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Non si tratta di consulenza finanziaria e non verranno fornite indicazioni su quali
|
|
||||||
criptovalute acquistare o vendere. L'obiettivo è fornire conoscenze e strumenti per
|
|
||||||
comprendere questo nuovo mondo e muoversi al suo interno con maggiore consapevolezza.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contact-cta">
|
|
||||||
<p class="kicker">Per informazioni sui corsi, modalità e disponibilità</p>
|
|
||||||
<p class="contact-links">
|
|
||||||
<a :href="`mailto:${SITE_EMAIL}`">Scrivimi via email</a>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<a href="https://t.me/Ito1505" target="_blank" rel="noopener">Scrivimi su Telegram</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.article-header {
|
|
||||||
max-width: 46rem;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 48rem) {
|
|
||||||
.article-header {
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
object-fit: cover;
|
|
||||||
margin-block: 0 3rem;
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose {
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-cta {
|
|
||||||
max-width: var(--width-prose);
|
|
||||||
margin: 3rem auto 0;
|
|
||||||
padding-top: 2rem;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-links {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-links a {
|
|
||||||
padding-bottom: 0.35rem;
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px solid var(--color-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-links a:hover {
|
|
||||||
color: var(--color-ink);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<CorralitoView locale="en" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<HomeView locale="en" />
|
||||||
|
</template>
|
||||||
@@ -1,241 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
|
||||||
|
|
||||||
const { locale, t } = useLocale()
|
|
||||||
|
|
||||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
|
||||||
key: () => `home-articles-${locale.value}`,
|
|
||||||
query: { page: 1, lang: locale },
|
|
||||||
})
|
|
||||||
|
|
||||||
const featured = computed(() => data.value?.items[0] ?? null)
|
|
||||||
|
|
||||||
const mediaUrl = useMediaUrl()
|
|
||||||
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
|
||||||
const route = useRoute()
|
|
||||||
const canonical = computed(() => `${config.siteUrl}${route.path}`)
|
|
||||||
|
|
||||||
// The home page is the site itself, so it carries the bare name as its title.
|
|
||||||
useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: SITE_NAME,
|
|
||||||
description: () => t('tagline'),
|
|
||||||
ogTitle: SITE_NAME,
|
|
||||||
ogDescription: () => t('tagline'),
|
|
||||||
ogType: 'website',
|
|
||||||
ogUrl: canonical,
|
|
||||||
ogImage: `${config.siteUrl}/logo.png`,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<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">{{ t('loadError') }}</p>
|
|
||||||
|
|
||||||
<p v-else-if="!featured">{{ t('noArticlesYet') }}</p>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
|
||||||
<NuxtLink
|
|
||||||
v-if="featuredCover"
|
|
||||||
class="featured-cover"
|
|
||||||
:to="`/blog/${featured.slug}`"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="featuredCover"
|
|
||||||
:alt="featured.cover?.alternativeText ?? ''"
|
|
||||||
:width="featured.cover?.width ?? undefined"
|
|
||||||
:height="featured.cover?.height ?? undefined"
|
|
||||||
>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<div class="featured-text">
|
|
||||||
<p class="kicker">
|
|
||||||
{{ featured.category ? featured.category.name : t('latestFallback') }}
|
|
||||||
</p>
|
|
||||||
<h2>
|
|
||||||
<NuxtLink :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLink>
|
|
||||||
</h2>
|
|
||||||
<p class="kicker date">
|
|
||||||
<time :datetime="isoDate(featured.publishedAt)">
|
|
||||||
{{ formatDate(featured.publishedAt, locale) }}
|
|
||||||
</time>
|
|
||||||
</p>
|
|
||||||
<p class="read-more">
|
|
||||||
<NuxtLink :to="`/blog/${featured.slug}`">
|
|
||||||
{{ t('readArticle') }}
|
|
||||||
</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<p class="more">
|
|
||||||
<NuxtLink to="/blog">{{ t('seeAllArticles') }}</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.more {
|
|
||||||
margin-top: var(--gap-section);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more a {
|
|
||||||
padding: 0.85rem 2.5rem;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 1px solid var(--color-ink);
|
|
||||||
color: var(--color-ink);
|
|
||||||
display: inline-block;
|
|
||||||
transition: background 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more a:hover {
|
|
||||||
background: var(--color-ink);
|
|
||||||
color: var(--color-bg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
|
export default withNuxt(
|
||||||
|
// Your custom configs here
|
||||||
|
)
|
||||||
+11
-5
@@ -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,44 +0,0 @@
|
|||||||
import type { Article } from '#shared/types/blog'
|
|
||||||
|
|
||||||
interface AdminUser {
|
|
||||||
firstname: string | null
|
|
||||||
lastname: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type RawArticle = Omit<Article, 'html' | 'summary' | 'author'> & {
|
|
||||||
content: string | null
|
|
||||||
createdBy?: AdminUser | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Joins the admin user's name parts; returns null when neither is set. */
|
|
||||||
function byline(user: AdminUser | null | undefined): string | null {
|
|
||||||
const name = [user?.firstname, user?.lastname].filter(Boolean).join(' ').trim()
|
|
||||||
return name || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<Article> => {
|
|
||||||
const slug = getRouterParam(event, 'slug')
|
|
||||||
const locale = localeParam(getQuery(event).lang)
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await strapiFetch<StrapiList<RawArticle>>(
|
|
||||||
`/api/articles?${ARTICLE_DETAIL_QUERY}&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
|
||||||
)
|
|
||||||
|
|
||||||
const article = response.data[0]
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { content, createdBy, ...rest } = article
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
html: renderMarkdown(content),
|
|
||||||
summary: summarise(content),
|
|
||||||
author: byline(createdBy),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
|
||||||
const query = getQuery(event)
|
|
||||||
const page = pageParam(query.page)
|
|
||||||
const locale = localeParam(query.lang)
|
|
||||||
const category = typeof query.category === 'string' ? query.category : null
|
|
||||||
|
|
||||||
const filter = category
|
|
||||||
? `&filters[category][slug][$eq]=${encodeURIComponent(category)}`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const response = await strapiFetch<StrapiList<ArticleSummary>>(
|
|
||||||
`/api/articles?${ARTICLE_SUMMARY_QUERY}&locale=${locale}&${pagination(page)}${filter}`
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: response.data,
|
|
||||||
page: response.meta.pagination.page,
|
|
||||||
pageCount: response.meta.pagination.pageCount,
|
|
||||||
total: response.meta.pagination.total,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { Category } from '#shared/types/blog'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<Category> => {
|
|
||||||
const slug = getRouterParam(event, 'slug')
|
|
||||||
const locale = localeParam(getQuery(event).lang)
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await strapiFetch<StrapiList<Category>>(
|
|
||||||
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
|
||||||
)
|
|
||||||
|
|
||||||
const category = response.data[0]
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return category
|
|
||||||
})
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { Category } from '#shared/types/blog'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<Category[]> => {
|
|
||||||
const locale = localeParam(getQuery(event).lang)
|
|
||||||
|
|
||||||
const response = await strapiFetch<StrapiList<Category>>(
|
|
||||||
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&sort[0]=name:asc&pagination[pageSize]=100`
|
|
||||||
)
|
|
||||||
|
|
||||||
return response.data
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Vanity redirect to the PocketBase admin UI, whose own route is fixed at
|
||||||
|
* `/_/`. Handled here (not in Caddy) so it works the same in dev, where
|
||||||
|
* Caddy isn't in the stack, and in production.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { public: config } = useRuntimeConfig()
|
||||||
|
return sendRedirect(event, `${config.pocketbaseUrl}/_/`, 302)
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Article, Category } from '#shared/types/blog'
|
||||||
|
|
||||||
|
interface RawCategory extends Category {
|
||||||
|
nameEn?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawArticle {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleEn: string | null
|
||||||
|
slug: string
|
||||||
|
publishedAt: string
|
||||||
|
content: string | null
|
||||||
|
contentEn: string | null
|
||||||
|
cover: string
|
||||||
|
coverAlt: string | null
|
||||||
|
coverAltEn: string | null
|
||||||
|
authorName: string | null
|
||||||
|
expand?: { category?: RawCategory }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<Article> => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = langParam(getQuery(event).lang)
|
||||||
|
|
||||||
|
const response = await pbFetch<PbList<RawArticle>>(
|
||||||
|
`/api/collections/articles/records?fields=${ARTICLE_DETAIL_FIELDS}&expand=category&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&${pagination(1, 1)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const article = response.items[0]
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTranslation = Boolean(article.titleEn && article.contentEn)
|
||||||
|
|
||||||
|
// An /en/ request for an article with no translation yet 404s cleanly,
|
||||||
|
// rather than silently falling back to Italian text on an English URL —
|
||||||
|
// see docs/content-model.md.
|
||||||
|
if (lang === 'en' && !hasTranslation) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = lang === 'en' ? article.titleEn! : article.title
|
||||||
|
const content = lang === 'en' ? article.contentEn : article.content
|
||||||
|
const coverAlt = lang === 'en' ? article.coverAltEn || article.coverAlt : article.coverAlt
|
||||||
|
const category = article.expand?.category
|
||||||
|
? {
|
||||||
|
name:
|
||||||
|
lang === 'en'
|
||||||
|
? article.expand.category.nameEn || article.expand.category.name
|
||||||
|
: article.expand.category.name,
|
||||||
|
slug: article.expand.category.slug,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
slug: article.slug,
|
||||||
|
publishedAt: article.publishedAt,
|
||||||
|
cover: toMediaImage('articles', article.id, article.cover, coverAlt),
|
||||||
|
category,
|
||||||
|
html: renderMarkdown(content),
|
||||||
|
summary: summarise(content),
|
||||||
|
author: article.authorName || null,
|
||||||
|
hasTranslation,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
|
||||||
|
import type { Locale } from '#shared/utils/locale'
|
||||||
|
|
||||||
|
interface RawCategory extends Category {
|
||||||
|
nameEn?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawArticleSummary {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleEn: string | null
|
||||||
|
slug: string
|
||||||
|
publishedAt: string
|
||||||
|
cover: string
|
||||||
|
coverAlt: string | null
|
||||||
|
coverAltEn: string | null
|
||||||
|
expand?: { category?: RawCategory }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(raw: RawArticleSummary, lang: Locale): ArticleSummary {
|
||||||
|
const title = lang === 'en' ? raw.titleEn || raw.title : raw.title
|
||||||
|
const coverAlt = lang === 'en' ? raw.coverAltEn || raw.coverAlt : raw.coverAlt
|
||||||
|
const category = raw.expand?.category
|
||||||
|
? {
|
||||||
|
name: lang === 'en' ? raw.expand.category.nameEn || raw.expand.category.name : raw.expand.category.name,
|
||||||
|
slug: raw.expand.category.slug,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
slug: raw.slug,
|
||||||
|
publishedAt: raw.publishedAt,
|
||||||
|
cover: toMediaImage('articles', raw.id, raw.cover, coverAlt),
|
||||||
|
category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const page = pageParam(query.page)
|
||||||
|
const lang = langParam(query.lang)
|
||||||
|
const category = typeof query.category === 'string' ? query.category : null
|
||||||
|
|
||||||
|
const filters = []
|
||||||
|
if (category) filters.push(`category.slug = ${quote(category)}`)
|
||||||
|
// An English listing only ever shows articles that have actually been
|
||||||
|
// translated — never a partial/fallback entry, see docs/content-model.md.
|
||||||
|
if (lang === 'en') filters.push(`titleEn != ''`)
|
||||||
|
const filter = filters.length ? `&filter=${encodeURIComponent(filters.join(' && '))}` : ''
|
||||||
|
|
||||||
|
const response = await pbFetch<PbList<RawArticleSummary>>(
|
||||||
|
`/api/collections/articles/records?fields=${ARTICLE_SUMMARY_FIELDS}&expand=category&sort=-publishedAt&${pagination(page)}${filter}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: response.items.map((raw) => toSummary(raw, lang)),
|
||||||
|
page: response.page,
|
||||||
|
pageCount: response.totalPages,
|
||||||
|
total: response.totalItems,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Category } from '#shared/types/blog'
|
||||||
|
|
||||||
|
interface RawCategory extends Category {
|
||||||
|
nameEn: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<Category> => {
|
||||||
|
const slug = getRouterParam(event, 'slug')
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = langParam(getQuery(event).lang)
|
||||||
|
|
||||||
|
const response = await pbFetch<PbList<RawCategory>>(
|
||||||
|
`/api/collections/categories/records?fields=${CATEGORY_FIELDS}&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&perPage=1`
|
||||||
|
)
|
||||||
|
|
||||||
|
const category = response.items[0]
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lang === 'en' && !category.nameEn) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: lang === 'en' ? category.nameEn! : category.name,
|
||||||
|
slug: category.slug,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Category } from '#shared/types/blog'
|
||||||
|
|
||||||
|
interface RawCategory extends Category {
|
||||||
|
nameEn: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<Category[]> => {
|
||||||
|
const lang = langParam(getQuery(event).lang)
|
||||||
|
// English listing only ever shows categories that have actually been
|
||||||
|
// translated — mirrors the articles listing policy.
|
||||||
|
const filter = lang === 'en' ? `&filter=${encodeURIComponent(`nameEn != ''`)}` : ''
|
||||||
|
|
||||||
|
const response = await pbFetch<PbList<RawCategory>>(
|
||||||
|
`/api/collections/categories/records?fields=${CATEGORY_FIELDS}&sort=name&perPage=100${filter}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.items.map((category) => ({
|
||||||
|
name: lang === 'en' ? category.nameEn || category.name : category.name,
|
||||||
|
slug: category.slug,
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import { marked } from 'marked'
|
import { 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,30 +1,17 @@
|
|||||||
/** Strapi query fragments. Only the fields the pages actually render are requested. */
|
/** PocketBase query fragments. Only the fields the pages actually render are requested. */
|
||||||
|
import type { Locale } from '#shared/utils/locale'
|
||||||
|
|
||||||
const list = (prefix: string, names: readonly string[]) =>
|
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 => {
|
||||||
@@ -32,7 +19,8 @@ export const pageParam = (value: unknown): number => {
|
|||||||
return Number.isInteger(page) && page > 0 ? page : 1
|
return Number.isInteger(page) && page > 0 ? page : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Locale = 'it' | 'en'
|
/** Reads a `lang` query param, falling back to Italian for anything but `en`. */
|
||||||
|
export const langParam = (value: unknown): Locale => (value === 'en' ? 'en' : 'it')
|
||||||
|
|
||||||
/** Reads the `lang` query param, falling back to the site default `it`. */
|
/** Quotes a value for PocketBase's `filter` DSL, escaping embedded quotes. */
|
||||||
export const localeParam = (value: unknown): Locale => (value === 'en' ? 'en' : 'it')
|
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,16 +1,15 @@
|
|||||||
const INTL_LOCALE = { it: 'it-IT', en: 'en-GB' } as const
|
import type { Locale } from './locale'
|
||||||
|
|
||||||
|
const INTL_LOCALE: Record<Locale, string> = { it: 'it-IT', en: 'en-GB' }
|
||||||
|
|
||||||
/** Human-readable date for display; returns an empty string when unset. */
|
/** Human-readable date for display; returns an empty string when unset. */
|
||||||
export function formatDate(value: string | null | undefined, locale: 'it' | 'en' = 'it'): string {
|
export function formatDate(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { 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(
|
export function formatDateTime(value: string | null | undefined, locale: Locale = 'it'): string {
|
||||||
value: string | null | undefined,
|
|
||||||
locale: 'it' | 'en' = 'it'
|
|
||||||
): string {
|
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { 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,171 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
},
|
||||||
|
corralito: {
|
||||||
|
metaTitle: 'Corsi di formazione sulle criptovalute',
|
||||||
|
metaDescription: 'Corsi pratici, individuali o di gruppo, per capire come funzionano le criptovalute e difendersi dal corralito.',
|
||||||
|
coverAlt: 'Manifesti e scritte contro le banche durante una crisi bancaria',
|
||||||
|
p1: 'Stai pensando di avvicinarti al mondo delle criptovalute (un mercato parallelo al mercato tradizionale finanziario), ma non sai da dove cominciare?',
|
||||||
|
p2: "È possibile organizzare corsi individuali o di gruppo dedicati alla conoscenza e al funzionamento delle criptovalute, con un approccio prevalentemente pratico e senza fornire consigli o raccomandazioni finanziarie.",
|
||||||
|
topicsIntro: 'Durante il corso possiamo affrontare, tra gli altri, questi argomenti:',
|
||||||
|
topics: [
|
||||||
|
'quali sono i principali tipi di criptovalute e quali differenze esistono tra loro;',
|
||||||
|
'come funziona una blockchain e cosa significa realmente effettuare una transazione;',
|
||||||
|
'dove e come vengono conservate le criptovalute: wallet, exchange e sistemi di custodia;',
|
||||||
|
'come acquistare e vendere criptovalute e quali sono le principali modalità disponibili;',
|
||||||
|
'come utilizzare un wallet e gestire correttamente le proprie chiavi;',
|
||||||
|
'quali sono i principali rischi legati alla sicurezza e alla custodia;',
|
||||||
|
'come orientarsi nel vastissimo universo delle criptovalute e dei token.',
|
||||||
|
],
|
||||||
|
disclaimer: "Non si tratta di consulenza finanziaria e non verranno fornite indicazioni su quali criptovalute acquistare o vendere. L'obiettivo è fornire conoscenze e strumenti per comprendere questo nuovo mondo e muoversi al suo interno con maggiore consapevolezza.",
|
||||||
|
contactKicker: 'Per informazioni sui corsi, modalità e disponibilità',
|
||||||
|
emailLink: 'Scrivimi via email',
|
||||||
|
telegramLink: 'Scrivimi su Telegram',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
skipLink: 'Skip to content',
|
||||||
|
tagline: 'Articles, guides and analysis.',
|
||||||
|
navAriaLabel: 'Main navigation',
|
||||||
|
navHome: 'Home',
|
||||||
|
navBlog: 'All articles',
|
||||||
|
navCorralito: 'How to protect yourself from the corralito',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
corralito: {
|
||||||
|
metaTitle: 'Cryptocurrency training courses',
|
||||||
|
metaDescription: 'Practical, individual or group courses to understand how cryptocurrencies work and protect yourself from the corralito.',
|
||||||
|
coverAlt: 'Banners and graffiti against banks during a banking crisis',
|
||||||
|
p1: "Are you thinking about getting into the world of cryptocurrencies (a market parallel to the traditional financial market), but don't know where to start?",
|
||||||
|
p2: 'I can organize individual or group courses dedicated to understanding how cryptocurrencies work, with a mostly hands-on approach and without giving financial advice or recommendations.',
|
||||||
|
topicsIntro: 'During the course we can cover, among others, these topics:',
|
||||||
|
topics: [
|
||||||
|
'the main types of cryptocurrencies and the differences between them;',
|
||||||
|
'how a blockchain works and what actually happens when you make a transaction;',
|
||||||
|
'where and how cryptocurrencies are stored: wallets, exchanges and custody systems;',
|
||||||
|
'how to buy and sell cryptocurrencies and the main methods available;',
|
||||||
|
'how to use a wallet and correctly manage your own keys;',
|
||||||
|
'the main risks related to security and custody;',
|
||||||
|
'how to find your way around the vast universe of cryptocurrencies and tokens.',
|
||||||
|
],
|
||||||
|
disclaimer: 'This is not financial advice, and no indications will be given on which cryptocurrencies to buy or sell. The goal is to provide knowledge and tools to understand this new world and navigate it with greater awareness.',
|
||||||
|
contactKicker: 'For information on courses, format and availability',
|
||||||
|
emailLink: 'Email me',
|
||||||
|
telegramLink: 'Message me on Telegram',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} 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)
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user