Intégrer au site
Construire la liste des projets, la feuille de route, une colonne entière, le journal et la page d'une fiche dans le design du site, avec les fonctions serveur, les appels du navigateur et les composants du module Projets.
Les pages /za/projects du module prennent le cadre neutre commun aux modules. Un site qui veut ses feuilles de route dans son propre design, avec son en-tête, son pied de page et ses composants, construit ses pages lui-même. Le module lui donne les données et les actions, le site garde la main sur l'apparence.
1. Choisir l'adresse
FRAZA_MODULE_PROJECTS_PUBLIC_PATH=/feuille-de-routeLes liens que le module écrit suivent cette adresse : ceux des courriels « C'est prévu », « C'est fait » et « Du nouveau », et ceux du flux RSS du journal. Chaque projet vit sous son slug, le projet mis en avant compris. Le site sert donc ces pages :
| Page | Adresse | Lit |
|---|---|---|
| Les projets | /feuille-de-route | getPublicProjects, ou getFeaturedProject pour un site qui n'en montre qu'un |
| La feuille de route d'un projet | /feuille-de-route/<projet> | getPublicRoadmap, getPublicTags, getVoteState, getPublicProjectLinks |
| Une colonne entière | /feuille-de-route/<projet>?colonne=<clé> | getPublicColumn, getVoteState |
| Le journal | /feuille-de-route/<projet>/journal | getPublicChangelog |
| Une fiche | /feuille-de-route/<projet>/<fiche> | getPublicItem, getVoteState |
La colonne entière et le journal sont facultatifs : un site qui ne les construit pas ne met pas de lien « Voir tout » ni « Journal des versions ». Sans la colonne entière, une colonne terminée ne montre que ses fiches les plus récentes : comparer total à items.length pour savoir qu'il en manque. Le flux RSS du journal, lui, est servi par le module dans tous les cas, et ses liens mènent à <adresse>/<projet>/journal.
Dès que la variable est réglée, les pages /za/projects ne s'affichent plus : chacune redirige (307) vers la même adresse sous /feuille-de-route, paramètres compris. Un lien ancien, un favori ou le bouton « Page publique » de l'admin mènent donc aux pages du site. Régler la variable une fois ces pages prêtes : pendant leur construction, la laisser vide garde les pages du module et les liens des courriels vers elles. Un chemin placé sous /za/projects ne déclenche aucune redirection.
Seules les pages redirigent. Les routes /za/api/projects/… restent servies : les pages du site s'en servent pour voter. La page de désabonnement, /za/projects/desabonnement, ne redirige pas non plus : le site n'a pas à la construire, et les liens des courriels déjà envoyés continuent d'y mener.
Une fiche ou un projet ne reçoit jamais le slug nouveau, suivi, mes-fiches, desabonnement ni journal. Le module Rétroaction peut donc servir son formulaire et son suivi sous la même adresse, avec FRAZA_MODULE_FEEDBACK_PUBLIC_PATH=/feuille-de-route. Voir Avec Rétroaction.
2. Lire les données côté serveur
@fraza-pro/module-projects/server se lit depuis un Server Component, pendant une requête : les fonctions qui disent si la personne a voté lisent sa session et son cookie. La session ne compte que pour un compte client : un compte d'équipe connecté à l'admin est un visiteur sur le site. Voir Le site, l'espace client et l'admin.
// app/(public)/feuille-de-route/[projet]/page.js
import { notFound } from 'next/navigation';
import { getProjectsPaths, getPublicProjects, getPublicRoadmap, getPublicTags, getVoteState } from '@fraza-pro/module-projects/server';
export default async function RoadmapPage({ params, searchParams }) {
const { projet } = await params;
const { etiquette } = await searchParams;
const project = (await getPublicProjects()).find((p) => p.slug === projet);
if (!project) notFound();
const [columns, tags, vote] = await Promise.all([
getPublicRoadmap({ project: projet, tag: etiquette ?? null }),
getPublicTags({ project: projet }),
getVoteState(),
]);
const paths = getProjectsPaths(project);
return columns.map((column) => (
<section key={column.key}>
<h2>{column.name}</h2>
{column.items.map((item) => (
<a key={item.slug} href={paths.item(item.slug)}>{item.title} ({item.voteCount})</a>
))}
</section>
));
}| Fonction | Rend |
|---|---|
getPublicProjects() | Les projets publics et actifs, le projet mis en avant d'abord, [{ slug, name, description, isFeatured }]. description est du Markdown. |
getFeaturedProject() | Le projet mis en avant, sous la même forme. Quand il est interne, le premier projet public et actif, dans l'ordre. null sans projet public. |
getPublicRoadmap({ tag, project }) | Les colonnes publiques dans leur ordre, [{ key, name, done, total, items }]. Une colonne terminée ne montre que ses vingt fiches les plus récentes : total les compte toutes, et getPublicColumn donne les autres. Chaque fiche : { slug, title, description, summary, kind, column, milestone, tag, shippedNote, voteCount, voted }. description est du Markdown ; summary en est le texte, coupé pour une carte ; kind vaut task, bug, idea ou improvement ; column vaut { key, name, done } ; milestone et tag sont des noms, ou null ; shippedNote n'est rempli que dans une colonne terminée. |
getPublicItem(slug, { project }) | Une fiche publique sous la même forme, avec updates : ses mises à jour publiques, la plus récente d'abord, chacune { id, body (Markdown), author, createdAt, edited }, et following : le compte client connecté la suit, ou null sans compte client. Ou null : appeler notFound(). |
getPublicColumn({ column, project, tag, page }) | Une colonne publique entière, 50 fiches par page : { column: { key, name, done }, items, total, page, pageCount }, les fiches sous la forme de getPublicRoadmap. column est sa clé. Une colonne terminée met les dernières livrées d'abord, une autre les plus votées. null pour une colonne inconnue ou interne. |
getPublicChangelog({ project, page }) | Le journal : les jalons fermés, le plus récemment fermé d'abord, 20 par page, { milestones, total, page, pageCount }. Chaque jalon : { name, anchor, description (Markdown), dueDate, closedAt, sections: [{ title, items }] }, les fiches sous la forme de getPublicRoadmap, sans voted. Seules les fiches publiques d'une colonne publique et terminée y sont, et un jalon sans aucune est laissé de côté. Voir Le journal. |
getPublicTags({ project }) | Les noms des étiquettes, pour un filtre. |
getVoteState() | { mode, loginRequired }. mode vaut user, anyone ou off ; user se lit off sur un site sans comptes clients. Avec loginRequired (mode user, sans compte client connecté), le bouton de vote mène à la connexion. |
getPublicProjectLinks({ project }) | Les liens d'une feuille de route à côté de ses fiches : { form, changelog, feed }, ou null pour un projet qui n'est pas public. form : le formulaire du module Rétroaction pour ce projet, ou null sans ce module, ou pour une boîte de réception privée ou archivée. Son slug peut différer de celui du projet : ne jamais le recomposer à la main. changelog : la page du journal, et feed : son flux RSS, ou null tant qu'aucun jalon fermé n'a de fiche publique terminée. |
getProjectsPaths(project) | { projects, roadmap, item(slug), changelog, column(key) }, d'après FRAZA_MODULE_PROJECTS_PUBLIC_PATH. Avec un projet ({ slug }), roadmap, item, changelog et column sont les adresses de ce projet. Sans lui, roadmap est la liste des projets. |
project est le slug d'un projet. Sans lui, c'est le projet mis en avant, ou le premier projet public quand il est interne. Un projet inconnu, interne ou archivé donne une feuille de route vide, aucune étiquette et une fiche null.
Aucune de ces fonctions ne rend les responsables, l'échéance, la priorité ni l'historique d'une fiche.
La page d'une fiche
// app/(public)/feuille-de-route/[projet]/[fiche]/page.js
import { notFound } from 'next/navigation';
import MarkdownView from '@fraza/core/shared/components/MarkdownView';
import { getPublicItem, getVoteState } from '@fraza-pro/module-projects/server';
import { FollowButton, ItemUpdates, VoteButton } from '@fraza-pro/module-projects/public';
export default async function Page({ params }) {
const { projet, fiche } = await params;
const [item, vote] = await Promise.all([getPublicItem(fiche, { project: projet }), getVoteState()]);
if (!item) notFound();
return (
<MaMiseEnPage titre={item.title}>
<VoteButton
slug={item.slug}
project={projet}
count={item.voteCount}
voted={item.voted}
mode={vote.mode}
loginHref={vote.loginRequired ? `/auth/login?next=/feuille-de-route/${projet}/${item.slug}` : null}
/>
<FollowButton
slug={item.slug}
project={projet}
following={item.following ?? false}
loginHref={item.following === null ? `/auth/login?next=/feuille-de-route/${projet}/${item.slug}` : null}
/>
<MarkdownView value={item.description} headingOffset={1} />
<ItemUpdates updates={item.updates} />
</MaMiseEnPage>
);
}La liste des projets
/feuille-de-route liste les projets de getPublicProjects(), chacun vers getProjectsPaths(project).roadmap. Un site qui n'a qu'un projet public peut plutôt rediriger vers lui :
// app/(public)/feuille-de-route/page.js
import { notFound, redirect } from 'next/navigation';
import { getFeaturedProject, getProjectsPaths } from '@fraza-pro/module-projects/server';
export default async function Page() {
const project = await getFeaturedProject();
if (!project) notFound();
redirect(getProjectsPaths(project).roadmap);
}3. Agir depuis le navigateur
@fraza-pro/module-projects/public donne de quoi voter depuis un composant du site, et relire les données dans le navigateur.
Un bouton de vote dans le design du site
'use client';
import { useVote } from '@fraza-pro/module-projects/public';
export function MonVote({ slug, project, count, voted, loginHref }) {
const vote = useVote({ slug, project, count, voted, loginHref });
return (
<button type="button" onClick={vote.toggle} disabled={vote.busy} aria-pressed={vote.voted}>
▲ {vote.count}
</button>
);
}useVote rend { count, voted, busy, error, toggle }. toggle vote, ou retire le vote. Avec loginHref, il mène là à la place : le passer quand getVoteState() dit loginRequired, par exemple /auth/login?next=/feuille-de-route/principal. Passer aussi project, le slug du projet de la fiche : sans lui, le vote vise une fiche du projet mis en avant.
Les fonctions d'appel
| Fonction | Rôle |
|---|---|
fetchProjects() | La réponse de GET /public/projects : projects. |
fetchRoadmap({ tag, project }) | La réponse de GET /public/roadmap : project, columns, tags, vote et formPath. |
fetchItem(slug, { project }) | La réponse de GET /public/items/:slug : item et vote. |
setVote(slug, voted, { project }) | Vote (true) ou retire le vote (false). Rend { voted, voteCount }. |
setFollow(slug, following, { project }) | Suit la fiche (true) ou ne la suit plus (false), pour le compte client connecté. Rend { following }. Sans session, l'erreur porte status 401 ; pour un compte qui n'est pas un compte client, status 403 et code client_account_required. |
fetchColumn(column, { project, tag, page }) | La réponse de GET /public/columns/:key : column, items, total, page, pageCount. |
fetchChangelog({ project, page }) | La réponse de GET /public/changelog : project, milestones, total, page, pageCount. |
Chaque project est le slug d'un projet. Sans lui, le projet mis en avant, ou le premier projet public quand il est interne.
Un refus lève une Error dont message est en français et s'affiche tel quel, dont code nomme les cas à traiter à part, login_required et vote_off, et dont status est le statut HTTP.
4. Ou reprendre les composants du module
Les composants suivent les jetons du thème : ils prennent les couleurs du site sans rien régler.
| Composant | Rôle |
|---|---|
VoteButton | Le bouton de vote du module. slug, count, voted, mode et loginHref viennent de la fiche et de getVoteState(). project : le slug du projet de la fiche. size : md ou lg. Avec mode à off, il n'affiche que le compteur. |
FollowButton | Suivre et Suivie. slug, following (following de getPublicItem), project, size (md ou lg). Avec loginHref, le bouton dit « Se connecter pour suivre » et y mène : le passer quand following vaut null. |
ItemUpdates | Les mises à jour d'une fiche (updates de getPublicItem ou de fetchItem), rendues depuis leur Markdown. title change le titre de la section, null le retire. |
formatDay est exporté aussi : une date comme la lisent les pages du module, « 30 oct. 2026 ». Les colonnes, elles, viennent de la base avec la feuille de route : l'équipe les nomme dans l'admin.
Afficher une description
La description d'une fiche ou d'un projet est du Markdown. Le core le rend en HTML sûr :
import MarkdownView from '@fraza/core/shared/components/MarkdownView';
<MarkdownView value={item.description} headingOffset={1} />Ou renderMarkdown de @fraza/core/shared/markdown pour un rendu à soi. Voir Markdown.
Un Server Component ne passe pas de fonction à ces composants, ni à aucun composant client : une icône ou un rappel déclenche une erreur de rendu. Passer des chaînes, des nombres et des booléens.
Un site sans rendu serveur
Les routes publiques suffisent : GET /za/api/projects/public/projects, GET /za/api/projects/public/roadmap?projet=<slug>, GET /za/api/projects/public/items/:slug?projet=<slug>, la colonne entière, le journal, le vote et le suivi. Voir API.
Le flux RSS
/za/api/projects/public/changelog.rss?projet=<slug> sert le journal d'un projet en RSS 2.0. Pour que les lecteurs de flux le trouvent depuis la page du journal du site :
// app/(public)/feuille-de-route/[projet]/journal/page.js
export async function generateMetadata({ params }) {
const { projet } = await params;
return {
title: 'Journal',
alternates: { types: { 'application/rss+xml': `/za/api/projects/public/changelog.rss?projet=${projet}` } },
};
}