@clauzier/admin-kit (0.23.0)
Installation
@clauzier:registry=npm install @clauzier/admin-kit@0.23.0"@clauzier/admin-kit": "0.23.0"About this package
@clauzier/admin-kit
Back-office React Admin généré depuis les schémas servis par cms-core.
Une coquille cliente ne décrit que son identité : titre, palette, URL d'API.
Les ressources, les formulaires, les libellés, l'écran de réglages et le menu
viennent du contrat /api/content-schemas — un type de contenu déclaré côté
socle apparaît dans l'admin sans qu'une ligne y soit écrite.
Installation
bun add @clauzier/admin-kit
Prérequis : cms-core ≥ 0.22 (brouillon, publication, contrat de sections v2 et menus liés aux pages). Face à un socle plus ancien, l'admin reste lisible mais n'ouvre ni les pages ni les menus.
Les paquets react-admin, @mui/material, @mui/icons-material,
@api-platform/admin, @tanstack/react-query, react, react-dom et
react-router-dom sont des peerDependencies : deux instances de
react-admin dans un même bundle donnent deux contextes et plus rien ne
fonctionne. La coquille les déclare donc explicitement.
Utilisation
import { createRoot } from 'react-dom/client';
import { AdminKitApp } from '@clauzier/admin-kit/app';
import '@clauzier/admin-kit/styles/admin.css';
import './palette.css';
createRoot(document.getElementById('root')!).render(
<AdminKitApp
apiUrl="https://api.exemple.tld/api"
title="Mon Club"
subtitle="Espace d'administration"
tokenKey="monclub_admin_token"
palette={{
primary: '#4f7cff',
secondary: '#e63946',
light: {
background: '#f4f4f8',
paper: '#ffffff',
text: '#12121a',
textMuted: '#5c5c70',
border: '#dcdce6',
},
dark: {
background: '#0b0b10',
paper: '#14141c',
text: '#f2f2f5',
textMuted: '#8a8a99',
border: '#24242e',
},
fontBody: "'Inter', system-ui, sans-serif",
}}
/>,
);
Props de AdminKitApp
| Prop | Type | Défaut | Rôle |
|---|---|---|---|
apiUrl |
string |
— | point d'entrée de l'API (…/api) |
title |
string |
— | titre de l'admin et de l'écran de connexion |
subtitle |
string |
— | sous-titre de l'écran de connexion |
tokenKey |
string |
— | clé localStorage du jeton Sanctum |
palette |
AdminPalette |
— | couleurs de marque et surfaces des deux modes (voir Thème) |
defaultTheme |
'light' | 'dark' |
'dark' |
mode au premier chargement |
messages |
object |
{} |
surcharges de traduction, fusionnées avec celles du kit et de react-admin |
children |
ReactNode |
— | <Resource> et <CustomRoutes> supplémentaires, transmis tels quels |
dashboard |
DashboardComponent |
SiteDashboard |
composant de la page d'accueil |
menuExtras |
readonly MenuExtra[] |
[] |
entrées de menu ajoutées à une section (Administration par défaut) |
mediaPage |
boolean |
true |
page Médias (grille, téléversement, suppression, copie d'URL) |
Ce que le kit construit
- Écran de connexion : lit
GET /auth/methodset propose ce que l'API annonce — formulaire email/mot de passe, bouton « Se connecter avec Discord » (CMS_ADMIN_URLposé côté API), ou les deux. Le retour Discord arrive sur/?code&nonce: le nonce est comparé à celui gardé enlocalStorage(<tokenKey>.nonce), le code échangé viaPOST /auth/exchange, la query retirée de l'URL.?error=forbidden(compte non admin) et les autres erreurs s'affichent sur l'écran de connexion. - Tableau de bord « Votre site » (
/,SiteDashboard) : quatre blocs, chacun masqué quand le socle ne sait pas l'alimenter.- Votre site en quelques étapes : la checklist de
GET /site/checklist(barre « 2 étapes sur 5 », un lien « Faire maintenant » par étape restante ;settings→/site,page:<id>→/pages/<id>,menu:<location>→ l'écran des menus du menu latéral,pages→/pages). Affichée seulement aveccapabilities.checklistet une réponse autre que 404. - Modifications à publier : les pages dont
draft_updated_atest renseigné dansGET /pages, avec « Tout publier » (une publication par page, en série, après confirmation). Absente tant que la liste n'expose pas ce champ. - Mise en ligne (
PublishCard, ex-RebuildCard) : état de la dernière mise en ligne, bouton « Mettre le site en ligne » (désactivé et expliqué quand le socle n'est pas configuré), lien « Voir le site » quandGET /sitedonnesite_url. Masquée face à un socle sansGET /site/rebuild. - Raccourcis vers chaque écran (
ShortcutGrid).
- Votre site en quelques étapes : la checklist de
- Pages (
/pages,PagesList) : les pages rangées en arbre d'après leur adresse (association/les-membressousassociation, page réelle ou groupe virtuel en italique, repliable), l'accueil (home_slugdeGET /site) en tête avec son badge, le statut (Brouillon / Publié / Modifications non publiées d'aprèsdraftUpdatedAt), la date de modification et les actions Modifier, Réglages, Dupliquer (capabilities.duplicate,POST /pages/{id}/duplicate), Voir en ligne (site_url, page publiée seulement) et Supprimer (confirmation nommée, désactivée sur l'accueil).- Nouvelle page (
NewPageDialog) : choix d'un modèle (étape sautée sanscapabilities.templatesou sans modèle servi parGET /page-templates), titre, « Placer sous » (pages de premier niveau) et adresse dérivée du titre jusqu'à saisie manuelle (slugify,deriveSlug,validateSlug). La disponibilité passe parGET /pages?slug=après 300 ms, avec repli sur la liste chargée quand le socle ignore le filtre ;api,admin,_astro,_apercu,authetmembressont refusés d'office. Création parPOST /pages/from-templateavec la capacité, sinon par la ressource Hydra (status: draft,visibility: public). - Réglages de la page (
PageSettingsDialog, aussi exporté pour l'éditeur) : adresse (avertissement quand une page publiée en change), Référencement (Google) généré depuispage_fields.seodu contrat de sections (repli titre, description à compteur, image de partage), Accès (visibilité et rôles). N'écrit queslug,seo,visibilityetrolespar-dessus le record courant, débarrassé de ses clés de lecture seule (writablePage). - Ouvrir une page mène à l'éditeur (
/pages/:id/editer,/pages/:idy redirige parBuilderRedirect). Sanscapabilities.draft(socle plus ancien), la liste reste lisible mais rien ne s'ouvre : un bandeau demande la mise à jour du socle et la création est masquée.
- Nouvelle page (
- Composer une page (
/pages/:id/editer,PageBuilder, chargé à la demande parLazyPageBuilderavec@dnd-kit) : hors de la mise en page habituelle, trois volets sous une barre haute.- Barre haute : retour aux pages, titre modifiable en place, statut
(
PageStatusChip), indicateur d'enregistrement (aria-live), Annuler / Rétablir, Publier, menu « Plus d'actions » (Dupliquer la page aveccapabilities.duplicate, Réglages de la page —PageSettingsDialog—, Annuler les modifications, Voir en ligne, Supprimer la page), aide. - Sections (gauche) : la pile de sections, glissable (poignée, clavier) et
dotée d'un menu Monter / Descendre / Dupliquer / Masquer / Supprimer, puis la
liste des sections à ajouter (recherche sans accents, groupée par
categoriesdu contrat, clic ou glisser vers la pile). - Centre : l'aperçu en direct (
PreviewFrame) dès que le socle annoncecapabilities.previewet unepreview_url(GET /site). La page d'aperçu du site (/_apercu/, astro-kit ≥ 0.17) est chargée dans un cadresandboxet reçoit le brouillon parpostMessageaprès sa poignée de maincms:ready— envoyé à la seule origine depreview_url, accepté de la seule fenêtre du cadre, attachée dès la pose du cadre puisque le site répond avant sonload. Les rendus complets sont regroupés (une image puis 120 ms) ; un simple changement de sélection part seul, et un clic sur une section dans l'aperçu la sélectionne dans l'éditeur. Ordinateur (1280 px réduit à la colonne) ou mobile (390 px) depuis la barre haute ; « Aperçu » ouvre la même page dans une fenêtre à part, nourrie par le même pont. Sans réponse du site sous 6 s, ou face à un site trop ancien, les sections s'affichent en cartes cliquables avec « Réessayer ». Le site doit connaître l'origine de l'admin à son build (CMS_ADMIN_URL). Une coquille remplace le composant par la proppreviewdePageBuilder(BuilderPreviewProps). - Section sélectionnée (droite) : ses champs générés par
inputFor(type: list→ListInput, lignes de sous-champs), un onglet Style depuissettings_fieldsrestreint àschemas[].settings(image de fond visible seulement avec un fond « image »), masqué sanscapabilities.settings. Le formulaire ne se remonte que sur un changement de contenu extérieur à la saisie (chargement, Annuler, Rétablir) : jamais sur une frappe. - Brouillon : chaque modification est enregistrée après 1,5 s par
PUT /pages/{id}/draft(useAutosave: jamais deux envois en parallèle, envoi immédiat avant Publier, à la fermeture de l'onglet et quand il passe en arrière-plan). Lesissuesrenvoyées s'affichent sous les champs et en badge sur la section ; un 409 bloque l'éditeur derrière « Recharger ». - Publier : confirmation nommée, puis
POST /pages/{id}/publish; un 422 marque chaque section à corriger sans rien publier. - Raccourcis : Ctrl+Z / Ctrl+Y (ou Ctrl+Maj+Z), Suppr, Alt+↑ / Alt+↓, Échap ; inactifs dans un champ ou un dialogue. Historique plafonné à 100 étapes, les frappes rapprochées sur une même section comptant pour une.
- Sous 1100 px, les volets deviennent des tiroirs.
- Barre haute : retour aux pages, titre modifiable en place, statut
(
- Menus (
/menus,MenusPage, dès qu'un typemenuexiste dans le contrat) : « Menu principal » et « Menu du pied de page » en onglets, chaque menu un arbre de deux niveaux à glisser-déposer (@dnd-kit) ou à déplacer au clavier (Monter, Descendre, Placer sous l'entrée précédente, Remonter au niveau principal). Une entrée est une page du site (kind: page,page_id), un lien extérieur (kind: url, option « Ouvrir dans un nouvel onglet » →target) ou une ancre d'une section de l'accueil (kind: anchor, ancres lues danssettings.anchordeGET /pages/{id}/document). Les entrées{ label, url }déjà en place sont lues telles quelles ; celles dont l'adresse désigne une page (/,/contact/,/#equipe) sont reliées à cette page et un bandeau l'annonce jusqu'à l'enregistrement. À l'écriture,urlaccompagne toujourspage_idavec l'adresse résolue : un socle ou un site plus ancien continue de la lire, le socle qui résout parpage_idl'ignore. Libellé vide = titre de la page ; une page en brouillon porte un badge, une page disparue un avertissement. Enregistrer écrit la fichemenu(data.location,data.items) par la ressourcecontent_menu, ou la crée (menu-<emplacement>, publiée) ; le site la reprend à la prochaine mise en ligne. Sans typemenudans le contrat, l'écran demande la mise à jour du socle. - Aide contextuelle : un bouton « ? » dans la barre haute ouvre un panneau
(
HelpPanel) dont le titre et les phrases viennent deadmin.help.screens.<écran>.{title, body[]};helpKeyFor(pathname)associe la route courante à un écran (dashboard,pages,builder,menus,media,users,settings,content) et le bouton s'efface sur une route inconnue. Une coquille surcharge une fiche parmessages, phrases comprises. - Vocabulaire non technique dans toutes les chaînes du kit : « section »
plutôt que bloc, « adresse de la page » plutôt que slug, « mise en ligne » plutôt
que reconstruction.
tests/wording.test.tséchoue si un mot technique revient. - Menu par sections : Pages / Contenu / Site / Administration. Un singleton
et un type
category: sitevont dans Site,category: contentdans Contenu ; sans catégorie,menurejoint Site (vers/menus) et le reste Contenu. - Réglages (
/<type>pour chaque singleton, « Réglages du site » poursite) : un onglet par groupe du contrat, « Général » pour les champs sans groupe, création de la fiche à la première sauvegarde. - Textes d'interface (
ui: labels) : une ligne par clé avec sa description, le défaut en placeholder et les variables disponibles ; seules les surcharges non vides sont enregistrées. - Saisie libre à suggestions (
ui: suggest) : un champtextdont lesoptionsse proposent en liste déroulante sans fermer la saisie — toute autre valeur reste acceptée, et la valeur enregistrée revient à l'identique à la ré-ouverture. - Catalogue de rôles (
ui: roles) : liste{ slug, label, discord_role_id, admin, public }éditée en lignes ; l'identifiant se dérive du libellé tant qu'il n'a pas été saisi à la main, un doublon bloque l'enregistrement. « Administration » ouvre le back-office aux porteurs du rôle Discord et le referme à ceux qui le perdent ; « Annuaire public » en fait une section de la page des membres, dans l'ordre de la liste. - Lots d'un giveaway (
ui: prizes) : liste{ prize, winner }éditée en lignes ; un gagnant vide vaut « tirage pas encore fait ». - Classement d'une compétition (
ui: standings) : liste{ place, pseudo, team }éditée en lignes ; une place vide vaut « participant non classé », et un classement renseigné ouvre la fiche de la compétition sur le site. - Accès d'une page : section « Accès » des réglages de la page, visibilité
publicoumembers, et pour cette dernière les rôles autorisés choisis dans le catalogue de la fiche Site (vide : tout membre connecté). - Écran Utilisateurs : comptes liés (une puce par fournisseur — Discord,
Steam — le survol donnant pseudo, identifiant et, pour Steam, heures de jeu et
bannissement VAC), présence sur le serveur Discord, rôles attribués à la main
(envoyés en
PUT /users/{id}sousroles) et rôles synchronisés depuis Discord en lecture seule. Un compte sans identité se connecte par mot de passe. - Le toast « Contenu publié — le site sera reconstruit » ne promet une
reconstruction que si
GET /site/rebuildrépondconfigured: true.
Points d'extension
import { AdminKitApp, registerInput } from '@clauzier/admin-kit';
import { CustomRoutes, Resource } from 'react-admin';
import { Route } from 'react-router-dom';
import BarChartIcon from '@mui/icons-material/BarChart';
registerInput('geo', (field, source) => <CarteInput source={source} label={field.label} />);
<AdminKitApp
{...identite}
dashboard={MonAccueil}
menuExtras={[{ label: 'Statistiques', to: '/stats', icon: <BarChartIcon /> }]}
>
<Resource name="commandes" list={CommandesList} />
<CustomRoutes>
<Route path="/stats" element={<Stats />} />
</CustomRoutes>
</AdminKitApp>;
registerInput(type, factory): un éditeur dédié pour untypeou unuide champ, sans patcher le socle.useAdminKit():apiUrl,token(),siteClient,pagesClient,blockContract,menuExtras,mediaPagedepuis n'importe quel écran rendu sous<AdminKitApp>.useSiteInfo()(@clauzier/admin-kit/data) :{ siteUrl, previewUrl, homeSlug }lus dansGET /site(home_slugvautaccueilpar défaut).ConfirmNamedetEmptyState(@clauzier/admin-kit/help) : une confirmation qui injecte%{name}dans son titre et son contenu, un état vide avec action.useMemberRoles()(@clauzier/admin-kit/members) : le catalogue de rôles de la fiche Site, mis en cache par react-query.menuSections(schemas, options)est pur : une coquille qui remplace le menu ou l'accueil part des mêmes sections.@clauzier/admin-kit/builder:builderReduceret ses helpers purs (serializeDraft,sameDraft,errorsFromIssues,catalogOf,searchCatalog,settingsFieldsFor,shortcutFor),useAutosave,PageBuilderet ses volets (StructureList,SectionPalette,Inspector,StructureFallback,PreviewFrame,ViewportToggle),usePreviewBridgeetusePreviewWindowavec le protocole pur d'aperçu (encodePreview,parseSiteMessage,isTrustedSource,previewFrameUrl) pour une coquille qui recompose l'écran.pagesResource()(@clauzier/admin-kit/pages) : la ressourcepagescomplète (liste en arbre, ouverture dans l'éditeur).pageTree,flattenTree,toPageSummary,pageStatus,slugify,deriveSlug,validateSlugetpageEditPathsont purs et exportés.
Thème
Le socle ne fournit aucune palette par défaut : une valeur de repli ferait
hériter l'identité d'un client à tous les autres. createAdminTheme construit
le thème MUI et émet les mêmes valeurs en custom properties --admin-*, pour
que la feuille de style et les composants partent d'une source unique.
Les couleurs d'état sont optionnelles : accents: { success, warning, danger, info }
complète ou remplace celles que MUI fournit pour le mode (createTheme().palette),
et alimente aussi palette.success/warning/error/info du thème. Les tokens dérivés
n'ont aucune valeur littérale : --admin-selection (= --admin-primary),
--admin-drop-indicator, --admin-drag-ghost, --admin-focus-ring,
--admin-overlay, --admin-success/-warning/-danger/-info et leurs variantes
-soft, toutes par color-mix() sur les tokens de base.
tests/socle.test.ts échoue si une couleur littérale, un var(--x, repli) ou
le nom d'un client apparaît dans src/.
Traductions
L'interface est en français (ra-language-french + les chaînes du kit). Une
coquille surcharge n'importe quelle chaîne, y compris celles de react-admin,
en passant un arbre partiel :
<AdminKitApp messages={{ ra: { action: { create: 'Ajouter' } } }} … />
Saisir un site multilingue
L'interface reste en français : ce que les langues ajoutent, c'est de quoi
saisir chaque version du contenu. Rien de tout cela n'apparaît tant que le
socle ne déclare qu'une langue (locales absent ou à un seul élément dans les
contrats) : l'écran est celui d'avant, au pixel.
Le socle marque au schéma les champs traduisibles (translatable: true, sur
text, richtext et media seulement). Le kit en tire tout le reste :
- un onglet de langue par formulaire — fiche de contenu, réglages, réglages d'une page, menus — et un sélecteur dans le bandeau de l'éditeur de pages ;
- dans une langue traduite, la
sourcede l'entrée devientdata.translations.<langue>.<champ>, et rien n'y est obligatoire : un champ vide laisse voir la version d'origine ; - les champs communs à toutes les langues (liens, images, choix dans une liste) ne s'y affichent pas, et l'onglet Style de l'éditeur disparaît : les régler là laisserait croire qu'on en tient une version par langue ;
- une liste dont un sous-champ se traduit reste saisissable, mais sa structure se fige : les traductions vivent dans l'élément, l'ajout et le retrait se font dans la langue d'origine ;
- l'aperçu de l'éditeur pointe la page d'aperçu de la langue (
/en/_apercu/) ; - une pastille signale ce qui reste à traduire : par section dans l'éditeur, par page dans la liste.
Les textes d'interface du site (ui: labels) se saisissent langue par langue :
la valeur stockée devient {fr: {…}, en: {…}}, et une carte plate héritée est
lue comme celle de la langue d'origine puis migrée à la première saisie.
Contrat de schémas
Toutes les clés au-delà du socle publié sont optionnelles et lues défensivement : un type de champ inconnu se replie sur une saisie texte plutôt que de casser l'écran. Le kit reste donc utilisable face à une API plus ancienne que lui.
| Clé | Effet |
|---|---|
label_plural |
libellé du menu — un pluriel français ne se dérive pas en ajoutant un s |
singleton |
une seule fiche, slug forcé au type : écran de réglages au lieu d'une liste |
category |
content ou site : section du menu ; inconnu → null, repli sur le type |
groups |
[{ key, label }] ordonnés : un onglet par groupe dans l'écran de réglages |
help |
texte d'aide sous le champ |
ui |
force un éditeur dédié (menu, labels, suggest, roles, prizes, standings…) |
group |
groupe du champ ; absent ou inconnu → « Général » |
in_list |
affiche le champ en colonne de liste |
reference_type |
type cible d'un champ reference, alimente la liste déroulante |
options |
pour ui: labels, les clés de libellé dans l'ordre d'affichage ; pour color, les teintes proposées |
option_labels |
pour ui: labels, description d'une clé ; sans point, titre d'un espace de noms ; pour color, le nom d'une teinte |
defaults |
pour ui: labels, défaut de chaque clé, montré en placeholder |
translatable |
le champ se saisit dans chaque langue : onglets de langue et écriture sous translations |
defaults_locales |
pour ui: labels, défaut de chaque clé par langue |
locales |
langues du site, la première servie à la racine ; une seule ou aucune → aucun onglet de langue |
capabilities.search |
affiche la boîte de recherche ; sans le paramètre q côté API elle ne ferait rien |
capabilities.statuses |
statuts acceptés par le socle |
Contrat de sections (GET /block-schemas)
fetchBlockContract lit le contrat entier et le garde dans useAdminKit().blockContract ;
fetchBlockSchemas reste disponible et n'en rend que schemas. Face à un socle
qui ne sert que schemas, tout le reste est neutre : categories déduites des
blocs, settings_fields: [], page_fields: {} et chaque capacité à false.
| Clé | Effet |
|---|---|
schemas[].description, icon, category, default_data, settings, preview |
métadonnées d'une section, lues telles quelles |
schemas[].fields[].help, ui, group, default, reference_type, item_fields, min_items, max_items |
forme alignée sur les champs d'entité ; type: list connu |
categories |
[{ key, label }] pour grouper la palette de sections |
settings_fields |
réglages de style communs, même forme que fields |
page_fields.seo |
champs de référencement d'une page |
capabilities.checklist |
affiche la checklist du tableau de bord |
capabilities.draft |
ouvre les pages dans l'éditeur (sinon mise à jour demandée) |
capabilities.settings, duplicate, preview |
onglet Style, action Dupliquer, aperçu en direct |
capabilities.publish, list_fields, templates, menus |
lus et exposés ; pilotent les écrans des versions suivantes |
locales |
langues du site : sélecteur de langue de l'éditeur, adresse de l'aperçu, pastilles de traduction |
Endpoints consommés
| Route | Usage |
|---|---|
GET /content-schemas |
contrat des types de contenu |
GET /block-schemas |
contrat des blocs de page (absent → éditeur sans blocs) |
GET /auth/methods |
moyens de connexion à afficher ({ password, discord }) |
POST /login, POST /logout |
session Sanctum |
GET /auth/discord/redirect?target=admin&nonce=… |
connexion Discord du back-office (retour sur /?code&nonce) |
POST /auth/exchange |
échange du code de retour Discord contre le jeton |
/content_entities, /pages, /blocks, /media |
ressources API Platform (Hydra) |
POST /media, DELETE /media/{id} |
téléversement avec progression, suppression |
GET /site |
catalogue de rôles (data.member_roles), site_url, preview_url, home_slug |
GET /site/checklist |
étapes du tableau de bord (404 → carte masquée) |
GET /site/rebuild |
état de la mise en ligne (404 → carte masquée) |
POST /site/rebuild |
demande de mise en ligne (409 si non configurée) |
GET /pages/{id}/document, PUT /pages/{id}/draft |
document et brouillon d'une page (pagesClient) |
POST /pages/{id}/publish, /discard, /unpublish, /duplicate |
actions de page (pagesClient) |
POST /pages/from-template, GET /page-templates |
création guidée (404 sur les modèles → étape sautée, création par la ressource) |
GET /pages?slug=… |
disponibilité d'une adresse (null si le filtre est ignoré) |
GET/POST /users, PUT/DELETE /users/{id} |
écran Utilisateurs |
Développement
bun install
bun run lint && bun run format:check && bun run typecheck && bun run test
Dependencies
Dependencies
| ID | Version |
|---|---|
| @dnd-kit/core | ^6.3 |
| @dnd-kit/sortable | ^10 |
| @dnd-kit/utilities | ^3.2 |
| ra-i18n-polyglot | ^5.15.0 |
| ra-input-rich-text | ^5.15.1 |
| ra-language-french | ^5.15.0 |
Development dependencies
| ID | Version |
|---|---|
| @eslint/js | 10.0.1 |
| @testing-library/react | 16.3.3 |
| @types/node | 26.4.1 |
| @types/react | 19.2.18 |
| @types/react-dom | 19.2.7 |
| eslint | 10.10.0 |
| eslint-plugin-react-hooks | 7.1.1 |
| jsdom | 30.0.1 |
| prettier | 3.9.6 |
| typescript | 6.0.3 |
| typescript-eslint | 8.69.0 |
| vitest | 5.0.0 |
Peer dependencies
| ID | Version |
|---|---|
| @api-platform/admin | ^4.0.0 |
| @mui/icons-material | ^9.0.0 |
| @mui/material | ^9.0.0 |
| @tanstack/react-query | ^5.83.0 |
| react | ^19.0.0 |
| react-admin | ^5.15.0 |
| react-dom | ^19.0.0 |
| react-router-dom | ^7.0.0 |