@lystech/sections 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,55 +1,109 @@
1
- # @lystech/sections
2
-
3
- Le contenu d'un site, modifiable par son propriétaire — sans reconstruction ni
4
- déploiement.
5
-
6
- ## Pourquoi un paquet à part de `@lystech/core`
7
-
8
- `core` exige React 18 et embarque antd, Mantine et React Query : 2,4 Mo de
9
- JavaScript et 1,2 Mo de CSS. Une clinique qui veut changer trois textes n'a
10
- aucune raison de charger un magasin, un panier et PayPal — et beaucoup de sites
11
- clients sont en React 19.
12
-
13
- Ce paquet pèse **2,9 Ko** et ne dépend que de React. `core` le réexporte, pour
14
- qu'une boutique qui l'a déjà n'ait rien de plus à installer.
15
-
16
- ## La plateforme fixe l'alphabet, pas les mots
17
-
18
- Le serveur ne connaît aucun type de bloc. « plat-du-jour », « praticien »,
19
- « tarifs » appartiennent au site, qui les déclare. Ce que la plateforme
20
- connaît, ce sont les types de champs — `text`, `richText`, `image`, `number`,
21
- `boolean`, `url`, `date`, `choice`, `list` — vrais dans tous les métiers, et
22
- suffisants pour fabriquer un formulaire pour un bloc jamais vu.
23
-
24
- ## Usage
25
-
26
- ```tsx
27
- import { SectionsProvider, useSection } from "@lystech/sections";
28
-
29
- <SectionsProvider site="armoise" page="massage" langue={i18n.language}>
30
- <MaPage />
31
- </SectionsProvider>;
32
-
33
- // Dans un composant déjà écrit :
34
- const { contenu: tarifs } = useSection("tarifs-massotherapie", {
35
- defaut: SITE.tarifs.massotherapie, // ce que le site affichait déjà
36
- });
37
- ```
38
-
39
- ### Le repli n'est pas une option
40
-
41
- `defaut` porte les valeurs actuelles du site. Tant que rien n'est publié — ou
42
- si l'API ne répond pas — ce sont elles qui s'affichent. Brancher un site
43
- existant ne peut donc rien casser : au pire il continue d'afficher ce qu'il
44
- affichait hier.
45
-
46
- ### Publié ≠ brouillon
47
-
48
- Le site public ne lit QUE le contenu publié. Un brouillon enregistré ne change
49
- rien pour les visiteurs tant que personne n'a cliqué « Publier ».
50
-
51
- ## Champs traduits
52
-
53
- Un champ déclaré `localized: true` stocke `{ fr: …, en: … }` ; le paquet rend
54
- la langue courante, avec repli sur une autre langue plutôt que sur du vide —
55
- une version anglaise oubliée vaut mieux affichée en français qu'absente.
1
+ # @lystech/sections
2
+
3
+ Le contenu d'un site, modifiable par son propriétaire — sans reconstruction ni
4
+ déploiement.
5
+
6
+ ## Pourquoi un paquet à part de `@lystech/core`
7
+
8
+ `core` exige React 18 et embarque antd, Mantine et React Query : 2,4 Mo de
9
+ JavaScript et 1,2 Mo de CSS. Une clinique qui veut changer trois textes n'a
10
+ aucune raison de charger un magasin, un panier et PayPal — et beaucoup de sites
11
+ clients sont en React 19.
12
+
13
+ Ce paquet pèse **4,7 Ko** en lecture (1,9 Ko compressé) et ne dépend que de
14
+ React. `core` le réexporte, pour qu'une boutique qui l'a déjà n'ait rien de
15
+ plus à installer.
16
+
17
+ ## La plateforme fixe l'alphabet, pas les mots
18
+
19
+ Le serveur ne connaît aucun type de bloc. « plat-du-jour », « praticien »,
20
+ « tarifs » appartiennent au site, qui les déclare. Ce que la plateforme
21
+ connaît, ce sont les types de champs — `text`, `richText`, `image`, `number`,
22
+ `boolean`, `url`, `date`, `choice`, `list` — vrais dans tous les métiers, et
23
+ suffisants pour fabriquer un formulaire pour un bloc jamais vu.
24
+
25
+ ## Usage
26
+
27
+ ```tsx
28
+ import { SectionsProvider, useSection } from "@lystech/sections";
29
+
30
+ <SectionsProvider site="armoise" page="massage" langue={i18n.language}>
31
+ <MaPage />
32
+ </SectionsProvider>;
33
+
34
+ // Dans un composant déjà écrit :
35
+ const { contenu: tarifs } = useSection("tarifs-massotherapie", {
36
+ defaut: SITE.tarifs.massotherapie, // ce que le site affichait déjà
37
+ });
38
+ ```
39
+
40
+ ### Le repli n'est pas une option
41
+
42
+ `defaut` porte les valeurs actuelles du site. Tant que rien n'est publié — ou
43
+ si l'API ne répond pas — ce sont elles qui s'affichent. Brancher un site
44
+ existant ne peut donc rien casser : au pire il continue d'afficher ce qu'il
45
+ affichait hier.
46
+
47
+ ### Publié ≠ brouillon
48
+
49
+ Le site public ne lit QUE le contenu publié. Un brouillon enregistré ne change
50
+ rien pour les visiteurs tant que personne n'a cliqué « Publier ».
51
+
52
+ ## Champs traduits
53
+
54
+ Un champ déclaré `localized: true` stocke `{ fr: …, en: … }` ; le paquet rend
55
+ la langue courante, avec repli sur une autre langue plutôt que sur du vide —
56
+ une version anglaise oubliée vaut mieux affichée en français qu'absente.
57
+
58
+ ## L'éditeur en place
59
+
60
+ La personne qui édite ouvre SON site avec un jeton dans l'adresse. Le paquet le
61
+ détecte, le retire aussitôt de la barre d'adresse, et charge l'interface
62
+ d'édition — 22 Ko qu'aucun visiteur ne télécharge jamais, parce que le
63
+ `import()` qui y mène n'est atteint que s'il y a un jeton.
64
+
65
+ Elle voit alors son site, ses polices, sa mise en page, avec chaque bloc
66
+ modifiable encadré. Ce qu'elle tape apparaît dedans à mesure qu'elle le tape.
67
+
68
+ ### Marquer les blocs dans la page
69
+
70
+ L'éditeur doit savoir quel rectangle de la page correspond à quel bloc. Aucun
71
+ moyen fiable de le deviner : le site le dit.
72
+
73
+ ```tsx
74
+ const { contenu, ancrage } = useSection("tarifs", { defaut });
75
+ return <section {...ancrage}>…</section>;
76
+ ```
77
+
78
+ Hors édition, c'est un attribut `data-` inerte : aucun poids, aucun effet.
79
+
80
+ ### Trois états, jamais confondus
81
+
82
+ | | Qui le voit |
83
+ |---|---|
84
+ | **Aperçu** | la personne qui édite, dans son navigateur |
85
+ | **Brouillon** | elle seule, enregistré sur le serveur |
86
+ | **Publié** | les visiteurs |
87
+
88
+ ### Le jeton
89
+
90
+ Délivré par `POST /site-sections/edit-token` depuis la console Lystech. Il vaut
91
+ quelques heures, pour UNE organisation, et il est **plafonné à l'éditeur** même
92
+ si la personne est administratrice : un lien qui fuite ne peut pas ouvrir la
93
+ console. Le droit est relu dans les rôles à chaque requête, donc retirer un
94
+ accès ferme les jetons déjà émis sur-le-champ.
95
+
96
+ ## Sites prérendus
97
+
98
+ Un site généré au build affiche les replis du code dans son HTML, puis bascule
99
+ sur le contenu publié une fois le JavaScript chargé — l'ancien prix clignote
100
+ vers le nouveau, et les moteurs de recherche n'indexent que l'ancien.
101
+
102
+ Passez le contenu au premier rendu pour supprimer les deux problèmes :
103
+
104
+ ```tsx
105
+ <SectionsProvider site="armoise" page="massage" sectionsInitiales={dejaLu}>
106
+ ```
107
+
108
+ où `dejaLu` vient d'un appel, dans le script de prérendu, à
109
+ `GET /site-sections/public/<site>/<page>`.
@@ -0,0 +1,56 @@
1
+ import { ReactNode } from 'react';
2
+ import { SectionPubliee } from './types';
3
+ type Etat = "chargement" | "pret" | "erreur";
4
+ type Contexte = {
5
+ /** Sous-domaine de l'organisation dans Lystech. */
6
+ site: string;
7
+ /** La page courante — l'éditeur en a besoin pour créer une section. */
8
+ page: string;
9
+ /** Langue courante — décide quelle version d'un champ traduit est rendue. */
10
+ langue: string;
11
+ apiBaseUrl: string;
12
+ sections: SectionPubliee[];
13
+ etat: Etat;
14
+ recharger: () => void;
15
+ /**
16
+ * L'APERÇU : ce que l'éditeur est en train d'écrire, avant publication.
17
+ *
18
+ * Il prime sur le contenu publié, par type de bloc. C'est ce qui rend
19
+ * l'édition « en place » : la cliente voit ses mots dans SA mise en page,
20
+ * avec SES polices et SES couleurs, au lieu d'un formulaire posé à côté
21
+ * d'un aperçu qui ne lui ressemble jamais tout à fait.
22
+ */
23
+ apercu: Record<string, Record<string, unknown> | null>;
24
+ poserApercu: (blockType: string, data: Record<string, unknown> | null) => void;
25
+ /** Vrai quand un jeton d'édition est actif. Faux pour tout visiteur. */
26
+ modeEdition: boolean;
27
+ };
28
+ export declare function SectionsProvider({ site, page, langue, apiBaseUrl, sectionsInitiales, children, }: {
29
+ /** Sous-domaine de l'organisation (ex. « armoise »). */
30
+ site: string;
31
+ /** La page dont on veut les sections (ex. « accueil »). */
32
+ page: string;
33
+ langue?: string | undefined;
34
+ apiBaseUrl?: string | undefined;
35
+ /**
36
+ * LE CONTENU DÉJÀ CONNU AU PREMIER RENDU — pour un site prérendu.
37
+ *
38
+ * Sans lui, une page générée au build affiche les REPLIS du code, puis
39
+ * bascule sur le contenu publié une fois le JavaScript chargé. Le visiteur
40
+ * voit l'ancien prix clignoter vers le nouveau, et les moteurs de recherche
41
+ * n'indexent jamais que l'ancien. Un script de prérendu qui lit l'API et
42
+ * passe le résultat ici supprime les deux problèmes.
43
+ */
44
+ sectionsInitiales?: SectionPubliee[] | undefined;
45
+ children: ReactNode;
46
+ }): import("react").JSX.Element;
47
+ /**
48
+ * Le contexte, ou `null` HORS d'un fournisseur.
49
+ *
50
+ * On ne lève pas : un composant branché sur les sections doit rester
51
+ * utilisable dans une page qui n'en a pas (une maquette, un test, une page
52
+ * d'erreur). Il affichera son repli, ce qui est exactement le bon
53
+ * comportement.
54
+ */
55
+ export declare function useSectionsContext(): Contexte | null;
56
+ export {};
@@ -0,0 +1,9 @@
1
+ export declare function SignatureLystech({ site, apiBaseUrl, className, mention, connexion, }: {
2
+ /** Sous-domaine de l'organisation. Pris du fournisseur si omis. */
3
+ site?: string | undefined;
4
+ apiBaseUrl?: string | undefined;
5
+ className?: string | undefined;
6
+ /** Les mots de la mention, si le site en préfère d'autres. */
7
+ mention?: string | undefined;
8
+ connexion?: string | undefined;
9
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,6 @@
1
+ import { Api } from './api';
2
+ import { PontEdition } from './pont';
3
+ export declare function Editeur({ api, pont }: {
4
+ api: Api;
5
+ pont: PontEdition;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ import { BlocDeclare } from '../types';
2
+ /** Une section telle que l'éditeur la voit — brouillon compris. */
3
+ export type SectionEditeur = {
4
+ UID: string;
5
+ page: string;
6
+ blockType: string;
7
+ position: number;
8
+ enabled: boolean;
9
+ data: Record<string, unknown> | null;
10
+ draft: Record<string, unknown> | null;
11
+ publishedOn: string | null;
12
+ };
13
+ export type Api = ReturnType<typeof creerApi>;
14
+ export declare function creerApi({ jeton, site, apiBaseUrl, surJetonRefuse, }: {
15
+ jeton: string;
16
+ site: string;
17
+ apiBaseUrl: string;
18
+ /**
19
+ * Appele quand le serveur refuse le jeton.
20
+ *
21
+ * UN JETON EXPIRE NE DOIT PAS LAISSER UNE BARRE D'EDITION A L'ECRAN. Sans
22
+ * ce rappel, la personne verrait « Mode edition », ouvrirait un panneau, y
23
+ * ecrirait, cliquerait « Publier » — et n'obtiendrait qu'une erreur, sans
24
+ * comprendre qu'il suffisait de se reconnecter.
25
+ */
26
+ surJetonRefuse: () => void;
27
+ }): {
28
+ /** Les blocs que le site a déclaré savoir afficher. Route publique. */
29
+ schemas: () => Promise<BlocDeclare[]>;
30
+ /** Ce qui existe sur une page, brouillons compris. */
31
+ pourPage: (page: string) => Promise<SectionEditeur[]>;
32
+ creer: (page: string, blockType: string, draft: Record<string, unknown>) => Promise<{
33
+ UID: string;
34
+ }>;
35
+ enregistrerBrouillon: (UID: string, draft: Record<string, unknown>) => Promise<any>;
36
+ publier: (UID: string) => Promise<any>;
37
+ /** Ramène le brouillon à ce qui est en ligne. */
38
+ annuler: (UID: string) => Promise<any>;
39
+ };
@@ -0,0 +1,9 @@
1
+ import { ChampDeclare } from '../types';
2
+ type Props = {
3
+ champ: ChampDeclare;
4
+ valeur: unknown;
5
+ changer: (v: unknown) => void;
6
+ langue: string;
7
+ };
8
+ export declare function Champ({ champ, valeur, changer, langue }: Props): import("react").JSX.Element;
9
+ export {};
@@ -0,0 +1,8 @@
1
+ import { PontEdition } from './pont';
2
+ export type { PontEdition } from './pont';
3
+ export declare function monterEditeur({ jeton, site, apiBaseUrl, pont, }: {
4
+ jeton: string;
5
+ site: string;
6
+ apiBaseUrl: string;
7
+ pont: PontEdition;
8
+ }): () => void;
@@ -0,0 +1,19 @@
1
+ export type PontEdition = {
2
+ /** Sous-domaine de l'organisation — affiché, jamais envoyé. */
3
+ site: string;
4
+ /** La page courante. Mise à jour par le fournisseur. */
5
+ page: string;
6
+ /** La langue affichée par le site en ce moment. */
7
+ langue: string;
8
+ /** Afficher un contenu non publié dans la page, ou `null` pour l'enlever. */
9
+ poserApercu: (blockType: string, data: Record<string, unknown> | null) => void;
10
+ /** Relire le contenu publié depuis le serveur. */
11
+ recharger: () => void;
12
+ /**
13
+ * Posé par l'ÉDITEUR, appelé par le FOURNISSEUR quand la page change.
14
+ *
15
+ * Sans cet avis, un site à page unique qui change d'adresse sans rien
16
+ * recharger laisserait l'éditeur sur les sections de la page précédente.
17
+ */
18
+ surChangementDePage?: (page: string) => void;
19
+ };
@@ -0,0 +1 @@
1
+ export declare const CSS = "\n:host {\n all: initial;\n --fond: #16181d;\n --fond-clair: #21242b;\n --bord: #333844;\n --texte: #f2f3f5;\n --texte-doux: #a3a9b6;\n --accent: #3ba58d;\n --accent-sombre: #2d8570;\n --danger: #d9776a;\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Arial, sans-serif;\n font-size: 14px;\n line-height: 1.45;\n color: var(--texte);\n}\n\n* { box-sizing: border-box; }\n\n/* LE CALQUE LAISSE PASSER LES CLICS. Un conteneur fixe en plein \u00E9cran qui\n intercepte tout rendrait le site inutilisable derri\u00E8re lui : on ne pourrait\n plus naviguer, ni m\u00EAme faire d\u00E9filer correctement. Seuls ses ENFANTS\n reprennent les clics. */\n.calque {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 2147483000;\n}\n.calque > * { pointer-events: auto; }\n\n/* ---------------------------------------------------------- CADRES ------ */\n\n.cadre {\n position: fixed;\n border: 2px dashed rgba(59, 165, 141, 0.55);\n border-radius: 6px;\n pointer-events: none;\n transition: border-color 120ms ease;\n}\n.cadre--actif { border-color: var(--accent); border-style: solid; }\n\n.pastille {\n position: fixed;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 5px 10px;\n border: 0;\n border-radius: 999px;\n background: var(--accent);\n color: #06120e;\n font-size: 12px;\n font-weight: 650;\n letter-spacing: 0.01em;\n cursor: pointer;\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);\n}\n.pastille:hover { background: #45bda2; }\n.pastille:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }\n\n/* ------------------------------------------------------------ BARRE ----- */\n\n.barre {\n position: fixed;\n left: 50%;\n bottom: 18px;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n gap: 14px;\n padding: 9px 14px;\n border: 1px solid var(--bord);\n border-radius: 999px;\n background: var(--fond);\n box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);\n max-width: calc(100vw - 24px);\n}\n.barre__point {\n width: 8px; height: 8px; border-radius: 50%;\n background: var(--accent);\n flex: none;\n}\n.barre__texte { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.barre__texte b { font-weight: 650; }\n.barre__compte { color: var(--texte-doux); font-size: 12px; white-space: nowrap; }\n\n/* ----------------------------------------------------------- PANNEAU ---- */\n\n.panneau {\n position: fixed;\n top: 0; right: 0; bottom: 0;\n width: min(420px, 100vw);\n display: flex;\n flex-direction: column;\n background: var(--fond);\n border-left: 1px solid var(--bord);\n box-shadow: -12px 0 40px rgba(0, 0, 0, 0.4);\n}\n@media (max-width: 520px) {\n .panneau { top: auto; height: 78vh; border-left: 0; border-top: 1px solid var(--bord); }\n}\n\n.panneau__tete {\n display: flex; align-items: flex-start; gap: 10px;\n padding: 16px 16px 12px;\n border-bottom: 1px solid var(--bord);\n}\n.panneau__titre { font-size: 15px; font-weight: 650; margin: 0; }\n.panneau__sous { margin: 3px 0 0; font-size: 12px; color: var(--texte-doux); }\n.panneau__corps { flex: 1; overflow-y: auto; padding: 14px 16px 18px; }\n.panneau__pied {\n display: flex; gap: 8px; align-items: center;\n padding: 12px 16px;\n border-top: 1px solid var(--bord);\n background: var(--fond-clair);\n}\n.panneau__pied .espace { flex: 1; }\n\n/* ------------------------------------------------------------ CHAMPS ---- */\n\n.champ { margin-bottom: 16px; }\n.champ__etiquette {\n display: block; margin-bottom: 5px;\n font-size: 12px; font-weight: 600; color: var(--texte);\n}\n.champ__aide { margin: 4px 0 0; font-size: 11.5px; color: var(--texte-doux); }\n.champ__langue {\n display: inline-block; margin: 0 0 4px;\n font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;\n text-transform: uppercase; color: var(--texte-doux);\n}\n\ninput[type=\"text\"], input[type=\"url\"], input[type=\"number\"], input[type=\"date\"],\nselect, textarea {\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--bord);\n border-radius: 6px;\n background: var(--fond-clair);\n color: var(--texte);\n font: inherit;\n font-size: 13px;\n}\ntextarea { min-height: 92px; resize: vertical; line-height: 1.5; }\ninput:focus, select:focus, textarea:focus {\n outline: none; border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(59, 165, 141, 0.18);\n}\n\n.bascule { display: flex; align-items: center; gap: 8px; }\n.bascule input { width: 16px; height: 16px; accent-color: var(--accent); }\n\n.vignette {\n margin-top: 8px; max-height: 110px; border-radius: 6px;\n border: 1px solid var(--bord); display: block;\n}\n\n/* --------------------------------------------------------- LISTES ------- */\n\n.liste__item {\n border: 1px solid var(--bord); border-radius: 8px;\n padding: 12px; margin-bottom: 10px; background: var(--fond-clair);\n}\n.liste__tete {\n display: flex; align-items: center; justify-content: space-between;\n margin-bottom: 8px;\n}\n.liste__rang { font-size: 11px; font-weight: 700; color: var(--texte-doux); }\n\n/* --------------------------------------------------------- BOUTONS ------ */\n\n.bouton {\n display: inline-flex; align-items: center; justify-content: center; gap: 6px;\n padding: 8px 14px; border-radius: 7px; border: 1px solid transparent;\n font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;\n background: var(--fond-clair); color: var(--texte); border-color: var(--bord);\n}\n.bouton:hover { background: #2a2e37; }\n.bouton:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.bouton--principal { background: var(--accent); border-color: var(--accent); color: #06120e; }\n.bouton--principal:hover { background: #45bda2; }\n.bouton--discret { background: transparent; border-color: transparent; color: var(--texte-doux); }\n.bouton--discret:hover { background: var(--fond-clair); color: var(--texte); }\n.bouton[disabled] { opacity: 0.5; cursor: default; }\n.bouton--icone { padding: 6px 9px; }\n\n.message {\n margin: 0 0 12px; padding: 9px 11px; border-radius: 7px;\n font-size: 12.5px; border: 1px solid;\n}\n.message--erreur { background: rgba(217, 119, 106, 0.12); border-color: rgba(217, 119, 106, 0.4); color: #f0b3a9; }\n.message--ok { background: rgba(59, 165, 141, 0.12); border-color: rgba(59, 165, 141, 0.4); color: #8fd9c6; }\n.message--info { background: var(--fond-clair); border-color: var(--bord); color: var(--texte-doux); }\n\n@media (prefers-reduced-motion: reduce) {\n * { transition: none !important; animation: none !important; }\n}\n";