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:
+4
-1
@@ -55,4 +55,7 @@ ZEN_PUBLIC_LOGO_URL=
|
|||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# DEVKIT (developer tools)
|
# DEVKIT (developer tools)
|
||||||
ZEN_DEVKIT=false
|
ZEN_DEVKIT=false
|
||||||
|
|
||||||
|
# MEDIA (gestionnaire de médias CMS — images, PDFs, vidéos attachés au contenu du site)
|
||||||
|
ZEN_MEDIA=false
|
||||||
@@ -211,6 +211,41 @@ export default function BlogCreatePage() {
|
|||||||
| `backLabel` | `string` | Label du bouton retour (défaut : `← Retour`). |
|
| `backLabel` | `string` | Label du bouton retour (défaut : `← Retour`). |
|
||||||
| `action` | `ReactNode` | Élément affiché à droite (bouton créer, etc.). |
|
| `action` | `ReactNode` | Élément affiché à droite (bouton créer, etc.). |
|
||||||
|
|
||||||
|
### Médias attachés au contenu
|
||||||
|
|
||||||
|
Pour qu'un module puisse attacher des médias (image à la une, galerie, PDF) à ses propres ressources, utiliser `@zen/core/features/media` côté serveur et `@zen/core/features/media/picker` côté client.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// côté serveur — au moment de sauvegarder un billet :
|
||||||
|
import { attachMedia, detachAllForSource } from '@zen/core/features/media';
|
||||||
|
|
||||||
|
await attachMedia({
|
||||||
|
mediaId: payload.featuredImageId,
|
||||||
|
sourceType: '@zen/module-blog:post',
|
||||||
|
sourceId: post.id,
|
||||||
|
field: 'featured_image',
|
||||||
|
});
|
||||||
|
|
||||||
|
// au moment de supprimer le billet : libérer toutes les références.
|
||||||
|
await detachAllForSource({ sourceType: '@zen/module-blog:post', sourceId: post.id });
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// côté client — sélecteur dans un formulaire :
|
||||||
|
'use client';
|
||||||
|
import MediaPicker from '@zen/core/features/media/picker';
|
||||||
|
|
||||||
|
<MediaPicker
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
accept="image/*"
|
||||||
|
visibility="public"
|
||||||
|
onSelect={(media) => setFeaturedImage(media)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le module Médias doit être activé (`ZEN_MEDIA=true`) dans le projet consommateur — sinon les permissions ne sont pas seedées et les API renvoient 403. Vérifier avec `isMediaEnabled()` côté serveur si on veut adapter l'UI.
|
||||||
|
|
||||||
### Widgets dashboard
|
### Widgets dashboard
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -356,6 +391,7 @@ Sous-entrées explicitement safe pour un import depuis un fichier `'use client'`
|
|||||||
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
|
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
|
||||||
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
|
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
|
||||||
| `@zen/core/features/admin/components` | Composants client : `AdminHeader`, `AdminShell`, `AdminSidebar`, `ThemeToggle`, modals. |
|
| `@zen/core/features/admin/components` | Composants client : `AdminHeader`, `AdminShell`, `AdminSidebar`, `ThemeToggle`, modals. |
|
||||||
|
| `@zen/core/features/media/picker` | Composant client `MediaPicker` (modale de sélection de média). Importer **uniquement ce sous-chemin** depuis un client — `@zen/core/features/media` (barrel) tire le code serveur. |
|
||||||
| `@zen/core/themes` | Tokens/utilitaires de thème. |
|
| `@zen/core/themes` | Tokens/utilitaires de thème. |
|
||||||
| `@zen/core/toast` | API toast côté client. |
|
| `@zen/core/toast` | API toast côté client. |
|
||||||
| `@zen/core/shared/icons` | Composants d'icônes. |
|
| `@zen/core/shared/icons` | Composants d'icônes. |
|
||||||
|
|||||||
@@ -31,4 +31,6 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit
|
|||||||
|
|
||||||
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
|
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
|
||||||
|
|
||||||
**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.
|
||||||
@@ -88,6 +88,12 @@
|
|||||||
"./features/admin/components": {
|
"./features/admin/components": {
|
||||||
"import": "./dist/features/admin/components/index.js"
|
"import": "./dist/features/admin/components/index.js"
|
||||||
},
|
},
|
||||||
|
"./features/media": {
|
||||||
|
"import": "./dist/features/media/index.js"
|
||||||
|
},
|
||||||
|
"./features/media/picker": {
|
||||||
|
"import": "./dist/features/media/components/MediaPicker.client.js"
|
||||||
|
},
|
||||||
"./features/provider": {
|
"./features/provider": {
|
||||||
"import": "./dist/features/provider/index.js"
|
"import": "./dist/features/provider/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import './pages/SettingsPage.client.js';
|
|||||||
import './pages/ConfirmEmailChangePage.client.js';
|
import './pages/ConfirmEmailChangePage.client.js';
|
||||||
import './widgets/index.client.js';
|
import './widgets/index.client.js';
|
||||||
import './devkit/DevkitPage.client.js';
|
import './devkit/DevkitPage.client.js';
|
||||||
|
import '../media/pages/MediaPage.client.js';
|
||||||
|
|
||||||
export default function AdminPageClient({ params, user, widgetData, appConfig, devkitEnabled }) {
|
export default function AdminPageClient({ params, user, widgetData, appConfig, devkitEnabled }) {
|
||||||
const parts = params?.admin || [];
|
const parts = params?.admin || [];
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
} from './registry.js';
|
} from './registry.js';
|
||||||
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
|
||||||
import { PERMISSIONS } from '@zen/core/users/constants';
|
import { PERMISSIONS } from '@zen/core/users/constants';
|
||||||
|
// Side-effect : déclenche l'enregistrement nav du module Médias (gated par
|
||||||
|
// ZEN_MEDIA en interne). Importé en haut du fichier pour que les side effects
|
||||||
|
// s'exécutent lors du premier import du barrel admin.
|
||||||
|
import '../media/navigation.js';
|
||||||
|
|
||||||
// Sections et items core — enregistrés à l'import de ce module.
|
// Sections et items core — enregistrés à l'import de ce module.
|
||||||
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
|
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
|
||||||
|
|||||||
@@ -13,13 +13,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
import { createTables as authCreate, dropTables as authDrop } from './auth/db.js';
|
||||||
|
import { createTables as mediaCreate, dropTables as mediaDrop } from './media/schema.server.js';
|
||||||
import { done, fail, info, step } from '@zen/core/shared/logger';
|
import { done, fail, info, step } from '@zen/core/shared/logger';
|
||||||
import { loadModulesForCli, validateModuleEnvVars } from '../core/modules/discover.server.js';
|
import { loadModulesForCli, validateModuleEnvVars } from '../core/modules/discover.server.js';
|
||||||
import { getRegisteredModules } from '../core/modules/registry.js';
|
import { getRegisteredModules } from '../core/modules/registry.js';
|
||||||
import { registerPermissions } from '../core/users/permissions-registry.js';
|
import { registerPermissions } from '../core/users/permissions-registry.js';
|
||||||
|
|
||||||
|
// Auth en premier (les autres features peuvent référencer zen_auth_users via FK).
|
||||||
|
// Les permissions des features additionnelles sont enregistrées au module-load
|
||||||
|
// dans leur schema.server.js, ce qui garantit qu'elles sont dans le registre
|
||||||
|
// avant le seed des rôles dans auth.createTables.
|
||||||
const CORE_FEATURES = [
|
const CORE_FEATURES = [
|
||||||
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
|
{ name: 'auth', createTables: authCreate, dropTables: authDrop },
|
||||||
|
{ name: 'media', createTables: mediaCreate, dropTables: mediaDrop },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function loadModules() {
|
async function loadModules() {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Media
|
||||||
|
|
||||||
|
Gestionnaire de médias CMS — bibliothèque centrale pour les images, PDFs, vidéos et autres fichiers attachés au contenu publié sur le site (pages, articles, modules tiers).
|
||||||
|
|
||||||
|
> `media` est conçu pour les **assets de contenu web** (la plupart privés, certains publics), pas pour le stockage personnel de fichiers utilisateurs.
|
||||||
|
|
||||||
|
## Activation
|
||||||
|
|
||||||
|
Le module est **désactivé par défaut**. Pour l'activer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
ZEN_MEDIA=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis appliquer le schéma BD :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx zen-db init
|
||||||
|
```
|
||||||
|
|
||||||
|
L'item « Médias » apparaît dans la sidebar admin sous une nouvelle section « Contenu », accessible aux utilisateurs ayant la permission `media.view`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| [`schema.server.js`](./schema.server.js) | Tables `zen_media` et `zen_media_references` + seed des permissions media. |
|
||||||
|
| [`api.server.js`](./api.server.js) | CRUD + attach/detach. Source de vérité unique pour la BD et S3. |
|
||||||
|
| [`routes.server.js`](./routes.server.js) | Routes API admin + route publique de service de fichier. |
|
||||||
|
| [`navigation.js`](./navigation.js) | Enregistre la section sidebar (gated par `ZEN_MEDIA`). |
|
||||||
|
| [`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`. |
|
||||||
|
| [`components/MediaPicker.client.js`](./components/MediaPicker.client.js) | Composant réutilisable par les modules tiers. |
|
||||||
|
|
||||||
|
### Modèle de données
|
||||||
|
|
||||||
|
`zen_media` — registre central des médias (slug URL-safe, clé S3, MIME, taille, visibilité, owner, alt/caption).
|
||||||
|
|
||||||
|
`zen_media_references` — table de jointure (media_id, source_type, source_id, field). FK `ON DELETE RESTRICT` empêche la suppression d'un média référencé par un contenu existant.
|
||||||
|
|
||||||
|
### Stockage
|
||||||
|
|
||||||
|
Tous les uploads sont stockés sous `media/<yyyy>/<mm>/<filename-unique>` via [`@zen/core/storage`](../../core/storage). **Tous les objets S3 sont privés** au niveau du bucket — l'accès public passe exclusivement par notre route HTTP qui valide la `visibility` en BD avant de proxyfier le contenu.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
- **Service de fichier** : `GET /zen/api/media/file/:slug`
|
||||||
|
- `visibility = 'public'` → servi sans authentification (cache long)
|
||||||
|
- `visibility = 'private'` → requiert une session avec `media.view`
|
||||||
|
- **CRUD admin** : `GET|POST|PATCH|DELETE /zen/api/media[/:id]`
|
||||||
|
|
||||||
|
Le slug est un identifiant aléatoire de 12 caractères (base64url) — non énumérable.
|
||||||
|
|
||||||
|
## API serveur (pour les modules consommateurs)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
uploadMedia,
|
||||||
|
getMediaById,
|
||||||
|
getMediaBySlug,
|
||||||
|
listMedia,
|
||||||
|
updateMedia,
|
||||||
|
deleteMedia,
|
||||||
|
attachMedia,
|
||||||
|
detachMedia,
|
||||||
|
detachAllForSource,
|
||||||
|
buildMediaUrl,
|
||||||
|
isMediaEnabled,
|
||||||
|
} from '@zen/core/features/media';
|
||||||
|
|
||||||
|
// Upload depuis une server action / API handler :
|
||||||
|
const { success, media } = await uploadMedia({
|
||||||
|
file, // File ou Buffer
|
||||||
|
uploadedBy: session.user.id,
|
||||||
|
visibility: 'public',
|
||||||
|
altText: 'Logo de la conférence 2026',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Au moment de sauvegarder un post avec featured image :
|
||||||
|
await attachMedia({
|
||||||
|
mediaId: media.id,
|
||||||
|
sourceType: 'post',
|
||||||
|
sourceId: post.id,
|
||||||
|
field: 'featured_image',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Au moment de supprimer le post : libérer toutes les références.
|
||||||
|
await detachAllForSource({ sourceType: 'post', sourceId: post.id });
|
||||||
|
```
|
||||||
|
|
||||||
|
## MediaPicker (composant client)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import MediaPicker from '@zen/core/features/media/picker';
|
||||||
|
|
||||||
|
function FeaturedImageField() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [media, setMedia] = useState(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)}>Choisir une image</button>
|
||||||
|
<MediaPicker
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
accept="image/*"
|
||||||
|
visibility="public"
|
||||||
|
uploadVisibility="public"
|
||||||
|
onSelect={(m) => { setMedia(m); }}
|
||||||
|
/>
|
||||||
|
{media && <img src={`/zen/api/media/file/${media.slug}`} alt={media.alt_text} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `isOpen` | `boolean` | Contrôlé par le parent. |
|
||||||
|
| `onClose` | `() => void` | |
|
||||||
|
| `onSelect` | `(media \| media[]) => void` | Reçoit l'objet média (ou tableau si `multiple`). |
|
||||||
|
| `accept` | `string` | Filtre MIME pour l'`<input type="file">` et le filtre par défaut de la grille. |
|
||||||
|
| `visibility` | `'public' \| 'private' \| 'any'` | Filtre par défaut. |
|
||||||
|
| `uploadVisibility` | `'public' \| 'private'` | Visibilité appliquée aux nouveaux uploads. |
|
||||||
|
| `multiple` | `boolean` | Sélection multiple avec validation explicite. |
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Clé | Description |
|
||||||
|
|---|---|
|
||||||
|
| `media.view` | Voir la liste et accéder aux médias privés. |
|
||||||
|
| `media.upload` | Téléverser et modifier les métadonnées. |
|
||||||
|
| `media.delete` | Supprimer un média non référencé. |
|
||||||
|
|
||||||
|
Toutes sont auto-attribuées au rôle `admin` au moment du seed.
|
||||||
|
|
||||||
|
## Limites
|
||||||
|
|
||||||
|
- Pas de génération de thumbnails — l'original est servi tel quel (le navigateur fait le redimensionnement). Un v2 pourrait intégrer `sharp` ou déléguer à Cloudflare Images.
|
||||||
|
- Pas de dossiers ni de tags — liste plate avec recherche par nom + filtre par type/visibilité.
|
||||||
|
- Pas d'édition d'image (crop, rotation).
|
||||||
|
- SVG bloqué par `validateUpload` (risque XSS).
|
||||||
|
|
||||||
|
## Distinction avec un futur module `files`
|
||||||
|
|
||||||
|
Le nom **`files`** est réservé à un futur module type Drive/Dropbox/Nextcloud (stockage personnel privé, partage, dossiers, versioning). Ce module-ci (`media`) est strictement orienté **assets attachés au contenu publié**.
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/**
|
||||||
|
* Media Feature — Server-side API
|
||||||
|
*
|
||||||
|
* Toutes les opérations CRUD sur le registre `zen_media` et `zen_media_references`.
|
||||||
|
* Ne contient AUCUNE logique HTTP — utilisable depuis routes.server.js, server
|
||||||
|
* actions, et modules externes (`@zen/module-*`).
|
||||||
|
*
|
||||||
|
* Stockage : délégué à `@zen/core/storage` (R2/Backblaze). La table est la
|
||||||
|
* source de vérité ; S3 ne stocke que les bytes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { query, queryOne } from '@zen/core/database';
|
||||||
|
import {
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
generateUniqueFilename,
|
||||||
|
validateUpload,
|
||||||
|
FILE_TYPE_PRESETS,
|
||||||
|
FILE_SIZE_LIMITS,
|
||||||
|
} from '@zen/core/storage';
|
||||||
|
import { generateId } from '../../core/users/password.js';
|
||||||
|
import { fail, info } from '@zen/core/shared/logger';
|
||||||
|
|
||||||
|
const SLUG_BYTES = 9; // 9 octets → 12 caractères base64url
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un slug URL-safe court (12 caractères) pour identifier un média
|
||||||
|
* publiquement sans exposer l'ID interne.
|
||||||
|
*/
|
||||||
|
function generateSlug() {
|
||||||
|
return randomBytes(SLUG_BYTES).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la clé S3 sous le préfixe `media/<yyyy>/<mm>/<filename-unique>`.
|
||||||
|
* Le préfixe `media/` reste **privé** côté S3 — l'accès public passe par
|
||||||
|
* notre route HTTP qui valide la visibilité en BD avant de proxyfier.
|
||||||
|
*/
|
||||||
|
function buildStorageKey(originalName) {
|
||||||
|
const now = new Date();
|
||||||
|
const yyyy = String(now.getUTCFullYear());
|
||||||
|
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const filename = generateUniqueFilename(originalName, 'media');
|
||||||
|
return `media/${yyyy}/${mm}/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devine la nature du fichier selon son MIME pour le filtrage UI et les
|
||||||
|
* validations spécifiques (images vs documents).
|
||||||
|
*/
|
||||||
|
export function classifyMime(mimeType) {
|
||||||
|
if (!mimeType) return 'other';
|
||||||
|
if (mimeType.startsWith('image/')) return 'image';
|
||||||
|
if (mimeType.startsWith('video/')) return 'video';
|
||||||
|
if (mimeType.startsWith('audio/')) return 'audio';
|
||||||
|
if (mimeType === 'application/pdf') return 'document';
|
||||||
|
if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('sheet')) return 'document';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_BY_EXT = {
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||||
|
'.gif': 'image/gif', '.webp': 'image/webp',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.txt': 'text/plain', '.csv': 'text/csv',
|
||||||
|
'.mp4': 'video/mp4', '.webm': 'video/webm',
|
||||||
|
'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg',
|
||||||
|
};
|
||||||
|
|
||||||
|
function deriveContentType(filename) {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
const dot = lower.lastIndexOf('.');
|
||||||
|
if (dot === -1) return 'application/octet-stream';
|
||||||
|
return MIME_BY_EXT[lower.slice(dot)] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload + insertion BD en une opération transactionnelle.
|
||||||
|
*
|
||||||
|
* Stratégie : on persiste en BD AVANT le commit final côté S3. En cas d'échec
|
||||||
|
* BD on supprime l'objet S3 fraîchement uploadé pour ne pas laisser d'orphelin.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {File|Buffer} args.file - Fichier source (FormData File ou Buffer brut)
|
||||||
|
* @param {string} [args.filename] - Nom original (requis si file est un Buffer)
|
||||||
|
* @param {number} [args.size] - Taille (requise si file est un Buffer)
|
||||||
|
* @param {string} args.uploadedBy - ID utilisateur qui upload
|
||||||
|
* @param {'private'|'public'} [args.visibility='private']
|
||||||
|
* @param {string} [args.altText]
|
||||||
|
* @param {string} [args.caption]
|
||||||
|
* @returns {Promise<{ success: true, media: object } | { success: false, error: string }>}
|
||||||
|
*/
|
||||||
|
export async function uploadMedia({ file, filename, size, uploadedBy, visibility = 'private', altText = null, caption = null }) {
|
||||||
|
let buffer;
|
||||||
|
let originalName;
|
||||||
|
let fileSize;
|
||||||
|
|
||||||
|
if (file && typeof file.arrayBuffer === 'function') {
|
||||||
|
buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
originalName = file.name ?? filename ?? 'unnamed';
|
||||||
|
fileSize = file.size ?? buffer.length;
|
||||||
|
} else if (Buffer.isBuffer(file)) {
|
||||||
|
if (!filename) return { success: false, error: 'filename is required when uploading a Buffer' };
|
||||||
|
buffer = file;
|
||||||
|
originalName = filename;
|
||||||
|
fileSize = size ?? buffer.length;
|
||||||
|
} else {
|
||||||
|
return { success: false, error: 'file must be a File or a Buffer' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImage = deriveContentType(originalName).startsWith('image/');
|
||||||
|
const validation = validateUpload({
|
||||||
|
filename: originalName,
|
||||||
|
size: fileSize,
|
||||||
|
allowedTypes: isImage ? FILE_TYPE_PRESETS.IMAGES : FILE_TYPE_PRESETS.DOCUMENTS,
|
||||||
|
maxSize: isImage ? FILE_SIZE_LIMITS.IMAGE : FILE_SIZE_LIMITS.DOCUMENT,
|
||||||
|
buffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
return { success: false, error: validation.errors.join(', ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = deriveContentType(originalName);
|
||||||
|
const storageKey = buildStorageKey(originalName);
|
||||||
|
|
||||||
|
const uploadResult = await uploadFile({
|
||||||
|
key: storageKey,
|
||||||
|
body: buffer,
|
||||||
|
contentType,
|
||||||
|
metadata: { uploadedBy, originalName },
|
||||||
|
cacheControl: visibility === 'public' ? 'public, max-age=31536000, immutable' : 'private, max-age=0, no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResult.success) {
|
||||||
|
return { success: false, error: 'Storage upload failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateId();
|
||||||
|
const slug = generateSlug();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inserted = await queryOne(
|
||||||
|
`INSERT INTO zen_media (id, slug, storage_key, original_name, mime_type, size_bytes, visibility, uploaded_by, alt_text, caption)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[id, slug, storageKey, originalName, contentType, fileSize, visibility, uploadedBy, altText, caption]
|
||||||
|
);
|
||||||
|
return { success: true, media: inserted };
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await deleteFile(storageKey);
|
||||||
|
} catch (rollbackError) {
|
||||||
|
fail(`uploadMedia: rollback delete failed for orphan ${storageKey}: ${rollbackError.message}`);
|
||||||
|
}
|
||||||
|
fail(`uploadMedia: DB insert failed: ${error.message}`);
|
||||||
|
return { success: false, error: 'Failed to record media' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMediaById(id) {
|
||||||
|
return queryOne(`SELECT * FROM zen_media WHERE id = $1`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMediaBySlug(slug) {
|
||||||
|
return queryOne(`SELECT * FROM zen_media WHERE slug = $1`, [slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste paginée + filtres facultatifs.
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.page=1]
|
||||||
|
* @param {number} [opts.limit=24]
|
||||||
|
* @param {string} [opts.search] - Recherche LIKE sur original_name
|
||||||
|
* @param {'image'|'video'|'audio'|'document'|'other'} [opts.kind]
|
||||||
|
* @param {'private'|'public'} [opts.visibility]
|
||||||
|
* @param {string} [opts.uploadedBy]
|
||||||
|
*/
|
||||||
|
export async function listMedia({ page = 1, limit = 24, search, kind, visibility, uploadedBy } = {}) {
|
||||||
|
const safePage = Math.max(1, Math.floor(page));
|
||||||
|
const safeLimit = Math.min(Math.max(1, Math.floor(limit)), 100);
|
||||||
|
const offset = (safePage - 1) * safeLimit;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
conditions.push(`original_name ILIKE $${params.length}`);
|
||||||
|
}
|
||||||
|
if (visibility) {
|
||||||
|
params.push(visibility);
|
||||||
|
conditions.push(`visibility = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (uploadedBy) {
|
||||||
|
params.push(uploadedBy);
|
||||||
|
conditions.push(`uploaded_by = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (kind) {
|
||||||
|
if (kind === 'image') conditions.push(`mime_type LIKE 'image/%'`);
|
||||||
|
else if (kind === 'video') conditions.push(`mime_type LIKE 'video/%'`);
|
||||||
|
else if (kind === 'audio') conditions.push(`mime_type LIKE 'audio/%'`);
|
||||||
|
else if (kind === 'document') conditions.push(`(mime_type = 'application/pdf' OR mime_type LIKE 'text/%' OR mime_type LIKE 'application/vnd%')`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
params.push(safeLimit, offset);
|
||||||
|
const rows = (await query(
|
||||||
|
`SELECT * FROM zen_media ${where} ORDER BY created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||||
|
params
|
||||||
|
)).rows;
|
||||||
|
|
||||||
|
const countParams = params.slice(0, -2);
|
||||||
|
const totalRow = await queryOne(`SELECT COUNT(*)::int AS count FROM zen_media ${where}`, countParams);
|
||||||
|
const total = totalRow?.count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
media: rows,
|
||||||
|
pagination: { page: safePage, limit: safeLimit, total, totalPages: Math.ceil(total / safeLimit) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour les metadata éditables d'un média (visibilité, alt, caption).
|
||||||
|
* La clé S3 et le contenu binaire ne sont jamais modifiés ici.
|
||||||
|
*/
|
||||||
|
export async function updateMedia(id, { visibility, altText, caption } = {}) {
|
||||||
|
const fields = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (visibility !== undefined) {
|
||||||
|
if (!['private', 'public'].includes(visibility)) {
|
||||||
|
return { success: false, error: 'Invalid visibility value' };
|
||||||
|
}
|
||||||
|
params.push(visibility);
|
||||||
|
fields.push(`visibility = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (altText !== undefined) {
|
||||||
|
params.push(altText || null);
|
||||||
|
fields.push(`alt_text = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (caption !== undefined) {
|
||||||
|
params.push(caption || null);
|
||||||
|
fields.push(`caption = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return { success: false, error: 'No fields to update' };
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const updated = await queryOne(
|
||||||
|
`UPDATE zen_media SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) return { success: false, error: 'Media not found' };
|
||||||
|
return { success: true, media: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les références. Utile pour l'UI ("utilisé par X contenus") et avant
|
||||||
|
* de tenter une suppression.
|
||||||
|
*/
|
||||||
|
export async function countReferences(mediaId) {
|
||||||
|
const row = await queryOne(
|
||||||
|
`SELECT COUNT(*)::int AS count FROM zen_media_references WHERE media_id = $1`,
|
||||||
|
[mediaId]
|
||||||
|
);
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReferences(mediaId) {
|
||||||
|
return (await query(
|
||||||
|
`SELECT media_id, source_type, source_id, field, created_at
|
||||||
|
FROM zen_media_references WHERE media_id = $1 ORDER BY created_at DESC`,
|
||||||
|
[mediaId]
|
||||||
|
)).rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un média (BD puis S3). Refuse si des références existent — la FK
|
||||||
|
* RESTRICT le bloque déjà au niveau BD, on retourne juste un message clair.
|
||||||
|
*/
|
||||||
|
export async function deleteMedia(id) {
|
||||||
|
const media = await getMediaById(id);
|
||||||
|
if (!media) return { success: false, error: 'Media not found' };
|
||||||
|
|
||||||
|
const refCount = await countReferences(id);
|
||||||
|
if (refCount > 0) {
|
||||||
|
return { success: false, error: `Ce média est utilisé par ${refCount} contenu(s). Détachez-le avant de le supprimer.`, referenceCount: refCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await query(`DELETE FROM zen_media WHERE id = $1`, [id]);
|
||||||
|
} catch (error) {
|
||||||
|
fail(`deleteMedia: DB delete failed for ${id}: ${error.message}`);
|
||||||
|
return { success: false, error: 'Failed to delete media record' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteFile(media.storage_key);
|
||||||
|
} catch (error) {
|
||||||
|
fail(`deleteMedia: S3 delete failed for ${media.storage_key} (orphan): ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(`deleteMedia: removed ${id} (${media.storage_key})`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attache un média à une ressource consommatrice. Utilisé par les modules
|
||||||
|
* externes (Posts, Pages, etc.) au moment de sauvegarder leur contenu.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {string} args.mediaId
|
||||||
|
* @param {string} args.sourceType - Ex: 'post', 'page', '@zen/module-shop:product'
|
||||||
|
* @param {string} args.sourceId
|
||||||
|
* @param {string} [args.field=''] - Ex: 'featured_image', 'gallery'
|
||||||
|
*/
|
||||||
|
export async function attachMedia({ mediaId, sourceType, sourceId, field = '' }) {
|
||||||
|
if (!mediaId || !sourceType || !sourceId) {
|
||||||
|
return { success: false, error: 'mediaId, sourceType and sourceId are required' };
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`INSERT INTO zen_media_references (media_id, source_type, source_id, field)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[mediaId, sourceType, sourceId, field]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detachMedia({ mediaId, sourceType, sourceId, field = '' }) {
|
||||||
|
if (!mediaId || !sourceType || !sourceId) {
|
||||||
|
return { success: false, error: 'mediaId, sourceType and sourceId are required' };
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`DELETE FROM zen_media_references WHERE media_id = $1 AND source_type = $2 AND source_id = $3 AND field = $4`,
|
||||||
|
[mediaId, sourceType, sourceId, field]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détache toutes les références d'une source donnée. À appeler lorsqu'un
|
||||||
|
* consommateur supprime sa propre ressource (post, page) pour libérer les
|
||||||
|
* médias attachés.
|
||||||
|
*/
|
||||||
|
export async function detachAllForSource({ sourceType, sourceId }) {
|
||||||
|
if (!sourceType || !sourceId) {
|
||||||
|
return { success: false, error: 'sourceType and sourceId are required' };
|
||||||
|
}
|
||||||
|
await query(
|
||||||
|
`DELETE FROM zen_media_references WHERE source_type = $1 AND source_id = $2`,
|
||||||
|
[sourceType, sourceId]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'URL servie par notre route HTTP. Le slug est utilisé en lieu et
|
||||||
|
* place de l'ID pour éviter l'énumération séquentielle.
|
||||||
|
*
|
||||||
|
* Cette URL fonctionne pour les deux niveaux de visibilité :
|
||||||
|
* - public → servi sans session
|
||||||
|
* - privé → servi uniquement aux sessions avec `media.view`
|
||||||
|
*/
|
||||||
|
export function buildMediaUrl(media) {
|
||||||
|
if (!media?.slug) return null;
|
||||||
|
return `/zen/api/media/file/${media.slug}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barre de filtres compacte : recherche par nom + type + visibilité.
|
||||||
|
* État contrôlé par le parent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Input, Select } from '@zen/core/shared/components';
|
||||||
|
|
||||||
|
const KIND_OPTIONS = [
|
||||||
|
{ value: '', label: 'Tous les types' },
|
||||||
|
{ value: 'image', label: 'Images' },
|
||||||
|
{ value: 'video', label: 'Vidéos' },
|
||||||
|
{ value: 'audio', label: 'Audio' },
|
||||||
|
{ value: 'document', label: 'Documents' },
|
||||||
|
{ value: 'other', label: 'Autre' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VISIBILITY_OPTIONS = [
|
||||||
|
{ value: '', label: 'Toute visibilité' },
|
||||||
|
{ value: 'public', label: 'Public' },
|
||||||
|
{ value: 'private', label: 'Privé' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MediaFilters({ filters, onChange }) {
|
||||||
|
const update = (patch) => onChange({ ...filters, ...patch });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={filters.search || ''}
|
||||||
|
onChange={(value) => update({ search: value })}
|
||||||
|
placeholder="Rechercher par nom de fichier..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-44">
|
||||||
|
<Select
|
||||||
|
value={filters.kind || ''}
|
||||||
|
onChange={(value) => update({ kind: value })}
|
||||||
|
options={KIND_OPTIONS}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-44">
|
||||||
|
<Select
|
||||||
|
value={filters.visibility || ''}
|
||||||
|
onChange={(value) => update({ visibility: value })}
|
||||||
|
options={VISIBILITY_OPTIONS}
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grille présentationnelle des médias. Composant pur : ne fait pas de fetch.
|
||||||
|
* Utilisé à la fois par la page admin et par le MediaPicker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Image01Icon, Pdf02Icon, Mp402Icon, Mp302Icon, File02Icon, EyeIcon, BlockedIcon, Tick02Icon } from '@zen/core/shared/icons';
|
||||||
|
|
||||||
|
const KIND_ICON = {
|
||||||
|
'image/jpeg': Image01Icon,
|
||||||
|
'image/png': Image01Icon,
|
||||||
|
'image/gif': Image01Icon,
|
||||||
|
'image/webp': Image01Icon,
|
||||||
|
'application/pdf': Pdf02Icon,
|
||||||
|
'video/mp4': Mp402Icon,
|
||||||
|
'audio/mpeg': Mp302Icon,
|
||||||
|
};
|
||||||
|
|
||||||
|
function iconForMime(mimeType) {
|
||||||
|
return KIND_ICON[mimeType] ?? File02Icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(mimeType) {
|
||||||
|
return typeof mimeType === 'string' && mimeType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!Number.isFinite(bytes)) return '';
|
||||||
|
if (bytes < 1024) return `${bytes} o`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaGrid({
|
||||||
|
items = [],
|
||||||
|
loading = false,
|
||||||
|
onSelect,
|
||||||
|
selectedIds = new Set(),
|
||||||
|
emptyMessage = 'Aucun média',
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className="aspect-square rounded-lg bg-neutral-100 dark:bg-neutral-900 animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{items.map(media => {
|
||||||
|
const Icon = iconForMime(media.mime_type);
|
||||||
|
const isSelected = selectedIds.has(media.id);
|
||||||
|
const url = `/zen/api/media/file/${media.slug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={media.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect?.(media)}
|
||||||
|
className={`
|
||||||
|
group relative flex flex-col items-stretch
|
||||||
|
rounded-lg overflow-hidden
|
||||||
|
border ${isSelected ? 'border-blue-500 ring-2 ring-blue-500/40' : 'border-neutral-200 dark:border-neutral-800'}
|
||||||
|
bg-white dark:bg-[#0B0B0B]
|
||||||
|
text-left transition-colors
|
||||||
|
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">
|
||||||
|
{isImage(media.mime_type) ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={media.alt_text || media.original_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon className="w-10 h-10 text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5 text-[11px] leading-tight">
|
||||||
|
<div className="truncate text-neutral-900 dark:text-white" title={media.original_name}>
|
||||||
|
{media.original_name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400">
|
||||||
|
{media.visibility === 'public' ? (
|
||||||
|
<span className="inline-flex items-center gap-0.5"><EyeIcon className="w-3 h-3" /> Public</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-0.5"><BlockedIcon className="w-3 h-3" /> Privé</span>
|
||||||
|
)}
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatSize(Number(media.size_bytes))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-1.5 right-1.5 rounded-full bg-blue-500 text-white p-0.5">
|
||||||
|
<Tick02Icon className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaPicker — composant réutilisable par les modules consommateurs (Posts,
|
||||||
|
* Pages, modules externes) pour choisir un média existant ou en uploader un.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <MediaPicker
|
||||||
|
* isOpen={open}
|
||||||
|
* onClose={() => setOpen(false)}
|
||||||
|
* accept="image/*"
|
||||||
|
* visibility="public"
|
||||||
|
* onSelect={(media) => setFeaturedImage(media)}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Modal, Button } from '@zen/core/shared/components';
|
||||||
|
import { useToast } from '@zen/core/toast';
|
||||||
|
import { CloudUploadIcon } from '@zen/core/shared/icons';
|
||||||
|
import MediaGrid from './MediaGrid.client.js';
|
||||||
|
import MediaFilters from './MediaFilters.client.js';
|
||||||
|
|
||||||
|
const MEDIA_API = '/zen/api/media';
|
||||||
|
|
||||||
|
function deriveKindFromAccept(accept) {
|
||||||
|
if (!accept) return undefined;
|
||||||
|
if (accept.startsWith('image/')) return 'image';
|
||||||
|
if (accept.startsWith('video/')) return 'video';
|
||||||
|
if (accept.startsWith('audio/')) return 'audio';
|
||||||
|
if (accept === 'application/pdf') return 'document';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaPicker({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
accept,
|
||||||
|
visibility,
|
||||||
|
multiple = false,
|
||||||
|
title = 'Sélectionner un média',
|
||||||
|
uploadVisibility = 'public',
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
search: '',
|
||||||
|
kind: deriveKindFromAccept(accept) ?? '',
|
||||||
|
visibility: visibility && visibility !== 'any' ? visibility : '',
|
||||||
|
});
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
|
|
||||||
|
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('Échec du chargement');
|
||||||
|
const data = await response.json();
|
||||||
|
setItems(data.media || []);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Échec du chargement des médias');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
fetchItems();
|
||||||
|
}, [isOpen, fetchItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSelect = (media) => {
|
||||||
|
if (multiple) {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(media.id)) next.delete(media.id);
|
||||||
|
else next.add(media.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onSelect?.(media);
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmMultiple = () => {
|
||||||
|
const selected = items.filter(m => selectedIds.has(m.id));
|
||||||
|
onSelect?.(selected);
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('visibility', uploadVisibility);
|
||||||
|
|
||||||
|
const response = await fetch(MEDIA_API, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Échec du téléversement');
|
||||||
|
|
||||||
|
toast.success('Média téléversé');
|
||||||
|
await fetchItems();
|
||||||
|
|
||||||
|
if (!multiple && data.media) {
|
||||||
|
onSelect?.(data.media);
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
size="xl"
|
||||||
|
footer={multiple ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-neutral-500">{selectedIds.size} sélectionné(s)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>Annuler</Button>
|
||||||
|
<Button variant="primary" onClick={handleConfirmMultiple} disabled={selectedIds.size === 0}>
|
||||||
|
Sélectionner
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" onClick={onClose}>Fermer</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center sm:justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<MediaFilters filters={filters} onChange={setFilters} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={CloudUploadIcon}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
loading={uploading}
|
||||||
|
>
|
||||||
|
Téléverser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MediaGrid
|
||||||
|
items={items}
|
||||||
|
loading={loading}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
emptyMessage="Aucun média ne correspond aux filtres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Media Feature — barrel public.
|
||||||
|
*
|
||||||
|
* API serveur (CRUD, références) exposée aux modules consommateurs.
|
||||||
|
* Composant client `MediaPicker` réutilisable depuis tout module externe.
|
||||||
|
*
|
||||||
|
* Activation : ce module est inerte tant que `ZEN_MEDIA=true` n'est pas défini.
|
||||||
|
* - L'enregistrement nav/page est conditionnel (voir navigation.js et la page).
|
||||||
|
* - Les fonctions d'API restent importables et opérationnelles si la BD est
|
||||||
|
* initialisée — mais n'auront aucun effet visible dans l'admin tant que le
|
||||||
|
* flag n'est pas activé.
|
||||||
|
*
|
||||||
|
* Side effects à l'import :
|
||||||
|
* - navigation.js → enregistre l'item sidebar si isMediaEnabled()
|
||||||
|
* - permissions enregistrées via schema.server.js (côté serveur uniquement)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './navigation.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
uploadMedia,
|
||||||
|
deleteMedia,
|
||||||
|
updateMedia,
|
||||||
|
getMediaById,
|
||||||
|
getMediaBySlug,
|
||||||
|
listMedia,
|
||||||
|
countReferences,
|
||||||
|
listReferences,
|
||||||
|
attachMedia,
|
||||||
|
detachMedia,
|
||||||
|
detachAllForSource,
|
||||||
|
buildMediaUrl,
|
||||||
|
classifyMime,
|
||||||
|
} from './api.server.js';
|
||||||
|
|
||||||
|
export { MEDIA_PERMISSIONS, MEDIA_PERMISSION_DEFINITIONS } from './permissions.js';
|
||||||
|
export { isMediaEnabled } from '@zen/core/shared/config';
|
||||||
|
|
||||||
|
// Routes API à enregistrer dans initializeZen.
|
||||||
|
export { routes } from './routes.server.js';
|
||||||
|
|
||||||
|
// DB schema export (utilisé par features/init.js).
|
||||||
|
export { createTables, dropTables } from './schema.server.js';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Media Feature — Admin navigation.
|
||||||
|
*
|
||||||
|
* Side effect : enregistre la section "Contenu" et l'item "Médias" dans la
|
||||||
|
* sidebar admin si le module est activé via ZEN_MEDIA=true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Import direct depuis le registre pour éviter une dépendance circulaire :
|
||||||
|
// admin/navigation.js → media/navigation.js → admin barrel → admin/navigation.js
|
||||||
|
import { registerNavSection, registerNavItem } from '../admin/registry.js';
|
||||||
|
import { isMediaEnabled } from '@zen/core/shared/config';
|
||||||
|
import { MEDIA_PERMISSIONS } from './permissions.js';
|
||||||
|
|
||||||
|
if (isMediaEnabled()) {
|
||||||
|
registerNavSection({ id: 'content', title: 'Contenu', icon: 'File02Icon', order: 25 });
|
||||||
|
registerNavItem({
|
||||||
|
id: 'media',
|
||||||
|
label: 'Médias',
|
||||||
|
icon: 'Image01Icon',
|
||||||
|
href: '/admin/media',
|
||||||
|
sectionId: 'content',
|
||||||
|
order: 10,
|
||||||
|
permission: MEDIA_PERMISSIONS.VIEW,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Media Feature — Permissions
|
||||||
|
*
|
||||||
|
* Catalogue de permissions du module Médias. Importé par schema.server.js
|
||||||
|
* (pour seed BD) et par routes.server.js (pour gating des routes admin).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MEDIA_PERMISSIONS = {
|
||||||
|
VIEW: 'media.view',
|
||||||
|
UPLOAD: 'media.upload',
|
||||||
|
DELETE: 'media.delete',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MEDIA_PERMISSION_DEFINITIONS = [
|
||||||
|
{ key: 'media.view', name: 'Voir les médias', description: 'Permet de consulter la liste des médias et leurs détails.', group_name: 'Médias' },
|
||||||
|
{ key: 'media.upload', name: 'Téléverser un média', description: 'Permet d\'ajouter de nouveaux médias.', group_name: 'Médias' },
|
||||||
|
{ key: 'media.delete', name: 'Supprimer un média', description: 'Permet de supprimer des médias non référencés.', group_name: 'Médias' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Media Feature — API Routes
|
||||||
|
*
|
||||||
|
* Routes admin (gated par permission) pour piloter le gestionnaire depuis
|
||||||
|
* l'interface, plus une route "publique" qui sert le contenu binaire des
|
||||||
|
* médias selon leur visibilité.
|
||||||
|
*
|
||||||
|
* La route de service de fichier est `/zen/api/media/file/:slug` (préfixe
|
||||||
|
* dédié pour ne pas entrer en collision avec les endpoints admin `/media/:id`) :
|
||||||
|
* - visibilité = 'public' → servie sans authentification
|
||||||
|
* - visibilité = 'private' → requiert une session avec media.view
|
||||||
|
*
|
||||||
|
* On garde tout sous /zen/api/ pour profiter du file-response builder du core
|
||||||
|
* qui détecte `{ success: true, file: {...} }` et stream le binaire.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineApiRoutes } from '@zen/core/api';
|
||||||
|
import { apiError, apiSuccess } from '@zen/core/api';
|
||||||
|
import { getFile } from '@zen/core/storage';
|
||||||
|
import { hasPermission } from '@zen/core/users';
|
||||||
|
import { getSessionCookieName } from '@zen/core/shared/config';
|
||||||
|
import { fail } from '@zen/core/shared/logger';
|
||||||
|
import {
|
||||||
|
uploadMedia,
|
||||||
|
deleteMedia,
|
||||||
|
updateMedia,
|
||||||
|
getMediaById,
|
||||||
|
getMediaBySlug,
|
||||||
|
listMedia,
|
||||||
|
countReferences,
|
||||||
|
listReferences,
|
||||||
|
} from './api.server.js';
|
||||||
|
import { MEDIA_PERMISSIONS } from './permissions.js';
|
||||||
|
|
||||||
|
const COOKIE_NAME = getSessionCookieName();
|
||||||
|
|
||||||
|
// ─── Admin handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleListMedia(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const result = await listMedia({
|
||||||
|
page: parseInt(url.searchParams.get('page') || '1', 10),
|
||||||
|
limit: parseInt(url.searchParams.get('limit') || '24', 10),
|
||||||
|
search: url.searchParams.get('search') || undefined,
|
||||||
|
kind: url.searchParams.get('kind') || undefined,
|
||||||
|
visibility: url.searchParams.get('visibility') || undefined,
|
||||||
|
});
|
||||||
|
return apiSuccess(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadMedia(request, _params, { session }) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
if (!file) return apiError('Bad Request', 'No file provided');
|
||||||
|
|
||||||
|
const visibility = formData.get('visibility') === 'public' ? 'public' : 'private';
|
||||||
|
const altText = formData.get('altText') || null;
|
||||||
|
const caption = formData.get('caption') || null;
|
||||||
|
|
||||||
|
const result = await uploadMedia({
|
||||||
|
file,
|
||||||
|
uploadedBy: session.user.id,
|
||||||
|
visibility,
|
||||||
|
altText,
|
||||||
|
caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) return apiError('Bad Request', result.error);
|
||||||
|
return apiSuccess({ media: result.media });
|
||||||
|
} catch (error) {
|
||||||
|
fail(`handleUploadMedia: ${error.message}`);
|
||||||
|
return apiError('Internal Server Error', 'Échec du téléversement');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetMediaAdmin(_request, { id }) {
|
||||||
|
const media = await getMediaById(id);
|
||||||
|
if (!media) return apiError('Not Found', 'Média introuvable');
|
||||||
|
const referenceCount = await countReferences(id);
|
||||||
|
return apiSuccess({ media, referenceCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleListReferencesAdmin(_request, { id }) {
|
||||||
|
const media = await getMediaById(id);
|
||||||
|
if (!media) return apiError('Not Found', 'Média introuvable');
|
||||||
|
const references = await listReferences(id);
|
||||||
|
return apiSuccess({ references });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateMedia(request, { id }) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const result = await updateMedia(id, {
|
||||||
|
visibility: body.visibility,
|
||||||
|
altText: body.altText,
|
||||||
|
caption: body.caption,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
const code = result.error === 'Media not found' ? 'Not Found' : 'Bad Request';
|
||||||
|
return apiError(code, result.error);
|
||||||
|
}
|
||||||
|
return apiSuccess({ media: result.media });
|
||||||
|
} catch (error) {
|
||||||
|
fail(`handleUpdateMedia: ${error.message}`);
|
||||||
|
return apiError('Internal Server Error', 'Échec de la mise à jour');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteMedia(_request, { id }) {
|
||||||
|
const result = await deleteMedia(id);
|
||||||
|
if (!result.success) {
|
||||||
|
if (result.error === 'Media not found') return apiError('Not Found', result.error);
|
||||||
|
if (result.referenceCount > 0) return apiError('Bad Request', result.error);
|
||||||
|
return apiError('Internal Server Error', result.error);
|
||||||
|
}
|
||||||
|
return apiSuccess({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public file serving ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleServeMediaBySlug(_request, { slug }) {
|
||||||
|
try {
|
||||||
|
if (!slug || /[^A-Za-z0-9_-]/.test(slug)) {
|
||||||
|
return apiError('Bad Request', 'Invalid slug');
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await getMediaBySlug(slug);
|
||||||
|
if (!media) return apiError('Not Found', 'Média introuvable');
|
||||||
|
|
||||||
|
// Visibilité privée : exiger une session avec media.view.
|
||||||
|
if (media.visibility !== 'public') {
|
||||||
|
const { cookies } = await import('next/headers');
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
if (!sessionToken) return apiError('Not Found', 'Média introuvable');
|
||||||
|
|
||||||
|
const { getSessionResolver } = await import('@zen/core/api');
|
||||||
|
const session = await getSessionResolver()(sessionToken);
|
||||||
|
if (!session) return apiError('Not Found', 'Média introuvable');
|
||||||
|
|
||||||
|
const allowed = await hasPermission(session.user.id, MEDIA_PERMISSIONS.VIEW);
|
||||||
|
if (!allowed) return apiError('Not Found', 'Média introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetched = await getFile(media.storage_key);
|
||||||
|
if (!fetched.success) {
|
||||||
|
fail(`handleServeMediaBySlug: storage miss for ${media.storage_key}: ${fetched.error}`);
|
||||||
|
return apiError('Not Found', 'Média introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
file: {
|
||||||
|
body: fetched.data.body,
|
||||||
|
contentType: media.mime_type,
|
||||||
|
contentLength: fetched.data.contentLength,
|
||||||
|
lastModified: fetched.data.lastModified,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
fail(`handleServeMediaBySlug: ${error.message}`);
|
||||||
|
return apiError('Internal Server Error', 'Failed to serve media');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const routes = defineApiRoutes([
|
||||||
|
{ path: '/media', method: 'GET', handler: handleListMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
|
||||||
|
{ path: '/media', method: 'POST', handler: handleUploadMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD },
|
||||||
|
{ path: '/media/:id', method: 'GET', handler: handleGetMediaAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
|
||||||
|
{ path: '/media/:id', method: 'PATCH', handler: handleUpdateMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.UPLOAD },
|
||||||
|
{ path: '/media/:id', method: 'DELETE', handler: handleDeleteMedia, auth: 'admin', permission: MEDIA_PERMISSIONS.DELETE },
|
||||||
|
{ path: '/media/:id/references', method: 'GET', handler: handleListReferencesAdmin, auth: 'admin', permission: MEDIA_PERMISSIONS.VIEW },
|
||||||
|
// Public file-serving route — préfixe dédié `/file/` pour éviter la collision
|
||||||
|
// avec `/media/:id`. Le handler enforce la visibilité en interne.
|
||||||
|
{ path: '/media/file/:slug', method: 'GET', handler: handleServeMediaBySlug, auth: 'public' },
|
||||||
|
]);
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Media Feature — Database Schema
|
||||||
|
*
|
||||||
|
* Tables :
|
||||||
|
* - zen_media : registre central des médias (slug, clé S3, visibilité, owner, metadata).
|
||||||
|
* - zen_media_references : tracking des usages pour empêcher la suppression d'un
|
||||||
|
* média référencé (ON DELETE RESTRICT côté FK).
|
||||||
|
*
|
||||||
|
* Les permissions media sont enregistrées au module-load pour qu'elles soient
|
||||||
|
* disponibles avant le seed des rôles (ce qui les ajoute automatiquement au rôle
|
||||||
|
* admin lors d'un `npx zen-db init`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, tableExists } from '@zen/core/database';
|
||||||
|
import { done, warn } from '@zen/core/shared/logger';
|
||||||
|
import { registerPermissions } from '../../core/users/permissions-registry.js';
|
||||||
|
import { MEDIA_PERMISSION_DEFINITIONS } from './permissions.js';
|
||||||
|
|
||||||
|
// Side effect : enregistre les permissions dès l'import du module.
|
||||||
|
registerPermissions(MEDIA_PERMISSION_DEFINITIONS);
|
||||||
|
|
||||||
|
const MEDIA_TABLES = [
|
||||||
|
{
|
||||||
|
name: 'zen_media',
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE zen_media (
|
||||||
|
id text NOT NULL PRIMARY KEY,
|
||||||
|
slug text NOT NULL UNIQUE,
|
||||||
|
storage_key text NOT NULL UNIQUE,
|
||||||
|
original_name text NOT NULL,
|
||||||
|
mime_type text NOT NULL,
|
||||||
|
size_bytes bigint NOT NULL,
|
||||||
|
visibility text NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'public')),
|
||||||
|
uploaded_by text REFERENCES zen_auth_users(id) ON DELETE SET NULL,
|
||||||
|
alt_text text,
|
||||||
|
caption text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
indexes: [
|
||||||
|
`CREATE INDEX zen_media_visibility_idx ON zen_media (visibility)`,
|
||||||
|
`CREATE INDEX zen_media_mime_type_idx ON zen_media (mime_type)`,
|
||||||
|
`CREATE INDEX zen_media_uploaded_by_idx ON zen_media (uploaded_by)`,
|
||||||
|
`CREATE INDEX zen_media_created_at_idx ON zen_media (created_at DESC)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'zen_media_references',
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE zen_media_references (
|
||||||
|
media_id text NOT NULL REFERENCES zen_media(id) ON DELETE RESTRICT,
|
||||||
|
source_type text NOT NULL,
|
||||||
|
source_id text NOT NULL,
|
||||||
|
field text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (media_id, source_type, source_id, field)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
indexes: [
|
||||||
|
`CREATE INDEX zen_media_references_source_idx ON zen_media_references (source_type, source_id)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent : insère les permissions media dans le catalogue BD et les attribue
|
||||||
|
* au rôle admin. Permet d'activer le module après un init initial sans recréer
|
||||||
|
* la BD entière.
|
||||||
|
*/
|
||||||
|
async function syncMediaPermissions() {
|
||||||
|
for (const perm of MEDIA_PERMISSION_DEFINITIONS) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`,
|
||||||
|
[perm.key, perm.name, perm.description, perm.group_name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
|
||||||
|
SELECT r.id, p.key
|
||||||
|
FROM zen_auth_roles r
|
||||||
|
CROSS JOIN zen_auth_permissions p
|
||||||
|
WHERE r.name = 'admin' AND p.key = ANY($1::text[])
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[MEDIA_PERMISSION_DEFINITIONS.map(p => p.key)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTables() {
|
||||||
|
const created = [];
|
||||||
|
const skipped = [];
|
||||||
|
|
||||||
|
for (const table of MEDIA_TABLES) {
|
||||||
|
const exists = await tableExists(table.name);
|
||||||
|
if (!exists) {
|
||||||
|
await query(table.sql);
|
||||||
|
for (const indexSql of table.indexes ?? []) {
|
||||||
|
await query(indexSql);
|
||||||
|
}
|
||||||
|
created.push(table.name);
|
||||||
|
done(`Created table: ${table.name}`);
|
||||||
|
} else {
|
||||||
|
skipped.push(table.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncMediaPermissions();
|
||||||
|
|
||||||
|
return { created, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropTables() {
|
||||||
|
const dropOrder = [...MEDIA_TABLES].reverse().map(t => t.name);
|
||||||
|
warn('Dropping all Zen media tables...');
|
||||||
|
for (const tableName of dropOrder) {
|
||||||
|
const exists = await tableExists(tableName);
|
||||||
|
if (exists) {
|
||||||
|
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
|
||||||
|
done(`Dropped table: ${tableName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done('All media tables dropped');
|
||||||
|
}
|
||||||
@@ -52,3 +52,7 @@ export function getAppConfig() {
|
|||||||
export function isDevkitEnabled() {
|
export function isDevkitEnabled() {
|
||||||
return process.env.ZEN_DEVKIT === 'true';
|
return process.env.ZEN_DEVKIT === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMediaEnabled() {
|
||||||
|
return process.env.ZEN_MEDIA === 'true';
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { configureRouter, registerFeatureRoutes, clearRouterConfig, clearFeature
|
|||||||
import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage';
|
import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage';
|
||||||
import { validateSession } from '../../features/auth/session.js';
|
import { validateSession } from '../../features/auth/session.js';
|
||||||
import { routes as authRoutes } from '../../features/auth/api.js';
|
import { routes as authRoutes } from '../../features/auth/api.js';
|
||||||
|
import { routes as mediaRoutes } from '../../features/media/routes.server.js';
|
||||||
|
import { MEDIA_PERMISSION_DEFINITIONS } from '../../features/media/permissions.js';
|
||||||
import { storageAccessPolicies } from '../../features/auth/storage-policies.js';
|
import { storageAccessPolicies } from '../../features/auth/storage-policies.js';
|
||||||
import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js';
|
import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js';
|
||||||
import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js';
|
import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js';
|
||||||
@@ -50,8 +52,10 @@ export async function initializeZen({ modules = [] } = {}) {
|
|||||||
|
|
||||||
configureRouter({ resolveSession: validateSession });
|
configureRouter({ resolveSession: validateSession });
|
||||||
registerFeatureRoutes(authRoutes);
|
registerFeatureRoutes(authRoutes);
|
||||||
|
registerFeatureRoutes(mediaRoutes);
|
||||||
registerStoragePolicies(storageAccessPolicies);
|
registerStoragePolicies(storageAccessPolicies);
|
||||||
registerPermissions(PERMISSION_DEFINITIONS);
|
registerPermissions(PERMISSION_DEFINITIONS);
|
||||||
|
registerPermissions(MEDIA_PERMISSION_DEFINITIONS);
|
||||||
|
|
||||||
// Activation des modules @zen/module-* via le manifeste statique fourni.
|
// Activation des modules @zen/module-* via le manifeste statique fourni.
|
||||||
registerModules(modules);
|
registerModules(modules);
|
||||||
|
|||||||
Reference in New Issue
Block a user