Give projects and members their own pages

The API has always served a project's and a member's detail, with
`path` and `alternates` in three languages, and the site had nowhere to
show it. A project now has its page — tagline, status, technologies,
description, technical decisions, what was learned, gallery, client,
dates, lead, team, repositories and open calls — and so does a member:
headline, bio, links, technologies and projects. Cards on the landing
link to both.

Addresses follow the API's rule read backwards (core/routes.ts):
pages at the root, records under a section named in their language,
the default language unprefixed. One catch-all route serves them all.
Not a route per shape: Analog registers each loader as an internal
Nitro route, and Nitro's router cannot hold `/:prefix/:slug` beside
`/:prefix/:section/:slug` — the first became unreachable.

The frame around a record (header, footer, lights, 404/503, title,
description, canonical, hreflang) is one component now, PageShell, and
each type only brings its body, all sharing one layout partial.

Also fixes chips that stepped down one row after the first, on the
landing's cards too: @pi/ui's reset gives every `li + li` a top margin,
right for prose and wrong for a row spaced by `gap`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 14:57:13 +02:00
parent 9af6b5aff9
commit 439f951f2e
33 changed files with 2001 additions and 745 deletions

View File

@@ -103,6 +103,47 @@ const data = {
],
};
// Detail endpoints: what a project's or a member's own page asks for. Richer
// than the listings — gallery, prose, team — because that is what the detail
// endpoints add.
const prose = (topic) =>
`<p>${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.</p>` +
'<ul><li>Pensado para que lo mantenga gente que no programa.</li><li>Con pruebas y documentación desde el primer día.</li></ul>';
const team = data['/public/v1/members'].slice(0, 3).map((m, i) => ({
...m,
role: ['Responsable', 'Frontend', 'Backend'][i],
}));
for (const listed of data['/public/v1/projects']) {
data[`/public/v1/projects/${listed.slug}`] = {
...listed,
description: prose(listed.title),
decisions: '<p>Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.</p>',
postmortem: '<p>Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.</p>',
meta_title: `${listed.title} · Pireon`,
meta_description: listed.tagline,
started_at: '2025-03-01T00:00:00Z',
ended_at: listed.status === 'completed' ? '2026-06-01T00:00:00Z' : null,
client: data['/public/v1/clients'][0],
members: team,
lead: team[0],
calls: data['/public/v1/calls'].slice(0, listed.id % 2 ? 1 : 0).map((c) => ({ ...c, path: `/colabora/${c.slug}` })),
alternates: { es: `/proyectos/${listed.slug}`, en: `/en/projects/${listed.slug}`, ca: `/ca/projectes/${listed.slug}` },
};
}
for (const listed of data['/public/v1/members']) {
data[`/public/v1/members/${listed.slug}`] = {
...listed,
bio: 'Programa desde hace años en proyectos de todo tipo.\nAhora dedica unas horas a la semana al colectivo, sobre todo a revisar código y acompañar a quien empieza.',
joined_at: '2024-09-01T00:00:00Z',
technologies: technologies.slice(0, 4),
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
alternates: { es: `/equipo/${listed.slug}`, en: `/en/team/${listed.slug}`, ca: `/ca/equip/${listed.slug}` },
};
}
createServer((req, res) => {
const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
const payload = data[path];