diff --git a/.env.example b/.env.example index 9487c48..374dfce 100644 --- a/.env.example +++ b/.env.example @@ -55,4 +55,7 @@ ZEN_PUBLIC_LOGO_URL= NEXT_TELEMETRY_DISABLED=1 # DEVKIT (developer tools) -ZEN_DEVKIT=false \ No newline at end of file +ZEN_DEVKIT=false + +# MEDIA (gestionnaire de médias CMS — images, PDFs, vidéos attachés au contenu du site) +ZEN_MEDIA=false \ No newline at end of file diff --git a/docs/MODULES.md b/docs/MODULES.md index f2c40f1..468487f 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -211,6 +211,41 @@ export default function BlogCreatePage() { | `backLabel` | `string` | Label du bouton retour (défaut : `← Retour`). | | `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'; + + 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 ```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/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/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/toast` | API toast côté client. | | `@zen/core/shared/icons` | Composants d'icônes. | diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 58bb857..9c4f818 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -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. -**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. \ No newline at end of file +**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. \ No newline at end of file diff --git a/package.json b/package.json index 8cf002b..40b4edf 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,12 @@ "./features/admin/components": { "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": { "import": "./dist/features/provider/index.js" }, diff --git a/src/features/admin/AdminPage.client.js b/src/features/admin/AdminPage.client.js index 260a05c..2c15a14 100644 --- a/src/features/admin/AdminPage.client.js +++ b/src/features/admin/AdminPage.client.js @@ -9,6 +9,7 @@ import './pages/SettingsPage.client.js'; import './pages/ConfirmEmailChangePage.client.js'; import './widgets/index.client.js'; import './devkit/DevkitPage.client.js'; +import '../media/pages/MediaPage.client.js'; export default function AdminPageClient({ params, user, widgetData, appConfig, devkitEnabled }) { const parts = params?.admin || []; diff --git a/src/features/admin/navigation.js b/src/features/admin/navigation.js index f3d0292..a74405c 100644 --- a/src/features/admin/navigation.js +++ b/src/features/admin/navigation.js @@ -6,6 +6,10 @@ import { } from './registry.js'; import { isDevkitEnabled } from '../../shared/lib/appConfig.js'; 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. registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 }); diff --git a/src/features/init.js b/src/features/init.js index 9f30d68..6519b25 100644 --- a/src/features/init.js +++ b/src/features/init.js @@ -13,13 +13,19 @@ */ 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 { loadModulesForCli, validateModuleEnvVars } from '../core/modules/discover.server.js'; import { getRegisteredModules } from '../core/modules/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 = [ - { name: 'auth', createTables: authCreate, dropTables: authDrop }, + { name: 'auth', createTables: authCreate, dropTables: authDrop }, + { name: 'media', createTables: mediaCreate, dropTables: mediaDrop }, ]; async function loadModules() { diff --git a/src/features/media/README.md b/src/features/media/README.md new file mode 100644 index 0000000..1eccd09 --- /dev/null +++ b/src/features/media/README.md @@ -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///` 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 ( + <> + + setOpen(false)} + accept="image/*" + visibility="public" + uploadVisibility="public" + onSelect={(m) => { setMedia(m); }} + /> + {media && {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'`` 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é**. diff --git a/src/features/media/api.server.js b/src/features/media/api.server.js new file mode 100644 index 0000000..246f5f9 --- /dev/null +++ b/src/features/media/api.server.js @@ -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///`. + * 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}`; +} diff --git a/src/features/media/components/MediaFilters.client.js b/src/features/media/components/MediaFilters.client.js new file mode 100644 index 0000000..ee19154 --- /dev/null +++ b/src/features/media/components/MediaFilters.client.js @@ -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 ( +
+
+ update({ search: value })} + placeholder="Rechercher par nom de fichier..." + /> +
+
+ update({ visibility: value })} + options={VISIBILITY_OPTIONS} + placeholder="" + /> +
+
+ ); +} diff --git a/src/features/media/components/MediaGrid.client.js b/src/features/media/components/MediaGrid.client.js new file mode 100644 index 0000000..dd75684 --- /dev/null +++ b/src/features/media/components/MediaGrid.client.js @@ -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 ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (items.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+ {items.map(media => { + const Icon = iconForMime(media.mime_type); + const isSelected = selectedIds.has(media.id); + const url = `/zen/api/media/file/${media.slug}`; + + return ( + + ); + })} +
+ ); +} diff --git a/src/features/media/components/MediaPicker.client.js b/src/features/media/components/MediaPicker.client.js new file mode 100644 index 0000000..206b5df --- /dev/null +++ b/src/features/media/components/MediaPicker.client.js @@ -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 + * 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 ( + + {selectedIds.size} sélectionné(s) +
+ + +
+
+ ) : ( +
+ +
+ )} + > +
+
+
+ +
+
+ + +
+
+ + +
+ + ); +} diff --git a/src/features/media/index.js b/src/features/media/index.js new file mode 100644 index 0000000..426945a --- /dev/null +++ b/src/features/media/index.js @@ -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'; diff --git a/src/features/media/navigation.js b/src/features/media/navigation.js new file mode 100644 index 0000000..4225edb --- /dev/null +++ b/src/features/media/navigation.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, + }); +} diff --git a/src/features/media/pages/MediaPage.client.js b/src/features/media/pages/MediaPage.client.js new file mode 100644 index 0000000..55016f6 --- /dev/null +++ b/src/features/media/pages/MediaPage.client.js @@ -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 ( + + {canDelete ? ( + + ) :
} +
+ + {canEdit && ( + + )} +
+
+ } + > +
+
+ {isImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {altText + ) : ( +
+ {media.mime_type} +
+ + Ouvrir le fichier + +
+ )} +
+ +
+
+ +
+ {}} disabled /> +
+

+ {visibility === 'public' ? 'Accessible sans connexion.' : 'Privé : accessible uniquement aux utilisateurs avec la permission media.view.'} +

+
+ + + )} + +