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:
2026-04-26 20:38:29 -04:00
parent e9a5750928
commit e5b21c0d54
7 changed files with 431 additions and 271 deletions
+3
View File
@@ -94,6 +94,9 @@
"./features/media/picker": { "./features/media/picker": {
"import": "./dist/features/media/components/MediaPicker.client.js" "import": "./dist/features/media/components/MediaPicker.client.js"
}, },
"./features/media/details-modal": {
"import": "./dist/features/media/components/MediaDetailsModal.client.js"
},
"./features/provider": { "./features/provider": {
"import": "./dist/features/provider/index.js" "import": "./dist/features/provider/index.js"
}, },
+2
View File
@@ -32,6 +32,7 @@ L'item « Médias » apparaît comme entrée top-level de la sidebar admin (sans
| [`permissions.js`](./permissions.js) | Catalogue de permissions (`media.view`, `media.upload`, `media.delete`). | | [`permissions.js`](./permissions.js) | Catalogue de permissions (`media.view`, `media.upload`, `media.delete`). |
| [`pages/MediaPage.client.js`](./pages/MediaPage.client.js) | Page admin `/admin/media`. | | [`pages/MediaPage.client.js`](./pages/MediaPage.client.js) | Page admin `/admin/media`. |
| [`components/MediaPicker.client.js`](./components/MediaPicker.client.js) | Composant réutilisable par les modules tiers. | | [`components/MediaPicker.client.js`](./components/MediaPicker.client.js) | Composant réutilisable par les modules tiers. |
| [`components/MediaDetailsModal.client.js`](./components/MediaDetailsModal.client.js) | Modale d'édition d'un média (visibilité, alt, caption). Réutilisable hors de la page admin Médias — utilisée notamment par le bloc image du BlockEditor. Accepte `media={…}` ou `slug="…"`. |
| [`components/MediaImage.client.js`](./components/MediaImage.client.js) | Wrapper d'affichage qui dispatche `next/image``<img>` selon la visibilité. | | [`components/MediaImage.client.js`](./components/MediaImage.client.js) | Wrapper d'affichage qui dispatche `next/image``<img>` selon la visibilité. |
### Modèle de données ### Modèle de données
@@ -56,6 +57,7 @@ Tous les uploads sont stockés sous `media/<yyyy>/<mm>/<filename-unique>` via [`
- `visibility = 'public'` → servi sans authentification (cache long) - `visibility = 'public'` → servi sans authentification (cache long)
- `visibility = 'private'` → requiert une session avec `media.view` - `visibility = 'private'` → requiert une session avec `media.view`
- **CRUD admin** : `GET|POST|PATCH|DELETE /zen/api/media[/:id]` - **CRUD admin** : `GET|POST|PATCH|DELETE /zen/api/media[/:id]`
- **Lecture par slug (admin)** : `GET /zen/api/media/by-slug/:slug` — utile depuis les composants qui n'ont que le slug (ex. bloc image du BlockEditor).
Le slug est un identifiant aléatoire de 12 caractères (base64url) — non énumérable. Le slug est un identifiant aléatoire de 12 caractères (base64url) — non énumérable.
@@ -0,0 +1,253 @@
'use client';
/**
* Modal d'édition d'un média — réutilisable depuis n'importe quel contexte
* (page admin Médias, bloc image du BlockEditor, …).
*
* Deux modes d'instanciation :
* - `media={media}` : objet média déjà chargé (chemin rapide).
* - `slug="…"` : charge le média via `GET /zen/api/media/by-slug/:slug`
* (utile depuis BlockEditor où on n'a que le slug).
*
* Side effects : PATCH `/zen/api/media/:id` pour la sauvegarde, DELETE pour
* la suppression. Permissions transmises par le parent via `canEdit` /
* `canDelete` ; à défaut, lecture seule.
*/
import { useState, useEffect } from 'react';
import { Modal, Button, Input, Textarea, Select } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { Delete02Icon, Copy01Icon } from '@zen/core/shared/icons';
import MediaImage from './MediaImage.client.js';
const MEDIA_API = '/zen/api/media';
const VISIBILITY_OPTIONS = [
{ value: 'private', label: 'Privé' },
{ value: 'public', label: 'Public' },
];
export default function MediaDetailsModal({
isOpen,
onClose,
media: initialMedia = null,
slug = null,
onUpdated,
onDeleted,
canEdit = true,
canDelete = false,
}) {
const toast = useToast();
const [media, setMedia] = useState(initialMedia);
const [loadError, setLoadError] = useState(null);
const [visibility, setVisibility] = useState(initialMedia?.visibility ?? 'private');
const [altText, setAltText] = useState(initialMedia?.alt_text || '');
const [caption, setCaption] = useState(initialMedia?.caption || '');
const [referenceCount, setReferenceCount] = useState(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// Chargement initial : si on a déjà un média, on l'utilise. Sinon on
// résout via le slug. Réinitialisé chaque fois que la modale s'ouvre.
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoadError(null);
if (initialMedia) {
setMedia(initialMedia);
setVisibility(initialMedia.visibility);
setAltText(initialMedia.alt_text || '');
setCaption(initialMedia.caption || '');
// Ref count en parallèle — non-bloquant pour l'affichage.
fetch(`${MEDIA_API}/${initialMedia.id}`, { credentials: 'include' })
.then(r => r.json())
.then(data => { if (!cancelled) setReferenceCount(data.referenceCount ?? 0); })
.catch(() => {});
return () => { cancelled = true; };
}
if (slug) {
fetch(`${MEDIA_API}/by-slug/${encodeURIComponent(slug)}`, { credentials: 'include' })
.then(async r => {
if (!r.ok) throw new Error(`Statut ${r.status}`);
return r.json();
})
.then(data => {
if (cancelled) return;
const m = data.media ?? data;
if (!m?.id) throw new Error('Média introuvable');
setMedia(m);
setVisibility(m.visibility);
setAltText(m.alt_text || '');
setCaption(m.caption || '');
setReferenceCount(data.referenceCount ?? null);
})
.catch(err => { if (!cancelled) setLoadError(err.message || 'Échec du chargement'); });
return () => { cancelled = true; };
}
setLoadError('Aucun média à afficher');
return () => { cancelled = true; };
}, [isOpen, initialMedia, slug]);
const url = media ? `/zen/api/media/file/${media.slug}` : null;
const isImage = media?.mime_type?.startsWith('image/');
const fullUrl = (url && typeof window !== 'undefined') ? `${window.location.origin}${url}` : url;
const handleSave = async () => {
if (!media) return;
setSaving(true);
try {
const response = await fetch(`${MEDIA_API}/${media.id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility, altText, caption }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Échec de la mise à jour');
toast.success('Média mis à jour');
setMedia(data.media);
onUpdated?.(data.media);
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!media) return;
if (!confirm('Supprimer définitivement ce média ?')) return;
setDeleting(true);
try {
const response = await fetch(`${MEDIA_API}/${media.id}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Échec de la suppression');
toast.success('Média supprimé');
onDeleted?.(media.id);
} catch (err) {
toast.error(err.message);
} finally {
setDeleting(false);
}
};
const handleCopyUrl = async () => {
if (!fullUrl) return;
try {
await navigator.clipboard.writeText(fullUrl);
toast.success('URL copiée');
} catch {
toast.error('Impossible de copier');
}
};
if (!isOpen) return null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={media?.original_name ?? 'Média'}
size="xl"
footer={
<div className="flex items-center justify-between">
{canDelete && media ? (
<Button variant="danger" icon={Delete02Icon} onClick={handleDelete} loading={deleting}>
Supprimer
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Fermer</Button>
{canEdit && media && (
<Button variant="primary" onClick={handleSave} loading={saving}>
Enregistrer
</Button>
)}
</div>
</div>
}
>
{loadError ? (
<div className="p-6 text-sm text-red-600 dark:text-red-400">{loadError}</div>
) : !media ? (
<div className="p-6 text-sm text-neutral-500">Chargement</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-neutral-50 dark:bg-neutral-950 rounded-lg overflow-hidden flex items-center justify-center min-h-64">
{isImage ? (
<MediaImage
media={media}
width={800}
height={600}
className="max-w-full max-h-96 w-auto h-auto object-contain"
alt={altText || media.original_name}
/>
) : (
<div className="text-center p-6 text-sm text-neutral-500">
{media.mime_type}
<br />
<a href={url} target="_blank" rel="noreferrer" className="text-blue-500 hover:underline mt-2 inline-block">
Ouvrir le fichier
</a>
</div>
)}
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 dark:text-white mb-1">URL publique</label>
<div className="flex gap-1">
<Input value={fullUrl ?? ''} onChange={() => {}} disabled />
<Button variant="secondary" icon={Copy01Icon} onClick={handleCopyUrl} />
</div>
<p className="mt-1 text-[11px] text-neutral-500">
{visibility === 'public' ? 'Accessible sans connexion.' : 'Privé : accessible uniquement aux utilisateurs avec la permission media.view.'}
</p>
</div>
<Select
label="Visibilité"
value={visibility}
onChange={setVisibility}
options={VISIBILITY_OPTIONS}
disabled={!canEdit}
placeholder=""
/>
{isImage && (
<Input
label="Texte alternatif"
value={altText}
onChange={setAltText}
disabled={!canEdit}
description="Décrit l'image pour les lecteurs d'écran et le SEO."
/>
)}
<Textarea
label="Légende"
value={caption}
onChange={setCaption}
disabled={!canEdit}
rows={2}
/>
<div className="text-xs text-neutral-500 dark:text-neutral-400 space-y-1 pt-2 border-t border-neutral-200 dark:border-neutral-800">
<div>Type : <span className="text-neutral-700 dark:text-neutral-300">{media.mime_type}</span></div>
<div>Taille : <span className="text-neutral-700 dark:text-neutral-300">{Math.round(Number(media.size_bytes) / 1024)} Ko</span></div>
{referenceCount !== null && (
<div>Utilisé par : <span className="text-neutral-700 dark:text-neutral-300">{referenceCount} contenu(s)</span></div>
)}
</div>
</div>
</div>
)}
</Modal>
);
}
+21 -194
View File
@@ -3,195 +3,23 @@
/** /**
* Page admin "/admin/media" — gestionnaire central des médias. * Page admin "/admin/media" — gestionnaire central des médias.
* *
* Listing + filtres + upload + détails (visibilité, alt, caption) + suppression. * Listing + filtres + upload + sélection. La modale d'édition (visibilité,
* Tout est gardé dans un seul fichier pour rester lisible — la complexité * alt, caption) est extraite dans `MediaDetailsModal.client.js` et réutilisée
* grandit-elle, on extraira un MediaDetailsDrawer dédié. * par d'autres contextes (ex. bloc image du BlockEditor).
*/ */
import { registerPage } from '../../admin/registry.js'; import { registerPage } from '../../admin/registry.js';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, Button, Modal, Input, Textarea, Select } from '@zen/core/shared/components'; import { Card, Button } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast'; import { useToast } from '@zen/core/toast';
import { CloudUploadIcon, Delete02Icon, Copy01Icon } from '@zen/core/shared/icons'; import { CloudUploadIcon } from '@zen/core/shared/icons';
import AdminHeader from '../../admin/components/AdminHeader.js'; import AdminHeader from '../../admin/components/AdminHeader.js';
import MediaGrid from '../components/MediaGrid.client.js'; import MediaGrid from '../components/MediaGrid.client.js';
import MediaFilters from '../components/MediaFilters.client.js'; import MediaFilters from '../components/MediaFilters.client.js';
import MediaImage from '../components/MediaImage.client.js'; import MediaDetailsModal from '../components/MediaDetailsModal.client.js';
const MEDIA_API = '/zen/api/media'; const MEDIA_API = '/zen/api/media';
const VISIBILITY_OPTIONS = [
{ value: 'private', label: 'Privé' },
{ value: 'public', label: 'Public' },
];
function MediaDetails({ media, onClose, onUpdated, onDeleted, canDelete, canEdit }) {
const toast = useToast();
const [visibility, setVisibility] = useState(media.visibility);
const [altText, setAltText] = useState(media.alt_text || '');
const [caption, setCaption] = useState(media.caption || '');
const [referenceCount, setReferenceCount] = useState(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
let cancelled = false;
fetch(`${MEDIA_API}/${media.id}`, { credentials: 'include' })
.then(r => r.json())
.then(data => { if (!cancelled) setReferenceCount(data.referenceCount ?? 0); })
.catch(() => {});
return () => { cancelled = true; };
}, [media.id]);
const url = `/zen/api/media/file/${media.slug}`;
const isImage = media.mime_type?.startsWith('image/');
const fullUrl = typeof window !== 'undefined' ? `${window.location.origin}${url}` : url;
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch(`${MEDIA_API}/${media.id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility, altText, caption }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Échec de la mise à jour');
toast.success('Média mis à jour');
onUpdated?.(data.media);
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm('Supprimer définitivement ce média ?')) return;
setDeleting(true);
try {
const response = await fetch(`${MEDIA_API}/${media.id}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Échec de la suppression');
toast.success('Média supprimé');
onDeleted?.(media.id);
} catch (err) {
toast.error(err.message);
} finally {
setDeleting(false);
}
};
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(fullUrl);
toast.success('URL copiée');
} catch {
toast.error('Impossible de copier');
}
};
return (
<Modal
isOpen={true}
onClose={onClose}
title={media.original_name}
size="xl"
footer={
<div className="flex items-center justify-between">
{canDelete ? (
<Button variant="danger" icon={Delete02Icon} onClick={handleDelete} loading={deleting}>
Supprimer
</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Fermer</Button>
{canEdit && (
<Button variant="primary" onClick={handleSave} loading={saving}>
Enregistrer
</Button>
)}
</div>
</div>
}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-neutral-50 dark:bg-neutral-950 rounded-lg overflow-hidden flex items-center justify-center min-h-64">
{isImage ? (
<MediaImage
media={media}
width={800}
height={600}
className="max-w-full max-h-96 w-auto h-auto object-contain"
alt={altText || media.original_name}
/>
) : (
<div className="text-center p-6 text-sm text-neutral-500">
{media.mime_type}
<br />
<a href={url} target="_blank" rel="noreferrer" className="text-blue-500 hover:underline mt-2 inline-block">
Ouvrir le fichier
</a>
</div>
)}
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 dark:text-white mb-1">URL publique</label>
<div className="flex gap-1">
<Input value={fullUrl} onChange={() => {}} disabled />
<Button variant="secondary" icon={Copy01Icon} onClick={handleCopyUrl} />
</div>
<p className="mt-1 text-[11px] text-neutral-500">
{visibility === 'public' ? 'Accessible sans connexion.' : 'Privé : accessible uniquement aux utilisateurs avec la permission media.view.'}
</p>
</div>
<Select
label="Visibilité"
value={visibility}
onChange={setVisibility}
options={VISIBILITY_OPTIONS}
disabled={!canEdit}
placeholder=""
/>
{isImage && (
<Input
label="Texte alternatif"
value={altText}
onChange={setAltText}
disabled={!canEdit}
description="Décrit l'image pour les lecteurs d'écran et le SEO."
/>
)}
<Textarea
label="Légende"
value={caption}
onChange={setCaption}
disabled={!canEdit}
rows={2}
/>
<div className="text-xs text-neutral-500 dark:text-neutral-400 space-y-1 pt-2 border-t border-neutral-200 dark:border-neutral-800">
<div>Type : <span className="text-neutral-700 dark:text-neutral-300">{media.mime_type}</span></div>
<div>Taille : <span className="text-neutral-700 dark:text-neutral-300">{Math.round(Number(media.size_bytes) / 1024)} Ko</span></div>
{referenceCount !== null && (
<div>Utilisé par : <span className="text-neutral-700 dark:text-neutral-300">{referenceCount} contenu(s)</span></div>
)}
</div>
</div>
</div>
</Modal>
);
}
const MediaPage = ({ user }) => { const MediaPage = ({ user }) => {
const toast = useToast(); const toast = useToast();
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
@@ -298,22 +126,21 @@ const MediaPage = ({ user }) => {
/> />
</Card> </Card>
{selected && ( <MediaDetailsModal
<MediaDetails isOpen={!!selected}
media={selected} media={selected}
onClose={() => setSelected(null)} onClose={() => setSelected(null)}
onUpdated={(updated) => { onUpdated={(updated) => {
setItems(prev => prev.map(m => m.id === updated.id ? updated : m)); setItems(prev => prev.map(m => m.id === updated.id ? updated : m));
setSelected(updated); setSelected(updated);
}} }}
onDeleted={(id) => { onDeleted={(id) => {
setItems(prev => prev.filter(m => m.id !== id)); setItems(prev => prev.filter(m => m.id !== id));
setSelected(null); setSelected(null);
}} }}
canDelete={canDelete} canDelete={canDelete}
canEdit={canUpload} canEdit={canUpload}
/> />
)}
</div> </div>
); );
}; };
+14
View File
@@ -81,6 +81,19 @@ async function handleGetMediaAdmin(_request, { id }) {
return apiSuccess({ media, referenceCount }); return apiSuccess({ media, referenceCount });
} }
// Variante par slug : utile depuis les composants qui ne connaissent que le
// slug (ex. bloc image du BlockEditor où `mediaSlug` est le seul lien
// persistant vers le média).
async function handleGetMediaBySlugAdmin(_request, { slug }) {
if (!slug || /[^A-Za-z0-9_-]/.test(slug)) {
return apiError('Bad Request', 'Invalid slug');
}
const media = await getMediaBySlug(slug);
if (!media) return apiError('Not Found', 'Média introuvable');
const referenceCount = await countReferences(media.id);
return apiSuccess({ media, referenceCount });
}
async function handleListReferencesAdmin(_request, { id }) { async function handleListReferencesAdmin(_request, { id }) {
const media = await getMediaById(id); const media = await getMediaById(id);
if (!media) return apiError('Not Found', 'Média introuvable'); if (!media) return apiError('Not Found', 'Média introuvable');
@@ -170,6 +183,7 @@ export const routes = defineApiRoutes([
{ path: '/media', method: 'GET', handler: handleListMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW }, { path: '/media', method: 'GET', handler: handleListMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
{ path: '/media', method: 'POST', handler: handleUploadMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD }, { path: '/media', method: 'POST', handler: handleUploadMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD },
{ path: '/media/:id', method: 'GET', handler: handleGetMediaAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW }, { path: '/media/:id', method: 'GET', handler: handleGetMediaAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
{ path: '/media/by-slug/:slug', method: 'GET', handler: handleGetMediaBySlugAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
{ path: '/media/:id', method: 'PATCH', handler: handleUpdateMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD }, { path: '/media/:id', method: 'PATCH', handler: handleUpdateMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD },
{ path: '/media/:id', method: 'DELETE', handler: handleDeleteMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.DELETE }, { path: '/media/:id', method: 'DELETE', handler: handleDeleteMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.DELETE },
{ path: '/media/:id/references', method: 'GET', handler: handleListReferencesAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW }, { path: '/media/:id/references', method: 'GET', handler: handleListReferencesAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
+31 -16
View File
@@ -300,14 +300,33 @@ Le formulaire d'insertion accepte deux sources :
### Liaison avec la médiathèque (`mediaSlug`) ### Liaison avec la médiathèque (`mediaSlug`)
Quand un bloc image est lié à un média (`mediaSlug` présent), le **média est 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 la source unique de vérité** pour `alt` et `caption`. Le bloc ne stocke ni
ni l'autre — l'éditeur affiche les valeurs du média en lecture seule avec un l'un ni l'autre. Toute mise à jour côté médiathèque est immédiatement
lien « Modifier dans la médiathèque ». Toute mise à jour de l'alt ou de la répercutée sur tous les blocs qui référencent ce média, sans toucher aux
légende côté médiathèque est immédiatement répercutée sur tous les blocs qui contenus.
référencent ce média, sans toucher aux contenus.
Pour les **URL externes** (pas de `mediaSlug`), les inputs alt/caption locaux Pour les **URL externes** (pas de `mediaSlug`), `alt` et `caption` vivent
restent disponibles sur le bloc — il n'y a pas d'autre source possible. 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) : 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` 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 et à l'export HTML (`blocksToHtml`), l'image est wrappée dans `<a>` avec
`target="_blank" rel="noopener noreferrer"` quand `newTab` est vrai. `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. - **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. - **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>`. Sérialisation HTML : `<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>`.
Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `<a>` 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` quand l'`<img>` y est imbriqué pour récupérer `href` / `target`. Si le `src`
@@ -10,7 +10,9 @@ import {
Image01Icon, Image01Icon,
Link02Icon, Link02Icon,
PencilEdit01Icon, PencilEdit01Icon,
Settings02Icon,
} from '@zen/core/shared/icons'; } from '@zen/core/shared/icons';
import { Modal, Input, Textarea, Button } from '@zen/core/shared/components';
import { import {
BOX_CLASS, BOX_CLASS,
ICON_BTN_ACTIVE_CLASS, ICON_BTN_ACTIVE_CLASS,
@@ -18,9 +20,11 @@ import {
SEPARATOR_VERTICAL_CLASS, SEPARATOR_VERTICAL_CLASS,
} from '../inline/menuStyles.js'; } from '../inline/menuStyles.js';
import { newBlockId } from '../utils/ids.js'; import { newBlockId } from '../utils/ids.js';
// Import relatif pour éviter une dépendance circulaire avec @zen/core (le bloc // Imports relatifs pour éviter une dépendance circulaire avec @zen/core
// image vit dans `shared` et tire MediaPicker depuis `features/media`). // (le bloc image vit dans `shared` et tire ses composants media depuis
// `features/media`).
import MediaPicker from '../../../../features/media/components/MediaPicker.client.js'; 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 // Bloc image. État vide = formulaire d'insertion d'URL. État rempli = image
// rendue + toolbar flottante (alignement, lien, remplacer, supprimer). // 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 [linkOpen, setLinkOpen] = useState(false);
const align = getAlign(block); const align = getAlign(block);
@@ -284,6 +288,14 @@ function ImageToolbar({ block, onPatch, onReplace, onRemove }) {
/> />
)} )}
</div> </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 <button
type="button" type="button"
title="Remplacer l'image" 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 }) { function ImageBlock({ block, onChange, disabled }) {
// `replacing` permet de revenir au formulaire URL sans perdre alt/caption/ // `replacing` permet de revenir au formulaire URL sans perdre alt/caption/
// align/href. Tant qu'il est `true`, on rend `ImageUrlForm` même si // align/href. Tant qu'il est `true`, on rend `ImageUrlForm` même si
// `block.src` est non vide. // `block.src` est non vide.
const [replacing, setReplacing] = useState(false); const [replacing, setReplacing] = useState(false);
const [metadataOpen, setMetadataOpen] = useState(false);
const showForm = !block.src || replacing; const showForm = !block.src || replacing;
const linkedSlug = block.mediaSlug || extractMediaSlug(block.src); const linkedSlug = block.mediaSlug || extractMediaSlug(block.src);
@@ -337,14 +402,16 @@ function ImageBlock({ block, onChange, disabled }) {
setReplacing(false); setReplacing(false);
} }
function handleAltChange(e) { // Mise à jour du snapshot après édition du média via MediaDetailsModal :
// Sans mediaSlug uniquement : si un média est lié, l'alt vit côté // on rafraîchit `_mediaAlt`/`_mediaCaption` pour que la légende affichée
// médiathèque (édition impossible depuis le bloc). // sous l'image reflète immédiatement les changements (sans attendre un
onChange?.({ alt: e.target.value }); // re-render serveur enrichi).
} function handleMediaUpdated(updatedMedia) {
if (!updatedMedia) return;
function handleCaptionChange(e) { onChange?.({
onChange?.({ caption: e.target.value }); _mediaAlt: updatedMedia.alt_text ?? '',
_mediaCaption: updatedMedia.caption ?? '',
});
} }
function handleRemove() { function handleRemove() {
@@ -421,66 +488,45 @@ function ImageBlock({ block, onChange, disabled }) {
block={block} block={block}
onPatch={(patch) => onChange?.(patch)} onPatch={(patch) => onChange?.(patch)}
onReplace={() => setReplacing(true)} onReplace={() => setReplacing(true)}
onEditMetadata={() => setMetadataOpen(true)}
onRemove={handleRemove} onRemove={handleRemove}
/> />
)} )}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> {/* Sous l'image : la légende uniquement, et seulement si elle existe.
{linkedSlug ? ( Pas de placeholder, pas de bloc gris, pas d'alt — l'alt reste sur
// Image liée à un média : alt et caption viennent du média. Pas l'attribut `<img alt>`. L'édition se fait via la modale ouverte
// d'inputs locaux. En lecture seule on affiche juste la légende. par le bouton « Métadonnées » de la toolbar. */}
// En édition on montre un bandeau avec un lien vers la médiathèque. {captionForRender ? (
disabled ? ( <div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400">
captionForRender ? ( {captionForRender}
<div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400"> </div>
{captionForRender} ) : null}
</div> {!disabled && metadataOpen && (
) : null linkedSlug ? (
) : ( <MediaDetailsModal
<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"> isOpen={metadataOpen}
<div className="text-sm italic text-neutral-600 dark:text-neutral-400 truncate"> slug={linkedSlug}
{captionForRender || <span className="not-italic text-neutral-400 dark:text-neutral-500">Aucune légende</span>} onClose={() => setMetadataOpen(false)}
</div> onUpdated={handleMediaUpdated}
<div className="text-xs text-neutral-500 dark:text-neutral-500 truncate"> canEdit
{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>
)
) : ( ) : (
<> <ExternalImageMetadataModal
<input isOpen={metadataOpen}
type="text" alt={block.alt ?? ''}
placeholder="Légende (optionnelle)" caption={block.caption ?? ''}
value={block.caption ?? ''} onSave={(patch) => onChange?.(patch)}
onChange={handleCaptionChange} onClose={() => setMetadataOpen(false)}
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>
</div> </div>
); );
} }
const Image = { const Image = {
type: 'image', type: 'image',
label: 'Image', label: 'Image',