c9f7b23498
- 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`
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
'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 });
|