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:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,8 +126,8 @@ const MediaPage = ({ user }) => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{selected && (
|
<MediaDetailsModal
|
||||||
<MediaDetails
|
isOpen={!!selected}
|
||||||
media={selected}
|
media={selected}
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onUpdated={(updated) => {
|
onUpdated={(updated) => {
|
||||||
@@ -313,7 +141,6 @@ const MediaPage = ({ user }) => {
|
|||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
canEdit={canUpload}
|
canEdit={canUpload}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 ? (
|
|
||||||
captionForRender ? (
|
|
||||||
<div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400">
|
<div className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400">
|
||||||
{captionForRender}
|
{captionForRender}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null}
|
||||||
|
{!disabled && metadataOpen && (
|
||||||
|
linkedSlug ? (
|
||||||
|
<MediaDetailsModal
|
||||||
|
isOpen={metadataOpen}
|
||||||
|
slug={linkedSlug}
|
||||||
|
onClose={() => setMetadataOpen(false)}
|
||||||
|
onUpdated={handleMediaUpdated}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<ExternalImageMetadataModal
|
||||||
<div className="text-sm italic text-neutral-600 dark:text-neutral-400 truncate">
|
isOpen={metadataOpen}
|
||||||
{captionForRender || <span className="not-italic text-neutral-400 dark:text-neutral-500">Aucune légende</span>}
|
alt={block.alt ?? ''}
|
||||||
</div>
|
caption={block.caption ?? ''}
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-500 truncate">
|
onSave={(patch) => onChange?.(patch)}
|
||||||
{altForRender || <span className="text-neutral-400 dark:text-neutral-500">Aucun texte alternatif</span>}
|
onClose={() => setMetadataOpen(false)}
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const Image = {
|
const Image = {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
label: 'Image',
|
label: 'Image',
|
||||||
|
|||||||
Reference in New Issue
Block a user