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:
@@ -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 });
|
||||
Reference in New Issue
Block a user