feat(media): add MediaImage wrapper to handle public/private image rendering
- add `MediaImage.client.js` component that routes to `next/image` for public media and native `<img>` for private media to prevent CDN cache leaking private content - replace direct `<img>` usage in `MediaGrid` and `MediaPage` with `MediaImage` - document `MediaImage` usage and rationale in `src/features/media/README.md` - update `docs/dev/ARCHITECTURE.md` to reference the `MediaImage` wrapper convention
This commit is contained in:
@@ -34,3 +34,5 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit
|
|||||||
**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail.
|
**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail.
|
||||||
|
|
||||||
**Médias** — Pour gérer les fichiers attachés au contenu publié (images, PDFs, vidéos), utiliser `src/features/media`. Activable via `ZEN_MEDIA=true`. Expose `uploadMedia`/`deleteMedia`/`attachMedia`/`detachMedia` côté serveur et un composant `MediaPicker` réutilisable côté client. Voir `src/features/media/README.md`. Distinct d'un futur module `files` (style Drive/Dropbox) — ne pas confondre les deux usages.
|
**Médias** — Pour gérer les fichiers attachés au contenu publié (images, PDFs, vidéos), utiliser `src/features/media`. Activable via `ZEN_MEDIA=true`. Expose `uploadMedia`/`deleteMedia`/`attachMedia`/`detachMedia` côté serveur et un composant `MediaPicker` réutilisable côté client. Voir `src/features/media/README.md`. Distinct d'un futur module `files` (style Drive/Dropbox) — ne pas confondre les deux usages.
|
||||||
|
|
||||||
|
Pour **afficher** une image média, utiliser le wrapper `MediaImage` du module (pas `<img>` direct, pas `next/image` direct). Il route les médias publics via `next/image` (srcset AVIF/WebP, lazy) et garde un `<img>` natif pour les médias privés — `/_next/image` cache la réponse optimisée par URL et fuiterait l'image privée à tous les visiteurs après le premier hit.
|
||||||
@@ -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/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
|
||||||
|
|
||||||
@@ -127,6 +128,25 @@ function FeaturedImageField() {
|
|||||||
| `uploadVisibility` | `'public' \| 'private'` | Visibilité appliquée aux nouveaux uploads. |
|
| `uploadVisibility` | `'public' \| 'private'` | Visibilité appliquée aux nouveaux uploads. |
|
||||||
| `multiple` | `boolean` | Sélection multiple avec validation explicite. |
|
| `multiple` | `boolean` | Sélection multiple avec validation explicite. |
|
||||||
|
|
||||||
|
## Affichage des images : `<MediaImage>`
|
||||||
|
|
||||||
|
Pour afficher un média image dans n'importe quel composant client, utiliser le wrapper `MediaImage` plutôt qu'un `<img>` direct :
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import MediaImage from '@zen/core/features/media/components/MediaImage.client';
|
||||||
|
|
||||||
|
<MediaImage media={media} width={800} height={600} alt="..." />
|
||||||
|
// ou en mode "remplir le conteneur parent" (parent doit être `position: relative`) :
|
||||||
|
<MediaImage media={media} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Le wrapper choisit automatiquement entre `next/image` et `<img>` natif :
|
||||||
|
|
||||||
|
- `media.visibility === 'public'` → `next/image` (srcset AVIF/WebP, lazy, cache CDN).
|
||||||
|
- `media.visibility === 'private'` → `<img>` natif. **C'est volontaire** : `/_next/image` met en cache la réponse optimisée par URL au niveau Next/CDN. Si on optimisait un média privé, l'image serait servie en clair à tout visiteur après le premier hit, contournant la vérification de session faite par `/zen/api/media/file/:slug`.
|
||||||
|
|
||||||
|
Côté projet hôte, **aucune configuration `next.config.js` n'est requise** : `/zen/api/media/file/...` est same-origin et ne nécessite pas de `remotePatterns`.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
| Clé | Description |
|
| Clé | Description |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Image01Icon, Pdf02Icon, Mp402Icon, Mp302Icon, File02Icon, EyeIcon, BlockedIcon, Tick02Icon } from '@zen/core/shared/icons';
|
import { Image01Icon, Pdf02Icon, Mp402Icon, Mp302Icon, File02Icon, EyeIcon, BlockedIcon, Tick02Icon } from '@zen/core/shared/icons';
|
||||||
|
import MediaImage from './MediaImage.client.js';
|
||||||
|
|
||||||
const KIND_ICON = {
|
const KIND_ICON = {
|
||||||
'image/jpeg': Image01Icon,
|
'image/jpeg': Image01Icon,
|
||||||
@@ -62,7 +63,6 @@ export default function MediaGrid({
|
|||||||
{items.map(media => {
|
{items.map(media => {
|
||||||
const Icon = iconForMime(media.mime_type);
|
const Icon = iconForMime(media.mime_type);
|
||||||
const isSelected = selectedIds.has(media.id);
|
const isSelected = selectedIds.has(media.id);
|
||||||
const url = `/zen/api/media/file/${media.slug}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -78,14 +78,13 @@ export default function MediaGrid({
|
|||||||
hover:border-neutral-300 dark:hover:border-neutral-700
|
hover:border-neutral-300 dark:hover:border-neutral-700
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="aspect-square bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center overflow-hidden">
|
<div className="relative aspect-square bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center overflow-hidden">
|
||||||
{isImage(media.mime_type) ? (
|
{isImage(media.mime_type) ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<MediaImage
|
||||||
<img
|
media={media}
|
||||||
src={url}
|
fill
|
||||||
alt={media.alt_text || media.original_name}
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 16vw"
|
||||||
className="w-full h-full object-cover"
|
className="object-cover"
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Icon className="w-10 h-10 text-neutral-400" />
|
<Icon className="w-10 h-10 text-neutral-400" />
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche un média image en optimisant via next/image quand c'est sûr.
|
||||||
|
*
|
||||||
|
* - visibility === 'public' → next/image (srcset AVIF/WebP, lazy, cache CDN).
|
||||||
|
* - sinon → <img> natif. Les médias privés ne doivent JAMAIS passer par
|
||||||
|
* /_next/image : l'optimiseur cache la réponse par URL et la servirait
|
||||||
|
* ensuite à n'importe quel visiteur, contournant la vérification de session
|
||||||
|
* faite par /zen/api/media/file/:slug.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import NextImage from 'next/image';
|
||||||
|
|
||||||
|
export default function MediaImage({
|
||||||
|
media,
|
||||||
|
alt,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
className,
|
||||||
|
sizes,
|
||||||
|
fill = false,
|
||||||
|
priority = false,
|
||||||
|
}) {
|
||||||
|
const url = `/zen/api/media/file/${media.slug}`;
|
||||||
|
const altText = alt ?? media.alt_text ?? media.original_name ?? '';
|
||||||
|
|
||||||
|
if (media.visibility !== 'public') {
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={altText}
|
||||||
|
className={className}
|
||||||
|
loading={priority ? 'eager' : 'lazy'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fill) {
|
||||||
|
return (
|
||||||
|
<NextImage
|
||||||
|
src={url}
|
||||||
|
alt={altText}
|
||||||
|
fill
|
||||||
|
sizes={sizes ?? '(max-width: 768px) 50vw, 25vw'}
|
||||||
|
className={className}
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextImage
|
||||||
|
src={url}
|
||||||
|
alt={altText}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
sizes={sizes}
|
||||||
|
className={className}
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { CloudUploadIcon, Delete02Icon, Copy01Icon } from '@zen/core/shared/icon
|
|||||||
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';
|
||||||
|
|
||||||
const MEDIA_API = '/zen/api/media';
|
const MEDIA_API = '/zen/api/media';
|
||||||
|
|
||||||
@@ -121,8 +122,13 @@ function MediaDetails({ media, onClose, onUpdated, onDeleted, canDelete, canEdit
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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">
|
<div className="bg-neutral-50 dark:bg-neutral-950 rounded-lg overflow-hidden flex items-center justify-center min-h-64">
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<MediaImage
|
||||||
<img src={url} alt={altText || media.original_name} className="max-w-full max-h-96 object-contain" />
|
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">
|
<div className="text-center p-6 text-sm text-neutral-500">
|
||||||
{media.mime_type}
|
{media.mime_type}
|
||||||
|
|||||||
Reference in New Issue
Block a user