diff --git a/package.json b/package.json index ead5ef3..8a9517b 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,9 @@ "./features/media/picker": { "import": "./dist/features/media/components/MediaPicker.client.js" }, + "./features/media/details-modal": { + "import": "./dist/features/media/components/MediaDetailsModal.client.js" + }, "./features/provider": { "import": "./dist/features/provider/index.js" }, diff --git a/src/features/media/README.md b/src/features/media/README.md index e5fcee7..533b1be 100644 --- a/src/features/media/README.md +++ b/src/features/media/README.md @@ -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`). | | [`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/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` ↔ `` selon la visibilité. | ### Modèle de données @@ -56,6 +57,7 @@ Tous les uploads sont stockés sous `media///` via [` - `visibility = 'public'` → servi sans authentification (cache long) - `visibility = 'private'` → requiert une session avec `media.view` - **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. diff --git a/src/features/media/components/MediaDetailsModal.client.js b/src/features/media/components/MediaDetailsModal.client.js new file mode 100644 index 0000000..f08ffcb --- /dev/null +++ b/src/features/media/components/MediaDetailsModal.client.js @@ -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 ( + + {canDelete && media ? ( + + ) :
} +
+ + {canEdit && media && ( + + )} +
+
+ } + > + {loadError ? ( +
{loadError}
+ ) : !media ? ( +
Chargement…
+ ) : ( +
+
+ {isImage ? ( + + ) : ( +
+ {media.mime_type} +
+ + Ouvrir le fichier + +
+ )} +
+ +
+
+ +
+ {}} disabled /> +
+

+ {visibility === 'public' ? 'Accessible sans connexion.' : 'Privé : accessible uniquement aux utilisateurs avec la permission media.view.'} +

+
+ + + )} + +