feat(media): add media management feature module

- add `ZEN_MEDIA` env flag and document it in `.env.example`
- add media schema, server routes, and API handlers (`api.server.js`, `routes.server.js`, `schema.server.js`)
- add `MediaPage`, `MediaGrid`, `MediaFilters`, and `MediaPicker` client components
- expose `@zen/core/features/media` and `@zen/core/features/media/picker` package exports
- register media navigation and permissions; wire module into `init.js`
- document media API, client picker usage, and boundary rules in `MODULES.md` and `ARCHITECTURE.md`
- add `src/features/media/README.md`
This commit is contained in:
2026-04-26 17:07:19 -04:00
parent f5d627f324
commit c9f7b23498
20 changed files with 1674 additions and 3 deletions
@@ -0,0 +1,318 @@
'use client';
/**
* Page admin "/admin/media" — gestionnaire central des médias.
*
* Listing + filtres + upload + détails (visibilité, alt, caption) + suppression.
* Tout est gardé dans un seul fichier pour rester lisible — la complexité
* grandit-elle, on extraira un MediaDetailsDrawer dédié.
*/
import { registerPage } from '../../admin/registry.js';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, Button, Modal, Input, Textarea, Select } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { CloudUploadIcon, Delete02Icon, Copy01Icon } from '@zen/core/shared/icons';
import AdminHeader from '../../admin/components/AdminHeader.js';
import MediaGrid from '../components/MediaGrid.client.js';
import MediaFilters from '../components/MediaFilters.client.js';
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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={altText || media.original_name} className="max-w-full max-h-96 object-contain" />
) : (
<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 toast = useToast();
const fileInputRef = useRef(null);
const canUpload = user?.permissions?.includes('media.upload');
const canDelete = user?.permissions?.includes('media.delete');
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [filters, setFilters] = useState({ search: '', kind: '', visibility: '' });
const [selected, setSelected] = useState(null);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ limit: '60' });
if (filters.search) params.set('search', filters.search);
if (filters.kind) params.set('kind', filters.kind);
if (filters.visibility) params.set('visibility', filters.visibility);
const response = await fetch(`${MEDIA_API}?${params}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Error ${response.status}`);
const data = await response.json();
setItems(data.media || []);
} catch (err) {
toast.error(err.message || 'Échec du chargement');
} finally {
setLoading(false);
}
}, [filters, toast]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleUpload = async (event) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
setUploading(true);
try {
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('visibility', 'private');
const response = await fetch(MEDIA_API, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await response.json();
if (!response.ok) {
toast.error(`${file.name} : ${data.message || 'échec'}`);
}
}
toast.success(`${files.length} fichier(s) téléversé(s)`);
await fetchItems();
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader
title="Médias"
description="Bibliothèque de fichiers utilisés dans le contenu du site."
action={canUpload && (
<>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleUpload}
className="hidden"
/>
<Button
variant="primary"
icon={CloudUploadIcon}
onClick={() => fileInputRef.current?.click()}
loading={uploading}
>
Téléverser
</Button>
</>
)}
/>
<Card variant="default" padding="default">
<MediaFilters filters={filters} onChange={setFilters} />
</Card>
<Card variant="default" padding="default">
<MediaGrid
items={items}
loading={loading}
onSelect={(media) => setSelected(media)}
emptyMessage={
filters.search || filters.kind || filters.visibility
? 'Aucun média ne correspond aux filtres'
: 'Aucun média téléversé pour le moment'
}
/>
</Card>
{selected && (
<MediaDetails
media={selected}
onClose={() => setSelected(null)}
onUpdated={(updated) => {
setItems(prev => prev.map(m => m.id === updated.id ? updated : m));
setSelected(updated);
}}
onDeleted={(id) => {
setItems(prev => prev.filter(m => m.id !== id));
setSelected(null);
}}
canDelete={canDelete}
canEdit={canUpload}
/>
)}
</div>
);
};
export default MediaPage;
registerPage({ slug: 'media', title: 'Médias', Component: MediaPage });