feat(media): extract media details into reusable modal component
- add `MediaDetailsModal.client.js` with support for `media={…}` or `slug="…"` props
- add `GET /zen/api/media/by-slug/:slug` route for slug-based lookup
- refactor `MediaPage.client.js` to use the new modal instead of inline details panel
- export `MediaDetailsModal` as `./features/media/details-modal` in package.json
- update `BlockEditor` image block to open `MediaDetailsModal` for media editing
- update media feature README to document new component and route
This commit is contained in:
@@ -300,14 +300,33 @@ Le formulaire d'insertion accepte deux sources :
|
||||
### Liaison avec la médiathèque (`mediaSlug`)
|
||||
|
||||
Quand un bloc image est lié à un média (`mediaSlug` présent), le **média est
|
||||
la source unique de vérité** pour `alt` et `caption`. Le bloc ne stocke ni l'un
|
||||
ni l'autre — l'éditeur affiche les valeurs du média en lecture seule avec un
|
||||
lien « Modifier dans la médiathèque ». Toute mise à jour de l'alt ou de la
|
||||
légende côté médiathèque est immédiatement répercutée sur tous les blocs qui
|
||||
référencent ce média, sans toucher aux contenus.
|
||||
la source unique de vérité** pour `alt` et `caption`. Le bloc ne stocke ni
|
||||
l'un ni l'autre. Toute mise à jour côté médiathèque est immédiatement
|
||||
répercutée sur tous les blocs qui référencent ce média, sans toucher aux
|
||||
contenus.
|
||||
|
||||
Pour les **URL externes** (pas de `mediaSlug`), les inputs alt/caption locaux
|
||||
restent disponibles sur le bloc — il n'y a pas d'autre source possible.
|
||||
Pour les **URL externes** (pas de `mediaSlug`), `alt` et `caption` vivent
|
||||
sur le bloc — il n'y a pas d'autre source possible.
|
||||
|
||||
### Affichage et édition des métadonnées
|
||||
|
||||
Sous l'image, on n'affiche **que la légende**, et **uniquement si elle
|
||||
existe**. Pas de placeholder « Aucune légende », pas de bandeau gris, pas
|
||||
d'alt visible (l'alt vit sur l'attribut `<img alt>`). L'objectif est que le
|
||||
rendu en édition reflète exactement ce que verra le lecteur final.
|
||||
|
||||
L'édition des métadonnées passe par un **bouton dédié** dans la toolbar
|
||||
flottante de l'image (icône `Settings02Icon`) :
|
||||
|
||||
- Image **liée à la médiathèque** → ouvre `MediaDetailsModal`, le **même
|
||||
composant** que celui de la page admin Médias (visibilité, alt, caption,
|
||||
URL, suppression). Toute édition met à jour `zen_media` directement.
|
||||
- Image **URL externe** → ouvre une modale légère avec deux champs (alt,
|
||||
caption) qui patchent le bloc local.
|
||||
|
||||
`MediaDetailsModal` est exposé en entry public `@zen/core/features/media/details-modal`
|
||||
et accepte soit `media={media}` (objet déjà chargé), soit `slug="…"` (le bloc
|
||||
image l'utilise — résolution via `GET /zen/api/media/by-slug/:slug`).
|
||||
|
||||
Champs internes au composant (préfixe `_`, jamais persistés) :
|
||||
|
||||
@@ -373,18 +392,14 @@ Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
|
||||
jamais wrappée dans `<a>` pour ne pas piéger les clics ; en mode `disabled`
|
||||
et à l'export HTML (`blocksToHtml`), l'image est wrappée dans `<a>` avec
|
||||
`target="_blank" rel="noopener noreferrer"` quand `newTab` est vrai.
|
||||
- **Métadonnées** (`Settings02Icon`) : ouvre la modale d'édition alt/caption.
|
||||
Pour une image liée à la médiathèque, c'est `MediaDetailsModal` (édition
|
||||
directe du média en BD). Pour une URL externe, c'est une modale légère qui
|
||||
patche `block.alt` / `block.caption`.
|
||||
- **Remplacer** : remet le formulaire URL avec l'URL courante préremplie.
|
||||
Conserve `alt`, `caption`, `align`, `href`. Échap pour annuler.
|
||||
Conserve `mediaSlug`, `align`, `href`. Échap pour annuler.
|
||||
- **Supprimer** : vide tous les champs ; le formulaire URL réapparaît.
|
||||
|
||||
Les inputs *Légende* et *Texte alternatif* sous l'image sont des `<input>`
|
||||
HTML standards (uniquement pour les images sans `mediaSlug` — cf.
|
||||
[Liaison avec la médiathèque](#liaison-avec-la-médiathèque-mediaslug)).
|
||||
Le clic dans ces inputs ne déclenche pas la sélection multi-blocs (cf. garde
|
||||
dans `handleContainerMouseDown` : tout `input`, `textarea`, `select` ou
|
||||
`[contenteditable="true"]` reçoit son focus normal et n'entre pas en mode
|
||||
sélection).
|
||||
|
||||
Sérialisation HTML : `<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>`.
|
||||
Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `<a>`
|
||||
quand l'`<img>` y est imbriqué pour récupérer `href` / `target`. Si le `src`
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
Image01Icon,
|
||||
Link02Icon,
|
||||
PencilEdit01Icon,
|
||||
Settings02Icon,
|
||||
} from '@zen/core/shared/icons';
|
||||
import { Modal, Input, Textarea, Button } from '@zen/core/shared/components';
|
||||
import {
|
||||
BOX_CLASS,
|
||||
ICON_BTN_ACTIVE_CLASS,
|
||||
@@ -18,9 +20,11 @@ import {
|
||||
SEPARATOR_VERTICAL_CLASS,
|
||||
} from '../inline/menuStyles.js';
|
||||
import { newBlockId } from '../utils/ids.js';
|
||||
// Import relatif pour éviter une dépendance circulaire avec @zen/core (le bloc
|
||||
// image vit dans `shared` et tire MediaPicker depuis `features/media`).
|
||||
// Imports relatifs pour éviter une dépendance circulaire avec @zen/core
|
||||
// (le bloc image vit dans `shared` et tire ses composants media depuis
|
||||
// `features/media`).
|
||||
import MediaPicker from '../../../../features/media/components/MediaPicker.client.js';
|
||||
import MediaDetailsModal from '../../../../features/media/components/MediaDetailsModal.client.js';
|
||||
|
||||
// Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image
|
||||
// rendue + toolbar flottante (alignement, lien, remplacer, supprimer).
|
||||
@@ -246,7 +250,7 @@ function LinkSubmenu({ initialHref, initialNewTab, onApply, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ImageToolbar({ block, onPatch, onReplace, onRemove }) {
|
||||
function ImageToolbar({ block, onPatch, onReplace, onEditMetadata, onRemove }) {
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const align = getAlign(block);
|
||||
|
||||
@@ -284,6 +288,14 @@ function ImageToolbar({ block, onPatch, onReplace, onRemove }) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Modifier les métadonnées (alt, légende)"
|
||||
onClick={onEditMetadata}
|
||||
className={ICON_BTN_CLASS}
|
||||
>
|
||||
<Settings02Icon width={16} height={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Remplacer l'image"
|
||||
@@ -304,11 +316,64 @@ function ImageToolbar({ block, onPatch, onReplace, onRemove }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Modale d'édition des métadonnées d'une image **sans** mediaSlug (URL
|
||||
// externe). Pour les images liées à la médiathèque, on ouvre directement
|
||||
// `MediaDetailsModal` qui édite le média en BD.
|
||||
function ExternalImageMetadataModal({ isOpen, onClose, alt, caption, onSave }) {
|
||||
const [draftAlt, setDraftAlt] = useState(alt ?? '');
|
||||
const [draftCaption, setDraftCaption] = useState(caption ?? '');
|
||||
|
||||
// Synchronise les drafts à chaque ouverture.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setDraftAlt(alt ?? '');
|
||||
setDraftCaption(caption ?? '');
|
||||
}, [isOpen, alt, caption]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Métadonnées de l'image"
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Annuler</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => { onSave({ alt: draftAlt, caption: draftCaption }); onClose(); }}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label="Texte alternatif"
|
||||
value={draftAlt}
|
||||
onChange={setDraftAlt}
|
||||
description="Décrit l'image pour les lecteurs d'écran et le SEO."
|
||||
/>
|
||||
<Textarea
|
||||
label="Légende"
|
||||
value={draftCaption}
|
||||
onChange={setDraftCaption}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageBlock({ block, onChange, disabled }) {
|
||||
// `replacing` permet de revenir au formulaire URL sans perdre alt/caption/
|
||||
// align/href. Tant qu'il est `true`, on rend `ImageUrlForm` même si
|
||||
// `block.src` est non vide.
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [metadataOpen, setMetadataOpen] = useState(false);
|
||||
const showForm = !block.src || replacing;
|
||||
const linkedSlug = block.mediaSlug || extractMediaSlug(block.src);
|
||||
|
||||
@@ -337,14 +402,16 @@ function ImageBlock({ block, onChange, disabled }) {
|
||||
setReplacing(false);
|
||||
}
|
||||
|
||||
function handleAltChange(e) {
|
||||
// Sans mediaSlug uniquement : si un média est lié, l'alt vit côté
|
||||
// médiathèque (édition impossible depuis le bloc).
|
||||
onChange?.({ alt: e.target.value });
|
||||
}
|
||||
|
||||
function handleCaptionChange(e) {
|
||||
onChange?.({ caption: e.target.value });
|
||||
// Mise à jour du snapshot après édition du média via MediaDetailsModal :
|
||||
// on rafraîchit `_mediaAlt`/`_mediaCaption` pour que la légende affichée
|
||||
// sous l'image reflète immédiatement les changements (sans attendre un
|
||||
// re-render serveur enrichi).
|
||||
function handleMediaUpdated(updatedMedia) {
|
||||
if (!updatedMedia) return;
|
||||
onChange?.({
|
||||
_mediaAlt: updatedMedia.alt_text ?? '',
|
||||
_mediaCaption: updatedMedia.caption ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
@@ -421,66 +488,45 @@ function ImageBlock({ block, onChange, disabled }) {
|
||||
block={block}
|
||||
onPatch={(patch) => onChange?.(patch)}
|
||||
onReplace={() => setReplacing(true)}
|
||||
onEditMetadata={() => setMetadataOpen(true)}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{linkedSlug ? (
|
||||
// Image liée à un média : alt et caption viennent du média. Pas
|
||||
// d'inputs locaux. En lecture seule on affiche juste la légende.
|
||||
// En édition on montre un bandeau avec un lien vers la médiathèque.
|
||||
disabled ? (
|
||||
captionForRender ? (
|
||||
<div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400">
|
||||
{captionForRender}
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 px-1 py-1 rounded border border-dashed border-neutral-200 dark:border-neutral-700/60 bg-neutral-50/50 dark:bg-neutral-800/30">
|
||||
<div className="text-sm italic text-neutral-600 dark:text-neutral-400 truncate">
|
||||
{captionForRender || <span className="not-italic text-neutral-400 dark:text-neutral-500">Aucune légende</span>}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-500 truncate">
|
||||
{altForRender || <span className="text-neutral-400 dark:text-neutral-500">Aucun texte alternatif</span>}
|
||||
</div>
|
||||
<a
|
||||
href={`/admin/media?slug=${encodeURIComponent(linkedSlug)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="self-start text-[11px] text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Modifier dans la médiathèque ↗
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
{/* Sous l'image : la légende uniquement, et seulement si elle existe.
|
||||
Pas de placeholder, pas de bloc gris, pas d'alt — l'alt reste sur
|
||||
l'attribut `<img alt>`. L'édition se fait via la modale ouverte
|
||||
par le bouton « Métadonnées » de la toolbar. */}
|
||||
{captionForRender ? (
|
||||
<div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400">
|
||||
{captionForRender}
|
||||
</div>
|
||||
) : null}
|
||||
{!disabled && metadataOpen && (
|
||||
linkedSlug ? (
|
||||
<MediaDetailsModal
|
||||
isOpen={metadataOpen}
|
||||
slug={linkedSlug}
|
||||
onClose={() => setMetadataOpen(false)}
|
||||
onUpdated={handleMediaUpdated}
|
||||
canEdit
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Légende (optionnelle)"
|
||||
value={block.caption ?? ''}
|
||||
onChange={handleCaptionChange}
|
||||
disabled={disabled}
|
||||
className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
|
||||
/>
|
||||
{!disabled && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Texte alternatif (accessibilité)"
|
||||
value={block.alt ?? ''}
|
||||
onChange={handleAltChange}
|
||||
className="w-full px-1 py-0.5 text-xs text-neutral-500 dark:text-neutral-500 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ExternalImageMetadataModal
|
||||
isOpen={metadataOpen}
|
||||
alt={block.alt ?? ''}
|
||||
caption={block.caption ?? ''}
|
||||
onSave={(patch) => onChange?.(patch)}
|
||||
onClose={() => setMetadataOpen(false)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const Image = {
|
||||
type: 'image',
|
||||
label: 'Image',
|
||||
|
||||
Reference in New Issue
Block a user