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:
2026-04-26 19:18:35 -04:00
parent 90e172f571
commit 8e37eb53ff
5 changed files with 102 additions and 11 deletions
+2
View File
@@ -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.
+20
View File
@@ -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}
/>
);
}
+8 -2
View File
@@ -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}