Compare commits
1
Commits
91540224a9
..
temp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754b84e5f6 |
@@ -112,14 +112,6 @@ Rotte: `/`, `/blog`, `/blog/[slug]`, `/category/[slug]`.
|
||||
|
||||
Il riferimento Hostinger è solo ispirazione visiva. Non copiare codice o asset.
|
||||
|
||||
## Documentazione
|
||||
|
||||
`docs/` descrive struttura e comportamento del repo (architettura, content model, frontend) a un
|
||||
livello più approfondito di questo file. Se implementi nuove funzionalità, endpoint, content type
|
||||
o cambi l'architettura (nuovo servizio, nuova rotta Caddy, nuovo modo di scambiare dati tra
|
||||
frontend e CMS), **aggiorna il file `docs/*.md` pertinente nello stesso commit**, o creane uno
|
||||
nuovo se non esiste una sezione adatta. Non lasciare `docs/` disallineata col codice.
|
||||
|
||||
## Priorità
|
||||
|
||||
Correttezza e integrità dati → sicurezza → semplicità → SEO/a11y → performance.
|
||||
@@ -141,6 +133,5 @@ built-in Strapi al reimplementare funzioni CMS in Nuxt.
|
||||
## Prima di dichiarare completo
|
||||
|
||||
Lint → test → typecheck → build dell'app toccata, con gli script del `package.json` relativo.
|
||||
Non affermare che un check è passato se non l'hai eseguito. Se hai toccato architettura o
|
||||
funzionalità, verifica di aver aggiornato `docs/` (vedi [Documentazione](#documentazione)). Chiudi
|
||||
riassumendo cosa è cambiato e quali rischi restano aperti.
|
||||
Non affermare che un check è passato se non l'hai eseguito. Chiudi riassumendo cosa è cambiato e
|
||||
quali rischi restano aperti.
|
||||
|
||||
@@ -6,4 +6,3 @@ ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=tobemodified
|
||||
JWT_SECRET=tobemodified
|
||||
ENCRYPTION_KEY=tobemodified
|
||||
STRAPI_TELEMETRY_DISABLED=true
|
||||
|
||||
@@ -6,7 +6,6 @@ COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Admin =>
|
||||
encryptionKey: env('ENCRYPTION_KEY')!,
|
||||
},
|
||||
flags: {
|
||||
nps: env.bool('FLAG_NPS', false),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', false),
|
||||
nps: env.bool('FLAG_NPS', true),
|
||||
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
|
||||
docLinks: env.bool('FLAG_DOC_LINKS', true),
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+24
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
@@ -7683,6 +7684,29 @@
|
||||
"npm": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@strapi/plugin-cloud": {
|
||||
"version": "5.52.1",
|
||||
"resolved": "https://registry.npmjs.org/@strapi/plugin-cloud/-/plugin-cloud-5.52.1.tgz",
|
||||
"integrity": "sha512-l5AZ03hhU4cz3ow1Cqvf8d6cJLMrDEjeHa0Jz90ObFygKVo4v3rSBL3WT++X8BZnBQRT6rGj5RrSMwTxL5bggw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@strapi/design-system": "2.2.4",
|
||||
"@strapi/icons": "2.2.4",
|
||||
"react-intl": "6.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0 <=26.x.x",
|
||||
"npm": ">=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@strapi/admin": "^5.0.0",
|
||||
"@strapi/strapi": "^5.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"styled-components": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@strapi/plugin-users-permissions": {
|
||||
"version": "5.52.1",
|
||||
"resolved": "https://registry.npmjs.org/@strapi/plugin-users-permissions/-/plugin-users-permissions-5.52.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -11,31 +11,41 @@
|
||||
"draftAndPublish": true,
|
||||
"populateCreatorFields": true
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"maxLength": 160
|
||||
"maxLength": 160,
|
||||
"pluginOptions": { "i18n": { "localized": true } }
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "title",
|
||||
"required": true
|
||||
"required": true,
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
},
|
||||
"content": {
|
||||
"type": "richtext",
|
||||
"required": true
|
||||
"required": true,
|
||||
"pluginOptions": { "i18n": { "localized": true } }
|
||||
},
|
||||
"cover": {
|
||||
"type": "media",
|
||||
"multiple": false,
|
||||
"allowedTypes": ["images"]
|
||||
"allowedTypes": ["images"],
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
},
|
||||
"category": {
|
||||
"type": "relation",
|
||||
"relation": "manyToOne",
|
||||
"target": "api::category.category",
|
||||
"inversedBy": "articles"
|
||||
"inversedBy": "articles",
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,30 @@
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"attributes": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"unique": true
|
||||
"unique": true,
|
||||
"pluginOptions": { "i18n": { "localized": true } }
|
||||
},
|
||||
"slug": {
|
||||
"type": "uid",
|
||||
"targetField": "name",
|
||||
"required": true
|
||||
"required": true,
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
},
|
||||
"articles": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::article.article",
|
||||
"mappedBy": "category"
|
||||
"mappedBy": "category",
|
||||
"pluginOptions": { "i18n": { "localized": false } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,25 @@ async function disablePublicSignUp(strapi: Core.Strapi) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,7 +36,6 @@ services:
|
||||
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
STRAPI_TELEMETRY_DISABLED: "true"
|
||||
volumes:
|
||||
- cms-uploads:/app/public/uploads
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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) — Strapi content types, 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 Strapi's admin UI (`/admin`) 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, three runtime components (Nuxt frontend, Strapi CMS, PostgreSQL), fronted by a
|
||||
single Caddy reverse proxy on one domain.
|
||||
@@ -1,99 +0,0 @@
|
||||
# Architecture
|
||||
|
||||
## Services
|
||||
|
||||
Four containers (production, see [docker-compose.yml](../docker-compose.yml)):
|
||||
|
||||
```text
|
||||
Browser → Caddy ─┬─ /admin and Strapi plugin paths → Strapi 5 (cms) → PostgreSQL (database)
|
||||
└─ everything else → Nuxt 4 SSR (frontend) → Strapi REST (internal)
|
||||
```
|
||||
|
||||
- **database** — `postgres:17-alpine`. Not exposed publicly; only reachable by `cms` on the
|
||||
Docker network. Data on the `postgres-data` volume.
|
||||
- **cms** — Strapi 5, the sole source of editorial truth. 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 `/api/*` (Nitro), which are the only code in the repo allowed to call Strapi.
|
||||
- **caddy** — single reverse proxy, single TLS certificate, single public domain
|
||||
(`PUBLIC_DOMAIN`). Routes by **path**, not subdomain.
|
||||
|
||||
`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}`, one path matcher
|
||||
`@cms` listing every Strapi/plugin top-level prefix that must bypass Nuxt:
|
||||
|
||||
```
|
||||
/admin* /content-manager* /content-type-builder* /upload* /i18n*
|
||||
/email* /content-releases* /review-workflows* /users-permissions* /cloud*
|
||||
```
|
||||
|
||||
Everything matching `@cms` goes to `cms:1337` (100MB body limit, for media uploads). Everything
|
||||
else — including `/api/*` — goes to `frontend:3000`.
|
||||
|
||||
**`/api/*` is reserved for Nuxt's own Nitro endpoints, never for Strapi.** Strapi's public REST
|
||||
API (`/api/articles`, `/api/categories`) is reached only from inside the Docker network, by the
|
||||
Nitro server, over `STRAPI_URL=http://cms:1337`. The browser never sees a Strapi URL for content
|
||||
— only for cover images (`PUBLIC_STRAPI_URL/uploads/...`, read-only, unauthenticated).
|
||||
|
||||
If you add a Strapi plugin that mounts its own admin API path, add its prefix to `@cms` in the
|
||||
Caddyfile — this is the one place that list is maintained.
|
||||
|
||||
## Request flow: reading an article
|
||||
|
||||
1. Browser requests `/blog/my-slug` → Caddy → Nuxt SSR.
|
||||
2. `frontend/app/pages/blog/[slug].vue` calls `useFetch('/api/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/api/articles/[slug].get.ts` calls `strapiFetch()` (in
|
||||
`frontend/server/utils/strapi.ts`), which hits `STRAPI_URL` (internal Docker address) with a
|
||||
Strapi-specific query built by `frontend/server/utils/queries.ts`.
|
||||
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 directly at `PUBLIC_STRAPI_URL/uploads/...` — the only
|
||||
asset the browser fetches straight from Strapi.
|
||||
|
||||
## Request flow: publishing content
|
||||
|
||||
1. Editor logs into `/admin` (Strapi admin panel, authenticated, editor/admin only — no public
|
||||
sign-up, see [content-model.md](./content-model.md)).
|
||||
2. Editor writes/edits an Article (Markdown body) or Category, and publishes it (Draft & Publish).
|
||||
3. Strapi writes to PostgreSQL. No cache to invalidate: the next public request for that
|
||||
slug hits Strapi live through the Nitro endpoint.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Defined in [.env.example](../.env.example) (root, drives docker-compose) and
|
||||
[cms/.env.example](../cms/.env.example) (standalone Strapi dev, e.g. `npm run develop` outside
|
||||
Docker).
|
||||
|
||||
| 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. |
|
||||
| `POSTGRES_DB/USER/PASSWORD` | database, cms | Postgres credentials. |
|
||||
| `APP_KEYS` | cms | Strapi session/cookie signing keys (comma-separated). |
|
||||
| `API_TOKEN_SALT` | cms | Salt for Strapi API token hashing. |
|
||||
| `ADMIN_JWT_SECRET` | cms | Signs Strapi admin panel JWTs. |
|
||||
| `TRANSFER_TOKEN_SALT` | cms | Salt for Strapi data-transfer tokens. |
|
||||
| `JWT_SECRET` | cms | Signs users-permissions (public API) JWTs. |
|
||||
| `ENCRYPTION_KEY` | cms | Strapi's encrypted-field key. |
|
||||
| `STRAPI_URL` | frontend (server-only) | Internal Docker address of Strapi (`http://cms:1337`); mapped to `NUXT_STRAPI_URL`. Never sent to the browser. |
|
||||
| `NUXT_STRAPI_TOKEN` | frontend (server-only) | Optional Bearer token for Strapi requests; unset by default. |
|
||||
| `PUBLIC_SITE_URL` | frontend | Canonical public site URL for SEO/OG tags; mapped to `NUXT_PUBLIC_SITE_URL`. |
|
||||
| `PUBLIC_STRAPI_URL` | frontend, browser | Public-facing Strapi origin for building absolute cover-image URLs; mapped to `NUXT_PUBLIC_STRAPI_URL`. |
|
||||
|
||||
All Strapi secrets have standalone copies in `cms/.env.example` for local development without
|
||||
Docker (defaults to SQLite there, since `DATABASE_CLIENT` is unset).
|
||||
|
||||
Never commit `.env` files or real secret values — keep `.env.example` sanitized (placeholders
|
||||
only).
|
||||
@@ -1,63 +0,0 @@
|
||||
# Content model & admin panel
|
||||
|
||||
## Content types
|
||||
|
||||
Defined in `cms/src/api/*/content-types/*/schema.json`. Both use plain Strapi factory
|
||||
defaults (`createCoreRouter`/`createCoreController`/`createCoreService`) — no custom controllers,
|
||||
routes, services, or policies exist for either type.
|
||||
|
||||
| Type | Fields |
|
||||
|---|---|
|
||||
| **Article** | `title` (string, required, max 160), `slug` (UID generated from title), `content` (richtext — Markdown source), `cover` (single media/image), `category` (many-to-one relation to Category) |
|
||||
| **Category** | `name` (string, required, unique), `slug` (UID from name), `articles` (inverse one-to-many) |
|
||||
|
||||
Deliberately minimal: no Author (the only author is the admin account), 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/strapi.ts`).
|
||||
- Publish date is Draft & Publish's `publishedAt`.
|
||||
- Social preview image is the cover.
|
||||
- The article byline is the admin user's name (`createdBy`, via `populateCreatorFields: true`),
|
||||
not a content field.
|
||||
|
||||
Draft & Publish is enabled on Article (`draftAndPublish: true`) and disabled on Category
|
||||
(`draftAndPublish: false`) — categories aren't drafted. Public URLs always use the slug, never the
|
||||
numeric id.
|
||||
|
||||
## Admin login & permissions
|
||||
|
||||
- The admin panel lives at `/admin`, routed by Caddy straight to the `cms` container
|
||||
([architecture.md](./architecture.md#path-routing-caddy)). It's Strapi's standard
|
||||
email/password admin authentication — 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.
|
||||
- `cms/src/index.ts` (`bootstrap`) runs two idempotent setup steps on every Strapi start:
|
||||
1. **`grantPublicReadAccess`** — grants the `public` role exactly `find`/`findOne` on Article
|
||||
and Category, and nothing else (no create/update/delete, no other content type). This is
|
||||
what lets the Nitro endpoints read published content without a token.
|
||||
2. **`disablePublicSignUp`** — turns off `users-permissions`' public registration
|
||||
(`allow_register: false`), since no front-end user accounts should ever exist.
|
||||
- Any permission beyond `find`/`findOne` for Public needs to be justified explicitly — this is a
|
||||
deliberate least-privilege boundary, not an oversight.
|
||||
- `cms/config/plugins.ts` further restricts the `upload` plugin's allowed MIME types (images,
|
||||
video, audio, PDF, office docs, text/CSV) and explicitly denies executables.
|
||||
|
||||
## Editorial workflow
|
||||
|
||||
1. Log into `/admin`.
|
||||
2. Create/edit a Category if needed (name → slug is generated automatically).
|
||||
3. Create/edit an Article: title (→ slug), Markdown content, cover image, category.
|
||||
4. Publish (Draft & Publish). Unpublished drafts are never served by the `find`/`findOne`
|
||||
permissions above — Strapi's default behavior already excludes non-published entries from the
|
||||
public API.
|
||||
5. The change is live immediately: the public site has no cache layer to invalidate (see
|
||||
[architecture.md](./architecture.md#request-flow-reading-an-article)).
|
||||
|
||||
## Why Markdown, not a rich-text/WYSIWYG field
|
||||
|
||||
`content` is a plain richtext (Markdown) field. Strapi stores the raw Markdown; conversion to HTML
|
||||
happens once, server-side, in the Nuxt Nitro endpoint (`renderMarkdown()`, using `marked`) — never
|
||||
in Strapi 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 admin, not arbitrary users.
|
||||
@@ -1,77 +0,0 @@
|
||||
# Frontend (Nuxt)
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | File | Behavior |
|
||||
|---|---|---|
|
||||
| `/` | `app/pages/index.vue` | Static hero/intro copy plus the single latest article, fetched via `/api/articles` (page 1), shown as a featured block. |
|
||||
| `/blog` | `app/pages/blog/index.vue` | Paginated archive (`PAGE_SIZE = 12`), grid of `ArticleCard`, prev/next via `?page=`. |
|
||||
| `/blog/[slug]` | `app/pages/blog/[slug].vue` | Full article: fetches `/api/articles/:slug`, renders the pre-converted `article.html`, SEO meta, canonical URL, Open Graph, JSON-LD `BlogPosting`, breadcrumb to its category. |
|
||||
| `/category/[slug]` | `app/pages/category/[slug].vue` | Fetches the category by slug, then a paginated, category-filtered article list; 404s if the category doesn't exist. |
|
||||
| `/come-difendersi-dal-corralito` | `app/pages/come-difendersi-dal-corralito.vue` | Fully static marketing page, no Strapi data. |
|
||||
|
||||
All pages are SSR (`useFetch`/`useSeoMeta`); nothing blog-related is client-only-rendered.
|
||||
|
||||
## Server (Nitro) endpoints — the only Strapi client
|
||||
|
||||
`frontend/server/api/`:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/articles` | Paginated list (`page` query, `PAGE_SIZE=12`), optional `category` slug filter. Proxies to Strapi with `ARTICLE_SUMMARY_QUERY`. Returns `Paginated<ArticleSummary>`. |
|
||||
| `GET /api/articles/:slug` | One article: fetches from Strapi with `ARTICLE_DETAIL_QUERY` (includes `content` + `createdBy`), converts Markdown to HTML, builds the meta description, derives the author byline. 400 without a slug, 404 if not found. Returns `Article`. |
|
||||
| `GET /api/categories` | All categories (name + slug only), sorted by name. |
|
||||
| `GET /api/categories/:slug` | One category by slug. 400/404 as above. |
|
||||
|
||||
This is the **single point of contact** with Strapi (`CLAUDE.md` rule): pages never call
|
||||
`$fetch` against Strapi directly, and `NUXT_STRAPI_URL` / any Strapi token never reach the client.
|
||||
If you add a view that needs new data, add the endpoint here and type its return in
|
||||
`frontend/shared/types/blog.ts` — don't scatter Strapi calls into components.
|
||||
|
||||
## Supporting utilities
|
||||
|
||||
- `frontend/server/utils/strapi.ts`
|
||||
- `strapiFetch<T>(path)` — server-only fetch against `runtimeConfig.strapiUrl`, with optional
|
||||
Bearer token; wraps failures as a 502 so internal details never leak to the client.
|
||||
- `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` — Strapi query-string builders kept intentionally minimal
|
||||
(only the fields each page actually renders): `ARTICLE_SUMMARY_QUERY`, `ARTICLE_DETAIL_QUERY`,
|
||||
`PAGE_SIZE`, `pagination()`, `pageParam()`.
|
||||
- `frontend/app/composables/useMediaUrl.ts` — the only composable; turns a Strapi image object
|
||||
into an absolute browser URL by prefixing `runtimeConfig.public.strapiUrl` unless already
|
||||
absolute.
|
||||
- `frontend/shared/utils/site.ts` — site constants (`SITE_NAME`, `SITE_EMAIL`, `SOCIAL_LINKS`).
|
||||
- `frontend/shared/utils/format.ts` — `it-IT` date formatting (`formatDate`, `formatDateTime`,
|
||||
`isoDate`).
|
||||
|
||||
## Types (`frontend/shared/types/blog.ts`)
|
||||
|
||||
```
|
||||
StrapiImage { url, alternativeText, width, height }
|
||||
Category { name, slug }
|
||||
ArticleSummary{ title, slug, publishedAt, cover: StrapiImage | null, category: Category | null }
|
||||
Article extends ArticleSummary { html, summary, author: string | null }
|
||||
Paginated<T> { items: T[], page, pageCount, total }
|
||||
```
|
||||
|
||||
`Article` is the detail shape (adds rendered HTML, meta summary, byline); `ArticleSummary` is what
|
||||
listing pages use.
|
||||
|
||||
## Layout & shared components
|
||||
|
||||
- `app/layouts/default.vue` — the only layout: header (logo, tagline, nav), `<main id="main">`
|
||||
slot, footer (contact email, nav, social links), with a skip-link for accessibility.
|
||||
- `app/components/ArticleCard.vue` — listing-grid card: cover (lazy, omitted if none), category
|
||||
kicker, title link, formatted date.
|
||||
- `app/components/SocialIcon.vue` — inlines SVG brand marks from `simple-icons` at build time
|
||||
(`?raw` imports) rather than bundling the whole icon set.
|
||||
|
||||
## 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`.
|
||||
@@ -1,3 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
const { locale } = useLocale()
|
||||
useHead({ htmlAttrs: { lang: locale } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
|
||||
@@ -5,6 +5,7 @@ const props = defineProps<{ article: ArticleSummary }>()
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const cover = computed(() => mediaUrl(props.article.cover))
|
||||
const { locale } = useLocale()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +37,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDate(article.publishedAt) }}
|
||||
{{ formatDate(article.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { STRINGS, type Strings } from '../i18n/strings'
|
||||
|
||||
export type Locale = 'it' | 'en'
|
||||
|
||||
/**
|
||||
* Site language, persisted in a cookie so SSR renders the right language on
|
||||
* the first request. `t(key)` reads the current locale's UI string.
|
||||
*/
|
||||
export function useLocale() {
|
||||
const locale = useCookie<Locale>('lang', { default: () => 'it', sameSite: 'lax' })
|
||||
|
||||
function setLocale(value: Locale) {
|
||||
locale.value = value
|
||||
}
|
||||
|
||||
function t<K extends keyof Strings>(key: K): Strings[K] {
|
||||
return STRINGS[locale.value][key]
|
||||
}
|
||||
|
||||
return { locale, setLocale, t }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** 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,9 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
const { locale, setLocale, t } = useLocale()
|
||||
|
||||
const otherLocale = computed(() => (locale.value === 'it' ? 'en' : 'it'))
|
||||
|
||||
/**
|
||||
* Switches the site language. On an article/category page, jumps to the
|
||||
* translated counterpart's slug if one exists; elsewhere reloads the current
|
||||
* route so it refetches its content in the new language.
|
||||
*/
|
||||
function switchLanguage() {
|
||||
setLocale(otherLocale.value)
|
||||
reloadNuxtApp({ path: useRoute().fullPath })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<a class="skip-link" href="#main">Vai al contenuto</a>
|
||||
<a class="skip-link" href="#main">{{ t('skipToContent') }}</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
@@ -12,16 +25,19 @@
|
||||
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<p class="tagline kicker">Articoli, guide e analisi.</p>
|
||||
<p class="tagline kicker">{{ t('tagline') }}</p>
|
||||
</div>
|
||||
|
||||
<nav class="site-nav" aria-label="Navigazione principale">
|
||||
<nav class="site-nav" :aria-label="t('navPrimary')">
|
||||
<div class="container nav-inner">
|
||||
<ul>
|
||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
||||
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
||||
</ul>
|
||||
<button type="button" class="lang-switch" @click="switchLanguage">
|
||||
{{ t('switchLanguage') }}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -34,24 +50,24 @@
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-block">
|
||||
<img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||
<p class="muted">Articoli, guide e analisi.</p>
|
||||
<p class="muted">{{ t('tagline') }}</p>
|
||||
<address class="contact">
|
||||
Contatti:
|
||||
{{ t('footerContact') }}
|
||||
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<nav class="footer-block" aria-label="Naviga">
|
||||
<p class="kicker">Naviga</p>
|
||||
<nav class="footer-block" :aria-label="t('footerNavigate')">
|
||||
<p class="kicker">{{ t('footerNavigate') }}</p>
|
||||
<ul>
|
||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
||||
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="footer-block" aria-label="Seguici">
|
||||
<p class="kicker">Seguici</p>
|
||||
<nav class="footer-block" :aria-label="t('footerFollow')">
|
||||
<p class="kicker">{{ t('footerFollow') }}</p>
|
||||
<ul>
|
||||
<li v-for="social in SOCIAL_LINKS" :key="social.name">
|
||||
<a class="social" :href="social.url" target="_blank" rel="noopener me">
|
||||
@@ -64,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<p class="container copyright kicker">
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. Tutti i diritti riservati.
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ t('footerRights') }}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -116,7 +132,10 @@
|
||||
|
||||
.nav-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.5rem;
|
||||
padding-block: 0.6rem;
|
||||
}
|
||||
|
||||
@@ -134,6 +153,24 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.35rem 0.75rem;
|
||||
color: var(--color-body);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang-switch:hover {
|
||||
color: var(--color-ink);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.site-nav {
|
||||
margin-top: 2rem;
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { Article } from '#shared/types/blog'
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
|
||||
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 })
|
||||
@@ -38,6 +43,7 @@ useHead({
|
||||
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,
|
||||
@@ -53,7 +59,7 @@ useHead({
|
||||
<article v-if="article">
|
||||
<header class="article-header">
|
||||
<nav class="kicker" aria-label="Percorso di navigazione">
|
||||
<NuxtLink to="/blog">Articoli</NuxtLink>
|
||||
<NuxtLink to="/blog">{{ t('breadcrumbArticles') }}</NuxtLink>
|
||||
<template v-if="article.category">
|
||||
<span aria-hidden="true"> / </span>
|
||||
<NuxtLink :to="`/category/${article.category.slug}`">
|
||||
@@ -66,11 +72,11 @@ useHead({
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDateTime(article.publishedAt) }}
|
||||
{{ formatDateTime(article.publishedAt, locale) }}
|
||||
</time>
|
||||
<template v-if="article.author">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>Di {{ article.author }}</span>
|
||||
<span>{{ t('byAuthor')(article.author) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
@@ -88,7 +94,7 @@ useHead({
|
||||
<div class="prose" v-html="article.html" />
|
||||
|
||||
<p class="back">
|
||||
<NuxtLink to="/blog">Torna a tutti gli articoli</NuxtLink>
|
||||
<NuxtLink to="/blog">{{ t('backToArticles') }}</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -8,8 +8,11 @@ const page = computed(() => {
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { locale, t } = useLocale()
|
||||
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
||||
query: { page },
|
||||
key: () => `blog-articles-${locale.value}-${page.value}`,
|
||||
query: { page, lang: locale },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
@@ -18,8 +21,8 @@ const canonical = computed(
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => (page.value > 1 ? `Articoli — pagina ${page.value}` : 'Articoli'),
|
||||
description: 'Tutti gli articoli pubblicati sul blog.',
|
||||
title: () => (page.value > 1 ? `${t('articlesTitle')} — ${t('pagePrefix')} ${page.value}` : t('articlesTitle')),
|
||||
description: () => t('allArticlesDescription'),
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
@@ -29,15 +32,15 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">L'archivio</p>
|
||||
<h1>Articoli</h1>
|
||||
<p class="kicker">{{ t('archiveKicker') }}</p>
|
||||
<h1>{{ t('articlesTitle') }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
||||
<p v-if="error">{{ t('loadError') }}</p>
|
||||
|
||||
<p v-else-if="status === 'pending'">Caricamento…</p>
|
||||
<p v-else-if="status === 'pending'">{{ t('loading') }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">Non è stato ancora pubblicato nulla.</p>
|
||||
<p v-else-if="!data?.items.length">{{ t('noArticlesYet') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
@@ -46,19 +49,19 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
|
||||
<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">
|
||||
Pagina precedente
|
||||
{{ t('prevPage') }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
||||
{{ t('pageOf')(data.page, data.pageCount) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
Pagina successiva
|
||||
{{ t('nextPage') }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -9,14 +9,20 @@ const page = computed(() => {
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`)
|
||||
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', {
|
||||
query: { page, category: slug },
|
||||
key: () => `category-articles-${slug}-${locale.value}-${page.value}`,
|
||||
query: { page, category: slug, lang: locale },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
@@ -25,8 +31,8 @@ const canonical = computed(
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: category.value.name,
|
||||
description: () => `Tutti gli articoli nella categoria ${category.value?.name ?? ''}.`,
|
||||
title: () => category.value?.name ?? '',
|
||||
description: () => t('categoryDescription')(category.value?.name ?? ''),
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
@@ -36,13 +42,13 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">Categoria</p>
|
||||
<p class="kicker">{{ t('categoryKicker') }}</p>
|
||||
<h1>{{ category?.name }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
||||
<p v-if="error">{{ t('loadError') }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">Non ci sono articoli in questa categoria.</p>
|
||||
<p v-else-if="!data?.items.length">{{ t('noArticlesInCategory') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
@@ -51,19 +57,19 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
|
||||
<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">
|
||||
Pagina precedente
|
||||
{{ t('prevPage') }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
||||
{{ t('pageOf')(data.page, data.pageCount) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
Pagina successiva
|
||||
{{ t('nextPage') }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<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',
|
||||
query: { page: 1 },
|
||||
key: () => `home-articles-${locale.value}`,
|
||||
query: { page: 1, lang: locale },
|
||||
})
|
||||
|
||||
const featured = computed(() => data.value?.items[0] ?? null)
|
||||
@@ -20,9 +22,9 @@ useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
|
||||
|
||||
useSeoMeta({
|
||||
title: SITE_NAME,
|
||||
description: 'Articoli, guide e analisi.',
|
||||
description: () => t('tagline'),
|
||||
ogTitle: SITE_NAME,
|
||||
ogDescription: 'Articoli, guide e analisi.',
|
||||
ogDescription: () => t('tagline'),
|
||||
ogType: 'website',
|
||||
ogUrl: canonical,
|
||||
ogImage: `${config.siteUrl}/logo.png`,
|
||||
@@ -67,9 +69,9 @@ useSeoMeta({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
||||
<p v-if="error">{{ t('loadError') }}</p>
|
||||
|
||||
<p v-else-if="!featured">Non è stato ancora pubblicato nulla.</p>
|
||||
<p v-else-if="!featured">{{ t('noArticlesYet') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||
@@ -90,23 +92,27 @@ useSeoMeta({
|
||||
|
||||
<div class="featured-text">
|
||||
<p class="kicker">
|
||||
{{ featured.category ? featured.category.name : 'Ultimo' }}
|
||||
{{ 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) }}
|
||||
{{ formatDate(featured.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
<p class="read-more">
|
||||
<NuxtLink :to="`/blog/${featured.slug}`">
|
||||
Leggi l'ultimo articolo
|
||||
{{ t('readArticle') }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p class="more">
|
||||
<NuxtLink to="/blog">{{ t('seeAllArticles') }}</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -209,4 +215,27 @@ useSeoMeta({
|
||||
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>
|
||||
|
||||
@@ -18,13 +18,14 @@ function byline(user: AdminUser | null | undefined): string | 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}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
`/api/articles?${ARTICLE_DETAIL_QUERY}&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
)
|
||||
|
||||
const article = response.data[0]
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -10,7 +11,7 @@ export default defineEventHandler(async (event): Promise<Paginated<ArticleSummar
|
||||
: ''
|
||||
|
||||
const response = await strapiFetch<StrapiList<ArticleSummary>>(
|
||||
`/api/articles?${ARTICLE_SUMMARY_QUERY}&${pagination(page)}${filter}`
|
||||
`/api/articles?${ARTICLE_SUMMARY_QUERY}&locale=${locale}&${pagination(page)}${filter}`
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,13 +2,14 @@ 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&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
)
|
||||
|
||||
const category = response.data[0]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (): Promise<Category[]> => {
|
||||
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&sort[0]=name:asc&pagination[pageSize]=100'
|
||||
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&sort[0]=name:asc&pagination[pageSize]=100`
|
||||
)
|
||||
|
||||
return response.data
|
||||
|
||||
@@ -31,3 +31,8 @@ export const pageParam = (value: unknown): number => {
|
||||
const page = Number(value)
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
export type Locale = 'it' | 'en'
|
||||
|
||||
/** Reads the `lang` query param, falling back to the site default `it`. */
|
||||
export const localeParam = (value: unknown): Locale => (value === 'en' ? 'en' : 'it')
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
const INTL_LOCALE = { it: 'it-IT', en: 'en-GB' } as const
|
||||
|
||||
/** Human-readable date for display; returns an empty string when unset. */
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
export function formatDate(value: string | null | undefined, locale: 'it' | 'en' = 'it'): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(value))
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long' }).format(new Date(value))
|
||||
}
|
||||
|
||||
/** Date and time of publication, shown in the article byline. */
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
export function formatDateTime(
|
||||
value: string | null | undefined,
|
||||
locale: 'it' | 'en' = 'it'
|
||||
): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long', timeStyle: 'short' })
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long', timeStyle: 'short' })
|
||||
.format(new Date(value))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user