+ {/* Header */}
+
+
+
+
Nuage
+
Gestionnaire de fichiers
+
+
+
fileInputRef.current?.click()}
+ loading={uploading}
+ icon={ }
+ >
+ Téléverser
+
+
+ {canCreateSubfolder && (
+
setShowNewFolder(true)}
+ icon={ }
+ >
+ Nouveau dossier
+
+ )}
+
+
{
+ if (currentFolder) {
+ setShareTarget({ id: currentFolder.id, type: 'folder', name: currentFolder.name });
+ }
+ }}
+ icon={ }
+ >
+ Partages
+
+
setViewMode(v => v === 'list' ? 'grid' : 'list')}
+ >
+ {viewMode === 'list' ? '⊞ Grille' : '☰ Liste'}
+
+
+
+
+ {/* Breadcrumb + Explorer */}
+
+
navigateTo(null),
+ active: breadcrumb.length === 0,
+ },
+ ...(breadcrumb.length > 3
+ ? [
+ {
+ key: 'ellipsis',
+ label: '···',
+ onClick: () => navigateTo(breadcrumb[0].id),
+ active: false,
+ },
+ ...breadcrumb.slice(-2).map((folder, i) => ({
+ key: folder.id,
+ label: folder.name,
+ onClick: () => navigateTo(folder.id),
+ active: i === 1,
+ })),
+ ]
+ : breadcrumb.map((folder, i) => ({
+ key: folder.id,
+ label: folder.name,
+ onClick: () => navigateTo(folder.id),
+ active: i === breadcrumb.length - 1,
+ }))
+ ),
+ ]}
+ />
+
+ {/* Upload queue indicator */}
+ {uploading && uploadQueue.length > 0 && (
+
+
Téléversement en cours…
+
+ {uploadQueue.map((f, i) => (
+
+ {f.done ? : }
+ {f.name}
+
+ ))}
+
+
+ )}
+
+ {/* Explorer area */}
+
+ { e.preventDefault(); if (!dragOverFolder) setDragOver(true); }}
+ onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) { setDragOver(false); setDragOverFolder(null); } }}
+ onDrop={handleDrop}
+ >
+ {viewMode === 'list' ? (
+ /* List view */
+
{
+ if (item._type === 'folder') navigateTo(item.id);
+ else if (isViewable(item.mime_type)) handleOpenViewer({ ...item, type: 'file', name: item.display_name });
+ }}
+ getRowProps={getRowProps}
+ renderActions={(item) => (
+ { e.stopPropagation(); openContextMenu(e, item._type === 'folder'
+ ? { ...item, type: 'folder', name: item.name }
+ : { ...item, type: 'file', name: item.display_name }
+ ); }}
+ className='-m-2'
+ >
+ ···
+
+ )}
+ showShareBadge
+ emptyMessage="Ce dossier est vide"
+ emptyDescription='Glissez des fichiers ici ou cliquez sur « Téléverser »'
+ size="sm"
+ />
+ ) : loading ? (
+
+
+
+ ) : allFoldersSorted.length === 0 && allFilesSorted.length === 0 ? (
+
+
+
Ce dossier est vide
+
Glissez des fichiers ici ou cliquez sur « Téléverser »
+
+ ) : (
+ /* Grid view */
+
+ {allFoldersSorted.map(folder => (
+
navigateTo(folder.id)}
+ onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOver(false); setDragOverFolder(folder.id); }}
+ onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null); }}
+ onDrop={e => { e.preventDefault(); e.stopPropagation(); setDragOverFolder(null); if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files, folder.id); }}
+ >
+
+
{folder.name}
+ {dragOverFolder === folder.id && (
+
Déposer ici
+ )}
+ {folder.has_active_share && dragOverFolder !== folder.id && (
+
+ )}
+
openContextMenu(e, { ...folder, type: 'folder', name: folder.name })}
+ className="absolute top-1 right-1 opacity-0 group-hover:opacity-100"
+ >
+ ···
+
+
+ ))}
+ {allFilesSorted.map(file => {
+ const FileIcon = getFileIcon(file.mime_type);
+ const fileColor = getFileColor(file.mime_type);
+ const canPreview = isViewable(file.mime_type);
+ return (
+
canPreview && handleOpenViewer({ ...file, type: 'file', name: file.display_name })}
+ >
+
+
{file.display_name}
+ {file.has_active_share && (
+
+ )}
+
openContextMenu(e, { ...file, type: 'file', name: file.display_name })}
+ className="absolute top-1 right-1 opacity-0 group-hover:opacity-100"
+ >
+ ···
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Context menu */}
+ {contextMenu && (
+
e.stopPropagation()}
+ >
+ {contextMenuActions(contextMenu.item).map((action, i) => (
+
{ action.action(); setContextMenu(null); }}
+ className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-neutral-50 dark:hover:bg-neutral-700 ${action.danger ? 'text-red-600' : 'text-neutral-700 dark:text-neutral-200'}`}
+ >
+
+ {action.label}
+
+ ))}
+
+ )}
+
+ {/* Share panel (side panel) */}
+ {shareTarget && (
+
+
setShareTarget(null)} />
+
+ setShareTarget(null)} />
+
+
+ )}
+
+ {/* New folder modal */}
+ {showNewFolder && (
+
{ setShowNewFolder(false); setNewFolderName(''); }}
+ title="Nouveau dossier"
+ size="sm"
+ footer={
+
+ { setShowNewFolder(false); setNewFolderName(''); }}>
+ Annuler
+
+
+ Créer
+
+
+ }
+ >
+ setNewFolderName(v)}
+ placeholder="Nom du dossier"
+ onKeyDown={e => e.key === 'Enter' && handleCreateFolder()}
+ />
+
+ )}
+
+ {/* Rename modal */}
+ {renameTarget && (
+
{ setRenameTarget(null); setRenameName(''); }}
+ title="Renommer"
+ size="sm"
+ footer={
+
+ { setRenameTarget(null); setRenameName(''); }}>
+ Annuler
+
+
+ Renommer
+
+
+ }
+ >
+ setRenameName(v)}
+ onKeyDown={e => e.key === 'Enter' && handleRenameConfirm()}
+ />
+
+ )}
+
+ {/* Delete confirmation modal */}
+ {deleteTarget && (
+
setDeleteTarget(null)}
+ title="Confirmer la suppression"
+ size="sm"
+ footer={
+
+ setDeleteTarget(null)}>Annuler
+
+ Supprimer
+
+
+ }
+ >
+
+
+
+ Voulez-vous vraiment supprimer{' '}
+ « {deleteTarget.name} »
+ {deleteTarget.type === 'folder' && ' et tout son contenu'} ? Cette action est irréversible.
+
+
+
+ )}
+
+ {/* File viewer modal */}
+ {viewerFile && (
+
+ )}
+
+ {/* Move modal */}
+ {moveTarget && (
+
setMoveTarget(null)}
+ title="Déplacer vers…"
+ size="sm"
+ footer={
+ setMoveTarget(null)}>Annuler
+ }
+ >
+
+ handleMoveConfirm(null)}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-200"
+ >
+ Racine (Nuage)
+
+ {allFolders
+ .filter(f => f.id !== moveTarget.id)
+ .map(f => (
+ handleMoveConfirm(f.id)}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-200"
+ >
+ {f.name}
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default ExplorerPage;
diff --git a/src/modules/nuage/admin/SharesPage.js b/src/modules/nuage/admin/SharesPage.js
new file mode 100644
index 0000000..38ea724
--- /dev/null
+++ b/src/modules/nuage/admin/SharesPage.js
@@ -0,0 +1,254 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import {
+ Link02Icon,
+ Delete02Icon,
+ UserCircle02Icon,
+ Folder01Icon,
+ FileSecurityIcon,
+ Copy01Icon,
+} from '../../../shared/Icons.js';
+import { Card, Badge, Button, Table, FilterTabs } from '../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+
+function formatDate(dateStr) {
+ if (!dateStr) return '—';
+ return new Date(dateStr).toLocaleDateString('fr-CA', {
+ year: 'numeric', month: 'short', day: 'numeric',
+ });
+}
+
+function isExpired(share) {
+ if (!share.expires_at) return false;
+ return new Date(share.expires_at) < new Date();
+}
+
+const SharesPage = ({ user }) => {
+ const router = useRouter();
+ const toast = useToast();
+ const [shares, setShares] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState('all'); // 'all' | 'user' | 'anonymous'
+ const [revoking, setRevoking] = useState(null);
+
+ useEffect(() => {
+ loadShares();
+ }, []);
+
+ const loadShares = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/zen/api/admin/nuage/shares', { credentials: 'include' });
+ const data = await res.json();
+ if (data.success) {
+ setShares(data.shares);
+ } else {
+ toast.error(data.error || 'Erreur lors du chargement');
+ }
+ } catch (e) {
+ console.error(e);
+ toast.error('Erreur réseau');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRevoke = async (id) => {
+ setRevoking(id);
+ try {
+ const res = await fetch(`/zen/api/admin/nuage/shares?id=${id}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage révoqué');
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setRevoking(null);
+ }
+ };
+
+ const copyLink = (token) => {
+ const url = `${window.location.origin}/zen/nuage/partage/${token}`;
+ navigator.clipboard.writeText(url).then(() => toast.success('Lien copié !'));
+ };
+
+ const filtered = shares.filter(s => {
+ if (filter === 'all') return true;
+ return s.share_type === filter;
+ });
+
+ const getShareStatus = (share) => {
+ if (!share.is_active) return { label: 'Révoqué', variant: 'default' };
+ if (isExpired(share)) return { label: 'Expiré', variant: 'danger' };
+ return { label: 'Actif', variant: 'success' };
+ };
+
+ const columns = [
+ {
+ key: 'type',
+ label: 'Type',
+ render: (share) => (
+
+ {share.share_type === 'user' ? (
+
+ ) : (
+
+ )}
+
+ {share.share_type === 'user' ? 'Utilisateur' : 'Lien anonyme'}
+
+
+ ),
+ skeleton: { height: 'h-4', width: '60%' },
+ },
+ {
+ key: 'recipient',
+ label: 'Destinataire',
+ render: (share) => (
+
+ {share.share_type === 'user' && share.name ? (
+ <>
+
+ {share.name}
+
+
{share.email}
+ >
+ ) : (
+
—
+ )}
+
+ ),
+ skeleton: { height: 'h-4', width: '70%' },
+ },
+ {
+ key: 'target',
+ label: 'Élément partagé',
+ render: (share) => (
+
+ {share.target_type === 'folder' ? (
+
+ ) : (
+
+ )}
+
+ {share.target_name || share.target_id}
+
+
+ ),
+ skeleton: { height: 'h-4', width: '50%' },
+ },
+ {
+ key: 'permission',
+ label: 'Permission',
+ render: (share) => (
+
+ {share.permission === 'reader' ? 'Lecteur' : 'Collaborateur'}
+
+ ),
+ skeleton: { height: 'h-4', width: '40%' },
+ },
+ {
+ key: 'expires_at',
+ label: 'Expiration',
+ render: (share) => (
+
+ {share.expires_at ? formatDate(share.expires_at) : 'Aucune'}
+
+ ),
+ skeleton: { height: 'h-4', width: '40%' },
+ },
+ {
+ key: 'status',
+ label: 'Statut',
+ render: (share) => {
+ const status = getShareStatus(share);
+ return
{status.label} ;
+ },
+ skeleton: { height: 'h-4', width: '30%' },
+ },
+ {
+ key: 'actions',
+ label: '',
+ render: (share) => (
+
+ {share.share_type === 'anonymous' && share.is_active && !isExpired(share) && (
+ copyLink(share.token)}
+ title="Copier le lien"
+ icon={ }
+ />
+ )}
+ {share.is_active && (
+ handleRevoke(share.id)}
+ loading={revoking === share.id}
+ title="Révoquer"
+ icon={ }
+ />
+ )}
+
+ ),
+ skeleton: { height: 'h-4', width: '20%' },
+ },
+ ];
+
+ const activeCount = shares.filter(s => s.is_active && !isExpired(s)).length;
+
+ return (
+
+ {/* Header */}
+
+
+
Partages
+
Gérez les liens et accès partagés
+
+
router.push('/admin/nuage/explorateur')}
+ icon={ }
+ >
+ Explorateur
+
+
+
+ {/* Filter tabs + Table */}
+
+
+
+ {!loading && filtered.length === 0 ? (
+
+
+
Aucun partage trouvé
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export default SharesPage;
diff --git a/src/modules/nuage/admin/index.js b/src/modules/nuage/admin/index.js
new file mode 100644
index 0000000..a7e0199
--- /dev/null
+++ b/src/modules/nuage/admin/index.js
@@ -0,0 +1,5 @@
+/**
+ * Nuage Admin Components
+ */
+export { default as ExplorerPage } from './ExplorerPage.js';
+export { default as SharesPage } from './SharesPage.js';
diff --git a/src/modules/nuage/api.js b/src/modules/nuage/api.js
new file mode 100644
index 0000000..29b68f8
--- /dev/null
+++ b/src/modules/nuage/api.js
@@ -0,0 +1,679 @@
+/**
+ * Nuage Module API Routes
+ * All API endpoints for the Nuage file manager
+ */
+
+import { validateSession } from '../../features/auth/lib/session.js';
+import { cookies } from 'next/headers';
+import { getSessionCookieName } from '../../shared/lib/appConfig.js';
+import {
+ getFolderContents,
+ getFolderBreadcrumb,
+ getFolderById,
+ createFolder,
+ renameFolder,
+ moveFolder,
+ deleteFolder,
+ getFolderItemCount,
+ getFileById,
+ uploadNuageFile,
+ renameFile,
+ moveFile,
+ deleteNuageFile,
+ proxyNuageFile,
+ getFolderUploadedSize,
+ getShareByToken,
+ getSharesForTarget,
+ getSharesForUser,
+ getAllActiveShares,
+ createShare,
+ updateShare,
+ revokeShare,
+ isShareValid,
+ searchUsers,
+ getSharedFolderContents,
+ getSharedBreadcrumb,
+ isFileInShare,
+ isFolderInShare,
+ getPasswordCookieName,
+ signPasswordToken,
+} from './crud.js';
+import { sendEmail } from '@hykocx/zen/email';
+import { render } from '@react-email/components';
+import { NuageShareEmail } from './email/index.js';
+
+const COOKIE_NAME = getSessionCookieName();
+
+const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500 MB hard cap
+
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+// ─── Auth helpers ────────────────────────────────────────────────────────────
+
+async function getAdminSession() {
+ const cookieStore = await cookies();
+ const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
+ if (!sessionToken) return null;
+ const session = await validateSession(sessionToken);
+ if (!session?.user?.id || session.user.role !== 'admin') return null;
+ return session;
+}
+
+async function getUserSession() {
+ const cookieStore = await cookies();
+ const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
+ if (!sessionToken) return null;
+ const session = await validateSession(sessionToken);
+ if (!session?.user?.id) return null;
+ return session;
+}
+
+// ─── Explorer: Folder Contents ───────────────────────────────────────────────
+
+async function handleGetContents(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const folderId = url.searchParams.get('folder') || null;
+
+ const contents = await getFolderContents(folderId);
+ const breadcrumb = await getFolderBreadcrumb(folderId);
+ const currentFolder = folderId ? await getFolderById(folderId) : null;
+
+ return { success: true, ...contents, breadcrumb, currentFolder };
+ } catch (error) {
+ console.error('handleGetContents error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Folders ─────────────────────────────────────────────────────────────────
+
+async function handleCreateFolder(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { name, parentId } = body;
+ if (!name?.trim()) return { success: false, error: 'Le nom du dossier est requis' };
+
+ const folder = await createFolder(name, parentId || null);
+ return { success: true, folder };
+ } catch (error) {
+ console.error('handleCreateFolder error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleUpdateFolder(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { id, action, name, newParentId } = body;
+ if (!id) return { success: false, error: 'ID du dossier requis' };
+
+ let folder;
+ if (action === 'rename') {
+ if (!name?.trim()) return { success: false, error: 'Le nom est requis' };
+ folder = await renameFolder(id, name);
+ } else if (action === 'move') {
+ folder = await moveFolder(id, newParentId || null);
+ } else {
+ return { success: false, error: 'Action inconnue' };
+ }
+
+ return { success: true, folder };
+ } catch (error) {
+ console.error('handleUpdateFolder error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleDeleteFolder(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du dossier requis' };
+
+ const count = await getFolderItemCount(id);
+ const forceDelete = url.searchParams.get('force') === 'true';
+ if (count > 0 && !forceDelete) {
+ return { success: false, error: 'FOLDER_NOT_EMPTY', itemCount: count };
+ }
+
+ await deleteFolder(id);
+ return { success: true };
+ } catch (error) {
+ console.error('handleDeleteFolder error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Files ───────────────────────────────────────────────────────────────────
+
+async function handleUploadFile(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const formData = await request.formData();
+ const file = formData.get('file');
+ const folderId = formData.get('folderId') || null;
+
+ if (!file || typeof file === 'string') {
+ return { success: false, error: 'Aucun fichier fourni' };
+ }
+
+ if (file.size > MAX_FILE_SIZE_BYTES) {
+ return { success: false, error: 'Fichier trop volumineux (max 500 Mo)' };
+ }
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const nuageFile = await uploadNuageFile(
+ folderId,
+ buffer,
+ file.name,
+ file.type || 'application/octet-stream',
+ buffer.length
+ );
+
+ return { success: true, file: nuageFile };
+ } catch (error) {
+ console.error('handleUploadFile error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleUpdateFile(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { id, action, displayName, newFolderId } = body;
+ if (!id) return { success: false, error: 'ID du fichier requis' };
+
+ let file;
+ if (action === 'rename') {
+ if (!displayName?.trim()) return { success: false, error: 'Le nom est requis' };
+ file = await renameFile(id, displayName);
+ } else if (action === 'move') {
+ file = await moveFile(id, newFolderId || null);
+ } else {
+ return { success: false, error: 'Action inconnue' };
+ }
+
+ return { success: true, file };
+ } catch (error) {
+ console.error('handleUpdateFile error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleDeleteFile(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du fichier requis' };
+
+ await deleteNuageFile(id);
+ return { success: true };
+ } catch (error) {
+ console.error('handleDeleteFile error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleGetDownloadUrl(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du fichier requis' };
+
+ return await proxyNuageFile(id);
+ } catch (error) {
+ console.error('handleGetDownloadUrl error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Shares ──────────────────────────────────────────────────────────────────
+
+async function handleGetShares(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const targetId = url.searchParams.get('targetId');
+ const targetType = url.searchParams.get('targetType');
+
+ if (targetId && targetType) {
+ const shares = await getSharesForTarget(targetId, targetType);
+ return { success: true, shares };
+ }
+
+ const shares = await getAllActiveShares();
+ return { success: true, shares };
+ } catch (error) {
+ console.error('handleGetShares error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleCreateShare(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const {
+ targetId, targetType, shareType, userId, permission,
+ password, expiresAt, uploadLimitBytes,
+ } = body;
+
+ if (!targetId || !targetType || !shareType || !permission) {
+ return { success: false, error: 'Paramètres manquants' };
+ }
+
+ const share = await createShare({
+ targetId, targetType, shareType,
+ userId: userId || null,
+ permission,
+ password: password || null,
+ expiresAt: expiresAt || null,
+ uploadLimitBytes: uploadLimitBytes || null,
+ });
+
+ return { success: true, share };
+ } catch (error) {
+ console.error('handleCreateShare error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleUpdateShare(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const {
+ id, permission, expiresAt, uploadLimitBytes,
+ password, clearPassword, userId, shareType, isActive,
+ } = body;
+
+ if (!id) return { success: false, error: 'ID du partage requis' };
+
+ const share = await updateShare(id, {
+ permission,
+ expiresAt,
+ uploadLimitBytes,
+ password: password || null,
+ clearPassword: clearPassword || false,
+ userId,
+ shareType,
+ isActive,
+ });
+
+ return { success: true, share };
+ } catch (error) {
+ console.error('handleUpdateShare error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleRevokeShare(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+ if (!id) return { success: false, error: 'ID du partage requis' };
+
+ const share = await revokeShare(id);
+ return { success: true, share };
+ } catch (error) {
+ console.error('handleRevokeShare error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+async function handleSendShareEmail(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const body = await request.json();
+ const { shareId, toEmail, toName, customMessage, shareUrl, targetName, targetType, shareType, expiresAt } = body;
+
+ if (!toEmail) return { success: false, error: 'Adresse courriel requise' };
+ if (!EMAIL_REGEX.test(toEmail)) return { success: false, error: 'Adresse courriel invalide' };
+
+ const appName = process.env.ZEN_NAME || 'Nuage';
+ const html = await render(
+ NuageShareEmail({
+ recipientName: toName || toEmail,
+ shareUrl,
+ targetName,
+ targetType: targetType || 'folder',
+ shareType,
+ expiresAt: expiresAt || null,
+ customMessage: customMessage || null,
+ companyName: appName,
+ })
+ );
+
+ await sendEmail({
+ to: toEmail,
+ subject: `Vous avez reçu un nouveau partage !`,
+ html,
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error('handleSendShareEmail error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── User search ─────────────────────────────────────────────────────────────
+
+async function handleSearchUsers(request) {
+ try {
+ const session = await getAdminSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const q = url.searchParams.get('q') || '';
+ if (q.length < 2) return { success: true, users: [] };
+
+ const users = await searchUsers(q, 10);
+ return { success: true, users };
+ } catch (error) {
+ console.error('handleSearchUsers error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Client-facing: My Shares ────────────────────────────────────────────────
+
+async function handleGetMyShares(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const shares = await getSharesForUser(session.user.id);
+
+ // Enrich with target names
+ const enriched = await Promise.all(
+ shares.map(async (share) => {
+ let targetName = '';
+ if (share.target_type === 'file') {
+ const file = await getFileById(share.target_id);
+ targetName = file?.display_name || 'Fichier inconnu';
+ } else {
+ const folder = await getFolderById(share.target_id);
+ targetName = folder?.name || 'Dossier inconnu';
+ }
+ return { ...share, target_name: targetName };
+ })
+ );
+
+ return { success: true, shares: enriched };
+ } catch (error) {
+ console.error('handleGetMyShares error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Client-facing: Shared folder contents ───────────────────────────────────
+
+async function handleGetSharedContents(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const shareId = url.searchParams.get('shareId');
+ const folderId = url.searchParams.get('folder') || null;
+
+ if (!shareId) return { success: false, error: 'shareId requis' };
+
+ const share = await getShareByToken(shareId);
+ if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
+ return { success: false, error: 'Accès refusé' };
+ }
+
+ const contents = await getSharedFolderContents(share, folderId);
+ const breadcrumb = await getSharedBreadcrumb(share, folderId);
+
+ return { success: true, ...contents, breadcrumb, permission: share.permission };
+ } catch (error) {
+ console.error('handleGetSharedContents error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Public share: File proxy download ───────────────────────────────────────
+
+async function handleShareFileProxy(request) {
+ try {
+ const url = new URL(request.url);
+ const token = url.searchParams.get('token');
+ const fileId = url.searchParams.get('fileId');
+
+ if (!token || !fileId) return { error: 'Bad Request', message: 'Paramètres manquants' };
+
+ const share = await getShareByToken(token);
+ if (!share || !isShareValid(share)) {
+ return { error: 'Unauthorized', message: 'Lien invalide ou expiré' };
+ }
+
+ // Enforce password protection server-side
+ if (share.password_hash) {
+ const cookieStore = await cookies();
+ const cookieValue = cookieStore.get(getPasswordCookieName(token))?.value;
+ if (!cookieValue || cookieValue !== signPasswordToken(token)) {
+ return { error: 'Unauthorized', message: 'Mot de passe requis' };
+ }
+ }
+
+ const file = await getFileById(fileId);
+ if (!file) return { error: 'Not Found', message: 'Fichier introuvable' };
+
+ // Ensure the requested file belongs to this share (prevent IDOR)
+ const inScope = await isFileInShare(fileId, share);
+ if (!inScope) return { error: 'Forbidden', message: 'Accès refusé' };
+
+ const { getFile } = await import('@hykocx/zen/storage');
+ const result = await getFile(file.r2_key);
+ if (!result.success) return { error: 'Not Found', message: 'Fichier introuvable' };
+
+ return {
+ success: true,
+ file: {
+ body: result.data.body,
+ contentType: file.mime_type || result.data.contentType,
+ contentLength: result.data.contentLength,
+ filename: file.display_name,
+ }
+ };
+ } catch (error) {
+ console.error('handleShareFileProxy error:', error);
+ return { error: 'Internal Server Error', message: error.message };
+ }
+}
+
+// ─── Client-facing: Download ─────────────────────────────────────────────────
+
+async function handleClientDownload(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const url = new URL(request.url);
+ const fileId = url.searchParams.get('fileId');
+ const shareId = url.searchParams.get('shareId');
+
+ if (!fileId || !shareId) return { success: false, error: 'Paramètres manquants' };
+
+ // Validate the share belongs to the user
+ const share = await getShareByToken(shareId);
+ if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
+ return { success: false, error: 'Accès refusé' };
+ }
+
+ // Ensure the requested file belongs to this share (prevent IDOR)
+ const inScope = await isFileInShare(fileId, share);
+ if (!inScope) return { success: false, error: 'Accès refusé' };
+
+ const inline = url.searchParams.get('inline') === 'true';
+ return await proxyNuageFile(fileId, { inline });
+ } catch (error) {
+ console.error('handleClientDownload error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Client-facing: Upload (collaborator) ────────────────────────────────────
+
+async function handleClientUpload(request) {
+ try {
+ const session = await getUserSession();
+ if (!session) return { success: false, error: 'Unauthorized' };
+
+ const formData = await request.formData();
+ const file = formData.get('file');
+ const shareId = formData.get('shareId');
+ const folderId = formData.get('folderId') || null;
+
+ if (!shareId) return { success: false, error: 'shareId requis' };
+
+ const share = await getShareByToken(shareId);
+ if (!share || !isShareValid(share) || share.user_id !== session.user.id) {
+ return { success: false, error: 'Accès refusé' };
+ }
+ if (share.permission !== 'collaborator') {
+ return { success: false, error: 'Permission insuffisante' };
+ }
+ if (share.target_type !== 'folder') {
+ return { success: false, error: 'L\'upload n\'est possible que sur un dossier partagé' };
+ }
+ if (!file || typeof file === 'string') {
+ return { success: false, error: 'Aucun fichier fourni' };
+ }
+
+ // Ensure the target folder is within the share scope (prevent IDOR)
+ if (folderId && folderId !== share.target_id) {
+ const inScope = await isFolderInShare(folderId, share);
+ if (!inScope) return { success: false, error: 'Accès refusé' };
+ }
+
+ if (file.size > MAX_FILE_SIZE_BYTES) {
+ return { success: false, error: 'Fichier trop volumineux (max 500 Mo)' };
+ }
+
+ // Check upload limit before buffering to avoid loading oversized files into memory
+ if (share.upload_limit_bytes) {
+ const fileSize = file.size;
+ const targetFolder = folderId || share.target_id;
+ const currentSize = await getFolderUploadedSize(targetFolder);
+ if (currentSize + fileSize > share.upload_limit_bytes) {
+ return { success: false, error: 'Limite de stockage du dossier partagé atteinte' };
+ }
+ }
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+
+ const targetFolderId = folderId || share.target_id;
+ const nuageFile = await uploadNuageFile(
+ targetFolderId,
+ buffer,
+ file.name,
+ file.type || 'application/octet-stream',
+ buffer.length
+ );
+
+ return { success: true, file: nuageFile };
+ } catch (error) {
+ console.error('handleClientUpload error:', error);
+ return { success: false, error: error.message };
+ }
+}
+
+// ─── Route Definitions ───────────────────────────────────────────────────────
+
+export default {
+ routes: [
+ // Admin: Explorer
+ { path: '/admin/nuage', method: 'GET', handler: handleGetContents, auth: 'admin' },
+
+ // Admin: Folders
+ { path: '/admin/nuage/folders', method: 'POST', handler: handleCreateFolder, auth: 'admin' },
+ { path: '/admin/nuage/folders', method: 'PUT', handler: handleUpdateFolder, auth: 'admin' },
+ { path: '/admin/nuage/folders', method: 'DELETE', handler: handleDeleteFolder, auth: 'admin' },
+
+ // Admin: Files
+ { path: '/admin/nuage/files', method: 'POST', handler: handleUploadFile, auth: 'admin' },
+ { path: '/admin/nuage/files', method: 'PUT', handler: handleUpdateFile, auth: 'admin' },
+ { path: '/admin/nuage/files', method: 'DELETE', handler: handleDeleteFile, auth: 'admin' },
+ { path: '/admin/nuage/files/download', method: 'GET', handler: handleGetDownloadUrl, auth: 'admin' },
+
+ // Admin: Shares
+ { path: '/admin/nuage/shares', method: 'GET', handler: handleGetShares, auth: 'admin' },
+ { path: '/admin/nuage/shares', method: 'POST', handler: handleCreateShare, auth: 'admin' },
+ { path: '/admin/nuage/shares', method: 'PATCH', handler: handleUpdateShare, auth: 'admin' },
+ { path: '/admin/nuage/shares', method: 'DELETE', handler: handleRevokeShare, auth: 'admin' },
+ { path: '/admin/nuage/shares/email', method: 'POST', handler: handleSendShareEmail, auth: 'admin' },
+
+ // Admin: User search
+ { path: '/admin/nuage/users', method: 'GET', handler: handleSearchUsers, auth: 'admin' },
+
+ // Public share proxy download (no auth required)
+ { path: '/nuage/share/download', method: 'GET', handler: handleShareFileProxy, auth: 'none' },
+
+ // Client-facing (authenticated user)
+ { path: '/nuage/me', method: 'GET', handler: handleGetMyShares, auth: 'user' },
+ { path: '/nuage/shared', method: 'GET', handler: handleGetSharedContents, auth: 'user' },
+ { path: '/nuage/download', method: 'GET', handler: handleClientDownload, auth: 'user' },
+ { path: '/nuage/upload', method: 'POST', handler: handleClientUpload, auth: 'user' },
+ ],
+};
+
+export {
+ handleGetContents,
+ handleCreateFolder,
+ handleUpdateFolder,
+ handleDeleteFolder,
+ handleUploadFile,
+ handleUpdateFile,
+ handleDeleteFile,
+ handleGetDownloadUrl,
+ handleGetShares,
+ handleCreateShare,
+ handleUpdateShare,
+ handleRevokeShare,
+ handleSendShareEmail,
+ handleSearchUsers,
+ handleGetMyShares,
+ handleGetSharedContents,
+ handleClientDownload,
+ handleClientUpload,
+ handleShareFileProxy,
+};
diff --git a/src/modules/nuage/components/FileViewerModal.js b/src/modules/nuage/components/FileViewerModal.js
new file mode 100644
index 0000000..046c507
--- /dev/null
+++ b/src/modules/nuage/components/FileViewerModal.js
@@ -0,0 +1,85 @@
+'use client';
+
+import React, { useEffect } from 'react';
+import { Cancel01Icon } from '../../../shared/Icons.js';
+
+// ─── Viewable file helpers ────────────────────────────────────────────────────
+
+const VIEWABLE_TYPES = [
+ 'image/',
+ 'application/pdf',
+ 'video/',
+ 'audio/',
+ 'text/plain',
+];
+
+export function isViewable(mimeType) {
+ if (!mimeType) return false;
+ return VIEWABLE_TYPES.some(t => mimeType.startsWith(t));
+}
+
+function getViewerType(mimeType) {
+ if (!mimeType) return null;
+ if (mimeType.startsWith('image/')) return 'image';
+ if (mimeType === 'application/pdf') return 'pdf';
+ if (mimeType.startsWith('video/')) return 'video';
+ if (mimeType.startsWith('audio/')) return 'audio';
+ if (mimeType === 'text/plain') return 'text';
+ return null;
+}
+
+// ─── File viewer modal ────────────────────────────────────────────────────────
+
+export default function FileViewerModal({ file, url, onClose }) {
+ const viewerType = getViewerType(file.mime_type);
+ const fileName = file.display_name || file.name;
+
+ useEffect(() => {
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [onClose]);
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Content */}
+
+ {viewerType === 'image' && (
+
+ )}
+ {(viewerType === 'pdf' || viewerType === 'text') && (
+
+ )}
+ {viewerType === 'video' && (
+
+ )}
+ {viewerType === 'audio' && (
+
+ )}
+
+
+ );
+}
diff --git a/src/modules/nuage/components/NuageFileTable.js b/src/modules/nuage/components/NuageFileTable.js
new file mode 100644
index 0000000..6e64fcc
--- /dev/null
+++ b/src/modules/nuage/components/NuageFileTable.js
@@ -0,0 +1,219 @@
+'use client';
+
+import React from 'react';
+import {
+ Folder01Icon,
+ File02Icon,
+ Image01Icon,
+ PlaySquareIcon,
+ Pdf02Icon,
+ Mp302Icon,
+ Ppt02Icon,
+ Gif02Icon,
+ Rar02Icon,
+ Raw02Icon,
+ Svg02Icon,
+ Tiff02Icon,
+ Txt02Icon,
+ Typescript03Icon,
+ Wav02Icon,
+ Xls02Icon,
+ Xml02Icon,
+ Zip02Icon,
+ Csv02Icon,
+ Doc02Icon,
+} from '../../../shared/Icons.js';
+import { Badge, Table } from '../../../shared/components';
+
+// ─── File type helpers ────────────────────────────────────────────────────────
+
+export function getFileIcon(mimeType) {
+ if (!mimeType) return File02Icon;
+ if (mimeType === 'image/gif') return Gif02Icon;
+ if (mimeType === 'image/svg+xml') return Svg02Icon;
+ if (mimeType === 'image/tiff') return Tiff02Icon;
+ if (mimeType.startsWith('image/')) return Image01Icon;
+ if (mimeType.startsWith('video/')) return PlaySquareIcon;
+ if (mimeType === 'application/pdf') return Pdf02Icon;
+ if (mimeType === 'audio/mpeg' || mimeType === 'audio/mp3') return Mp302Icon;
+ if (mimeType === 'audio/wav' || mimeType === 'audio/wave') return Wav02Icon;
+ if (mimeType === 'application/vnd.ms-powerpoint' || mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return Ppt02Icon;
+ if (mimeType === 'application/vnd.rar' || mimeType === 'application/x-rar-compressed') return Rar02Icon;
+ if (mimeType === 'image/x-raw' || mimeType === 'image/x-canon-cr2' || mimeType === 'image/x-nikon-nef') return Raw02Icon;
+ if (mimeType === 'text/plain') return Txt02Icon;
+ if (mimeType === 'application/typescript' || mimeType === 'text/typescript') return Typescript03Icon;
+ if (mimeType === 'application/vnd.ms-excel' || mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return Xls02Icon;
+ if (mimeType === 'application/xml' || mimeType === 'text/xml') return Xml02Icon;
+ if (mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed') return Zip02Icon;
+ if (mimeType === 'text/csv') return Csv02Icon;
+ if (mimeType === 'application/msword' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return Doc02Icon;
+ return File02Icon;
+}
+
+export function getFileColor(mimeType) {
+ if (!mimeType) return 'text-neutral-500';
+ if (mimeType.startsWith('image/')) return 'text-teal-700';
+ if (mimeType.startsWith('video/')) return 'text-red-800';
+ if (mimeType === 'application/pdf') return 'text-red-800';
+ if (mimeType === 'audio/mpeg' || mimeType === 'audio/mp3' || mimeType === 'audio/wav' || mimeType === 'audio/wave') return 'text-indigo-700';
+ if (mimeType === 'application/vnd.ms-powerpoint' || mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return 'text-orange-700';
+ if (mimeType === 'application/vnd.rar' || mimeType === 'application/x-rar-compressed') return 'text-amber-800';
+ if (mimeType === 'text/plain') return 'text-neutral-500';
+ if (mimeType === 'application/typescript' || mimeType === 'text/typescript') return 'text-blue-700';
+ if (mimeType === 'application/vnd.ms-excel' || mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return 'text-green-700';
+ if (mimeType === 'application/xml' || mimeType === 'text/xml') return 'text-orange-800';
+ if (mimeType === 'application/zip' || mimeType === 'application/x-zip-compressed') return 'text-amber-700';
+ if (mimeType === 'text/csv') return 'text-emerald-700';
+ if (mimeType === 'application/msword' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'text-blue-800';
+ return 'text-neutral-500';
+}
+
+export function formatBytes(bytes) {
+ if (!bytes || bytes === 0) return '0 o';
+ const units = ['o', 'Ko', 'Mo', 'Go'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
+}
+
+export function formatDate(dateStr) {
+ if (!dateStr) return '';
+ return new Date(dateStr).toLocaleDateString('fr-CA', {
+ year: 'numeric', month: 'short', day: 'numeric',
+ });
+}
+
+// ─── NuageFileTable ───────────────────────────────────────────────────────────
+
+/**
+ * Unified file listing table for the Nuage module.
+ *
+ * Each item must have a `_type` field: 'folder' | 'file' | 'share'.
+ *
+ * Props:
+ * items — array of rows (folders, files, or shares with `_type`)
+ * loading — show skeleton rows
+ * sortBy — active sort key
+ * sortOrder — 'asc' | 'desc'
+ * onSort — (key) => void — called when a sortable header is clicked
+ * onRowClick — (item) => void
+ * renderActions — (item) => ReactNode — content for the actions column
+ * showShareBadge — show a "Partagé" badge on items with has_active_share
+ * emptyMessage — primary empty state text
+ * emptyDescription — secondary empty state text
+ * size — Table size prop (default 'sm')
+ * className — extra class on the Table
+ * getRowProps — (item) => object — extra props per row
+ */
+export default function NuageFileTable({
+ items = [],
+ loading = false,
+ sortBy,
+ sortOrder,
+ onSort,
+ onRowClick,
+ renderActions,
+ showShareBadge = false,
+ emptyMessage = 'Ce dossier est vide',
+ emptyDescription,
+ size = 'sm',
+ className,
+ getRowProps,
+}) {
+ const sortable = !!onSort;
+
+ const columns = [
+ {
+ key: 'name',
+ label: 'Nom',
+ sortable,
+ render: (item) => {
+ if (item._type === 'share') {
+ const isFolder = item.target_type === 'folder';
+ const isExpired = item.expires_at && new Date(item.expires_at) < new Date();
+ const Icon = isFolder ? Folder01Icon : File02Icon;
+ return (
+
+
+
+ {item.target_name || (isFolder ? 'Dossier' : 'Fichier')}
+
+
+ );
+ }
+
+ if (item._type === 'folder') {
+ return (
+
+
+ {item.name}
+ {showShareBadge && item.has_active_share && (
+ Partagé
+ )}
+
+ );
+ }
+
+ // file
+ const FileIcon = getFileIcon(item.mime_type);
+ const fileColor = getFileColor(item.mime_type);
+ return (
+
+
+ {item.display_name}
+ {showShareBadge && item.has_active_share && (
+ Partagé
+ )}
+
+ );
+ },
+ skeleton: { height: 'h-4', width: '60%' },
+ },
+ {
+ key: 'date',
+ label: 'Date',
+ sortable,
+ render: (item) => {
+ if (item._type === 'share') {
+ return item.expires_at ? (
+
Expire {formatDate(item.expires_at)}
+ ) : null;
+ }
+ return
{formatDate(item.created_at)} ;
+ },
+ skeleton: { height: 'h-4', width: '40%' },
+ },
+ {
+ key: 'size',
+ label: 'Taille',
+ sortable,
+ render: (item) => {
+ if (item._type !== 'file') return
— ;
+ return
{formatBytes(item.size)} ;
+ },
+ skeleton: { height: 'h-4', width: '30%' },
+ },
+ ...(renderActions ? [{
+ key: 'actions',
+ label: '',
+ render: (item) => renderActions(item),
+ skeleton: { height: 'h-8', width: '40px' },
+ }] : []),
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/modules/nuage/components/SharePanel.js b/src/modules/nuage/components/SharePanel.js
new file mode 100644
index 0000000..074eaa8
--- /dev/null
+++ b/src/modules/nuage/components/SharePanel.js
@@ -0,0 +1,776 @@
+'use client';
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ Cancel01Icon,
+ Link02Icon,
+ Copy01Icon,
+ Delete02Icon,
+ UserCircle02Icon,
+} from '../../../shared/Icons.js';
+import { Button, Badge, Loading, Input, Modal, Textarea } from '../../../shared/components';
+import { formatBytes, formatDate } from './NuageFileTable.js';
+import { useToast } from '@hykocx/zen/toast';
+
+// ─── User search input ────────────────────────────────────────────────────────
+
+function UserSearchInput({ selectedUser, onSelect, onClear, query, onQueryChange, results, searching }) {
+ return (
+
+
Utilisateur
+ {selectedUser ? (
+
+
+
{selectedUser.name}
+
{selectedUser.email}
+
+
+
+
+
+ ) : (
+ <>
+
+ {results.length > 0 && (
+
+ {results.map(u => (
+
onSelect(u)}
+ className="w-full text-left px-3 py-2 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors first:rounded-t-xl last:rounded-b-xl"
+ >
+ {u.name}
+ {u.email}
+
+ ))}
+
+ )}
+ {searching &&
Recherche…
}
+ >
+ )}
+
+ );
+}
+
+// ─── Share panel ──────────────────────────────────────────────────────────────
+
+export default function SharePanel({ target, onClose }) {
+ const toast = useToast();
+ const [shares, setShares] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [tab, setTab] = useState('list'); // 'list' | 'create'
+
+ // Create share form
+ const [shareType, setShareType] = useState('anonymous'); // 'user' | 'anonymous'
+ const [permission, setPermission] = useState('reader');
+ const [password, setPassword] = useState('');
+ const [expiresPreset, setExpiresPreset] = useState('');
+ const [uploadLimit, setUploadLimit] = useState('');
+ const [creating, setCreating] = useState(false);
+ const [createdShare, setCreatedShare] = useState(null);
+
+ // User search (create)
+ const [userQuery, setUserQuery] = useState('');
+ const [userResults, setUserResults] = useState([]);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [searchingUsers, setSearchingUsers] = useState(false);
+ const searchTimeout = useRef(null);
+
+ // Email modal
+ const [showEmailModal, setShowEmailModal] = useState(false);
+ const [emailAddress, setEmailAddress] = useState('');
+ const [emailMessage, setEmailMessage] = useState('');
+ const [sendingEmail, setSendingEmail] = useState(false);
+ const [emailShareTarget, setEmailShareTarget] = useState(null);
+
+ // Edit modal
+ const [editingShare, setEditingShare] = useState(null);
+ const [editShareType, setEditShareType] = useState('anonymous');
+ const [editPermission, setEditPermission] = useState('reader');
+ const [editExpiresPreset, setEditExpiresPreset] = useState('keep');
+ const [editUploadLimit, setEditUploadLimit] = useState('');
+ const [editPassword, setEditPassword] = useState('');
+ const [editClearPassword, setEditClearPassword] = useState(false);
+ const [editSelectedUser, setEditSelectedUser] = useState(null);
+ const [editUserQuery, setEditUserQuery] = useState('');
+ const [editUserResults, setEditUserResults] = useState([]);
+ const [editSearchingUsers, setEditSearchingUsers] = useState(false);
+ const [updating, setUpdating] = useState(false);
+ const editSearchTimeout = useRef(null);
+
+ useEffect(() => {
+ loadShares();
+ }, [target]);
+
+ const loadShares = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(
+ `/zen/api/admin/nuage/shares?targetId=${target.id}&targetType=${target.type}`,
+ { credentials: 'include' }
+ );
+ const data = await res.json();
+ if (data.success) setShares(data.shares);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const makeUserSearchHandler = (setQuery, setSelected, setResults, setSearching, timeoutRef) => (q) => {
+ setQuery(q);
+ setSelected(null);
+ clearTimeout(timeoutRef.current);
+ if (q.length < 2) { setResults([]); return; }
+ timeoutRef.current = setTimeout(async () => {
+ setSearching(true);
+ try {
+ const res = await fetch(`/zen/api/admin/nuage/users?q=${encodeURIComponent(q)}`, { credentials: 'include' });
+ const data = await res.json();
+ if (data.success) setResults(data.users);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setSearching(false);
+ }
+ }, 300);
+ };
+
+ const handleUserSearch = makeUserSearchHandler(setUserQuery, setSelectedUser, setUserResults, setSearchingUsers, searchTimeout);
+ const handleEditUserSearch = makeUserSearchHandler(setEditUserQuery, setEditSelectedUser, setEditUserResults, setEditSearchingUsers, editSearchTimeout);
+
+ const getExpiresAt = (preset) => {
+ if (!preset || preset === 'keep') return preset === 'keep' ? undefined : null;
+ const days = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 }[preset];
+ if (!days) return null;
+ const d = new Date();
+ d.setDate(d.getDate() + days);
+ return d.toISOString();
+ };
+
+ const handleCreate = async () => {
+ if (shareType === 'user' && !selectedUser) {
+ toast.error('Veuillez sélectionner un utilisateur');
+ return;
+ }
+ setCreating(true);
+ try {
+ const res = await fetch('/zen/api/admin/nuage/shares', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ targetId: target.id,
+ targetType: target.type,
+ shareType,
+ userId: shareType === 'user' ? selectedUser?.id : null,
+ permission,
+ password: shareType === 'anonymous' && password ? password : null,
+ expiresAt: getExpiresAt(expiresPreset),
+ uploadLimitBytes: permission === 'collaborator' && target.type === 'folder' && uploadLimit
+ ? parseInt(uploadLimit) * 1024 * 1024
+ : null,
+ }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage créé');
+ setCreatedShare(data.share);
+ loadShares();
+ setTab('list');
+ setPassword('');
+ setExpiresPreset('');
+ setUploadLimit('');
+ setSelectedUser(null);
+ setUserQuery('');
+ } else {
+ toast.error(data.error || 'Erreur lors de la création du partage');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const openEditModal = (share) => {
+ setEditingShare(share);
+ setEditShareType(share.share_type);
+ setEditPermission(share.permission);
+ setEditExpiresPreset('keep');
+ setEditUploadLimit(share.upload_limit_bytes ? String(Math.round(share.upload_limit_bytes / (1024 * 1024))) : '');
+ setEditPassword('');
+ setEditClearPassword(false);
+ setEditSelectedUser(share.share_type === 'user' ? { id: share.user_id, name: share.name, email: share.email } : null);
+ setEditUserQuery('');
+ setEditUserResults([]);
+ };
+
+ const handleUpdate = async () => {
+ if (editShareType === 'user' && !editSelectedUser) {
+ toast.error('Veuillez sélectionner un utilisateur');
+ return;
+ }
+ setUpdating(true);
+ try {
+ const body = { id: editingShare.id };
+ body.permission = editPermission;
+ body.shareType = editShareType;
+ body.userId = editShareType === 'user' ? (editSelectedUser?.id || null) : null;
+
+ const expiresAt = getExpiresAt(editExpiresPreset);
+ if (expiresAt !== undefined) body.expiresAt = expiresAt;
+
+ if (editPermission === 'collaborator' && target.type === 'folder') {
+ body.uploadLimitBytes = editUploadLimit ? parseInt(editUploadLimit) * 1024 * 1024 : null;
+ } else {
+ body.uploadLimitBytes = null;
+ }
+
+ if (editShareType === 'anonymous') {
+ if (editClearPassword) {
+ body.clearPassword = true;
+ } else if (editPassword) {
+ body.password = editPassword;
+ }
+ } else {
+ body.clearPassword = true;
+ }
+
+ const res = await fetch('/zen/api/admin/nuage/shares', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage modifié');
+ setEditingShare(null);
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur lors de la modification');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setUpdating(false);
+ }
+ };
+
+ const handleRevoke = async (shareId) => {
+ try {
+ const res = await fetch(`/zen/api/admin/nuage/shares?id=${shareId}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage révoqué');
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ }
+ };
+
+ const getSharePublicUrl = (token) => {
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ return `${base}/zen/nuage/partage/${token}`;
+ };
+
+ const copyToClipboard = (text) => {
+ navigator.clipboard.writeText(text).then(() => toast.success('Lien copié !'));
+ };
+
+ const openEmailModal = (share) => {
+ setEmailShareTarget(share);
+ setEmailAddress(share.share_type === 'user' ? (share.email || '') : '');
+ setEmailMessage('');
+ setShowEmailModal(true);
+ };
+
+ const handleSendEmail = async () => {
+ if (!emailAddress) { toast.error('Adresse courriel requise'); return; }
+ setSendingEmail(true);
+ try {
+ const shareUrl = getSharePublicUrl(emailShareTarget.token);
+ const res = await fetch('/zen/api/admin/nuage/shares/email', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ shareId: emailShareTarget.id,
+ toEmail: emailAddress,
+ toName: emailShareTarget.share_type === 'user' ? (emailShareTarget.name || '') : '',
+ customMessage: emailMessage || null,
+ shareUrl,
+ targetName: target.name,
+ targetType: target.type,
+ shareType: emailShareTarget.share_type,
+ expiresAt: emailShareTarget.expires_at || null,
+ }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Courriel envoyé');
+ setShowEmailModal(false);
+ } else {
+ toast.error(data.error || 'Erreur lors de l\'envoi');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setSendingEmail(false);
+ }
+ };
+
+ const isShareExpired = (share) => {
+ if (!share.expires_at) return false;
+ return new Date(share.expires_at) < new Date();
+ };
+
+ const activeTabClass = 'border-b-2 border-neutral-900 dark:border-white text-neutral-900 dark:text-white font-medium';
+ const inactiveTabClass = 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300';
+ const activeChipClass = 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 border-neutral-900 dark:border-white';
+ const inactiveChipClass = 'bg-transparent border-neutral-200 dark:border-neutral-700 text-neutral-500 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-500';
+
+ return (
+
+ {/* Header */}
+
+
+
Partager
+
{target.name}
+
+
+
+
+
+
+ {/* Tabs */}
+
+ setTab('list')}
+ className={`py-2.5 text-xs mr-4 transition-colors ${tab === 'list' ? activeTabClass : inactiveTabClass}`}
+ >
+ Actifs {shares.length > 0 && ({shares.length}) }
+
+ setTab('create')}
+ className={`py-2.5 text-xs transition-colors ${tab === 'create' ? activeTabClass : inactiveTabClass}`}
+ >
+ + Nouveau
+
+
+
+
+ {/* ── List tab ── */}
+ {tab === 'list' && (
+
+ {loading ? (
+
+ ) : shares.length === 0 ? (
+
+
+
Aucun partage actif
+
+ ) : (
+ shares.map(share => {
+ const expired = isShareExpired(share);
+ const url = getSharePublicUrl(share.token);
+ return (
+
+
+
+
+ {share.share_type === 'user' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {share.share_type === 'user'
+ ? share.name || share.email || 'Utilisateur'
+ : 'Lien anonyme'}
+
+
+ {share.permission === 'reader' ? 'Lecteur' : 'Collaborateur'}
+ {share.expires_at && ` · ${formatDate(share.expires_at)}`}
+ {share.upload_limit_bytes && ` · ${formatBytes(share.upload_limit_bytes)}`}
+
+
+
+
+ {!share.is_active ? 'Révoqué' : expired ? 'Expiré' : 'Actif'}
+
+
+
+ {share.is_active && !expired && (
+ <>
+ copyToClipboard(url)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
+ >
+ Copier
+
+ openEmailModal(share)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
+ >
+ Envoyer
+
+ >
+ )}
+ openEditModal(share)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
+ >
+ Modifier
+
+ {share.is_active && !expired && (
+ handleRevoke(share.id)}
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors ml-auto"
+ >
+ Révoquer
+
+ )}
+
+
+ );
+ })
+ )}
+
+ )}
+
+ {/* ── Create tab ── */}
+ {tab === 'create' && (
+
+ {/* Share type */}
+
+
Type
+
+ setShareType('anonymous')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${shareType === 'anonymous' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lien
+
+ setShareType('user')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${shareType === 'user' ? activeChipClass : inactiveChipClass}`}
+ >
+ Utilisateur
+
+
+
+
+ {/* User search */}
+ {shareType === 'user' && (
+
{ setSelectedUser(u); setUserQuery(''); setUserResults([]); }}
+ onClear={() => { setSelectedUser(null); setUserQuery(''); }}
+ query={userQuery}
+ onQueryChange={handleUserSearch}
+ results={userResults}
+ searching={searchingUsers}
+ />
+ )}
+
+ {/* Permission */}
+
+
Permission
+
+ setPermission('reader')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${permission === 'reader' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lecteur
+
+ setPermission('collaborator')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${permission === 'collaborator' ? activeChipClass : inactiveChipClass}`}
+ >
+ Collaborateur
+
+
+
+
+ {/* Upload limit (collaborator + folder only) */}
+ {permission === 'collaborator' && target.type === 'folder' && (
+
+ setUploadLimit(v)}
+ placeholder="Illimité"
+ description="Quota de stockage total pour ce partage"
+ />
+
+ )}
+
+ {/* Password (anonymous only) */}
+ {shareType === 'anonymous' && (
+
+ setPassword(v)}
+ placeholder="Aucun"
+ />
+
+ )}
+
+ {/* Expiration */}
+
+
Expiration
+
+ {['', '24h', '7d', '30d', '90d'].map(preset => (
+ setExpiresPreset(preset)}
+ className={`px-2.5 py-1 text-xs rounded-xl border font-medium transition-all ${expiresPreset === preset ? activeChipClass : inactiveChipClass}`}
+ >
+ {preset === '' ? 'Aucune' : preset === '24h' ? '24 h' : preset === '7d' ? '7 j' : preset === '30d' ? '30 j' : '90 j'}
+
+ ))}
+
+
+
+
+ {shareType === 'anonymous' ? 'Générer le lien' : 'Créer le partage'}
+
+
+ )}
+
+
+ {/* Email modal */}
+ {showEmailModal && (
+
setShowEmailModal(false)}
+ title="Envoyer par courriel"
+ size="sm"
+ footer={
+
+ setShowEmailModal(false)}>Annuler
+ Envoyer
+
+ }
+ >
+
+ setEmailAddress(v)}
+ placeholder="courriel@exemple.com"
+ />
+
+
+ )}
+
+ {/* Edit share modal */}
+ {editingShare && (
+
setEditingShare(null)}
+ title="Modifier le partage"
+ size="sm"
+ footer={
+
+ setEditingShare(null)}>Annuler
+ Enregistrer
+
+ }
+ >
+
+ {/* Type */}
+
+
Type
+
+ setEditShareType('anonymous')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editShareType === 'anonymous' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lien
+
+ setEditShareType('user')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editShareType === 'user' ? activeChipClass : inactiveChipClass}`}
+ >
+ Utilisateur
+
+
+
+
+ {/* User search */}
+ {editShareType === 'user' && (
+
{ setEditSelectedUser(u); setEditUserQuery(''); setEditUserResults([]); }}
+ onClear={() => { setEditSelectedUser(null); setEditUserQuery(''); }}
+ query={editUserQuery}
+ onQueryChange={handleEditUserSearch}
+ results={editUserResults}
+ searching={editSearchingUsers}
+ />
+ )}
+
+ {/* Permission */}
+
+
Permission
+
+ setEditPermission('reader')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editPermission === 'reader' ? activeChipClass : inactiveChipClass}`}
+ >
+ Lecteur
+
+ setEditPermission('collaborator')}
+ className={`flex-1 py-1.5 text-xs rounded-xl border font-medium transition-all ${editPermission === 'collaborator' ? activeChipClass : inactiveChipClass}`}
+ >
+ Collaborateur
+
+
+
+
+ {/* Upload limit (collaborator + folder only) */}
+ {editPermission === 'collaborator' && target.type === 'folder' && (
+ setEditUploadLimit(v)}
+ placeholder="Illimité"
+ description="Quota de stockage total pour ce partage"
+ />
+ )}
+
+ {/* Password (anonymous only) */}
+ {editShareType === 'anonymous' && (
+
+
Mot de passe
+ {editingShare.password_hash && !editClearPassword && (
+
+
Mot de passe défini
+
setEditClearPassword(true)}
+ className="text-[11px] font-medium text-red-500 hover:text-red-600 transition-colors"
+ >
+ Supprimer
+
+
+ )}
+ {editClearPassword && (
+
+
Mot de passe supprimé
+
setEditClearPassword(false)}
+ className="text-[11px] font-medium text-neutral-500 hover:text-neutral-700 transition-colors"
+ >
+ Annuler
+
+
+ )}
+ {!editClearPassword && (
+
setEditPassword(v)}
+ placeholder={editingShare.password_hash ? 'Nouveau mot de passe…' : 'Ajouter un mot de passe…'}
+ />
+ )}
+
+ )}
+
+ {/* Expiration */}
+
+
+ Expiration
+ {editingShare.expires_at && editExpiresPreset === 'keep' && (
+
+ (actuellement : {formatDate(editingShare.expires_at)})
+
+ )}
+
+
+ {['keep', '', '24h', '7d', '30d', '90d'].map(preset => (
+ setEditExpiresPreset(preset)}
+ className={`px-2.5 py-1 text-xs rounded-xl border font-medium transition-all ${editExpiresPreset === preset ? activeChipClass : inactiveChipClass}`}
+ >
+ {preset === 'keep' ? 'Conserver' : preset === '' ? 'Aucune' : preset === '24h' ? '24 h' : preset === '7d' ? '7 j' : preset === '30d' ? '30 j' : '90 j'}
+
+ ))}
+
+
+
+ {/* Reactivate revoked share */}
+ {!editingShare.is_active && (
+
+
+
Réactiver le partage
+
Ce partage est actuellement révoqué
+
+
{
+ setUpdating(true);
+ try {
+ const res = await fetch('/zen/api/admin/nuage/shares', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ id: editingShare.id, isActive: true }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ toast.success('Partage réactivé');
+ setEditingShare(null);
+ loadShares();
+ } else {
+ toast.error(data.error || 'Erreur');
+ }
+ } catch (e) {
+ toast.error('Erreur réseau');
+ } finally {
+ setUpdating(false);
+ }
+ }}>
+ Réactiver
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/modules/nuage/crud.js b/src/modules/nuage/crud.js
new file mode 100644
index 0000000..ca418b2
--- /dev/null
+++ b/src/modules/nuage/crud.js
@@ -0,0 +1,461 @@
+/**
+ * Nuage Module — CRUD
+ * Database operations for folders, files, and shares + R2 integration
+ */
+
+import { query } from '@hykocx/zen/database';
+import { uploadFile, deleteFile, proxyFile } from '@hykocx/zen/storage';
+import crypto from 'crypto';
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+function sanitizeFilename(name) {
+ const sanitized = name
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
+ .replace(/_+/g, '_')
+ .substring(0, 200);
+ return sanitized || 'fichier';
+}
+
+function generateNuageFilePath(fileId, filename) {
+ return `nuage/files/${fileId}/${sanitizeFilename(filename)}`;
+}
+
+// ─── Folders ────────────────────────────────────────────────────────────────
+
+export async function getFolderById(id) {
+ if (!id || !UUID_REGEX.test(id)) return null;
+ const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [id]);
+ return result.rows[0] || null;
+}
+
+export async function getFolderContents(folderId = null) {
+ const param = folderId || null;
+
+ const folderResult = await query(
+ `SELECT f.*,
+ (SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'folder' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share
+ FROM zen_nuage_folders f
+ WHERE f.parent_id ${param ? '= $1' : 'IS NULL'}
+ ORDER BY f.name ASC`,
+ param ? [param] : []
+ );
+
+ const fileResult = await query(
+ `SELECT f.*,
+ (SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'file' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share
+ FROM zen_nuage_files f
+ WHERE f.folder_id ${param ? '= $1' : 'IS NULL'}
+ ORDER BY f.display_name ASC`,
+ param ? [param] : []
+ );
+
+ return {
+ folders: folderResult.rows,
+ files: fileResult.rows,
+ };
+}
+
+export async function getFolderBreadcrumb(folderId) {
+ if (!folderId) return [];
+ const breadcrumb = [];
+ let currentId = folderId;
+
+ while (currentId) {
+ const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [currentId]);
+ const folder = result.rows[0];
+ if (!folder) break;
+ breadcrumb.unshift(folder);
+ currentId = folder.parent_id;
+ }
+ return breadcrumb;
+}
+
+export async function createFolder(name, parentId = null) {
+ let depth = 0;
+ if (parentId) {
+ const parent = await getFolderById(parentId);
+ if (!parent) throw new Error('Dossier parent introuvable');
+ depth = parent.depth + 1;
+ if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte');
+ }
+ const result = await query(
+ `INSERT INTO zen_nuage_folders (name, parent_id, depth) VALUES ($1, $2, $3) RETURNING *`,
+ [name.trim(), parentId, depth]
+ );
+ return result.rows[0];
+}
+
+export async function renameFolder(id, name) {
+ const result = await query(
+ `UPDATE zen_nuage_folders SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
+ [name.trim(), id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function moveFolder(id, newParentId) {
+ let depth = 0;
+ if (newParentId) {
+ const parent = await getFolderById(newParentId);
+ if (!parent) throw new Error('Dossier parent introuvable');
+ depth = parent.depth + 1;
+ if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte');
+ }
+ const result = await query(
+ `UPDATE zen_nuage_folders SET parent_id = $1, depth = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *`,
+ [newParentId, depth, id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function deleteFolder(id) {
+ // Collect all r2_keys recursively before deleting
+ const r2Keys = await collectFolderR2Keys(id);
+ for (const key of r2Keys) {
+ await deleteFile(key).catch(() => {});
+ }
+ await query('DELETE FROM zen_nuage_folders WHERE id = $1', [id]);
+ return { success: true };
+}
+
+async function collectFolderR2Keys(folderId) {
+ const keys = [];
+ const filesResult = await query('SELECT r2_key FROM zen_nuage_files WHERE folder_id = $1', [folderId]);
+ for (const row of filesResult.rows) keys.push(row.r2_key);
+
+ const subFolders = await query('SELECT id FROM zen_nuage_folders WHERE parent_id = $1', [folderId]);
+ for (const sub of subFolders.rows) {
+ const subKeys = await collectFolderR2Keys(sub.id);
+ keys.push(...subKeys);
+ }
+ return keys;
+}
+
+export async function getFolderItemCount(folderId) {
+ const folders = await query('SELECT COUNT(*) FROM zen_nuage_folders WHERE parent_id = $1', [folderId]);
+ const files = await query('SELECT COUNT(*) FROM zen_nuage_files WHERE folder_id = $1', [folderId]);
+ return parseInt(folders.rows[0].count) + parseInt(files.rows[0].count);
+}
+
+// ─── Files ──────────────────────────────────────────────────────────────────
+
+export async function getFileById(id) {
+ if (!id || !UUID_REGEX.test(id)) return null;
+ const result = await query('SELECT * FROM zen_nuage_files WHERE id = $1', [id]);
+ return result.rows[0] || null;
+}
+
+export async function uploadNuageFile(folderId, buffer, originalName, mimeType, size) {
+ const fileId = crypto.randomUUID();
+ const r2Key = generateNuageFilePath(fileId, originalName);
+
+ const uploadResult = await uploadFile({ key: r2Key, body: buffer, contentType: mimeType });
+ if (!uploadResult.success) throw new Error(uploadResult.error || 'Échec du téléversement vers R2');
+
+ const result = await query(
+ `INSERT INTO zen_nuage_files (id, folder_id, original_name, display_name, mime_type, size, r2_key)
+ VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
+ [fileId, folderId || null, originalName, originalName, mimeType, size, r2Key]
+ );
+ return result.rows[0];
+}
+
+export async function renameFile(id, displayName) {
+ const result = await query(
+ `UPDATE zen_nuage_files SET display_name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
+ [displayName.trim(), id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function moveFile(id, newFolderId) {
+ const result = await query(
+ `UPDATE zen_nuage_files SET folder_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`,
+ [newFolderId || null, id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function deleteNuageFile(id) {
+ const file = await getFileById(id);
+ if (!file) throw new Error('Fichier introuvable');
+ await deleteFile(file.r2_key).catch(() => {});
+ await query('DELETE FROM zen_nuage_files WHERE id = $1', [id]);
+ return { success: true };
+}
+
+export async function proxyNuageFile(id, { inline = false } = {}) {
+ const file = await getFileById(id);
+ if (!file) throw new Error('Fichier introuvable');
+ return proxyFile(file.r2_key, { filename: inline ? undefined : file.display_name });
+}
+
+export async function getFolderUploadedSize(folderId) {
+ const result = await query(
+ `SELECT COALESCE(SUM(size), 0) AS total FROM zen_nuage_files WHERE folder_id = $1`,
+ [folderId]
+ );
+ return parseInt(result.rows[0].total);
+}
+
+// ─── Shares ─────────────────────────────────────────────────────────────────
+
+export async function getShareByToken(token) {
+ const result = await query('SELECT * FROM zen_nuage_shares WHERE token = $1', [token]);
+ return result.rows[0] || null;
+}
+
+export async function getShareInfoForMetadata(token) {
+ const result = await query(
+ `SELECT s.*,
+ CASE
+ WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id)
+ WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id)
+ END AS target_name
+ FROM zen_nuage_shares s
+ WHERE s.token = $1`,
+ [token]
+ );
+ return result.rows[0] || null;
+}
+
+export async function getSharesForTarget(targetId, targetType) {
+ const result = await query(
+ `SELECT s.*, u.name, u.email
+ FROM zen_nuage_shares s
+ LEFT JOIN zen_auth_users u ON u.id = s.user_id
+ WHERE s.target_id = $1 AND s.target_type = $2
+ ORDER BY s.created_at DESC`,
+ [targetId, targetType]
+ );
+ return result.rows;
+}
+
+export async function getSharesForUser(userId) {
+ const result = await query(
+ `SELECT s.*
+ FROM zen_nuage_shares s
+ WHERE s.user_id = $1
+ AND s.is_active = true
+ AND (s.expires_at IS NULL OR s.expires_at > CURRENT_TIMESTAMP)
+ ORDER BY s.created_at DESC`,
+ [userId]
+ );
+ return result.rows;
+}
+
+export async function getAllActiveShares() {
+ const result = await query(
+ `SELECT s.*, u.name, u.email,
+ CASE
+ WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id)
+ WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id)
+ END AS target_name
+ FROM zen_nuage_shares s
+ LEFT JOIN zen_auth_users u ON u.id = s.user_id
+ ORDER BY s.created_at DESC`
+ );
+ return result.rows;
+}
+
+export async function createShare({
+ targetId,
+ targetType,
+ shareType,
+ userId = null,
+ permission,
+ password = null,
+ expiresAt = null,
+ uploadLimitBytes = null,
+}) {
+ const token = crypto.randomBytes(32).toString('hex');
+ let passwordHash = null;
+
+ if (password) {
+ const salt = crypto.randomBytes(16).toString('hex');
+ passwordHash = await new Promise((resolve, reject) => {
+ crypto.scrypt(password, salt, 64, (err, key) => {
+ if (err) reject(err);
+ else resolve(`${salt}:${key.toString('hex')}`);
+ });
+ });
+ }
+
+ const result = await query(
+ `INSERT INTO zen_nuage_shares
+ (token, target_type, target_id, share_type, user_id, permission, password_hash, expires_at, upload_limit_bytes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING *`,
+ [token, targetType, targetId, shareType, userId, permission, passwordHash, expiresAt, uploadLimitBytes]
+ );
+ return result.rows[0];
+}
+
+export async function revokeShare(id) {
+ const result = await query(
+ `UPDATE zen_nuage_shares SET is_active = false WHERE id = $1 RETURNING *`,
+ [id]
+ );
+ return result.rows[0] || null;
+}
+
+export async function updateShare(id, {
+ permission,
+ expiresAt,
+ uploadLimitBytes,
+ password,
+ clearPassword,
+ userId,
+ shareType,
+ isActive,
+} = {}) {
+ let passwordHash = undefined;
+ if (clearPassword) {
+ passwordHash = null;
+ } else if (password) {
+ const salt = crypto.randomBytes(16).toString('hex');
+ passwordHash = await new Promise((resolve, reject) => {
+ crypto.scrypt(password, salt, 64, (err, key) => {
+ if (err) reject(err);
+ else resolve(`${salt}:${key.toString('hex')}`);
+ });
+ });
+ }
+
+ const sets = [];
+ const values = [];
+ let idx = 1;
+
+ if (permission !== undefined) { sets.push(`permission = $${idx++}`); values.push(permission); }
+ if (expiresAt !== undefined) { sets.push(`expires_at = $${idx++}`); values.push(expiresAt); }
+ if (uploadLimitBytes !== undefined){ sets.push(`upload_limit_bytes = $${idx++}`); values.push(uploadLimitBytes); }
+ if (passwordHash !== undefined) { sets.push(`password_hash = $${idx++}`); values.push(passwordHash); }
+ if (userId !== undefined) { sets.push(`user_id = $${idx++}`); values.push(userId); }
+ if (shareType !== undefined) { sets.push(`share_type = $${idx++}`); values.push(shareType); }
+ if (isActive !== undefined) { sets.push(`is_active = $${idx++}`); values.push(isActive); }
+
+ if (sets.length === 0) throw new Error('Aucun champ à modifier');
+
+ values.push(id);
+ const result = await query(
+ `UPDATE zen_nuage_shares SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
+ values
+ );
+ return result.rows[0] || null;
+}
+
+export function isShareValid(share) {
+ if (!share || !share.is_active) return false;
+ if (share.expires_at && new Date(share.expires_at) < new Date()) return false;
+ return true;
+}
+
+export async function verifySharePassword(share, password) {
+ if (!share.password_hash) return true;
+ const [salt, storedKey] = share.password_hash.split(':');
+ return new Promise((resolve, reject) => {
+ crypto.scrypt(password, salt, 64, (err, derivedKey) => {
+ if (err) reject(err);
+ else {
+ const storedBuf = Buffer.from(storedKey, 'hex');
+ resolve(
+ storedBuf.length === derivedKey.length &&
+ crypto.timingSafeEqual(storedBuf, derivedKey)
+ );
+ }
+ });
+ });
+}
+
+// ─── User search (for share panel) ──────────────────────────────────────────
+
+export async function searchUsers(q, limit = 10) {
+ const like = `%${q}%`;
+ const result = await query(
+ `SELECT id, name, email
+ FROM zen_auth_users
+ WHERE (name ILIKE $1 OR email ILIKE $1)
+ ORDER BY name
+ LIMIT $2`,
+ [like, limit]
+ );
+ return result.rows;
+}
+
+// ─── Shared content access (for public pages and client section) ─────────────
+
+export async function getSharedFolderContents(share, folderId = null) {
+ if (share.target_type === 'file') {
+ const file = await getFileById(share.target_id);
+ return { folders: [], files: file ? [file] : [] };
+ }
+
+ // For folder shares: start at the shared root or a sub-folder within it
+ const targetId = folderId || share.target_id;
+
+ // Verify the requested folder is within the share scope
+ if (folderId && folderId !== share.target_id) {
+ const breadcrumb = await getFolderBreadcrumb(folderId);
+ const inScope = breadcrumb.some(f => f.id === share.target_id);
+ if (!inScope) throw new Error('Dossier hors de la portée du partage');
+ }
+
+ return getFolderContents(targetId);
+}
+
+export async function getSharedBreadcrumb(share, folderId = null) {
+ if (!folderId || folderId === share.target_id) return [];
+ const full = await getFolderBreadcrumb(folderId);
+ const rootIndex = full.findIndex(f => f.id === share.target_id);
+ return rootIndex >= 0 ? full.slice(rootIndex + 1) : [];
+}
+
+// ─── Share scope validators ──────────────────────────────────────────────────
+
+/**
+ * Checks whether a file belongs to the scope of a share.
+ * For file shares: the file must be the shared file itself.
+ * For folder shares: the file must live inside the shared folder tree.
+ */
+export async function isFileInShare(fileId, share) {
+ const file = await getFileById(fileId);
+ if (!file) return false;
+
+ if (share.target_type === 'file') {
+ return file.id === share.target_id;
+ }
+
+ // Folder share: file must be in the shared folder or a descendant
+ if (!file.folder_id) return false;
+ if (file.folder_id === share.target_id) return true;
+ const breadcrumb = await getFolderBreadcrumb(file.folder_id);
+ return breadcrumb.some(f => f.id === share.target_id);
+}
+
+/**
+ * Checks whether a folder belongs to the scope of a share.
+ * Only meaningful for folder shares; the folder must be the root or a descendant.
+ */
+export async function isFolderInShare(folderId, share) {
+ if (share.target_type !== 'folder') return false;
+ if (folderId === share.target_id) return true;
+ const breadcrumb = await getFolderBreadcrumb(folderId);
+ return breadcrumb.some(f => f.id === share.target_id);
+}
+
+// ─── Password cookie helpers (server-side enforcement) ───────────────────────
+
+export function getPasswordCookieName(token) {
+ return `zen_nuage_pw_${token}`;
+}
+
+export function signPasswordToken(token) {
+ const secret = process.env.ZEN_SESSION_SECRET;
+ if (!secret) throw new Error('ZEN_SESSION_SECRET environment variable is not set');
+ return crypto.createHmac('sha256', secret).update(token).digest('hex');
+}
diff --git a/src/modules/nuage/dashboard/ClientNuageSection.js b/src/modules/nuage/dashboard/ClientNuageSection.js
new file mode 100644
index 0000000..0cfd8ca
--- /dev/null
+++ b/src/modules/nuage/dashboard/ClientNuageSection.js
@@ -0,0 +1,424 @@
+'use client';
+
+/**
+ * Client Nuage Section
+ * Unified file explorer for the client portal.
+ * Shares are displayed as folders/files at the root level.
+ * Clicking a folder share navigates into it like a real file explorer.
+ *
+ * Props:
+ * apiBasePath — base URL for the Zen API (default: '/zen/api')
+ * emptyMessage — message shown when there are no active shares
+ * newTab — open file download links in a new tab
+ */
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ CloudUploadIcon,
+ Tick02Icon,
+} from '../../../shared/Icons.js';
+import { Loading, Button, Card, Breadcrumb } from '../../../shared/components';
+import NuageFileTable from '../components/NuageFileTable.js';
+
+// ─── Main explorer ─────────────────────────────────────────────────────────────
+
+export default function ClientNuageSection({
+ apiBasePath = '/zen/api',
+ emptyMessage = 'Aucun document partagé pour le moment.',
+ newTab = false,
+}) {
+ const fileInputRef = useRef(null);
+ const isInitialMount = useRef(true);
+ const skipHistoryPush = useRef(false);
+
+ // Navigation
+ const [activeShare, setActiveShare] = useState(null);
+ const [folderId, setFolderId] = useState(null);
+
+ // Data
+ const [shares, setShares] = useState([]);
+ const [contents, setContents] = useState({ folders: [], files: [] });
+ const [breadcrumb, setBreadcrumb] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Upload
+ const [uploading, setUploading] = useState(false);
+ const [uploadQueue, setUploadQueue] = useState([]);
+ const [uploadError, setUploadError] = useState(null);
+
+ // Sort (only used inside a share)
+ const [sortBy, setSortBy] = useState('name');
+ const [sortOrder, setSortOrder] = useState('asc');
+
+ // ─── Data fetching ──────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ if (!activeShare) {
+ fetchShares();
+ } else {
+ fetchContents();
+ }
+ }, [activeShare, folderId]);
+
+ // Sync navigation state to URL history
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (isInitialMount.current) {
+ isInitialMount.current = false;
+ return;
+ }
+ if (skipHistoryPush.current) {
+ skipHistoryPush.current = false;
+ return;
+ }
+ const url = new URL(window.location.href);
+ if (activeShare) {
+ url.searchParams.set('share', activeShare.token);
+ if (folderId) url.searchParams.set('folder', folderId);
+ else url.searchParams.delete('folder');
+ } else {
+ url.searchParams.delete('share');
+ url.searchParams.delete('folder');
+ }
+ window.history.pushState(
+ { shareToken: activeShare?.token ?? null, folderId: folderId ?? null },
+ '',
+ url.toString()
+ );
+ }, [activeShare, folderId]);
+
+ // Handle browser back/forward
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const onPop = (event) => {
+ skipHistoryPush.current = true;
+ const state = event.state;
+ if (!state?.shareToken) {
+ setActiveShare(null);
+ setFolderId(null);
+ setBreadcrumb([]);
+ setContents({ folders: [], files: [] });
+ } else {
+ const share = shares.find(s => s.token === state.shareToken);
+ setActiveShare(share || null);
+ setFolderId(state.folderId || null);
+ }
+ };
+ window.addEventListener('popstate', onPop);
+ return () => window.removeEventListener('popstate', onPop);
+ }, [shares]);
+
+ const fetchShares = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ const res = await fetch(`${base}${apiBasePath}/nuage/me`, { credentials: 'include' });
+ const data = await res.json();
+ if (!res.ok || !data.success) throw new Error(data.error || 'Erreur lors du chargement');
+ const allShares = data.shares || [];
+ setShares(allShares);
+ // Restore navigation from URL on initial load
+ const urlParams = new URLSearchParams(window.location.search);
+ const shareTokenFromUrl = urlParams.get('share');
+ if (shareTokenFromUrl) {
+ const share = allShares.find(s => s.token === shareTokenFromUrl);
+ if (share) {
+ skipHistoryPush.current = true;
+ setActiveShare(share);
+ setFolderId(urlParams.get('folder') || null);
+ }
+ }
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const fetchContents = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ const params = new URLSearchParams({ shareId: activeShare.token });
+ if (folderId) params.set('folder', folderId);
+ const res = await fetch(`${base}${apiBasePath}/nuage/shared?${params}`, { credentials: 'include' });
+ const data = await res.json();
+ if (!res.ok || !data.success) throw new Error(data.error || 'Erreur');
+ setContents({ folders: data.folders || [], files: data.files || [] });
+ setBreadcrumb(data.breadcrumb || []);
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // ─── Navigation ─────────────────────────────────────────────────────────────
+
+ const goToRoot = () => {
+ setActiveShare(null);
+ setFolderId(null);
+ setBreadcrumb([]);
+ setContents({ folders: [], files: [] });
+ setSortBy('name');
+ setSortOrder('asc');
+ };
+
+ const goToShareRoot = () => {
+ setFolderId(null);
+ };
+
+ const openShare = (share) => {
+ const isExpired = share.expires_at && new Date(share.expires_at) < new Date();
+ if (isExpired) return;
+ setActiveShare(share);
+ setFolderId(null);
+ setBreadcrumb([]);
+ };
+
+ // ─── Sort helpers ────────────────────────────────────────────────────────────
+
+ const sorted = (items, isFolder) => {
+ return [...items].sort((a, b) => {
+ let valA, valB;
+ if (sortBy === 'name') {
+ valA = (isFolder ? a.name : a.display_name)?.toLowerCase();
+ valB = (isFolder ? b.name : b.display_name)?.toLowerCase();
+ } else if (sortBy === 'date') {
+ valA = a.created_at;
+ valB = b.created_at;
+ } else if (sortBy === 'size' && !isFolder) {
+ valA = a.size;
+ valB = b.size;
+ } else {
+ return 0;
+ }
+ if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
+ if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
+ return 0;
+ });
+ };
+
+ const toggleSort = (field) => {
+ if (sortBy === field) setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
+ else { setSortBy(field); setSortOrder('asc'); }
+ };
+
+ // ─── Download ────────────────────────────────────────────────────────────────
+
+ const getProxyUrl = (fileId) => {
+ const params = new URLSearchParams({ fileId, shareId: activeShare.token });
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+ return `${base}${apiBasePath}/nuage/download?${params}`;
+ };
+
+ const handleDownload = (file) => {
+ const a = document.createElement('a');
+ a.href = getProxyUrl(file.id);
+ a.download = file.display_name;
+ if (newTab) a.target = '_blank';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ };
+
+ // ─── Upload ──────────────────────────────────────────────────────────────────
+
+ const uploadFiles = async (files) => {
+ const fileList = Array.from(files);
+ setUploading(true);
+ setUploadError(null);
+ setUploadQueue(fileList.map(f => ({ name: f.name, done: false })));
+ const base = typeof window !== 'undefined' ? window.location.origin : '';
+
+ for (let i = 0; i < fileList.length; i++) {
+ const file = fileList[i];
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('shareId', activeShare.token);
+ if (folderId) formData.append('folderId', folderId);
+ try {
+ const res = await fetch(`${base}${apiBasePath}/nuage/upload`, {
+ method: 'POST',
+ credentials: 'include',
+ body: formData,
+ });
+ const data = await res.json();
+ if (data.success) {
+ setUploadQueue(q => q.map((f, idx) => idx === i ? { ...f, done: true } : f));
+ } else {
+ setUploadError(data.error || 'Erreur lors du téléversement');
+ }
+ } catch (e) {
+ setUploadError('Erreur réseau');
+ }
+ }
+
+ setUploading(false);
+ setUploadQueue([]);
+ fetchContents();
+ };
+
+ // ─── Table data ──────────────────────────────────────────────────────────────
+
+ let tableData = [];
+ let tableSortBy;
+ let tableSortOrder;
+ let tableOnSort;
+
+ if (!activeShare) {
+ // Root: shares as rows (expired shares hidden)
+ tableData = shares
+ .filter(share => !share.expires_at || new Date(share.expires_at) >= new Date())
+ .map(share => ({ ...share, _type: 'share' }));
+ } else {
+ // Inside a share: folders + files
+ tableData = [
+ ...sorted(contents.folders, true).map(f => ({ ...f, _type: 'folder' })),
+ ...sorted(contents.files, false).map(f => ({ ...f, _type: 'file' })),
+ ];
+ tableSortBy = sortBy;
+ tableSortOrder = sortOrder;
+ tableOnSort = toggleSort;
+ }
+
+
+ // ─── Breadcrumb ──────────────────────────────────────────────────────────────
+
+ const breadcrumbItems = [
+ {
+ key: 'root',
+ label: 'Mes fichiers',
+ onClick: goToRoot,
+ active: !activeShare,
+ },
+ ...(activeShare
+ ? [
+ {
+ key: 'share',
+ label: activeShare.target_name || 'Partage',
+ onClick: goToShareRoot,
+ active: breadcrumb.length === 0,
+ },
+ ...(breadcrumb.length > 3
+ ? [
+ { key: 'ellipsis', label: '···', onClick: () => setFolderId(breadcrumb[0].id), active: false },
+ ...breadcrumb.slice(-2).map((f, i) => ({
+ key: f.id,
+ label: f.name,
+ onClick: () => setFolderId(f.id),
+ active: i === 1,
+ })),
+ ]
+ : breadcrumb.map((f, i) => ({
+ key: f.id,
+ label: f.name,
+ onClick: () => setFolderId(f.id),
+ active: i === breadcrumb.length - 1,
+ }))
+ ),
+ ]
+ : []),
+ ];
+
+ // ─── Render ───────────────────────────────────────────────────────────────────
+
+ const showUploadZone = activeShare &&
+ activeShare.permission === 'collaborator' &&
+ activeShare.target_type === 'folder';
+
+ return (
+
+
{ if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = ''; }}
+ />
+
+ {/* Breadcrumb */}
+
+
+ {/* Upload zone (collaborator folder shares only) */}
+ {showUploadZone && (
+
e.preventDefault()}
+ onDrop={e => { e.preventDefault(); uploadFiles(e.dataTransfer.files); }}
+ >
+
+
+ {uploading ? 'Téléversement…' : 'Glissez des fichiers ici'}
+
+
fileInputRef.current?.click()}
+ loading={uploading}
+ icon={ }
+ >
+ Choisir des fichiers
+
+ {uploadError &&
{uploadError}
}
+
+ )}
+
+ {/* Upload queue */}
+ {uploading && uploadQueue.length > 0 && (
+
+
Téléversement en cours…
+
+ {uploadQueue.map((f, i) => (
+
+ {f.done ? : }
+ {f.name}
+
+ ))}
+
+
+ )}
+
+ {/* Explorer table */}
+
+ {error ? (
+ {error}
+ ) : (
+ {
+ if (item._type === 'share') {
+ openShare(item);
+ } else if (item._type === 'folder') {
+ setFolderId(item.id);
+ }
+ }}
+ renderActions={(item) => {
+ if (item._type !== 'file') return null;
+ return (
+ { e.stopPropagation(); handleDownload(item); }}
+ className='-m-2'
+ >
+ Télécharger
+
+ );
+ }}
+ emptyMessage={!activeShare ? emptyMessage : 'Ce dossier est vide'}
+ size="sm"
+ className='min-h-64'
+ />
+ )}
+
+
+
+ );
+}
diff --git a/src/modules/nuage/dashboard/index.js b/src/modules/nuage/dashboard/index.js
new file mode 100644
index 0000000..a20ff45
--- /dev/null
+++ b/src/modules/nuage/dashboard/index.js
@@ -0,0 +1,10 @@
+/**
+ * Nuage Dashboard Module
+ * Exports client-facing section for the user portal
+ *
+ * Stats actions are kept separate to avoid 'use server' conflicts:
+ * import via modules.actions.js or directly from statsActions.js
+ */
+
+export { default as ClientNuageSection } from './ClientNuageSection.js';
+export { default } from './ClientNuageSection.js';
diff --git a/src/modules/nuage/db.js b/src/modules/nuage/db.js
new file mode 100644
index 0000000..1f53dd2
--- /dev/null
+++ b/src/modules/nuage/db.js
@@ -0,0 +1,109 @@
+/**
+ * Nuage Module — Database
+ * Creates tables for folders, files, and shares
+ */
+
+import { query } from '@hykocx/zen/database';
+
+async function tableExists(tableName) {
+ const result = await query(
+ `SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = $1
+ )`,
+ [tableName]
+ );
+ return result.rows[0].exists;
+}
+
+/**
+ * Create all Nuage tables
+ * @returns {Promise<{ created: string[], skipped: string[] }>}
+ */
+export async function createTables() {
+ const created = [];
+ const skipped = [];
+
+ // zen_nuage_folders
+ if (!(await tableExists('zen_nuage_folders'))) {
+ await query(`
+ CREATE TABLE zen_nuage_folders (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ parent_id UUID REFERENCES zen_nuage_folders(id) ON DELETE CASCADE,
+ depth INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ await query(`CREATE INDEX ON zen_nuage_folders(parent_id)`);
+ console.log(' ✓ Created zen_nuage_folders');
+ created.push('zen_nuage_folders');
+ } else {
+ console.log(' - Skipped zen_nuage_folders (already exists)');
+ skipped.push('zen_nuage_folders');
+ }
+
+ // zen_nuage_files
+ if (!(await tableExists('zen_nuage_files'))) {
+ await query(`
+ CREATE TABLE zen_nuage_files (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ folder_id UUID REFERENCES zen_nuage_folders(id) ON DELETE CASCADE,
+ original_name VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255) NOT NULL,
+ mime_type VARCHAR(255) NOT NULL,
+ size BIGINT NOT NULL DEFAULT 0,
+ r2_key TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ await query(`CREATE INDEX ON zen_nuage_files(folder_id)`);
+ console.log(' ✓ Created zen_nuage_files');
+ created.push('zen_nuage_files');
+ } else {
+ console.log(' - Skipped zen_nuage_files (already exists)');
+ skipped.push('zen_nuage_files');
+ }
+
+ // zen_nuage_shares
+ // References zen_auth_users (CMS auth users table)
+ if (!(await tableExists('zen_nuage_shares'))) {
+ await query(`
+ CREATE TABLE zen_nuage_shares (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ token VARCHAR(255) UNIQUE NOT NULL,
+ target_type VARCHAR(10) NOT NULL CHECK (target_type IN ('file', 'folder')),
+ target_id UUID NOT NULL,
+ share_type VARCHAR(10) NOT NULL CHECK (share_type IN ('user', 'anonymous')),
+ user_id TEXT REFERENCES zen_auth_users(id) ON DELETE CASCADE,
+ permission VARCHAR(15) NOT NULL CHECK (permission IN ('reader', 'collaborator')),
+ password_hash TEXT,
+ expires_at TIMESTAMPTZ,
+ upload_limit_bytes BIGINT,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ await query(`CREATE INDEX ON zen_nuage_shares(token)`);
+ await query(`CREATE INDEX ON zen_nuage_shares(target_id, target_type, is_active)`);
+ await query(`CREATE INDEX ON zen_nuage_shares(user_id, is_active)`);
+ console.log(' ✓ Created zen_nuage_shares');
+ created.push('zen_nuage_shares');
+ } else {
+ console.log(' - Skipped zen_nuage_shares (already exists)');
+ skipped.push('zen_nuage_shares');
+ }
+
+ return { created, skipped };
+}
+
+/**
+ * Drop all Nuage tables (in dependency order)
+ */
+export async function dropTables() {
+ await query('DROP TABLE IF EXISTS zen_nuage_shares CASCADE');
+ await query('DROP TABLE IF EXISTS zen_nuage_files CASCADE');
+ await query('DROP TABLE IF EXISTS zen_nuage_folders CASCADE');
+}
diff --git a/src/modules/nuage/email/NuageShareEmail.jsx b/src/modules/nuage/email/NuageShareEmail.jsx
new file mode 100644
index 0000000..9f91b32
--- /dev/null
+++ b/src/modules/nuage/email/NuageShareEmail.jsx
@@ -0,0 +1,105 @@
+/**
+ * Nuage Share Notification Email
+ * Sent when sharing a file or folder with a user or via anonymous link
+ */
+
+import { Button, Section, Text, Link } from '@react-email/components';
+import { BaseLayout } from '@hykocx/zen/email/templates';
+
+/**
+ * @param {object} props
+ * @param {string} props.recipientName — Name or email of the recipient
+ * @param {string} props.shareUrl — Full URL to access the share
+ * @param {string} props.targetName — Name of the shared file or folder
+ * @param {'file'|'folder'} props.targetType
+ * @param {'user'|'anonymous'} props.shareType
+ * @param {string|null} props.expiresAt — ISO date string of expiration, or null
+ * @param {string|null} props.customMessage — Optional message from the sender
+ * @param {string} props.companyName
+ */
+export const NuageShareEmail = ({
+ recipientName,
+ shareUrl,
+ targetName,
+ targetType = 'folder',
+ shareType = 'anonymous',
+ expiresAt = null,
+ customMessage = null,
+ companyName,
+}) => {
+ const appName = companyName || process.env.ZEN_NAME || 'Nuage';
+ const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@exemple.com';
+
+ const isFile = targetType === 'file';
+ const formattedExpiry = expiresAt
+ ? new Date(expiresAt).toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' })
+ : null;
+ const preview = `Le ${isFile ? 'fichier' : 'dossier'} « ${targetName} » a été partagé avec vous.${formattedExpiry ? ` Ce partage reste accessible jusqu'au ${formattedExpiry}.` : ''}`;
+
+ return (
+
+
+ Bonjour {recipientName},{' '}
+ {isFile ? 'un fichier a été partagé avec vous.' : 'un dossier a été partagé avec vous.'}
+ {formattedExpiry && <>{' '}Ce partage expire le {formattedExpiry} .>}
+
+
+ {customMessage && (
+
+ )}
+
+
+
+ Détails du partage
+
+
+ Type :
+ {isFile ? 'Fichier' : 'Dossier'}
+
+
+ Nom :
+ {targetName}
+
+ {formattedExpiry && (
+
+ Expire le :
+ {formattedExpiry}
+
+ )}
+
+
+
+
+ {isFile ? 'Accéder au fichier' : 'Accéder au dossier'}
+
+
+
+
+ Lien :{' '}
+
+ {shareUrl}
+
+
+
+
+ {shareType === 'anonymous'
+ ? 'Ce lien est accessible à toute personne qui le possède.'
+ : 'Ce partage est destiné à votre compte uniquement.'}
+ {' '}Si vous n'attendiez pas ce message, ignorez-le.
+
+
+ );
+};
diff --git a/src/modules/nuage/email/index.js b/src/modules/nuage/email/index.js
new file mode 100644
index 0000000..b22a15c
--- /dev/null
+++ b/src/modules/nuage/email/index.js
@@ -0,0 +1,4 @@
+/**
+ * Nuage Email Templates
+ */
+export { NuageShareEmail } from './NuageShareEmail.jsx';
diff --git a/src/modules/nuage/metadata.js b/src/modules/nuage/metadata.js
new file mode 100644
index 0000000..258a88e
--- /dev/null
+++ b/src/modules/nuage/metadata.js
@@ -0,0 +1,69 @@
+/**
+ * Nuage Metadata Utilities
+ * Functions to generate dynamic metadata for nuage share pages
+ */
+
+import { generateMetadata, generateRobots } from '../../shared/lib/metadata/index.js';
+import { getShareInfoForMetadata } from './crud.js';
+import { getAppName } from '../../shared/lib/appConfig.js';
+
+/**
+ * Generate metadata for a public share page.
+ *
+ * @param {string} token - Share token
+ * @param {Object} options - Generation options
+ * @returns {Promise
} Next.js metadata object
+ */
+export async function generateShareMetadata(token, options = {}) {
+ try {
+ const share = await getShareInfoForMetadata(token);
+
+ if (!share || !share.is_active) {
+ return generateMetadata({
+ title: 'Lien invalide',
+ description: 'Ce lien de partage n\'est plus valide.',
+ robots: generateRobots({ index: false, follow: false }),
+ }, options);
+ }
+
+ if (share.expires_at && new Date(share.expires_at) < new Date()) {
+ return generateMetadata({
+ title: 'Lien expiré',
+ description: 'Ce lien de partage a expiré.',
+ robots: generateRobots({ index: false, follow: false }),
+ }, options);
+ }
+
+ const appName = options.appName || getAppName();
+ const isFolder = share.target_type === 'folder';
+ const typeLabel = isFolder ? 'Dossier partagé' : 'Fichier partagé';
+ const targetName = share.target_name || typeLabel;
+
+ return generateMetadata({
+ title: targetName !== typeLabel ? `${typeLabel} — ${targetName}` : typeLabel,
+ description: `Accédez au ${isFolder ? 'dossier' : 'fichier'} partagé « ${targetName} ».`,
+ openGraph: {
+ title: targetName !== typeLabel ? `${typeLabel} — ${targetName}` : typeLabel,
+ description: `Accédez au ${isFolder ? 'dossier' : 'fichier'} partagé « ${targetName} ».`,
+ type: 'website',
+ },
+ robots: generateRobots({ index: false, follow: false }),
+ }, { ...options, appName });
+ } catch (error) {
+ console.error('Error generating nuage share metadata:', error);
+
+ return generateMetadata({
+ title: 'Fichier partagé',
+ description: 'Accédez au contenu partagé.',
+ robots: generateRobots({ index: false, follow: false }),
+ }, options);
+ }
+}
+
+/**
+ * Metadata configuration for module registration.
+ * Maps route types to their metadata generator functions.
+ */
+export default {
+ share: generateShareMetadata,
+};
diff --git a/src/modules/nuage/module.config.js b/src/modules/nuage/module.config.js
new file mode 100644
index 0000000..e191199
--- /dev/null
+++ b/src/modules/nuage/module.config.js
@@ -0,0 +1,44 @@
+/**
+ * Nuage Module Configuration
+ * File manager with Cloudflare R2 storage and share system
+ *
+ * This file is used by both server and client:
+ * - Server: navigation, publicRoutes, basic info
+ * - Client: adminPages, publicPages (lazy-loaded components)
+ */
+
+import { lazy } from 'react';
+
+export default {
+ name: 'nuage',
+ displayName: 'Nuage',
+ version: '1.0.0',
+ description: 'Gestionnaire de fichiers intégré avec partage de documents via Cloudflare R2',
+
+ dependencies: [],
+
+ envVars: [],
+
+ navigation: {
+ id: 'nuage',
+ title: 'Nuage',
+ icon: 'CloudIcon',
+ items: [
+ { name: 'Explorateur', href: '/admin/nuage/explorateur', icon: 'Folder01Icon' },
+ { name: 'Partages', href: '/admin/nuage/partages', icon: 'Link02Icon' },
+ ],
+ },
+
+ adminPages: {
+ '/admin/nuage/explorateur': lazy(() => import('./admin/ExplorerPage.js')),
+ '/admin/nuage/partages': lazy(() => import('./admin/SharesPage.js')),
+ },
+
+ publicPages: {
+ default: lazy(() => import('./pages/NuagePublicPages.js')),
+ },
+
+ publicRoutes: [
+ { pattern: 'partage/:token', description: 'Accès à un partage anonyme' },
+ ],
+};
diff --git a/src/modules/nuage/pages/NuagePublicPages.js b/src/modules/nuage/pages/NuagePublicPages.js
new file mode 100644
index 0000000..76428e5
--- /dev/null
+++ b/src/modules/nuage/pages/NuagePublicPages.js
@@ -0,0 +1,427 @@
+'use client';
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ CloudUploadIcon,
+ Alert01Icon,
+} from '../../../shared/Icons.js';
+import { Loading, Input, Button, Card, Breadcrumb } from '../../../shared/components';
+import NuageFileTable, { formatBytes } from '../components/NuageFileTable.js';
+
+// ─── Password Gate ────────────────────────────────────────────────────────────
+
+function PasswordGate({ onSubmit, error }) {
+ const [password, setPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!password.trim()) return;
+ setLoading(true);
+ await onSubmit(password);
+ setLoading(false);
+ };
+
+ return (
+
+
+
+
+
+
+
Accès protégé
+
Ce lien est protégé par un mot de passe.
+
+
+
+
+ );
+}
+
+// ─── Error Page ───────────────────────────────────────────────────────────────
+
+function ErrorPage({ message }) {
+ return (
+
+
+
+
Lien invalide
+
{message || 'Ce lien n\'est plus valide.'}
+
+
+ );
+}
+
+// ─── Content View ─────────────────────────────────────────────────────────────
+
+function ContentView({
+ token,
+ permission,
+ targetType,
+ uploadLimitBytes,
+ getSharedContentsAction,
+ uploadToShareAction,
+ publicLogoWhite,
+ publicLogoBlack,
+ publicDashboardUrl,
+}) {
+ const fileInputRef = useRef(null);
+ const skipHistoryPush = useRef(false);
+ const [folderId, setFolderId] = useState(null);
+ const [contents, setContents] = useState({ folders: [], files: [] });
+ const [breadcrumb, setBreadcrumb] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const [uploadError, setUploadError] = useState(null);
+ const [dragOver, setDragOver] = useState(false);
+
+ useEffect(() => {
+ loadContents();
+ }, [folderId]);
+
+ // Sync folder navigation to URL history
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ if (skipHistoryPush.current) {
+ skipHistoryPush.current = false;
+ return;
+ }
+ const url = new URL(window.location.href);
+ if (folderId) url.searchParams.set('folder', folderId);
+ else url.searchParams.delete('folder');
+ window.history.pushState({ folderId: folderId ?? null }, '', url.toString());
+ }, [folderId]);
+
+ // Handle browser back/forward
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const onPop = (event) => {
+ skipHistoryPush.current = true;
+ const id = event.state?.folderId ?? null;
+ setFolderId(id);
+ };
+ window.addEventListener('popstate', onPop);
+ return () => window.removeEventListener('popstate', onPop);
+ }, []);
+
+ const loadContents = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await getSharedContentsAction(token, folderId);
+ if (result.success) {
+ setContents({ folders: result.folders || [], files: result.files || [] });
+ setBreadcrumb(result.breadcrumb || []);
+ } else {
+ setError(result.error || 'Erreur lors du chargement');
+ }
+ } catch (e) {
+ setError('Erreur lors du chargement du contenu');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDownload = (fileId, filename) => {
+ const a = document.createElement('a');
+ a.href = `/zen/api/nuage/share/download?token=${encodeURIComponent(token)}&fileId=${encodeURIComponent(fileId)}`;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ };
+
+ const uploadFiles = async (files) => {
+ setUploading(true);
+ setUploadError(null);
+ for (const file of Array.from(files)) {
+ const formData = new FormData();
+ formData.append('file', file);
+ if (folderId) formData.append('folderId', folderId);
+ const result = await uploadToShareAction(token, formData);
+ if (!result.success) {
+ setUploadError(result.error || 'Erreur lors du téléversement');
+ }
+ }
+ setUploading(false);
+ loadContents();
+ };
+
+ const totalItems = contents.folders.length + contents.files.length;
+ const currentFolderName = breadcrumb.length > 0
+ ? breadcrumb[breadcrumb.length - 1].name
+ : targetType === 'file' ? 'Fichier partagé' : 'Dossier partagé';
+
+ const subtext = loading
+ ? 'Chargement…'
+ : error
+ ? 'Erreur lors du chargement'
+ : totalItems === 0
+ ? permission === 'collaborator' ? 'Aucun fichier · Vous pouvez déposer des fichiers' : 'Aucun fichier'
+ : `${totalItems} élément${totalItems > 1 ? 's' : ''} · ${permission === 'reader' ? 'Lecture seule' : 'Collaborateur'}`;
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Logo — far left */}
+
+ {(publicLogoWhite || publicLogoBlack) ? (() => {
+ const logoHref = publicDashboardUrl && publicDashboardUrl.trim() !== '' ? publicDashboardUrl.trim() : null;
+ const logoImgs = (
+ <>
+ {publicLogoBlack && (
+
+ )}
+ {publicLogoWhite && (
+
+ )}
+ >
+ );
+ return logoHref ? (
+
+ {logoImgs}
+
+ ) : logoImgs;
+ })() : (
+
+
+
+ )}
+
+
+ {/* Title + Subtext — centered */}
+
+
+ {currentFolderName}
+
+
+ {subtext}
+
+
+
+
+
+
+ {/* Breadcrumb */}
+ {breadcrumb.length > 0 && (
+
setFolderId(null) },
+ ...breadcrumb.map((folder, i) => ({
+ key: folder.id,
+ label: folder.name,
+ onClick: i === breadcrumb.length - 1 ? undefined : () => setFolderId(folder.id),
+ active: i === breadcrumb.length - 1,
+ })),
+ ]}
+ />
+ )}
+
+ {/* Upload area for collaborators */}
+ {permission === 'collaborator' && (
+ { e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={e => { e.preventDefault(); setDragOver(false); uploadFiles(e.dataTransfer.files); }}
+ >
+
{ if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = ''; }}
+ />
+
+
+ {uploading ? 'Téléversement en cours…' : 'Glissez vos fichiers ici'}
+
+ {uploadLimitBytes && (
+
Limite totale du dossier : {formatBytes(uploadLimitBytes)}
+ )}
+
fileInputRef.current?.click()}
+ loading={uploading}
+ size="sm"
+ >
+ Choisir des fichiers
+
+ {uploadError && (
+
{uploadError}
+ )}
+
+ )}
+
+ {/* Content */}
+
+ {error ? (
+ {error}
+ ) : (
+ ({ ...f, _type: 'folder' })),
+ ...contents.files.map(f => ({ ...f, _type: 'file' })),
+ ]}
+ loading={loading}
+ onRowClick={(item) => {
+ if (item._type === 'folder') setFolderId(item.id);
+ }}
+ renderActions={(item) => {
+ if (item._type !== 'file') return null;
+ return (
+ { e.stopPropagation(); handleDownload(item.id, item.display_name); }}
+ className='-m-2'
+ >
+ Télécharger
+
+ );
+ }}
+ emptyMessage="Ce dossier est vide"
+ size="sm"
+ className="min-h-40"
+ />
+ )}
+
+
+
+ );
+}
+
+// ─── Main router ──────────────────────────────────────────────────────────────
+
+/**
+ * Nuage Public Pages Router
+ * Routes:
+ * - /zen/nuage/partage/{token} — Anonymous share access
+ */
+const NuagePublicPages = ({
+ path = [],
+ getShareByTokenAction,
+ verifySharePasswordAction,
+ getSharedContentsAction,
+ uploadToShareAction,
+ publicLogoWhite = '',
+ publicLogoBlack = '',
+ publicDashboardUrl = '',
+}) => {
+ const token = path[2];
+
+ const [status, setStatus] = useState('loading'); // 'loading' | 'password' | 'content' | 'error'
+ const [shareInfo, setShareInfo] = useState(null);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [passwordError, setPasswordError] = useState('');
+ const [passwordVerified, setPasswordVerified] = useState(false);
+
+ useEffect(() => {
+ if (!token) {
+ setStatus('error');
+ setErrorMessage('Lien invalide');
+ return;
+ }
+ if (!getShareByTokenAction) {
+ setStatus('error');
+ setErrorMessage('Configuration manquante');
+ return;
+ }
+ loadShare();
+ }, [token]);
+
+ const loadShare = async () => {
+ try {
+ const result = await getShareByTokenAction(token);
+
+ if (!result.success) {
+ setStatus('error');
+ setErrorMessage(result.message || 'Ce lien n\'est plus valide');
+ return;
+ }
+
+ setShareInfo(result.share);
+
+ if (result.requiresPassword && !passwordVerified) {
+ setStatus('password');
+ } else {
+ setStatus('content');
+ }
+ } catch (e) {
+ setStatus('error');
+ setErrorMessage('Erreur lors du chargement');
+ }
+ };
+
+ const handlePasswordSubmit = async (password) => {
+ setPasswordError('');
+ try {
+ const result = await verifySharePasswordAction(token, password);
+ if (result.success) {
+ setPasswordVerified(true);
+ setStatus('content');
+ } else {
+ setPasswordError(result.error || 'Mot de passe incorrect');
+ }
+ } catch (e) {
+ setPasswordError('Erreur lors de la vérification');
+ }
+ };
+
+ if (status === 'loading') {
+ return (
+
+
+
+ );
+ }
+
+ if (status === 'error') {
+ return ;
+ }
+
+ if (status === 'password') {
+ return ;
+ }
+
+ if (status === 'content' && shareInfo) {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+export default NuagePublicPages;
diff --git a/src/modules/nuage/pages/index.js b/src/modules/nuage/pages/index.js
new file mode 100644
index 0000000..fe60dc0
--- /dev/null
+++ b/src/modules/nuage/pages/index.js
@@ -0,0 +1,4 @@
+/**
+ * Nuage Public Pages
+ */
+export { default as NuagePublicPages } from './NuagePublicPages.js';
diff --git a/src/modules/page.js b/src/modules/page.js
new file mode 100644
index 0000000..43672b8
--- /dev/null
+++ b/src/modules/page.js
@@ -0,0 +1,114 @@
+/**
+ * Public Pages (Zen) - Server Component Wrapper for Next.js App Router
+ *
+ * This is a complete server component that handles all public module routes.
+ * Users can simply re-export this in their app/zen/[...zen]/page.js:
+ *
+ * ```javascript
+ * export { default, generateMetadata } from '@hykocx/zen/modules/page';
+ * ```
+ *
+ * Module actions are loaded from the static modules.actions.js registry.
+ */
+
+import { PublicPagesLayout, PublicPagesClient } from '@hykocx/zen/modules/pages';
+import { getMetadataGenerator } from '@hykocx/zen/modules/metadata';
+import { getAppConfig } from '@hykocx/zen';
+import { getModuleActions } from '@hykocx/zen/modules/actions';
+
+/**
+ * Per-module path configuration.
+ * Defines how to extract the token and metadata type from the URL path.
+ * Default: token at path[1], action at path[2] (e.g. invoice).
+ */
+const MODULE_PATH_CONFIG = {
+ nuage: {
+ getToken: (path) => path[2],
+ getMetadataType: () => 'share',
+ },
+};
+
+/**
+ * Determine metadata type from path action (default for invoice-style modules).
+ * @param {string} action - Route action (e.g., 'pdf', 'receipt')
+ * @returns {string} Metadata type
+ */
+function getDefaultMetadataType(action) {
+ if (action === 'pdf') return 'pdf';
+ if (action === 'receipt') return 'receipt';
+ return 'payment';
+}
+
+/**
+ * Generate metadata for public pages
+ * Uses the static metadata registry from modules.metadata.js
+ */
+export async function generateMetadata({ params }) {
+ const resolvedParams = await params;
+ const path = resolvedParams.zen || [];
+
+ const moduleName = path[0]; // e.g., 'invoice' or 'nuage'
+
+ const modulePathConfig = MODULE_PATH_CONFIG[moduleName];
+ const token = modulePathConfig ? modulePathConfig.getToken(path) : path[1];
+ const metadataType = modulePathConfig
+ ? modulePathConfig.getMetadataType(path)
+ : getDefaultMetadataType(path[2]);
+
+ if (moduleName && token) {
+ const generator = getMetadataGenerator(moduleName, metadataType);
+
+ if (generator && typeof generator === 'function') {
+ try {
+ return await generator(token);
+ } catch (error) {
+ console.error(`[ZEN] Error generating metadata for ${moduleName}/${metadataType}:`, error);
+ }
+ }
+ }
+
+ // Default metadata
+ return {
+ title: process.env.ZEN_NAME || 'ZEN',
+ description: process.env.ZEN_DESCRIPTION || '',
+ };
+}
+
+/**
+ * Default export - Public pages component
+ */
+export default async function ZenPage({ params }) {
+ const resolvedParams = await params;
+ const path = resolvedParams.zen || [];
+ const moduleName = path[0]; // e.g., 'invoice'
+
+ const config = getAppConfig();
+
+ // Get actions for the requested module from static registry
+ const moduleActions = getModuleActions(moduleName);
+
+ // Get additional config props if available
+ const additionalProps = {};
+ if (moduleActions.isStripeEnabled) {
+ additionalProps.stripeEnabled = await moduleActions.isStripeEnabled();
+ }
+ if (moduleActions.isInteracEnabled) {
+ additionalProps.interacEnabled = await moduleActions.isInteracEnabled();
+ }
+ if (moduleActions.getInteracEmail) {
+ additionalProps.interacEmail = await moduleActions.getInteracEmail();
+ }
+ if (moduleActions.getPublicPageConfig) {
+ Object.assign(additionalProps, await moduleActions.getPublicPageConfig());
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/modules/pages.js b/src/modules/pages.js
new file mode 100644
index 0000000..14a37da
--- /dev/null
+++ b/src/modules/pages.js
@@ -0,0 +1,19 @@
+'use client';
+
+/**
+ * Public Module Pages
+ * Layout and routing for public module pages (e.g., invoice payment)
+ */
+
+export { default as PublicPagesLayout } from './PublicPagesLayout.js';
+export { default as PublicPagesClient } from './PublicPagesClient.js';
+
+// Page loaders for dynamic module page loading
+export {
+ getModulePageLoader,
+ getModulePublicPageLoader,
+ getModuleDashboardWidgets,
+ MODULE_ADMIN_PAGES,
+ MODULE_PUBLIC_PAGES,
+ MODULE_DASHBOARD_WIDGETS
+} from './modules.pages.js';
diff --git a/src/modules/posts/.env.example b/src/modules/posts/.env.example
new file mode 100644
index 0000000..6a5893e
--- /dev/null
+++ b/src/modules/posts/.env.example
@@ -0,0 +1,16 @@
+#################################
+# MODULE POSTS
+ZEN_MODULE_POSTS=true
+
+# List of post types (pipe-separated, lowercase)
+# Optional display label: key:Label (e.g. actu:Actualités)
+ZEN_MODULE_ZEN_MODULE_POSTS_TYPES=blogue:Blogue|cve:CVE|emploi:Emplois
+
+# Fields for each type: name:type|name:type|...
+# Supported field types: title, slug, text, markdown, date, category, image
+# Relation field: name:relation:target_post_type (e.g. keywords:relation:mots-cle)
+ZEN_MODULE_POSTS_TYPE_BLOGUE=title:title|slug:slug|category:category|date:date|resume:text|content:markdown|image:image
+ZEN_MODULE_POSTS_TYPE_CVE=title:title|slug:slug|cve_id:text|severity:text|description:markdown|date:date|keywords:relation:mots-cle
+ZEN_MODULE_POSTS_TYPE_EMPLOI=title:title|slug:slug|date:date|description:markdown
+ZEN_MODULE_POSTS_TYPE_MOTS-CLE=title:title|slug:slug
+#################################
diff --git a/src/modules/posts/README.md b/src/modules/posts/README.md
new file mode 100644
index 0000000..4b13f3a
--- /dev/null
+++ b/src/modules/posts/README.md
@@ -0,0 +1,547 @@
+# Posts Module
+
+Configurable Custom Post Types via environment variables. Inspired by the WordPress CPT concept: each project declares its own content types (blog, CVE, job, event, etc.) with the fields it needs, without modifying code.
+
+---
+
+## Features
+
+- **Multiple post types** in a single module (blog, CVE, job...)
+- **Dynamic fields** per type: title, slug, text, markdown, date, datetime, color, category, image, relation
+- **Generic admin**: forms adapt automatically to the config
+- **Public API** per type for integration in a Next.js site
+- **Optional categories** per type (enabled if a `category` field is defined)
+- **Relations** between types (many-to-many, e.g. CVE → Tags)
+- **Unique slugs** per type (scoped: `blogue/mon-article` ≠ `cve/mon-article`)
+
+---
+
+## Installation
+
+### 1. Environment variables
+
+Copy variables from [`.env.example`](.env.example) into your `.env`:
+
+> If no label is provided (`ZEN_MODULE_POSTS_TYPES=blogue`), the display name will be the key with the first letter capitalized (`Blogue`).
+
+**Optional (images):**
+
+If one of your types uses the `image` field, configure Zen storage in your main `.env` (`ZEN_STORAGE_REGION`, `ZEN_STORAGE_ACCESS_KEY`, `ZEN_STORAGE_SECRET_KEY`, `ZEN_STORAGE_BUCKET`).
+
+### 2. Available field types
+
+| Type | `.env` syntax | Description |
+|---|---|---|
+| `title` | `name:title` | Main text field — auto-generates the slug |
+| `slug` | `name:slug` | Unique URL slug per type — auto-filled from title |
+| `text` | `name:text` | Free text area (textarea) |
+| `markdown` | `name:markdown` | Markdown editor with preview |
+| `date` | `name:date` | Date picker only (YYYY-MM-DD) |
+| `datetime` | `name:datetime` | Date **and time** picker (YYYY-MM-DDTHH:MM) |
+| `color` | `name:color` | Color picker — stores a hex code `#rrggbb` |
+| `category` | `name:category` | Dropdown linked to the category table |
+| `image` | `name:image` | Image upload to Zen storage |
+| `relation` | `name:relation:target_type` | Multi-select to posts of another type |
+
+> **Rule:** each type must have at least one `title` field and one `slug` field. The `category` field automatically creates the `zen_posts_category` table. The `relation` field automatically creates the `zen_posts_relations` table.
+
+#### `date` vs `datetime`
+
+- `date` → stores `"2026-03-14"` — sufficient for blog posts, events
+- `datetime` → stores `"2026-03-14T10:30:00.000Z"` (ISO 8601, UTC) — needed for CVEs, security bulletins, precise schedules
+
+### `relation` field — Linking posts together
+
+The `relation` field associates multiple posts of another type (many-to-many). Example: news posts referencing a source and tags.
+
+```bash
+ZEN_MODULE_POSTS_TYPE_ACTUALITE=title:title|slug:slug|date:datetime|resume:text|content:markdown|image:image|source:relation:source|tags:relation:tag
+ZEN_MODULE_POSTS_TYPE_TAG=title:title|slug:slug
+ZEN_MODULE_POSTS_TYPE_SOURCE=title:title|slug:slug
+```
+
+- The name before `relation` (`source`, `tags`) is the field name in the form and in the API response
+- The value after `relation` (`source`, `tag`) is the target post type
+- Selection is **multiple** (multi-select with real-time search)
+- Relations are stored in `zen_posts_relations` (junction table)
+- In the API, relations are returned as an array of objects containing **all fields** of the linked post: `tags: [{ id, slug, title, color, ... }]`
+
+### 3. Database tables
+
+Tables are created automatically with `npx zen-db init`. For reference, here are the module tables:
+
+| Table | Description |
+|---|---|
+| `zen_posts` | Posts (all types — custom fields in `data JSONB`) |
+| `zen_posts_category` | Categories per type (if a `category` field is defined) |
+| `zen_posts_relations` | Relations between posts (if a `relation` field is defined) |
+
+> **Design:** all custom fields are stored in the `data JSONB` column. Adding or removing a field in `.env` requires no SQL migration.
+
+---
+
+## Admin interface
+
+| Page | URL |
+|---|---|
+| Post list for a type | `/admin/posts/{type}/list` |
+| Create a post | `/admin/posts/{type}/new` |
+| Edit a post | `/admin/posts/{type}/edit/{id}` |
+| Category list for a type | `/admin/posts/{type}/categories` |
+| Create a category | `/admin/posts/{type}/categories/new` |
+| Edit a category | `/admin/posts/{type}/categories/edit/{id}` |
+
+---
+
+## Public API (no authentication)
+
+### Config
+
+```
+GET /zen/api/posts/config
+```
+
+Returns the list of all configured types with their fields.
+
+### Post list
+
+```
+GET /zen/api/posts/{type}
+```
+
+**Query parameters:**
+
+| Parameter | Default | Description |
+|---|---|---|
+| `page` | `1` | Current page |
+| `limit` | `20` | Results per page |
+| `category_id` | — | Filter by category |
+| `sortBy` | `created_at` | Sort by (field name of the type) |
+| `sortOrder` | `DESC` | `ASC` or `DESC` |
+| `withRelations` | `false` | `true` to include relation fields in each post |
+
+> **Performance:** `withRelations=true` runs an additional SQL query per post. Use with a reasonable `limit` (≤ 20). On a detail page, prefer `/posts/{type}/{slug}` which always loads relations.
+
+**Response without `withRelations` (default):**
+```json
+{
+ "success": true,
+ "posts": [
+ {
+ "id": 1,
+ "post_type": "actualite",
+ "slug": "faille-critique-openssh",
+ "title": "Faille critique dans OpenSSH",
+ "date": "2026-03-14T10:30:00.000Z",
+ "resume": "Une faille critique...",
+ "image": "blog/1234567890-image.webp",
+ "created_at": "2026-03-14T12:00:00Z",
+ "updated_at": "2026-03-14T12:00:00Z"
+ }
+ ],
+ "total": 42,
+ "totalPages": 3,
+ "page": 1,
+ "limit": 20
+}
+```
+
+**Response with `withRelations=true`:**
+```json
+{
+ "success": true,
+ "posts": [
+ {
+ "id": 1,
+ "slug": "faille-critique-openssh",
+ "title": "Faille critique dans OpenSSH",
+ "date": "2026-03-14T10:30:00.000Z",
+ "source": [
+ { "id": 3, "slug": "cert-fr", "post_type": "source", "title": "CERT-FR" }
+ ],
+ "tags": [
+ { "id": 7, "slug": "openssh", "post_type": "tag", "title": "OpenSSH" },
+ { "id": 8, "slug": "vulnerabilite", "post_type": "tag", "title": "Vulnérabilité" }
+ ]
+ }
+ ]
+}
+```
+
+### Single post by slug
+
+```
+GET /zen/api/posts/{type}/{slug}
+```
+
+Relations are **always included** on a single post.
+
+**Response for a news post:**
+```json
+{
+ "success": true,
+ "post": {
+ "id": 1,
+ "post_type": "actualite",
+ "slug": "faille-critique-openssh",
+ "title": "Faille critique dans OpenSSH",
+ "date": "2026-03-14T10:30:00.000Z",
+ "resume": "Une faille critique a été découverte...",
+ "content": "# Détails\n\n...",
+ "image": "blog/1234567890-image.webp",
+
+ "source": [
+ { "id": 3, "slug": "cert-fr", "post_type": "source", "title": "CERT-FR" }
+ ],
+ "tags": [
+ { "id": 7, "slug": "openssh", "post_type": "tag", "title": "OpenSSH" },
+ { "id": 8, "slug": "vulnerabilite", "post_type": "tag", "title": "Vulnérabilité" }
+ ],
+
+ "created_at": "2026-03-14T12:00:00Z",
+ "updated_at": "2026-03-14T12:00:00Z"
+ }
+}
+```
+
+### Categories
+
+```
+GET /zen/api/posts/{type}/categories
+```
+
+Returns the list of active categories for the type (to populate a filter).
+
+### Images
+
+Image keys are built as follows: `/zen/api/storage/{image_field_value}`
+
+```jsx
+
+```
+
+---
+
+## Next.js integration examples
+
+### News list (without relations)
+
+```js
+// app/actualites/page.js
+export default async function ActualitesPage() {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&sortBy=date&sortOrder=DESC`
+ );
+ const { posts, total, totalPages } = await res.json();
+
+ return (
+
+ {posts.map(post => (
+
+ {post.title}
+ {post.resume}
+ {post.image && }
+
+ ))}
+
+ );
+}
+```
+
+### News list with tags and source (withRelations)
+
+```js
+// app/actualites/page.js
+export default async function ActualitesPage() {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&withRelations=true`
+ );
+ const { posts } = await res.json();
+
+ return (
+
+ {posts.map(post => (
+
+ {post.title}
+
+ {/* Source (array, usually 1 element) */}
+ {post.source?.[0] && (
+ Source : {post.source[0].title}
+ )}
+
+ {/* Tags (array of 0..N elements) */}
+
+
+ ))}
+
+ );
+}
+```
+
+### News detail page (relations always included)
+
+```js
+// app/actualites/[slug]/page.js
+export default async function ActualiteDetailPage({ params }) {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}`
+ );
+ const { post } = await res.json();
+
+ if (!post) notFound();
+
+ return (
+
+ {post.title}
+
+ {/* datetime field: display with time */}
+
+ {new Date(post.date).toLocaleString('fr-FR')}
+
+
+ {/* Source — array even with a single element */}
+ {post.source?.[0] && (
+
+ Source : {post.source[0].title}
+
+ )}
+
+ {/* Tags */}
+ {post.tags?.length > 0 && (
+
+ )}
+
+ {post.image && }
+ {post.content}
+
+ );
+}
+```
+
+### CVE detail page
+
+```js
+// app/cve/[slug]/page.js
+export default async function CVEDetailPage({ params }) {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/cve/${params.slug}`
+ );
+ const { post } = await res.json();
+
+ if (!post) notFound();
+
+ return (
+
+ {post.title}
+ ID : {post.cve_id}
+ Sévérité : {post.severity} — Score : {post.score}
+ Produit : {post.product}
+
+ {/* Disclosure date and time */}
+
+ {new Date(post.date).toLocaleString('fr-FR')}
+
+
+ {/* Associated tags */}
+ {post.tags?.length > 0 && (
+
+ {post.tags.map(tag => (
+ {tag.title}
+ ))}
+
+ )}
+
+ {post.description}
+
+ );
+}
+```
+
+### Dynamic SEO metadata
+
+```js
+// app/actualites/[slug]/page.js
+export async function generateMetadata({ params }) {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}`
+ );
+ const { post } = await res.json();
+ if (!post) return {};
+
+ return {
+ title: post.title,
+ description: post.resume,
+ openGraph: {
+ title: post.title,
+ description: post.resume,
+ images: post.image ? [`/zen/api/storage/${post.image}`] : [],
+ },
+ };
+}
+```
+
+---
+
+## Adding a new post type
+
+Edit `.env` only — no database restart needed:
+
+```bash
+# Before
+ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités
+
+# After — adding the 'evenement' type
+ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités|evenement:Événements
+ZEN_MODULE_POSTS_TYPE_EVENEMENT=title:title|slug:slug|date:datetime|location:text|description:markdown|image:image
+```
+
+Restart the server. Tables are unchanged (new fields use the existing JSONB).
+
+## Modifying fields on an existing type
+
+Update the `ZEN_MODULE_POSTS_TYPE_*` variable and restart. Existing posts keep their data in JSONB even if a field is removed from the config — it simply won't appear in the form anymore.
+
+---
+
+## Programmatic usage (importers / fetchers)
+
+CRUD functions are directly importable server-side. No need to go through the HTTP API — ideal for cron jobs, import scripts, or automated fetchers.
+
+### Available functions
+
+```js
+import {
+ createPost, // Create a post
+ updatePost, // Update a post
+ getPostBySlug, // Find by slug
+ getPostByField, // Find by any JSONB field
+ upsertPost, // Create or update (idempotent, for importers)
+ getPosts, // List with pagination
+ deletePost, // Delete
+} from '@hykocx/zen/modules/posts/crud';
+```
+
+### `upsertPost(postType, rawData, uniqueField)`
+
+Key function for importers: creates the post if it doesn't exist, updates it otherwise.
+
+- `postType`: the post type (`'cve'`, `'actualite'`, etc.)
+- `rawData`: post data (same fields as for `createPost`)
+- `uniqueField`: the deduplication key field (`'slug'` by default, or `'cve_id'`, etc.)
+
+Returns `{ post, created: boolean }`.
+
+### Example — CVE fetcher (cron job)
+
+```js
+// src/cron/fetch-cves.js
+import { upsertPost } from '@hykocx/zen/modules/posts/crud';
+
+export async function fetchAndImportCVEs() {
+ // 1. Fetch data from an external source
+ const response = await fetch('https://api.example.com/cves/recent');
+ const { cves } = await response.json();
+
+ const results = { created: 0, updated: 0, errors: 0 };
+
+ for (const cve of cves) {
+ try {
+ // 2. Resolve relations — ensure tags exist
+ const tagIds = [];
+ for (const tagName of (cve.tags || [])) {
+ const { post: tag } = await upsertPost('tag', { title: tagName }, 'slug');
+ tagIds.push(tag.id);
+ }
+
+ // 3. Upsert the CVE (deduplicated on cve_id)
+ const { created } = await upsertPost('cve', {
+ title: cve.title, // title field
+ cve_id: cve.id, // text field
+ severity: cve.severity, // text field
+ score: String(cve.cvssScore), // text field
+ product: cve.affectedProduct, // text field
+ date: cve.publishedAt, // datetime field (ISO 8601)
+ description: cve.description, // markdown field
+ tags: tagIds, // relation:tag field — array of IDs
+ }, 'cve_id'); // deduplicate on cve_id
+
+ created ? results.created++ : results.updated++;
+ } catch (err) {
+ console.error(`[CVE import] Error for ${cve.id}:`, err.message);
+ results.errors++;
+ }
+ }
+
+ console.log(`[CVE import] Done — created: ${results.created}, updated: ${results.updated}, errors: ${results.errors}`);
+ return results;
+}
+```
+
+### Example — News fetcher with source
+
+```js
+import { upsertPost, getPostByField } from '@hykocx/zen/modules/posts/crud';
+
+export async function fetchAndImportActualites(sourceName, sourceUrl, articles) {
+ // 1. Ensure the source exists
+ const { post: source } = await upsertPost('source', {
+ title: sourceName,
+ color: '#3b82f6',
+ }, 'slug');
+
+ for (const article of articles) {
+ await upsertPost('actualite', {
+ title: article.title,
+ date: article.publishedAt,
+ resume: article.summary,
+ content: article.content,
+ source: [source.id], // relation:source — array of IDs
+ tags: [], // relation:tag
+ }, 'slug');
+ }
+}
+```
+
+### Rules for relation fields in `rawData`
+
+`relation` fields must receive an **array of IDs** of existing posts:
+
+```js
+// Correct
+{ tags: [7, 8, 12], source: [3] }
+
+// Incorrect — no slugs or objects
+{ tags: ['openssh', 'vuln'], source: { id: 3 } }
+```
+
+If the linked posts don't exist yet, create them first with `upsertPost` then use their IDs.
+
+---
+
+## Admin API (authentication required)
+
+These routes require an active admin session.
+
+| Method | Route | Description |
+|---|---|---|
+| `GET` | `/zen/api/admin/posts/config` | Full config for all types |
+| `GET` | `/zen/api/admin/posts/search?type={type}&q={query}` | Search posts for the relation picker |
+| `GET` | `/zen/api/admin/posts/posts?type={type}` | Post list for a type |
+| `GET` | `/zen/api/admin/posts/posts?type={type}&withRelations=true` | List with relations included |
+| `GET` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Post by ID (relations always included) |
+| `POST` | `/zen/api/admin/posts/posts?type={type}` | Create a post |
+| `PUT` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Update a post |
+| `DELETE` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Delete a post |
+| `POST` | `/zen/api/admin/posts/upload-image` | Upload an image |
+| `GET` | `/zen/api/admin/posts/categories?type={type}` | Category list |
+| `POST` | `/zen/api/admin/posts/categories?type={type}` | Create a category |
+| `PUT` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Update a category |
+| `DELETE` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Delete a category |
diff --git a/src/modules/posts/admin/PostCreatePage.js b/src/modules/posts/admin/PostCreatePage.js
new file mode 100644
index 0000000..a02c2d4
--- /dev/null
+++ b/src/modules/posts/admin/PostCreatePage.js
@@ -0,0 +1,245 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Button, Card } from '../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+import { getTodayString } from '../../../shared/lib/dates.js';
+import PostFormFields from './PostFormFields.js';
+
+function slugifyTitle(title) {
+ if (!title || typeof title !== 'string') return '';
+ return title
+ .toLowerCase()
+ .trim()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
+function getPostTypeFromPath(pathname) {
+ const segments = (pathname || '').split('/').filter(Boolean);
+ // /admin/posts/{type}/new → segments[2]
+ return segments[2] || '';
+}
+
+const PostCreatePage = () => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const toast = useToast();
+
+ const postType = getPostTypeFromPath(pathname);
+
+ const [typeConfig, setTypeConfig] = useState(null);
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [uploading, setUploading] = useState(false);
+
+ const [formData, setFormData] = useState({});
+ const [errors, setErrors] = useState({});
+ const [slugTouched, setSlugTouched] = useState(false);
+
+ useEffect(() => {
+ loadConfig();
+ }, []);
+
+ // Only sync title → slug when title content changes (not when slug is cleared)
+ useEffect(() => {
+ if (!typeConfig || slugTouched) return;
+ const titleField = typeConfig.titleField;
+ const slugField = typeConfig.slugField;
+ if (titleField && slugField && formData[titleField]) {
+ setFormData(prev => ({ ...prev, [slugField]: slugifyTitle(prev[titleField]) }));
+ }
+ }, [formData[typeConfig?.titleField], typeConfig]);
+
+ const loadConfig = async () => {
+ try {
+ const response = await fetch('/zen/api/admin/posts/config', { credentials: 'include' });
+ const data = await response.json();
+ if (data.success && data.config.types[postType]) {
+ const config = data.config.types[postType];
+ setTypeConfig(config);
+
+ // Initialize form data with defaults
+ const defaults = {};
+ for (const field of config.fields) {
+ if (field.type === 'date') defaults[field.name] = getTodayString();
+ else if (field.type === 'relation') defaults[field.name] = [];
+ else defaults[field.name] = '';
+ }
+ setFormData(defaults);
+
+ if (config.hasCategory) loadCategories();
+ } else {
+ toast.error('Type de post introuvable');
+ }
+ } catch (error) {
+ console.error('Error loading config:', error);
+ toast.error('Impossible de charger la configuration');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadCategories = async () => {
+ try {
+ const response = await fetch(
+ `/zen/api/admin/posts/categories?type=${postType}&limit=1000&is_active=true`,
+ { credentials: 'include' }
+ );
+ const data = await response.json();
+ if (data.success) setCategories(data.categories || []);
+ } catch (error) {
+ console.error('Error loading categories:', error);
+ }
+ };
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ if (field === typeConfig?.slugField) setSlugTouched(true);
+ if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
+ };
+
+ const handleImageChange = async (fieldName, e) => {
+ const file = e.target?.files?.[0];
+ if (!file) return;
+ try {
+ setUploading(true);
+ const fd = new FormData();
+ fd.append('file', file);
+ const response = await fetch('/zen/api/admin/posts/upload-image', {
+ method: 'POST',
+ credentials: 'include',
+ body: fd
+ });
+ const data = await response.json();
+ if (data.success && data.key) {
+ setFormData(prev => ({ ...prev, [fieldName]: data.key }));
+ toast.success('Image téléchargée');
+ } else {
+ toast.error(data.error || 'Échec du téléchargement');
+ }
+ } catch (error) {
+ console.error('Error uploading image:', error);
+ toast.error('Échec du téléchargement');
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const validateForm = () => {
+ const newErrors = {};
+ if (typeConfig?.titleField && !formData[typeConfig.titleField]?.trim()) {
+ newErrors[typeConfig.titleField] = 'Ce champ est requis';
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validateForm()) return;
+
+ try {
+ setSaving(true);
+ const payload = { ...formData };
+ // Convert category to integer or null
+ if (typeConfig?.hasCategory) {
+ const catField = typeConfig.fields.find(f => f.type === 'category');
+ if (catField) {
+ payload[catField.name] = payload[catField.name] ? parseInt(payload[catField.name]) : null;
+ }
+ }
+ // Convert relation fields to arrays of IDs
+ for (const field of (typeConfig?.fields || []).filter(f => f.type === 'relation')) {
+ const items = payload[field.name];
+ payload[field.name] = Array.isArray(items) ? items.map(i => (typeof i === 'object' ? i.id : i)) : [];
+ }
+ // Convert datetime fields to ISO 8601 UTC
+ for (const field of (typeConfig?.fields || []).filter(f => f.type === 'datetime')) {
+ const val = payload[field.name];
+ if (val && !val.includes('Z')) payload[field.name] = val + ':00.000Z';
+ }
+
+ const response = await fetch(`/zen/api/admin/posts/posts?type=${postType}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(payload)
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ toast.success('Post créé avec succès');
+ router.push(`/admin/posts/${postType}/list`);
+ } else {
+ toast.error(data.error || data.message || 'Échec de la création');
+ }
+ } catch (error) {
+ console.error('Error creating post:', error);
+ toast.error('Échec de la création');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const label = typeConfig?.label || capitalize(postType);
+
+ return (
+
+
+
+
Créer — {label}
+
Ajouter un nouvel élément
+
+
router.push(`/admin/posts/${postType}/list`)}>
+ ← Retour
+
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+function capitalize(str) {
+ if (!str) return '';
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+export default PostCreatePage;
diff --git a/src/modules/posts/admin/PostEditPage.js b/src/modules/posts/admin/PostEditPage.js
new file mode 100644
index 0000000..9c5eeb4
--- /dev/null
+++ b/src/modules/posts/admin/PostEditPage.js
@@ -0,0 +1,271 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Button, Card } from '../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+import { formatDateForInput, formatDateTimeForInput } from '../../../shared/lib/dates.js';
+import PostFormFields from './PostFormFields.js';
+
+function slugifyTitle(title) {
+ if (!title || typeof title !== 'string') return '';
+ return title
+ .toLowerCase()
+ .trim()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
+function getParamsFromPath(pathname) {
+ const segments = (pathname || '').split('/').filter(Boolean);
+ // /admin/posts/{type}/edit/{id} → segments[2], segments[4]
+ return { postType: segments[2] || '', postId: segments[4] || '' };
+}
+
+const PostEditPage = () => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const toast = useToast();
+
+ const { postType, postId } = getParamsFromPath(pathname);
+
+ const [typeConfig, setTypeConfig] = useState(null);
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [uploading, setUploading] = useState(false);
+
+ const [formData, setFormData] = useState({});
+ const [errors, setErrors] = useState({});
+ const [slugTouched, setSlugTouched] = useState(false);
+
+ useEffect(() => {
+ if (postType && postId) loadConfig();
+ }, [postType, postId]);
+
+ // Only sync title → slug when title content changes (not when slug is cleared)
+ useEffect(() => {
+ if (!typeConfig || slugTouched) return;
+ const titleField = typeConfig.titleField;
+ const slugField = typeConfig.slugField;
+ if (titleField && slugField && formData[titleField]) {
+ setFormData(prev => ({ ...prev, [slugField]: slugifyTitle(prev[titleField]) }));
+ }
+ }, [formData[typeConfig?.titleField], typeConfig]);
+
+ const loadConfig = async () => {
+ try {
+ const [configRes, postRes] = await Promise.all([
+ fetch('/zen/api/admin/posts/config', { credentials: 'include' }),
+ fetch(`/zen/api/admin/posts/posts?type=${postType}&id=${postId}`, { credentials: 'include' })
+ ]);
+
+ const configData = await configRes.json();
+ const postData = await postRes.json();
+
+ if (!configData.success || !configData.config.types[postType]) {
+ toast.error('Type de post introuvable');
+ return;
+ }
+
+ const config = configData.config.types[postType];
+ setTypeConfig(config);
+
+ if (!postData.success || !postData.post) {
+ toast.error('Post introuvable');
+ router.push(`/admin/posts/${postType}/list`);
+ return;
+ }
+
+ const post = postData.post;
+
+ // Populate form data from post
+ const initial = {};
+ for (const field of config.fields) {
+ if (field.type === 'slug') {
+ initial[field.name] = post.slug || '';
+ } else if (field.type === 'category') {
+ initial[field.name] = post.category_id ? String(post.category_id) : '';
+ } else if (field.type === 'date') {
+ initial[field.name] = post[field.name] ? formatDateForInput(post[field.name]) : '';
+ } else if (field.type === 'datetime') {
+ initial[field.name] = post[field.name] ? formatDateTimeForInput(post[field.name]) : '';
+ } else if (field.type === 'relation') {
+ // Relations come as [{ id, title, slug }] from getPostById
+ initial[field.name] = Array.isArray(post[field.name]) ? post[field.name] : [];
+ } else {
+ initial[field.name] = post[field.name] || '';
+ }
+ }
+ setFormData(initial);
+ setSlugTouched(true); // Don't auto-generate slug on edit
+
+ if (config.hasCategory) loadCategories();
+ } catch (error) {
+ console.error('Error loading post:', error);
+ toast.error('Impossible de charger le post');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadCategories = async () => {
+ try {
+ const response = await fetch(
+ `/zen/api/admin/posts/categories?type=${postType}&limit=1000&is_active=true`,
+ { credentials: 'include' }
+ );
+ const data = await response.json();
+ if (data.success) setCategories(data.categories || []);
+ } catch (error) {
+ console.error('Error loading categories:', error);
+ }
+ };
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ if (field === typeConfig?.slugField) setSlugTouched(value !== '');
+ if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
+ };
+
+ const handleImageChange = async (fieldName, e) => {
+ const file = e.target?.files?.[0];
+ if (!file) return;
+ try {
+ setUploading(true);
+ const fd = new FormData();
+ fd.append('file', file);
+ const response = await fetch('/zen/api/admin/posts/upload-image', {
+ method: 'POST',
+ credentials: 'include',
+ body: fd
+ });
+ const data = await response.json();
+ if (data.success && data.key) {
+ setFormData(prev => ({ ...prev, [fieldName]: data.key }));
+ toast.success('Image téléchargée');
+ } else {
+ toast.error(data.error || 'Échec du téléchargement');
+ }
+ } catch (error) {
+ console.error('Error uploading image:', error);
+ toast.error('Échec du téléchargement');
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const validateForm = () => {
+ const newErrors = {};
+ if (typeConfig?.titleField && !formData[typeConfig.titleField]?.trim()) {
+ newErrors[typeConfig.titleField] = 'Ce champ est requis';
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validateForm()) return;
+
+ try {
+ setSaving(true);
+ const payload = { ...formData };
+ if (typeConfig?.hasCategory) {
+ const catField = typeConfig.fields.find(f => f.type === 'category');
+ if (catField) {
+ payload[catField.name] = payload[catField.name] ? parseInt(payload[catField.name]) : null;
+ }
+ }
+ // Convert relation fields to arrays of IDs
+ for (const field of (typeConfig?.fields || []).filter(f => f.type === 'relation')) {
+ const items = payload[field.name];
+ payload[field.name] = Array.isArray(items) ? items.map(i => (typeof i === 'object' ? i.id : i)) : [];
+ }
+ // Convert datetime fields to ISO 8601 UTC
+ for (const field of (typeConfig?.fields || []).filter(f => f.type === 'datetime')) {
+ const val = payload[field.name];
+ if (val && !val.includes('Z')) payload[field.name] = val + ':00.000Z';
+ }
+
+ const response = await fetch(`/zen/api/admin/posts/posts?type=${postType}&id=${postId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(payload)
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ toast.success('Post mis à jour avec succès');
+ router.push(`/admin/posts/${postType}/list`);
+ } else {
+ toast.error(data.error || data.message || 'Échec de la mise à jour');
+ }
+ } catch (error) {
+ console.error('Error updating post:', error);
+ toast.error('Échec de la mise à jour');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const label = typeConfig?.label || capitalize(postType);
+
+ return (
+
+
+
+
Modifier — {label}
+
Modifier un élément existant
+
+
router.push(`/admin/posts/${postType}/list`)}>
+ ← Retour
+
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+function capitalize(str) {
+ if (!str) return '';
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+export default PostEditPage;
diff --git a/src/modules/posts/admin/PostFormFields.js b/src/modules/posts/admin/PostFormFields.js
new file mode 100644
index 0000000..be01c39
--- /dev/null
+++ b/src/modules/posts/admin/PostFormFields.js
@@ -0,0 +1,359 @@
+'use client';
+
+import React, { useState, useEffect, useRef } from 'react';
+import { Input, Select, Textarea, MarkdownEditor } from '../../../shared/components';
+
+/**
+ * Dynamic field renderer for post forms.
+ *
+ * Relation fields expect formData[fieldName] = [{ id, title }]
+ * (array of objects for display, converted to IDs on submit by the parent).
+ */
+const PostFormFields = ({
+ fields = [],
+ formData = {},
+ onChange,
+ errors = {},
+ slugValue,
+ onSlugFocus,
+ categories = [],
+ uploading = false,
+ onImageChange,
+}) => {
+ return (
+
+ {fields.map((field) => {
+ switch (field.type) {
+ case 'title':
+ return (
+
+ onChange(field.name, value)}
+ placeholder={`${capitalize(field.name)}...`}
+ error={errors[field.name]}
+ />
+
+ );
+
+ case 'slug':
+ return (
+
+ onChange(field.name, value)}
+ onFocus={onSlugFocus}
+ placeholder="url-slug (généré depuis le titre)"
+ />
+
+ );
+
+ case 'text':
+ return (
+
+
+ {capitalize(field.name)}
+
+
+ );
+
+ case 'markdown':
+ return (
+
+ onChange(field.name, value)}
+ rows={14}
+ placeholder={`${capitalize(field.name)} en Markdown...`}
+ />
+
+ );
+
+ case 'date':
+ return (
+
+ onChange(field.name, value)}
+ error={errors[field.name]}
+ />
+
+ );
+
+ case 'datetime':
+ return (
+
+ onChange(field.name, value)}
+ error={errors[field.name]}
+ />
+
+ );
+
+ case 'color':
+ return (
+
+
+ {capitalize(field.name)}
+
+
+ onChange(field.name, e.target.value)}
+ className="h-9 w-14 cursor-pointer rounded border border-neutral-300 dark:border-neutral-600 bg-neutral-100 dark:bg-neutral-800 p-0.5"
+ />
+
+ {formData[field.name] || '#000000'}
+
+ {formData[field.name] && (
+ onChange(field.name, '')}
+ className="text-xs text-neutral-500 hover:text-red-400"
+ >
+ Réinitialiser
+
+ )}
+
+ {errors[field.name] && (
+
{errors[field.name]}
+ )}
+
+ );
+
+ case 'category':
+ return (
+
+ onChange(field.name, value)}
+ options={[
+ { value: '', label: 'Aucune' },
+ ...categories.map(c => ({ value: c.id, label: c.title }))
+ ]}
+ />
+
+ );
+
+ case 'image':
+ return (
+
+
+ {capitalize(field.name)}
+
+
onImageChange && onImageChange(field.name, e)}
+ disabled={uploading}
+ className="block w-full text-sm text-neutral-500 dark:text-neutral-400 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-neutral-200 dark:file:bg-neutral-700 file:text-neutral-700 dark:file:text-white"
+ />
+ {formData[field.name] && (
+
+
+
onChange(field.name, '')}
+ className="text-sm text-red-400 hover:text-red-300"
+ >
+ Supprimer
+
+
+ )}
+
+ );
+
+ case 'relation':
+ return (
+
+ onChange(field.name, items)}
+ />
+
+ );
+
+ default:
+ return (
+
+ onChange(field.name, value)}
+ error={errors[field.name]}
+ />
+
+ );
+ }
+ })}
+
+ );
+};
+
+// ============================================================================
+// RelationSelector — self-contained multi-select for post relations
+// value: [{ id, title }]
+// onChange: (newValue: [{ id, title }]) => void
+// ============================================================================
+
+const RelationSelector = ({ label, targetType, value = [], onChange }) => {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const inputRef = useRef(null);
+ const dropdownRef = useRef(null);
+
+ // Debounced search
+ useEffect(() => {
+ if (!targetType) return;
+ const timer = setTimeout(() => {
+ fetchResults(query);
+ }, 250);
+ return () => clearTimeout(timer);
+ }, [query, targetType]);
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ const handleClick = (e) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ }, []);
+
+ const fetchResults = async (q) => {
+ if (!targetType) return;
+ try {
+ setLoading(true);
+ const params = new URLSearchParams({ type: targetType, q, limit: '20' });
+ const res = await fetch(`/zen/api/admin/posts/search?${params}`, { credentials: 'include' });
+ const data = await res.json();
+ if (data.success) {
+ // Filter out already-selected items
+ const selectedIds = new Set(value.map(v => v.id));
+ setResults((data.posts || []).filter(p => !selectedIds.has(p.id)));
+ }
+ } catch (err) {
+ console.error('RelationSelector fetch error:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAdd = (item) => {
+ onChange([...value, { id: item.id, title: item.title || item.slug }]);
+ setQuery('');
+ setOpen(false);
+ setResults([]);
+ };
+
+ const handleRemove = (id) => {
+ onChange(value.filter(v => v.id !== id));
+ };
+
+ return (
+
+ {label && (
+
{label}
+ )}
+
+ {/* Selected chips */}
+ {value.length > 0 && (
+
+ {value.map((item) => (
+
+ {item.title}
+ handleRemove(item.id)}
+ className="text-neutral-400 hover:text-red-400 ml-1 leading-none"
+ aria-label="Retirer"
+ >
+ ×
+
+
+ ))}
+
+ )}
+
+ {/* Search input + dropdown */}
+
+
{
+ setQuery(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => {
+ setOpen(true);
+ if (!results.length) fetchResults(query);
+ }}
+ placeholder={`Rechercher dans ${targetType || '…'}…`}
+ className="w-full rounded bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 text-sm text-neutral-900 dark:text-neutral-100 px-3 py-2 placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:border-neutral-500 dark:focus:border-neutral-400"
+ />
+
+ {open && (
+
+ {loading ? (
+
Chargement…
+ ) : results.length === 0 ? (
+
+ {query ? 'Aucun résultat' : 'Tapez pour rechercher'}
+
+ ) : (
+ results.map((item) => (
+
handleAdd(item)}
+ className="w-full text-left px-3 py-2 text-sm text-neutral-800 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700 focus:bg-neutral-100 dark:focus:bg-neutral-700 focus:outline-none"
+ >
+ {item.title || item.slug}
+ {item.slug}
+
+ ))
+ )}
+
+ )}
+
+
+ );
+};
+
+function capitalize(str) {
+ if (!str) return '';
+ return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
+}
+
+export default PostFormFields;
diff --git a/src/modules/posts/admin/PostsIndexPage.js b/src/modules/posts/admin/PostsIndexPage.js
new file mode 100644
index 0000000..9ac2652
--- /dev/null
+++ b/src/modules/posts/admin/PostsIndexPage.js
@@ -0,0 +1,104 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { Book02Icon, Layers01Icon } from '../../../shared/Icons.js';
+import { Card, Button } from '../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+
+/**
+ * Posts index page — shows all configured post types.
+ * The user selects a type to navigate to its list.
+ */
+const PostsIndexPage = () => {
+ const router = useRouter();
+ const toast = useToast();
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadConfig();
+ }, []);
+
+ const loadConfig = async () => {
+ try {
+ const response = await fetch('/zen/api/admin/posts/config', { credentials: 'include' });
+ const data = await response.json();
+ if (data.success) {
+ setConfig(data.config);
+ } else {
+ toast.error('Impossible de charger la configuration des posts');
+ }
+ } catch (error) {
+ console.error('Error loading posts config:', error);
+ toast.error('Impossible de charger la configuration des posts');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const types = config ? Object.values(config.types) : [];
+
+ return (
+
+
+
+
Posts
+
Gérez vos types de contenu
+
+
+
+ {loading ? (
+
+ {[1, 2].map(i => (
+
+ ))}
+
+ ) : types.length === 0 ? (
+
+
+ Aucun type de post configuré. Ajoutez ZEN_MODULE_ZEN_MODULE_POSTS_TYPES dans votre fichier .env.
+
+
+ ) : (
+
+ {types.map(type => (
+
+
+
+
+ {type.label}
+
+
+ {type.fields.length} champ{type.fields.length !== 1 ? 's' : ''} • {type.fields.map(f => f.type).join(', ')}
+
+
+ router.push(`/admin/posts/${type.key}/list`)}
+ icon={ }
+ >
+ Posts
+
+ {type.hasCategory && (
+ router.push(`/admin/posts/${type.key}/categories`)}
+ icon={ }
+ >
+ Catégories
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default PostsIndexPage;
diff --git a/src/modules/posts/admin/PostsListPage.js b/src/modules/posts/admin/PostsListPage.js
new file mode 100644
index 0000000..634b645
--- /dev/null
+++ b/src/modules/posts/admin/PostsListPage.js
@@ -0,0 +1,316 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon, Layers01Icon } from '../../../shared/Icons.js';
+import { Table, Button, Card, Pagination } from '../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+import { formatDateForDisplay, formatDateTimeForDisplay } from '../../../shared/lib/dates.js';
+
+/**
+ * Generic posts list page.
+ * Reads postType from the URL path: /admin/posts/{type}/list
+ */
+const PostsListPage = () => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const toast = useToast();
+
+ const postType = getPostTypeFromPath(pathname);
+
+ const [typeConfig, setTypeConfig] = useState(null);
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [deleting, setDeleting] = useState(false);
+
+ const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
+ const [sortBy, setSortBy] = useState('created_at');
+ const [sortOrder, setSortOrder] = useState('desc');
+
+ useEffect(() => {
+ setTypeConfig(null);
+ setPosts([]);
+ setPagination({ page: 1, limit: 20, total: 0, totalPages: 0 });
+ loadConfig();
+ }, [postType]);
+
+ useEffect(() => {
+ if (typeConfig) loadPosts();
+ }, [typeConfig, sortBy, sortOrder, pagination.page, pagination.limit]);
+
+ const loadConfig = async () => {
+ try {
+ const response = await fetch('/zen/api/admin/posts/config', { credentials: 'include' });
+ const data = await response.json();
+ if (data.success && data.config.types[postType]) {
+ setTypeConfig(data.config.types[postType]);
+ } else {
+ toast.error('Type de post introuvable');
+ }
+ } catch (error) {
+ console.error('Error loading config:', error);
+ toast.error('Impossible de charger la configuration');
+ }
+ };
+
+ const loadPosts = async () => {
+ try {
+ setLoading(true);
+ const searchParams = new URLSearchParams({
+ type: postType,
+ page: pagination.page.toString(),
+ limit: pagination.limit.toString(),
+ sortBy,
+ sortOrder
+ });
+ const response = await fetch(`/zen/api/admin/posts/posts?${searchParams}`, { credentials: 'include' });
+ const data = await response.json();
+
+ if (data.success) {
+ setPosts(data.posts || []);
+ setPagination(prev => ({
+ ...prev,
+ total: data.total || 0,
+ totalPages: data.totalPages || 0,
+ page: data.page || 1
+ }));
+ } else {
+ toast.error(data.error || 'Échec du chargement des posts');
+ }
+ } catch (error) {
+ console.error('Error loading posts:', error);
+ toast.error('Échec du chargement des posts');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDeletePost = async (post) => {
+ const titleField = typeConfig?.titleField;
+ const title = titleField ? post[titleField] : `#${post.id}`;
+ if (!confirm(`Êtes-vous sûr de vouloir supprimer "${title}" ?`)) return;
+
+ try {
+ setDeleting(true);
+ const response = await fetch(`/zen/api/admin/posts/posts?type=${postType}&id=${post.id}`, {
+ method: 'DELETE',
+ credentials: 'include'
+ });
+ const data = await response.json();
+ if (data.success) {
+ toast.success('Post supprimé avec succès');
+ loadPosts();
+ } else {
+ toast.error(data.error || 'Échec de la suppression');
+ }
+ } catch (error) {
+ console.error('Error deleting post:', error);
+ toast.error('Échec de la suppression');
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const buildColumns = () => {
+ if (!typeConfig) return [];
+
+ const cols = [];
+
+ for (const field of typeConfig.fields) {
+ if (field.type === 'slug') continue; // shown under title
+ if (field.type === 'markdown') continue; // body content — edit page only
+
+ if (field.type === 'title') {
+ cols.push({
+ key: field.name,
+ label: capitalize(field.name),
+ sortable: true,
+ render: (post) => (
+
+
{post[field.name] || '-'}
+ {typeConfig.slugField && (
+
{post.slug}
+ )}
+
+ ),
+ skeleton: { height: 'h-4', width: '40%', secondary: { height: 'h-3', width: '30%' } }
+ });
+ } else if (field.type === 'date') {
+ cols.push({
+ key: field.name,
+ label: capitalize(field.name),
+ sortable: true,
+ render: (post) => (
+
+ {post[field.name] ? formatDateForDisplay(post[field.name], 'fr-FR') : '-'}
+
+ ),
+ skeleton: { height: 'h-4', width: '100px' }
+ });
+ } else if (field.type === 'datetime') {
+ cols.push({
+ key: field.name,
+ label: capitalize(field.name),
+ sortable: true,
+ render: (post) => (
+
+ {post[field.name] ? formatDateTimeForDisplay(post[field.name]) : '-'}
+
+ ),
+ skeleton: { height: 'h-4', width: '140px' }
+ });
+ } else if (field.type === 'color') {
+ cols.push({
+ key: field.name,
+ label: capitalize(field.name),
+ sortable: false,
+ render: (post) => post[field.name] ? (
+
+
+ {post[field.name]}
+
+ ) : - ,
+ skeleton: { height: 'h-5', width: '80px' }
+ });
+ } else if (field.type === 'category') {
+ cols.push({
+ key: 'category_title',
+ label: 'Catégorie',
+ sortable: false,
+ render: (post) => (
+ {post.category_title || '-'}
+ ),
+ skeleton: { height: 'h-4', width: '25%' }
+ });
+ } else if (field.type === 'image') {
+ cols.push({
+ key: field.name,
+ label: capitalize(field.name),
+ sortable: false,
+ render: (post) =>
+ post[field.name] ? (
+
+ ) : (
+ -
+ ),
+ skeleton: { height: 'h-10', width: '40px' }
+ });
+ } else if (field.type === 'text') {
+ cols.push({
+ key: field.name,
+ label: capitalize(field.name),
+ sortable: false,
+ render: (post) => (
+
+ {post[field.name] || - }
+
+ ),
+ skeleton: { height: 'h-4', width: '35%' }
+ });
+ }
+ }
+
+ cols.push({
+ key: 'actions',
+ label: 'Actions',
+ render: (post) => (
+
+
router.push(`/admin/posts/${postType}/edit/${post.id}`)}
+ disabled={deleting}
+ icon={ }
+ className="p-2"
+ />
+ handleDeletePost(post)}
+ disabled={deleting}
+ icon={ }
+ className="p-2"
+ />
+
+ ),
+ skeleton: { height: 'h-8', width: '80px' }
+ });
+
+ return cols;
+ };
+
+ const label = typeConfig?.label || capitalize(postType);
+
+ return (
+
+
+
+
{label}
+
Gérez vos {label.toLowerCase()}s
+
+
+
router.push(`/admin/posts/${postType}/new`)}
+ icon={ }
+ >
+ Créer un {label.toLowerCase()}
+
+ {typeConfig?.hasCategory && (
+
router.push(`/admin/posts/${postType}/categories`)}
+ icon={ }
+ >
+ Catégories
+
+ )}
+
+
+
+
+ {
+ const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
+ setSortBy(newSortBy);
+ setSortOrder(newSortOrder);
+ }}
+ emptyMessage={`Aucun ${label.toLowerCase()} trouvé`}
+ emptyDescription={`Créez votre premier ${label.toLowerCase()}`}
+ />
+ setPagination(prev => ({ ...prev, page: p }))}
+ onLimitChange={(l) => setPagination(prev => ({ ...prev, limit: l, page: 1 }))}
+ limit={pagination.limit}
+ total={pagination.total}
+ loading={loading}
+ showPerPage={true}
+ showStats={true}
+ />
+
+
+ );
+};
+
+function getPostTypeFromPath(pathname) {
+ const segments = (pathname || '').split('/').filter(Boolean);
+ // /admin/posts/{type}/list → segments[2]
+ return segments[2] || '';
+}
+
+function capitalize(str) {
+ if (!str) return '';
+ return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
+}
+
+export default PostsListPage;
diff --git a/src/modules/posts/api.js b/src/modules/posts/api.js
new file mode 100644
index 0000000..27ed701
--- /dev/null
+++ b/src/modules/posts/api.js
@@ -0,0 +1,472 @@
+/**
+ * Posts Module - API Routes
+ */
+
+import {
+ createPost,
+ getPostById,
+ getPostBySlug,
+ getPosts,
+ searchPosts,
+ updatePost,
+ deletePost
+} from './crud.js';
+
+import {
+ createCategory,
+ getCategoryById,
+ getCategories,
+ getActiveCategories,
+ updateCategory,
+ deleteCategory
+} from './categories/crud.js';
+
+import {
+ uploadImage,
+ deleteFile,
+ generateBlogFilePath,
+ generateUniqueFilename,
+ validateUpload,
+ FILE_TYPE_PRESETS,
+ FILE_SIZE_LIMITS
+} from '@hykocx/zen/storage';
+
+import { getPostsConfig, getPostType } from './config.js';
+
+// ============================================================================
+// Config
+// ============================================================================
+
+async function handleGetConfig() {
+ try {
+ const config = getPostsConfig();
+ return { success: true, config };
+ } catch (error) {
+ return { success: false, error: error.message || 'Failed to get config' };
+ }
+}
+
+// ============================================================================
+// Posts (admin)
+// ============================================================================
+
+async function handleGetPosts(request) {
+ try {
+ const url = new URL(request.url);
+ const postType = url.searchParams.get('type');
+ if (!postType) return { success: false, error: 'Post type is required' };
+ if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
+
+ const id = url.searchParams.get('id');
+ if (id) {
+ const post = await getPostById(postType, parseInt(id));
+ if (!post) return { success: false, error: 'Post not found' };
+ return { success: true, post };
+ }
+
+ const page = parseInt(url.searchParams.get('page')) || 1;
+ const limit = parseInt(url.searchParams.get('limit')) || 20;
+ const search = url.searchParams.get('search') || '';
+ const category_id = url.searchParams.get('category_id') || null;
+ const sortBy = url.searchParams.get('sortBy') || 'created_at';
+ const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
+ const withRelations = url.searchParams.get('withRelations') === 'true';
+
+ const result = await getPosts(postType, {
+ page,
+ limit,
+ search,
+ category_id: category_id ? parseInt(category_id) : null,
+ sortBy,
+ sortOrder,
+ withRelations
+ });
+
+ return {
+ success: true,
+ posts: result.posts,
+ total: result.pagination.total,
+ totalPages: result.pagination.totalPages,
+ page: result.pagination.page,
+ limit: result.pagination.limit
+ };
+ } catch (error) {
+ console.error('[Posts] Error GET posts:', error);
+ return { success: false, error: error.message || 'Failed to fetch posts' };
+ }
+}
+
+async function handleCreatePost(request) {
+ try {
+ const url = new URL(request.url);
+ const postType = url.searchParams.get('type');
+ if (!postType) return { success: false, error: 'Post type is required' };
+
+ const body = await request.json();
+ const postData = body.post || body;
+ if (!postData || Object.keys(postData).length === 0) {
+ return { success: false, error: 'Post data is required' };
+ }
+
+ const post = await createPost(postType, postData);
+ return { success: true, post, message: 'Post created successfully' };
+ } catch (error) {
+ console.error('[Posts] Error creating post:', error);
+ return { success: false, error: error.message || 'Failed to create post' };
+ }
+}
+
+async function handleUpdatePost(request) {
+ try {
+ const url = new URL(request.url);
+ const body = await request.json();
+ const postType = url.searchParams.get('type');
+ const id = url.searchParams.get('id') || body.id;
+
+ if (!postType) return { success: false, error: 'Post type is required' };
+ if (!id) return { success: false, error: 'Post ID is required' };
+
+ const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
+ if (!updates || Object.keys(updates).length === 0) {
+ return { success: false, error: 'Update data is required' };
+ }
+
+ const typeConfig = getPostType(postType);
+
+ // Handle old image cleanup
+ const existing = await getPostById(postType, parseInt(id));
+ if (!existing) return { success: false, error: 'Post not found' };
+
+ const post = await updatePost(postType, parseInt(id), updates);
+
+ // Clean up replaced images
+ if (typeConfig) {
+ for (const field of typeConfig.fields.filter(f => f.type === 'image')) {
+ const oldKey = existing._data?.[field.name] || null;
+ const newKey = updates[field.name];
+ if (oldKey && newKey !== undefined && newKey !== oldKey) {
+ try {
+ await deleteFile(oldKey);
+ } catch (err) {
+ console.warn(`[Posts] Error deleting old image ${oldKey}:`, err.message);
+ }
+ }
+ }
+ }
+
+ return { success: true, post, message: 'Post updated successfully' };
+ } catch (error) {
+ console.error('[Posts] Error updating post:', error);
+ return { success: false, error: error.message || 'Failed to update post' };
+ }
+}
+
+async function handleDeletePost(request) {
+ try {
+ const url = new URL(request.url);
+ const postType = url.searchParams.get('type');
+ const id = url.searchParams.get('id');
+
+ if (!postType) return { success: false, error: 'Post type is required' };
+ if (!id) return { success: false, error: 'Post ID is required' };
+
+ const deleted = await deletePost(postType, parseInt(id));
+ if (!deleted) return { success: false, error: 'Post not found' };
+ return { success: true, message: 'Post deleted successfully' };
+ } catch (error) {
+ console.error('[Posts] Error deleting post:', error);
+ return { success: false, error: 'Failed to delete post' };
+ }
+}
+
+// ============================================================================
+// Image upload (admin)
+// ============================================================================
+
+async function handleUploadImage(request) {
+ try {
+ const formData = await request.formData();
+ const file = formData.get('file');
+
+ if (!file) return { success: false, error: 'No file provided' };
+
+ const validation = validateUpload({
+ filename: file.name,
+ size: file.size,
+ allowedTypes: FILE_TYPE_PRESETS.IMAGES,
+ maxSize: FILE_SIZE_LIMITS.IMAGE
+ });
+
+ if (!validation.valid) {
+ return { success: false, error: validation.errors.join(', ') };
+ }
+
+ const uniqueFilename = generateUniqueFilename(file.name);
+ const key = generateBlogFilePath(Date.now(), uniqueFilename);
+ const buffer = Buffer.from(await file.arrayBuffer());
+
+ const uploadResult = await uploadImage({
+ key,
+ body: buffer,
+ contentType: file.type,
+ metadata: { originalName: file.name }
+ });
+
+ if (!uploadResult.success) {
+ return { success: false, error: uploadResult.error || 'Upload failed' };
+ }
+
+ return { success: true, key: uploadResult.data.key };
+ } catch (error) {
+ console.error('[Posts] Error uploading image:', error);
+ return { success: false, error: error.message || 'Upload failed' };
+ }
+}
+
+// ============================================================================
+// Categories (admin)
+// ============================================================================
+
+async function handleGetCategories(request) {
+ try {
+ const url = new URL(request.url);
+ const postType = url.searchParams.get('type');
+ if (!postType) return { success: false, error: 'Post type is required' };
+
+ const id = url.searchParams.get('id');
+ if (id) {
+ const category = await getCategoryById(postType, parseInt(id));
+ if (!category) return { success: false, error: 'Category not found' };
+ return { success: true, category };
+ }
+
+ const page = parseInt(url.searchParams.get('page')) || 1;
+ const limit = parseInt(url.searchParams.get('limit')) || 20;
+ const search = url.searchParams.get('search') || '';
+ const is_active = url.searchParams.get('is_active');
+ const sortBy = url.searchParams.get('sortBy') || 'title';
+ const sortOrder = url.searchParams.get('sortOrder') || 'ASC';
+
+ const result = await getCategories(postType, {
+ page,
+ limit,
+ search,
+ is_active: is_active === 'true' ? true : is_active === 'false' ? false : null,
+ sortBy,
+ sortOrder
+ });
+
+ return {
+ success: true,
+ categories: result.categories,
+ total: result.pagination.total,
+ totalPages: result.pagination.totalPages,
+ page: result.pagination.page,
+ limit: result.pagination.limit
+ };
+ } catch (error) {
+ console.error('[Posts] Error GET categories:', error);
+ return { success: false, error: error.message || 'Failed to fetch categories' };
+ }
+}
+
+async function handleCreateCategory(request) {
+ try {
+ const url = new URL(request.url);
+ const postType = url.searchParams.get('type');
+ if (!postType) return { success: false, error: 'Post type is required' };
+
+ const body = await request.json();
+ const categoryData = body.category || body;
+ if (!categoryData || Object.keys(categoryData).length === 0) {
+ return { success: false, error: 'Category data is required' };
+ }
+
+ const category = await createCategory(postType, categoryData);
+ return { success: true, category, message: 'Category created successfully' };
+ } catch (error) {
+ console.error('[Posts] Error creating category:', error);
+ return { success: false, error: error.message || 'Failed to create category' };
+ }
+}
+
+async function handleUpdateCategory(request) {
+ try {
+ const url = new URL(request.url);
+ const body = await request.json();
+ const postType = url.searchParams.get('type');
+ const id = url.searchParams.get('id') || body.id;
+
+ if (!postType) return { success: false, error: 'Post type is required' };
+ if (!id) return { success: false, error: 'Category ID is required' };
+
+ const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
+ if (!updates || Object.keys(updates).length === 0) {
+ return { success: false, error: 'Update data is required' };
+ }
+
+ const existing = await getCategoryById(postType, parseInt(id));
+ if (!existing) return { success: false, error: 'Category not found' };
+
+ const category = await updateCategory(postType, parseInt(id), updates);
+ return { success: true, category, message: 'Category updated successfully' };
+ } catch (error) {
+ console.error('[Posts] Error updating category:', error);
+ return { success: false, error: error.message || 'Failed to update category' };
+ }
+}
+
+async function handleDeleteCategory(request) {
+ try {
+ const url = new URL(request.url);
+ const postType = url.searchParams.get('type');
+ const id = url.searchParams.get('id');
+
+ if (!postType) return { success: false, error: 'Post type is required' };
+ if (!id) return { success: false, error: 'Category ID is required' };
+
+ await deleteCategory(postType, parseInt(id));
+ return { success: true, message: 'Category deleted successfully' };
+ } catch (error) {
+ console.error('[Posts] Error deleting category:', error);
+ if (error.message.includes('Cannot delete')) {
+ return { success: false, error: error.message };
+ }
+ return { success: false, error: 'Failed to delete category' };
+ }
+}
+
+// ============================================================================
+// Relation search (admin — used by RelationSelector picker)
+// ============================================================================
+
+async function handleSearchPosts(request) {
+ try {
+ const url = new URL(request.url);
+ const postType = url.searchParams.get('type');
+ if (!postType) return { success: false, error: 'Post type is required' };
+ if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
+
+ const q = url.searchParams.get('q') || '';
+ const limit = parseInt(url.searchParams.get('limit')) || 20;
+
+ const posts = await searchPosts(postType, q, limit);
+ return { success: true, posts };
+ } catch (error) {
+ console.error('[Posts] Error searching posts:', error);
+ return { success: false, error: error.message || 'Failed to search posts' };
+ }
+}
+
+// ============================================================================
+// Public API
+// ============================================================================
+
+async function handlePublicGetConfig() {
+ try {
+ const config = getPostsConfig();
+ return { success: true, config };
+ } catch (error) {
+ return { success: false, error: error.message || 'Failed to get config' };
+ }
+}
+
+async function handlePublicGetPosts(request, params) {
+ try {
+ const postType = params?.type;
+ if (!postType) return { success: false, error: 'Post type is required' };
+ if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
+
+ const url = new URL(request.url);
+ const page = parseInt(url.searchParams.get('page')) || 1;
+ const limit = parseInt(url.searchParams.get('limit')) || 20;
+ const category_id = url.searchParams.get('category_id') || null;
+ const sortBy = url.searchParams.get('sortBy') || 'created_at';
+ const sortOrder = (url.searchParams.get('sortOrder') || 'DESC').toUpperCase();
+ const withRelations = url.searchParams.get('withRelations') === 'true';
+
+ const result = await getPosts(postType, {
+ page,
+ limit,
+ category_id: category_id ? parseInt(category_id) : null,
+ sortBy,
+ sortOrder,
+ withRelations
+ });
+
+ return {
+ success: true,
+ posts: result.posts,
+ total: result.pagination.total,
+ totalPages: result.pagination.totalPages,
+ page: result.pagination.page,
+ limit: result.pagination.limit
+ };
+ } catch (error) {
+ console.error('[Posts] Error public GET posts:', error);
+ return { success: false, error: error.message || 'Failed to fetch posts' };
+ }
+}
+
+async function handlePublicGetPostBySlug(request, params) {
+ try {
+ const postType = params?.type;
+ const slug = params?.slug;
+ if (!postType || !slug) return { success: false, error: 'Post type and slug are required' };
+
+ const post = await getPostBySlug(postType, slug);
+ if (!post) return { success: false, error: 'Post not found' };
+ return { success: true, post };
+ } catch (error) {
+ console.error('[Posts] Error public GET post by slug:', error);
+ return { success: false, error: error.message || 'Failed to fetch post' };
+ }
+}
+
+async function handlePublicGetCategories(request, params) {
+ try {
+ const postType = params?.type;
+ if (!postType) return { success: false, error: 'Post type is required' };
+
+ const categories = await getActiveCategories(postType);
+ return { success: true, categories };
+ } catch (error) {
+ console.error('[Posts] Error public GET categories:', error);
+ return { success: false, error: error.message || 'Failed to fetch categories' };
+ }
+}
+
+// ============================================================================
+// Route Definitions
+// ============================================================================
+
+export default {
+ routes: [
+ // Admin config
+ { path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' },
+
+ // Admin posts
+ { path: '/admin/posts/posts', method: 'GET', handler: handleGetPosts, auth: 'admin' },
+ { path: '/admin/posts/posts', method: 'POST', handler: handleCreatePost, auth: 'admin' },
+ { path: '/admin/posts/posts', method: 'PUT', handler: handleUpdatePost, auth: 'admin' },
+ { path: '/admin/posts/posts', method: 'DELETE', handler: handleDeletePost, auth: 'admin' },
+
+ // Admin image upload
+ { path: '/admin/posts/upload-image', method: 'POST', handler: handleUploadImage, auth: 'admin' },
+
+ // Admin relation search (for RelationSelector picker)
+ { path: '/admin/posts/search', method: 'GET', handler: handleSearchPosts, auth: 'admin' },
+
+ // Admin categories
+ { path: '/admin/posts/categories', method: 'GET', handler: handleGetCategories, auth: 'admin' },
+ { path: '/admin/posts/categories', method: 'POST', handler: handleCreateCategory, auth: 'admin' },
+ { path: '/admin/posts/categories', method: 'PUT', handler: handleUpdateCategory, auth: 'admin' },
+ { path: '/admin/posts/categories', method: 'DELETE', handler: handleDeleteCategory, auth: 'admin' },
+
+ // Public
+ { path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' },
+ { path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
+ { path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
+ { path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' },
+ ]
+};
diff --git a/src/modules/posts/categories/admin/CategoriesListPage.js b/src/modules/posts/categories/admin/CategoriesListPage.js
new file mode 100644
index 0000000..0265f3e
--- /dev/null
+++ b/src/modules/posts/categories/admin/CategoriesListPage.js
@@ -0,0 +1,198 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon } from '../../../../shared/Icons.js';
+import { Table, Button, StatusBadge, Card, Pagination } from '../../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+
+function getPostTypeFromPath(pathname) {
+ const segments = (pathname || '').split('/').filter(Boolean);
+ // /admin/posts/{type}/categories → segments[2]
+ return segments[2] || '';
+}
+
+const CategoriesListPage = () => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const toast = useToast();
+
+ const postType = getPostTypeFromPath(pathname);
+
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [deleting, setDeleting] = useState(false);
+ const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
+ const [sortBy, setSortBy] = useState('title');
+ const [sortOrder, setSortOrder] = useState('asc');
+
+ const columns = [
+ {
+ key: 'title',
+ label: 'Titre',
+ sortable: true,
+ render: (cat) => {cat.title}
,
+ skeleton: { height: 'h-4', width: '40%' }
+ },
+ {
+ key: 'description',
+ label: 'Description',
+ sortable: false,
+ render: (cat) => (
+
+ {cat.description || - }
+
+ ),
+ skeleton: { height: 'h-4', width: '60%' }
+ },
+ {
+ key: 'posts_count',
+ label: 'Posts',
+ sortable: false,
+ render: (cat) => {cat.posts_count || 0}
,
+ skeleton: { height: 'h-4', width: '30px' }
+ },
+ {
+ key: 'is_active',
+ label: 'Statut',
+ sortable: true,
+ render: (cat) => (
+
+ {cat.is_active ? 'Actif' : 'Inactif'}
+
+ ),
+ skeleton: { height: 'h-6', width: '70px', className: 'rounded-full' }
+ },
+ {
+ key: 'actions',
+ label: 'Actions',
+ render: (cat) => (
+
+
router.push(`/admin/posts/${postType}/categories/edit/${cat.id}`)}
+ disabled={deleting}
+ icon={ }
+ className="p-2"
+ />
+ handleDeleteCategory(cat)}
+ disabled={deleting}
+ icon={ }
+ className="p-2"
+ />
+
+ ),
+ skeleton: { height: 'h-8', width: '80px' }
+ }
+ ];
+
+ useEffect(() => {
+ loadCategories();
+ }, [postType, sortBy, sortOrder, pagination.page, pagination.limit]);
+
+ const loadCategories = async () => {
+ try {
+ setLoading(true);
+ const searchParams = new URLSearchParams({
+ type: postType,
+ page: pagination.page.toString(),
+ limit: pagination.limit.toString(),
+ sortBy,
+ sortOrder
+ });
+ const response = await fetch(`/zen/api/admin/posts/categories?${searchParams}`, { credentials: 'include' });
+ const data = await response.json();
+
+ if (data.success) {
+ setCategories(data.categories || []);
+ setPagination(prev => ({
+ ...prev,
+ total: data.total || 0,
+ totalPages: data.totalPages || 0,
+ page: data.page || 1
+ }));
+ } else {
+ toast.error(data.error || 'Échec du chargement des catégories');
+ }
+ } catch (error) {
+ console.error('Error loading categories:', error);
+ toast.error('Échec du chargement des catégories');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDeleteCategory = async (category) => {
+ if (!confirm(`Êtes-vous sûr de vouloir supprimer la catégorie "${category.title}" ?`)) return;
+ try {
+ setDeleting(true);
+ const response = await fetch(`/zen/api/admin/posts/categories?type=${postType}&id=${category.id}`, {
+ method: 'DELETE',
+ credentials: 'include'
+ });
+ const data = await response.json();
+ if (data.success) {
+ toast.success('Catégorie supprimée avec succès');
+ loadCategories();
+ } else {
+ toast.error(data.error || 'Échec de la suppression de la catégorie');
+ }
+ } catch (error) {
+ console.error('Error deleting category:', error);
+ toast.error('Échec de la suppression de la catégorie');
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ return (
+
+
+
+
Catégories
+
Gérez les catégories de {postType || 'posts'}
+
+
router.push(`/admin/posts/${postType}/categories/new`)}
+ icon={ }
+ >
+ Créer une catégorie
+
+
+
+
+ {
+ const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
+ setSortBy(newSortBy);
+ setSortOrder(newSortOrder);
+ }}
+ emptyMessage="Aucune catégorie trouvée"
+ emptyDescription="Créez votre première catégorie"
+ />
+ setPagination(prev => ({ ...prev, page: p }))}
+ onLimitChange={(l) => setPagination(prev => ({ ...prev, limit: l, page: 1 }))}
+ limit={pagination.limit}
+ total={pagination.total}
+ loading={loading}
+ showPerPage={true}
+ showStats={true}
+ />
+
+
+ );
+};
+
+export default CategoriesListPage;
diff --git a/src/modules/posts/categories/admin/CategoryCreatePage.js b/src/modules/posts/categories/admin/CategoryCreatePage.js
new file mode 100644
index 0000000..e720547
--- /dev/null
+++ b/src/modules/posts/categories/admin/CategoryCreatePage.js
@@ -0,0 +1,124 @@
+'use client';
+
+import React, { useState } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Button, Card, Input, Textarea } from '../../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+
+function getPostTypeFromPath(pathname) {
+ const segments = (pathname || '').split('/').filter(Boolean);
+ // /admin/posts/{type}/categories/new → segments[2]
+ return segments[2] || '';
+}
+
+const CategoryCreatePage = () => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const toast = useToast();
+
+ const postType = getPostTypeFromPath(pathname);
+
+ const [saving, setSaving] = useState(false);
+ const [formData, setFormData] = useState({ title: '', description: '', is_active: true });
+ const [errors, setErrors] = useState({});
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
+ };
+
+ const validateForm = () => {
+ const newErrors = {};
+ if (!formData.title.trim()) newErrors.title = 'Le titre est requis';
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validateForm()) return;
+
+ try {
+ setSaving(true);
+ const response = await fetch(`/zen/api/admin/posts/categories?type=${postType}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(formData)
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ toast.success('Catégorie créée avec succès');
+ router.push(`/admin/posts/${postType}/categories`);
+ } else {
+ toast.error(data.error || 'Échec de la création');
+ }
+ } catch (error) {
+ console.error('Error creating category:', error);
+ toast.error('Échec de la création');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
Créer une catégorie
+
Ajouter une nouvelle catégorie
+
+
router.push(`/admin/posts/${postType}/categories`)}>
+ ← Retour
+
+
+
+
+
+ );
+};
+
+export default CategoryCreatePage;
diff --git a/src/modules/posts/categories/admin/CategoryEditPage.js b/src/modules/posts/categories/admin/CategoryEditPage.js
new file mode 100644
index 0000000..b8b2d02
--- /dev/null
+++ b/src/modules/posts/categories/admin/CategoryEditPage.js
@@ -0,0 +1,158 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Button, Card, Input, Textarea } from '../../../../shared/components';
+import { useToast } from '@hykocx/zen/toast';
+
+function getParamsFromPath(pathname) {
+ const segments = (pathname || '').split('/').filter(Boolean);
+ // /admin/posts/{type}/categories/edit/{id} → segments[2], segments[5]
+ return { postType: segments[2] || '', categoryId: segments[5] || '' };
+}
+
+const CategoryEditPage = () => {
+ const router = useRouter();
+ const pathname = usePathname();
+ const toast = useToast();
+
+ const { postType, categoryId } = getParamsFromPath(pathname);
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [formData, setFormData] = useState({ title: '', description: '', is_active: true });
+ const [errors, setErrors] = useState({});
+
+ useEffect(() => {
+ if (postType && categoryId) loadCategory();
+ }, [postType, categoryId]);
+
+ const loadCategory = async () => {
+ try {
+ const response = await fetch(
+ `/zen/api/admin/posts/categories?type=${postType}&id=${categoryId}`,
+ { credentials: 'include' }
+ );
+ const data = await response.json();
+ if (data.success && data.category) {
+ setFormData({
+ title: data.category.title || '',
+ description: data.category.description || '',
+ is_active: data.category.is_active ?? true
+ });
+ } else {
+ toast.error('Catégorie introuvable');
+ router.push(`/admin/posts/${postType}/categories`);
+ }
+ } catch (error) {
+ console.error('Error loading category:', error);
+ toast.error('Impossible de charger la catégorie');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
+ };
+
+ const validateForm = () => {
+ const newErrors = {};
+ if (!formData.title.trim()) newErrors.title = 'Le titre est requis';
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validateForm()) return;
+
+ try {
+ setSaving(true);
+ const response = await fetch(`/zen/api/admin/posts/categories?type=${postType}&id=${categoryId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(formData)
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ toast.success('Catégorie mise à jour avec succès');
+ router.push(`/admin/posts/${postType}/categories`);
+ } else {
+ toast.error(data.error || 'Échec de la mise à jour');
+ }
+ } catch (error) {
+ console.error('Error updating category:', error);
+ toast.error('Échec de la mise à jour');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
Modifier la catégorie
+
Modifier une catégorie existante
+
+
router.push(`/admin/posts/${postType}/categories`)}>
+ ← Retour
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
+ router.push(`/admin/posts/${postType}/categories`)} disabled={saving}>
+ Annuler
+
+
+ {saving ? 'Enregistrement...' : 'Enregistrer'}
+
+
+
+ )}
+
+ );
+};
+
+export default CategoryEditPage;
diff --git a/src/modules/posts/categories/crud.js b/src/modules/posts/categories/crud.js
new file mode 100644
index 0000000..aef1288
--- /dev/null
+++ b/src/modules/posts/categories/crud.js
@@ -0,0 +1,183 @@
+/**
+ * Posts Module - Categories CRUD
+ * Categories are scoped by post_type.
+ */
+
+import { query } from '@hykocx/zen/database';
+
+/**
+ * Create a new category for a post type.
+ * @param {string} postType
+ * @param {Object} categoryData
+ * @returns {Promise}
+ */
+export async function createCategory(postType, categoryData) {
+ const { title, description = null, is_active = true } = categoryData;
+
+ if (!title) throw new Error('Title is required');
+
+ const result = await query(
+ `INSERT INTO zen_posts_category (post_type, title, description, is_active)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [postType, title, description, is_active]
+ );
+
+ return result.rows[0];
+}
+
+/**
+ * Get category by ID (scoped to postType for safety).
+ * @param {string} postType
+ * @param {number} id
+ * @returns {Promise}
+ */
+export async function getCategoryById(postType, id) {
+ const result = await query(
+ `SELECT c.*,
+ (SELECT COUNT(*) FROM zen_posts WHERE category_id = c.id AND post_type = $1) as posts_count
+ FROM zen_posts_category c
+ WHERE c.id = $2 AND c.post_type = $1`,
+ [postType, id]
+ );
+ return result.rows[0] || null;
+}
+
+/**
+ * Get all categories for a post type with pagination.
+ * @param {string} postType
+ * @param {Object} options
+ * @returns {Promise}
+ */
+export async function getCategories(postType, options = {}) {
+ const {
+ page = 1,
+ limit = 50,
+ search = '',
+ is_active = null,
+ sortBy = 'title',
+ sortOrder = 'ASC'
+ } = options;
+
+ const offset = (page - 1) * limit;
+ const conditions = ['c.post_type = $1'];
+ const params = [postType];
+ let paramIndex = 2;
+
+ if (search) {
+ conditions.push(`(c.title ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex})`);
+ params.push(`%${search}%`);
+ paramIndex++;
+ }
+
+ if (is_active !== null) {
+ conditions.push(`c.is_active = $${paramIndex}`);
+ params.push(is_active);
+ paramIndex++;
+ }
+
+ const whereClause = `WHERE ${conditions.join(' AND ')}`;
+
+ const countResult = await query(
+ `SELECT COUNT(*) FROM zen_posts_category c ${whereClause}`,
+ params
+ );
+ const total = parseInt(countResult.rows[0].count);
+
+ const validSort = ['title', 'created_at', 'updated_at'].includes(sortBy) ? sortBy : 'title';
+ const validOrder = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
+
+ const result = await query(
+ `SELECT c.*,
+ (SELECT COUNT(*) FROM zen_posts WHERE category_id = c.id AND post_type = $1) as posts_count
+ FROM zen_posts_category c
+ ${whereClause}
+ ORDER BY c.${validSort} ${validOrder}
+ LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
+ [...params, limit, offset]
+ );
+
+ return {
+ categories: result.rows,
+ pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
+ };
+}
+
+/**
+ * Get all active categories for a post type (for dropdowns).
+ * @param {string} postType
+ * @returns {Promise}
+ */
+export async function getActiveCategories(postType) {
+ const result = await query(
+ `SELECT id, title FROM zen_posts_category
+ WHERE post_type = $1 AND is_active = true
+ ORDER BY title ASC`,
+ [postType]
+ );
+ return result.rows;
+}
+
+/**
+ * Update a category.
+ * @param {string} postType
+ * @param {number} id
+ * @param {Object} updates
+ * @returns {Promise}
+ */
+export async function updateCategory(postType, id, updates) {
+ const allowedFields = ['title', 'description', 'is_active'];
+ const setFields = [];
+ const values = [];
+ let paramIndex = 1;
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (allowedFields.includes(key)) {
+ setFields.push(`${key} = $${paramIndex}`);
+ values.push(value);
+ paramIndex++;
+ }
+ }
+
+ if (setFields.length === 0) throw new Error('No valid fields to update');
+
+ setFields.push(`updated_at = CURRENT_TIMESTAMP`);
+ values.push(postType, id);
+
+ const result = await query(
+ `UPDATE zen_posts_category
+ SET ${setFields.join(', ')}
+ WHERE post_type = $${paramIndex} AND id = $${paramIndex + 1}
+ RETURNING *`,
+ values
+ );
+
+ return result.rows[0];
+}
+
+/**
+ * Delete a category (blocked if posts reference it).
+ * @param {string} postType
+ * @param {number} id
+ * @returns {Promise}
+ */
+export async function deleteCategory(postType, id) {
+ const postsResult = await query(
+ `SELECT COUNT(*) FROM zen_posts WHERE category_id = $1 AND post_type = $2`,
+ [id, postType]
+ );
+ const postsCount = parseInt(postsResult.rows[0].count);
+
+ if (postsCount > 0) {
+ throw new Error(`Cannot delete category with ${postsCount} associated posts`);
+ }
+
+ const result = await query(
+ `DELETE FROM zen_posts_category WHERE post_type = $1 AND id = $2 RETURNING *`,
+ [postType, id]
+ );
+
+ if (result.rowCount === 0) throw new Error('Category not found');
+
+ return result.rows[0];
+}
diff --git a/src/modules/posts/config.js b/src/modules/posts/config.js
new file mode 100644
index 0000000..47e4acb
--- /dev/null
+++ b/src/modules/posts/config.js
@@ -0,0 +1,134 @@
+/**
+ * Posts Module - Config Parser
+ * Parses ZEN_MODULE_ZEN_MODULE_POSTS_TYPES and ZEN_MODULE_POSTS_TYPE_* environment variables into a structured config.
+ *
+ * .env format:
+ * ZEN_MODULE_ZEN_MODULE_POSTS_TYPES=blogue|cve|emploi
+ * ZEN_MODULE_POSTS_TYPE_BLOGUE=title:title|slug:slug|category:category|date:date|resume:text|content:markdown|image:image
+ * ZEN_MODULE_POSTS_TYPE_CVE=title:title|slug:slug|cve_id:text|severity:text|description:markdown|date:date
+ *
+ * Supported field types: title, slug, text, markdown, date, datetime, color, category, image, relation
+ *
+ * Relation field format: name:relation:target_post_type
+ * e.g. keywords:relation:mots-cle
+ */
+
+const VALID_FIELD_TYPES = ['title', 'slug', 'text', 'markdown', 'date', 'datetime', 'color', 'category', 'image', 'relation'];
+
+let _cachedConfig = null;
+
+/**
+ * Parse a single type's field string into an array of field definitions.
+ * Format: "name:type" or "name:type:param" (param used for relation target)
+ * e.g. "title:title|slug:slug|date:date|keywords:relation:mots-cle"
+ * -> [{ name: 'title', type: 'title' }, ..., { name: 'keywords', type: 'relation', target: 'mots-cle' }]
+ * @param {string} fieldString
+ * @returns {Array<{name: string, type: string, target?: string}>}
+ */
+function parseFields(fieldString) {
+ if (!fieldString) return [];
+
+ return fieldString
+ .split('|')
+ .map(part => {
+ const segments = part.trim().split(':');
+ const name = segments[0];
+ const type = segments[1];
+ const param = segments[2] || null;
+ if (!name) return null;
+ const resolvedType = VALID_FIELD_TYPES.includes(type) ? type : 'text';
+ const field = { name: name.trim(), type: resolvedType };
+ if (resolvedType === 'relation' && param) {
+ field.target = param.trim().toLowerCase();
+ }
+ return field;
+ })
+ .filter(Boolean);
+}
+
+/**
+ * Parse all ZEN_MODULE_POSTS_TYPE_* env vars and build the config object.
+ * @returns {Object} Parsed config
+ */
+function buildConfig() {
+ const enabled = process.env.ZEN_MODULE_POSTS === 'true';
+
+ if (!enabled) {
+ return { enabled: false, types: {} };
+ }
+
+ const typesRaw = process.env.ZEN_MODULE_ZEN_MODULE_POSTS_TYPES || '';
+ // Each entry can be "key" or "key:Label" — only lowercase the key part
+ const typeKeys = typesRaw
+ .split('|')
+ .map(k => {
+ const [key, ...rest] = k.trim().split(':');
+ const label = rest.join(':'); // preserve colons in label if any
+ return label ? `${key.toLowerCase()}:${label}` : key.toLowerCase();
+ })
+ .filter(Boolean);
+
+ const types = {};
+
+ for (const entry of typeKeys) {
+ // Support "key:Label" format (label is optional)
+ const [key, customLabel] = entry.split(':');
+ const envKey = `ZEN_MODULE_POSTS_TYPE_${key.toUpperCase()}`;
+ const fieldString = process.env[envKey] || '';
+ const fields = parseFields(fieldString);
+
+ const titleField = fields.find(f => f.type === 'title')?.name || null;
+ const slugField = fields.find(f => f.type === 'slug')?.name || null;
+ const hasCategory = fields.some(f => f.type === 'category');
+ const hasRelations = fields.some(f => f.type === 'relation');
+ const label = customLabel || (key.charAt(0).toUpperCase() + key.slice(1));
+
+ types[key] = {
+ key,
+ label,
+ fields,
+ hasCategory,
+ hasRelations,
+ titleField,
+ slugField,
+ };
+ }
+
+ return { enabled: true, types };
+}
+
+/**
+ * Get the parsed posts config (cached after first call).
+ * @returns {{ enabled: boolean, types: Object }}
+ */
+export function getPostsConfig() {
+ if (!_cachedConfig) {
+ _cachedConfig = buildConfig();
+ }
+ return _cachedConfig;
+}
+
+/**
+ * Get a single post type config by key.
+ * @param {string} key - Type key (e.g. 'blogue')
+ * @returns {Object|null}
+ */
+export function getPostType(key) {
+ const config = getPostsConfig();
+ return config.types[key] || null;
+}
+
+/**
+ * Check if the posts module is enabled.
+ * @returns {boolean}
+ */
+export function isPostsEnabled() {
+ return process.env.ZEN_MODULE_POSTS === 'true';
+}
+
+/**
+ * Reset cached config (useful for testing).
+ */
+export function resetConfig() {
+ _cachedConfig = null;
+}
diff --git a/src/modules/posts/crud.js b/src/modules/posts/crud.js
new file mode 100644
index 0000000..17449ed
--- /dev/null
+++ b/src/modules/posts/crud.js
@@ -0,0 +1,525 @@
+/**
+ * Posts Module - CRUD Operations
+ * Uses a single zen_posts table with JSONB for custom fields.
+ * Relation fields are stored in zen_posts_relations (many-to-many).
+ */
+
+import { query } from '@hykocx/zen/database';
+import { deleteFile } from '@hykocx/zen/storage';
+import { getPostType } from './config.js';
+
+function slugify(text) {
+ if (!text || typeof text !== 'string') return '';
+ return text
+ .toLowerCase()
+ .trim()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
+async function ensureUniqueSlug(postType, baseSlug, excludeId = null) {
+ let slug = baseSlug || 'post';
+ let n = 1;
+ for (;;) {
+ let result;
+ if (excludeId != null) {
+ result = await query(
+ `SELECT id FROM zen_posts WHERE post_type = $1 AND slug = $2 AND id != $3`,
+ [postType, slug, excludeId]
+ );
+ } else {
+ result = await query(
+ `SELECT id FROM zen_posts WHERE post_type = $1 AND slug = $2`,
+ [postType, slug]
+ );
+ }
+ if (result.rows.length === 0) return slug;
+ n++;
+ slug = `${baseSlug}-${n}`;
+ }
+}
+
+function getImageKeys(typeConfig, data) {
+ if (!typeConfig || !data) return [];
+ return typeConfig.fields
+ .filter(f => f.type === 'image')
+ .map(f => data[f.name])
+ .filter(Boolean);
+}
+
+/**
+ * Save relation field values for a post.
+ * Replaces all existing relations for the given field names.
+ * @param {number} postId
+ * @param {Object} relationUpdates - { fieldName: [id, id, ...] }
+ */
+async function saveRelations(postId, relationUpdates) {
+ for (const [fieldName, ids] of Object.entries(relationUpdates)) {
+ // Delete existing relations for this field
+ await query(
+ `DELETE FROM zen_posts_relations WHERE post_id = $1 AND field_name = $2`,
+ [postId, fieldName]
+ );
+
+ if (!ids || ids.length === 0) continue;
+
+ // Insert new relations
+ for (let i = 0; i < ids.length; i++) {
+ const relatedId = parseInt(ids[i]);
+ if (!relatedId) continue;
+ await query(
+ `INSERT INTO zen_posts_relations (post_id, field_name, related_post_id, sort_order)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (post_id, field_name, related_post_id) DO UPDATE SET sort_order = $4`,
+ [postId, fieldName, relatedId, i]
+ );
+ }
+ }
+}
+
+/**
+ * Load relation fields for a post, grouped by field name.
+ * Returns { fieldName: [{ id, slug, title }] }
+ * @param {number} postId
+ * @param {Object} typeConfig
+ * @returns {Promise}
+ */
+async function loadRelations(postId, typeConfig) {
+ const relationFields = typeConfig.fields.filter(f => f.type === 'relation');
+ if (relationFields.length === 0) return {};
+
+ const result = await query(
+ `SELECT r.field_name, r.sort_order,
+ p.id as related_id, p.slug as related_slug, p.post_type as related_type, p.data as related_data
+ FROM zen_posts_relations r
+ JOIN zen_posts p ON p.id = r.related_post_id
+ WHERE r.post_id = $1
+ ORDER BY r.field_name, r.sort_order`,
+ [postId]
+ );
+
+ const grouped = {};
+ for (const field of relationFields) {
+ grouped[field.name] = [];
+ }
+
+ for (const row of result.rows) {
+ if (!grouped[row.field_name]) continue;
+ const data = typeof row.related_data === 'string' ? JSON.parse(row.related_data) : (row.related_data || {});
+ const relatedTypeConfig = getPostType(row.related_type);
+ const titleValue = relatedTypeConfig?.titleField ? data[relatedTypeConfig.titleField] : null;
+ grouped[row.field_name].push({
+ id: row.related_id,
+ slug: row.related_slug,
+ post_type: row.related_type,
+ title: titleValue || row.related_slug,
+ // Inclure tous les champs JSONB du post lié (color, text, etc.)
+ ...data,
+ });
+ }
+
+ return grouped;
+}
+
+/**
+ * Create a new post.
+ * Relation field values should be arrays of IDs: { keywords: [1, 5, 12] }
+ * @param {string} postType
+ * @param {Object} rawData
+ * @returns {Promise}
+ */
+export async function createPost(postType, rawData) {
+ const typeConfig = getPostType(postType);
+ if (!typeConfig) throw new Error(`Unknown post type: ${postType}`);
+
+ const slugFieldName = typeConfig.slugField;
+ const titleFieldName = typeConfig.titleField;
+ const rawSlug = slugFieldName ? rawData[slugFieldName] : null;
+ const rawTitle = titleFieldName ? rawData[titleFieldName] : null;
+
+ if (!rawTitle) throw new Error('Title field is required');
+
+ const baseSlug = rawSlug?.trim() ? slugify(rawSlug) : slugify(rawTitle);
+ const slug = await ensureUniqueSlug(postType, baseSlug || 'post');
+
+ const categoryField = typeConfig.fields.find(f => f.type === 'category');
+ const category_id = categoryField ? (rawData[categoryField.name] || null) : null;
+
+ // Build data JSONB — exclude slug, category and relation fields (stored separately)
+ const data = {};
+ for (const field of typeConfig.fields) {
+ if (field.type === 'slug') continue;
+ if (field.type === 'category') continue;
+ if (field.type === 'relation') continue;
+ data[field.name] = rawData[field.name] ?? null;
+ }
+
+ const result = await query(
+ `INSERT INTO zen_posts (post_type, slug, data, category_id)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [postType, slug, JSON.stringify(data), category_id || null]
+ );
+
+ const postId = result.rows[0].id;
+
+ // Save relation fields
+ const relationUpdates = {};
+ for (const field of typeConfig.fields.filter(f => f.type === 'relation')) {
+ const ids = rawData[field.name];
+ relationUpdates[field.name] = Array.isArray(ids) ? ids : [];
+ }
+ if (Object.keys(relationUpdates).length > 0) {
+ await saveRelations(postId, relationUpdates);
+ }
+
+ return getPostById(postType, postId);
+}
+
+/**
+ * Get post by ID (includes relation fields).
+ * @param {string} postType
+ * @param {number} id
+ * @returns {Promise}
+ */
+export async function getPostById(postType, id) {
+ const result = await query(
+ `SELECT p.*, c.title as category_title
+ FROM zen_posts p
+ LEFT JOIN zen_posts_category c ON p.category_id = c.id
+ WHERE p.post_type = $1 AND p.id = $2`,
+ [postType, id]
+ );
+ if (!result.rows[0]) return null;
+
+ const post = flattenPost(result.rows[0]);
+ const typeConfig = getPostType(postType);
+ if (typeConfig?.hasRelations) {
+ const relations = await loadRelations(id, typeConfig);
+ Object.assign(post, relations);
+ }
+ return post;
+}
+
+/**
+ * Get post by slug (includes relation fields).
+ * @param {string} postType
+ * @param {string} slug
+ * @returns {Promise}
+ */
+export async function getPostBySlug(postType, slug) {
+ const result = await query(
+ `SELECT p.*, c.title as category_title
+ FROM zen_posts p
+ LEFT JOIN zen_posts_category c ON p.category_id = c.id
+ WHERE p.post_type = $1 AND p.slug = $2`,
+ [postType, slug]
+ );
+ if (!result.rows[0]) return null;
+
+ const post = flattenPost(result.rows[0]);
+ const typeConfig = getPostType(postType);
+ if (typeConfig?.hasRelations) {
+ const relations = await loadRelations(result.rows[0].id, typeConfig);
+ Object.assign(post, relations);
+ }
+ return post;
+}
+
+/**
+ * Get posts for a type with pagination and filters.
+ * Pass withRelations: true to include relation fields (adds one query per post — use sparingly on large lists).
+ * @param {string} postType
+ * @param {Object} options
+ * @returns {Promise}
+ */
+export async function getPosts(postType, options = {}) {
+ const {
+ page = 1,
+ limit = 20,
+ search = '',
+ category_id = null,
+ sortBy = 'created_at',
+ sortOrder = 'DESC',
+ withRelations = false
+ } = options;
+
+ const typeConfig = getPostType(postType);
+ const offset = (page - 1) * limit;
+ const conditions = ['p.post_type = $1'];
+ const params = [postType];
+ let paramIndex = 2;
+
+ if (search && typeConfig?.titleField) {
+ conditions.push(`(p.data->>'${typeConfig.titleField}' ILIKE $${paramIndex})`);
+ params.push(`%${search}%`);
+ paramIndex++;
+ }
+
+ if (category_id != null) {
+ conditions.push(`p.category_id = $${paramIndex}`);
+ params.push(category_id);
+ paramIndex++;
+ }
+
+ const whereClause = `WHERE ${conditions.join(' AND ')}`;
+
+ const countResult = await query(
+ `SELECT COUNT(*) FROM zen_posts p ${whereClause}`,
+ params
+ );
+ const total = parseInt(countResult.rows[0].count);
+
+ const validOrder = sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
+ let orderExpr = 'p.created_at';
+
+ if (sortBy === 'created_at' || sortBy === 'updated_at') {
+ orderExpr = `p.${sortBy}`;
+ } else if (typeConfig) {
+ const sortField = typeConfig.fields.find(f => f.name === sortBy);
+ if (sortField) {
+ orderExpr = sortField.type === 'date'
+ ? `(NULLIF(p.data->>'${sortBy}', ''))::date`
+ : sortField.type === 'datetime'
+ ? `(NULLIF(p.data->>'${sortBy}', ''))::timestamptz`
+ : `p.data->>'${sortBy}'`;
+ }
+ }
+
+ const postsResult = await query(
+ `SELECT p.*, c.title as category_title
+ FROM zen_posts p
+ LEFT JOIN zen_posts_category c ON p.category_id = c.id
+ ${whereClause}
+ ORDER BY ${orderExpr} ${validOrder}
+ LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
+ [...params, limit, offset]
+ );
+
+ const posts = postsResult.rows.map(flattenPost);
+
+ if (withRelations && typeConfig?.hasRelations) {
+ for (const post of posts) {
+ const relations = await loadRelations(post.id, typeConfig);
+ Object.assign(post, relations);
+ }
+ }
+
+ return {
+ posts,
+ pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
+ };
+}
+
+/**
+ * Search posts of a type by title (for relation picker).
+ * @param {string} postType
+ * @param {string} search
+ * @param {number} limit
+ * @returns {Promise}
+ */
+export async function searchPosts(postType, search = '', limit = 20) {
+ const typeConfig = getPostType(postType);
+ const titleField = typeConfig?.titleField;
+
+ let result;
+ if (search && titleField) {
+ result = await query(
+ `SELECT id, slug, data->>'${titleField}' as title
+ FROM zen_posts
+ WHERE post_type = $1 AND data->>'${titleField}' ILIKE $2
+ ORDER BY data->>'${titleField}' ASC
+ LIMIT $3`,
+ [postType, `%${search}%`, limit]
+ );
+ } else {
+ result = await query(
+ `SELECT id, slug, data->>'${titleField || 'title'}' as title
+ FROM zen_posts
+ WHERE post_type = $1
+ ORDER BY created_at DESC
+ LIMIT $2`,
+ [postType, limit]
+ );
+ }
+
+ return result.rows;
+}
+
+/**
+ * Update a post.
+ * Relation field values should be arrays of IDs: { keywords: [1, 5, 12] }
+ * @param {string} postType
+ * @param {number} id
+ * @param {Object} rawData
+ * @returns {Promise}
+ */
+export async function updatePost(postType, id, rawData) {
+ const typeConfig = getPostType(postType);
+ if (!typeConfig) throw new Error(`Unknown post type: ${postType}`);
+
+ const existing = await getPostById(postType, id);
+ if (!existing) throw new Error('Post not found');
+
+ const slugFieldName = typeConfig.slugField;
+ let slug = existing.slug;
+
+ if (slugFieldName && rawData[slugFieldName] !== undefined) {
+ const newSlug = slugify(rawData[slugFieldName]) || slugify(existing[typeConfig.titleField] || '') || 'post';
+ slug = await ensureUniqueSlug(postType, newSlug, id);
+ }
+
+ const categoryField = typeConfig.fields.find(f => f.type === 'category');
+ let category_id = existing.category_id;
+ if (categoryField && rawData[categoryField.name] !== undefined) {
+ category_id = rawData[categoryField.name] || null;
+ }
+
+ const existingData = existing._data || {};
+ const newData = { ...existingData };
+
+ for (const field of typeConfig.fields) {
+ if (field.type === 'slug') continue;
+ if (field.type === 'category') continue;
+ if (field.type === 'relation') continue;
+ if (rawData[field.name] !== undefined) {
+ newData[field.name] = rawData[field.name];
+ }
+ }
+
+ await query(
+ `UPDATE zen_posts
+ SET slug = $1, data = $2, category_id = $3, updated_at = CURRENT_TIMESTAMP
+ WHERE post_type = $4 AND id = $5`,
+ [slug, JSON.stringify(newData), category_id || null, postType, id]
+ );
+
+ // Update relation fields if provided
+ const relationUpdates = {};
+ for (const field of typeConfig.fields.filter(f => f.type === 'relation')) {
+ if (rawData[field.name] !== undefined) {
+ const ids = rawData[field.name];
+ relationUpdates[field.name] = Array.isArray(ids) ? ids : [];
+ }
+ }
+ if (Object.keys(relationUpdates).length > 0) {
+ await saveRelations(id, relationUpdates);
+ }
+
+ return getPostById(postType, id);
+}
+
+/**
+ * Delete a post and clean up its image(s) from storage.
+ * Relations are deleted by CASCADE on zen_posts_relations.post_id.
+ * @param {string} postType
+ * @param {number} id
+ * @returns {Promise}
+ */
+export async function deletePost(postType, id) {
+ const post = await getPostById(postType, id);
+ if (!post) return false;
+
+ const typeConfig = getPostType(postType);
+ const imageKeys = getImageKeys(typeConfig, post._data);
+
+ const result = await query(
+ `DELETE FROM zen_posts WHERE post_type = $1 AND id = $2`,
+ [postType, id]
+ );
+
+ if (result.rowCount === 0) return false;
+
+ for (const imageKey of imageKeys) {
+ try {
+ const deleteResult = await deleteFile(imageKey);
+ if (!deleteResult.success) {
+ console.warn(`[Posts] Failed to delete image from storage: ${imageKey}`, deleteResult.error);
+ }
+ } catch (err) {
+ console.warn(`[Posts] Error deleting image from storage: ${imageKey}`, err.message);
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Get post by a specific field value stored in JSONB data.
+ * Useful for deduplication in importers (e.g. find by cve_id).
+ * @param {string} postType
+ * @param {string} fieldName
+ * @param {string} fieldValue
+ * @returns {Promise}
+ */
+export async function getPostByField(postType, fieldName, fieldValue) {
+ const result = await query(
+ `SELECT p.*, c.title as category_title
+ FROM zen_posts p
+ LEFT JOIN zen_posts_category c ON p.category_id = c.id
+ WHERE p.post_type = $1 AND p.data->>'${fieldName}' = $2
+ LIMIT 1`,
+ [postType, String(fieldValue)]
+ );
+ if (!result.rows[0]) return null;
+
+ const post = flattenPost(result.rows[0]);
+ const typeConfig = getPostType(postType);
+ if (typeConfig?.hasRelations) {
+ const relations = await loadRelations(result.rows[0].id, typeConfig);
+ Object.assign(post, relations);
+ }
+ return post;
+}
+
+/**
+ * Create or update a post based on a unique field (e.g. cve_id, slug).
+ * If a post with the same uniqueField value already exists, it will be updated.
+ * Otherwise a new post will be created.
+ * Useful for importers / scheduled fetchers.
+ *
+ * @param {string} postType
+ * @param {Object} rawData
+ * @param {string} uniqueField - Name of the field to use for deduplication (e.g. 'cve_id', 'slug')
+ * @returns {Promise<{ post: Object, created: boolean }>}
+ */
+export async function upsertPost(postType, rawData, uniqueField = 'slug') {
+ const typeConfig = getPostType(postType);
+ if (!typeConfig) throw new Error(`Unknown post type: ${postType}`);
+
+ let existing = null;
+
+ if (uniqueField === 'slug') {
+ const slugFieldName = typeConfig.slugField;
+ const titleFieldName = typeConfig.titleField;
+ const rawSlug = slugFieldName ? rawData[slugFieldName] : null;
+ const rawTitle = titleFieldName ? rawData[titleFieldName] : null;
+ const baseSlug = rawSlug?.trim() ? slugify(rawSlug) : slugify(rawTitle || '');
+ if (baseSlug) {
+ existing = await getPostBySlug(postType, baseSlug);
+ }
+ } else {
+ const uniqueValue = rawData[uniqueField];
+ if (uniqueValue != null) {
+ existing = await getPostByField(postType, uniqueField, uniqueValue);
+ }
+ }
+
+ if (existing) {
+ const post = await updatePost(postType, existing.id, rawData);
+ return { post, created: false };
+ }
+
+ const post = await createPost(postType, rawData);
+ return { post, created: true };
+}
+
+function flattenPost(row) {
+ const { data, ...rest } = row;
+ const parsed = typeof data === 'string' ? JSON.parse(data) : (data || {});
+ return { ...rest, ...parsed, _data: parsed };
+}
diff --git a/src/modules/posts/db.js b/src/modules/posts/db.js
new file mode 100644
index 0000000..0b51a60
--- /dev/null
+++ b/src/modules/posts/db.js
@@ -0,0 +1,140 @@
+/**
+ * Posts Module - Database
+ * Creates zen_posts and zen_posts_category tables.
+ */
+
+import { query } from '@hykocx/zen/database';
+import { getPostsConfig } from './config.js';
+
+async function tableExists(tableName) {
+ const result = await query(
+ `SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = $1
+ )`,
+ [tableName]
+ );
+ return result.rows[0].exists;
+}
+
+async function createPostsCategoryTable() {
+ const tableName = 'zen_posts_category';
+ const exists = await tableExists(tableName);
+
+ if (exists) {
+ console.log(`- Table already exists: ${tableName}`);
+ return { created: false, tableName };
+ }
+
+ await query(`
+ CREATE TABLE zen_posts_category (
+ id SERIAL PRIMARY KEY,
+ post_type VARCHAR(100) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+
+ await query(`CREATE INDEX idx_zen_posts_category_post_type ON zen_posts_category(post_type)`);
+ await query(`CREATE INDEX idx_zen_posts_category_is_active ON zen_posts_category(is_active)`);
+
+ console.log(`✓ Created table: ${tableName}`);
+ return { created: true, tableName };
+}
+
+async function createPostsTable() {
+ const tableName = 'zen_posts';
+ const exists = await tableExists(tableName);
+
+ if (exists) {
+ console.log(`- Table already exists: ${tableName}`);
+ return { created: false, tableName };
+ }
+
+ await query(`
+ CREATE TABLE zen_posts (
+ id SERIAL PRIMARY KEY,
+ post_type VARCHAR(100) NOT NULL,
+ slug VARCHAR(500) NOT NULL,
+ data JSONB NOT NULL DEFAULT '{}',
+ category_id INTEGER REFERENCES zen_posts_category(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(post_type, slug)
+ )
+ `);
+
+ await query(`CREATE INDEX idx_zen_posts_post_type ON zen_posts(post_type)`);
+ await query(`CREATE INDEX idx_zen_posts_post_type_slug ON zen_posts(post_type, slug)`);
+ await query(`CREATE INDEX idx_zen_posts_category_id ON zen_posts(category_id)`);
+ await query(`CREATE INDEX idx_zen_posts_data_gin ON zen_posts USING GIN (data)`);
+
+ console.log(`✓ Created table: ${tableName}`);
+ return { created: true, tableName };
+}
+
+async function createPostsRelationsTable() {
+ const tableName = 'zen_posts_relations';
+ const exists = await tableExists(tableName);
+
+ if (exists) {
+ console.log(`- Table already exists: ${tableName}`);
+ return { created: false, tableName };
+ }
+
+ await query(`
+ CREATE TABLE zen_posts_relations (
+ id SERIAL PRIMARY KEY,
+ post_id INTEGER NOT NULL REFERENCES zen_posts(id) ON DELETE CASCADE,
+ field_name VARCHAR(100) NOT NULL,
+ related_post_id INTEGER NOT NULL REFERENCES zen_posts(id) ON DELETE CASCADE,
+ sort_order INTEGER DEFAULT 0,
+ UNIQUE(post_id, field_name, related_post_id)
+ )
+ `);
+
+ await query(`CREATE INDEX idx_zen_posts_relations_post_id ON zen_posts_relations(post_id)`);
+ await query(`CREATE INDEX idx_zen_posts_relations_related ON zen_posts_relations(related_post_id)`);
+
+ console.log(`✓ Created table: ${tableName}`);
+ return { created: true, tableName };
+}
+
+/**
+ * Create all posts-related tables.
+ * zen_posts_category is only created if at least one type uses the 'category' field.
+ * zen_posts_relations is only created if at least one type uses the 'relation' field.
+ * @returns {Promise}
+ */
+export async function createTables() {
+ const created = [];
+ const skipped = [];
+
+ const config = getPostsConfig();
+ const needsRelations = Object.values(config.types).some(t => t.hasRelations);
+
+ // zen_posts_category must always be created before zen_posts
+ // because zen_posts has a FK reference to it
+ console.log('\n--- Posts Categories ---');
+ const catResult = await createPostsCategoryTable();
+ if (catResult.created) created.push(catResult.tableName);
+ else skipped.push(catResult.tableName);
+
+ console.log('\n--- Posts ---');
+ const postResult = await createPostsTable();
+ if (postResult.created) created.push(postResult.tableName);
+ else skipped.push(postResult.tableName);
+
+ if (needsRelations) {
+ console.log('\n--- Posts Relations ---');
+ const relResult = await createPostsRelationsTable();
+ if (relResult.created) created.push(relResult.tableName);
+ else skipped.push(relResult.tableName);
+ }
+
+ return { created, skipped };
+}
diff --git a/src/modules/posts/module.config.js b/src/modules/posts/module.config.js
new file mode 100644
index 0000000..636bc22
--- /dev/null
+++ b/src/modules/posts/module.config.js
@@ -0,0 +1,98 @@
+/**
+ * Posts Module Configuration
+ * Navigation and adminPages are generated dynamically from ZEN_MODULE_ZEN_MODULE_POSTS_TYPES env var.
+ */
+
+import { lazy } from 'react';
+import { getPostsConfig } from './config.js';
+
+// Lazy components — shared across all post types
+const PostsListPage = lazy(() => import('./admin/PostsListPage.js'));
+const PostCreatePage = lazy(() => import('./admin/PostCreatePage.js'));
+const PostEditPage = lazy(() => import('./admin/PostEditPage.js'));
+const CategoriesListPage = lazy(() => import('./categories/admin/CategoriesListPage.js'));
+const CategoryCreatePage = lazy(() => import('./categories/admin/CategoryCreatePage.js'));
+const CategoryEditPage = lazy(() => import('./categories/admin/CategoryEditPage.js'));
+
+const postsConfig = getPostsConfig();
+
+// Build adminPages and navigation dynamically from configured post types
+const adminPages = {};
+const navigationSections = [];
+
+for (const type of Object.values(postsConfig.types)) {
+ // Register routes for this post type
+ adminPages[`/admin/posts/${type.key}/list`] = PostsListPage;
+ adminPages[`/admin/posts/${type.key}/new`] = PostCreatePage;
+ adminPages[`/admin/posts/${type.key}/edit`] = PostEditPage;
+
+ const navItems = [
+ { name: type.label, href: `/admin/posts/${type.key}/list`, icon: 'Book02Icon' },
+ ];
+
+ if (type.hasCategory) {
+ adminPages[`/admin/posts/${type.key}/categories`] = CategoriesListPage;
+ adminPages[`/admin/posts/${type.key}/categories/new`] = CategoryCreatePage;
+ adminPages[`/admin/posts/${type.key}/categories/edit`] = CategoryEditPage;
+ navItems.push({ name: 'Catégories', href: `/admin/posts/${type.key}/categories`, icon: 'Layers01Icon' });
+ }
+
+ navigationSections.push({
+ id: `posts-${type.key}`,
+ title: type.label,
+ icon: 'Book02Icon',
+ items: navItems,
+ });
+}
+
+/**
+ * Client-side page resolver using path patterns (no env vars needed).
+ * Called by getModulePageLoader in modules.pages.js.
+ *
+ * URL patterns:
+ * /admin/posts/{type}/list
+ * /admin/posts/{type}/new
+ * /admin/posts/{type}/edit
+ * /admin/posts/{type}/categories
+ * /admin/posts/{type}/categories/new
+ * /admin/posts/{type}/categories/edit
+ */
+function pageResolver(path) {
+ const parts = path.split('/').filter(Boolean);
+ // ['admin', 'posts', type, action, ...]
+ if (parts[0] !== 'admin' || parts[1] !== 'posts' || parts.length < 4) return null;
+ const action = parts[3];
+ if (action === 'list') return PostsListPage;
+ if (action === 'new') return PostCreatePage;
+ if (action === 'edit') return PostEditPage;
+ if (action === 'categories') {
+ if (parts[4] === 'new') return CategoryCreatePage;
+ if (parts[4] === 'edit') return CategoryEditPage;
+ return CategoriesListPage;
+ }
+ return null;
+}
+
+export default {
+ name: 'posts',
+ displayName: 'Posts',
+ version: '1.0.0',
+ description: 'Multi-type custom post system configurable via environment variables',
+
+ dependencies: [],
+
+ envVars: ['ZEN_MODULE_ZEN_MODULE_POSTS_TYPES'],
+
+ // Array of sections — one per post type (server-side, env vars available)
+ navigation: navigationSections,
+
+ // Used server-side by discovery.js to register paths in the registry
+ adminPages,
+
+ // Used client-side by getModulePageLoader — pattern-based, no env vars needed
+ pageResolver,
+
+ publicPages: {},
+ publicRoutes: [],
+ dashboardWidgets: [],
+};
diff --git a/src/shared/Icons.js b/src/shared/Icons.js
new file mode 100644
index 0000000..23a639c
--- /dev/null
+++ b/src/shared/Icons.js
@@ -0,0 +1,581 @@
+export const Invoice03Icon = (props) => (
+
+
+
+);
+
+
+export const ChevronDownIcon = (props) => (
+
+
+
+);
+
+export const ChevronRightIcon = (props) => (
+
+
+
+);
+
+export const UserCircle02Icon = (props) => (
+
+
+
+
+
+);
+
+export const ManagerIcon = (props) => (
+
+
+
+
+
+);
+
+export const DashboardSquare03Icon = (props) => (
+
+
+
+
+
+
+);
+
+export const PackageIcon = (props) => (
+
+
+
+
+);
+
+export const Ticket01Icon = (props) => (
+
+
+
+);
+
+export const CodesandboxIcon = (props) => (
+
+
+
+
+
+
+
+
+);
+
+export const InboxIcon = (props) => (
+
+
+
+);
+
+export const Folder01Icon = (props) => (
+
+
+
+);
+
+export const CouponPercentIcon = (props) => (
+
+
+
+);
+
+export const Settings02Icon = (props) => (
+
+
+
+);
+
+export const Wallet03Icon = (props) => (
+
+
+
+
+);
+
+export const PaintBrush04Icon = (props) => (
+
+
+
+);
+
+export const CoinsDollarIcon = (props) => (
+
+
+
+
+);
+
+export const TaxesIcon = (props) => (
+
+
+
+
+
+
+
+
+);
+
+export const ServerStack01Icon = (props) => (
+
+
+
+
+);
+
+export const ConnectIcon = (props) => (
+
+
+
+
+);
+
+export const Layers01Icon = (props) => (
+
+
+
+
+
+);
+
+export const Settings04Icon = (props) => (
+
+
+
+);
+
+export const Wrench01Icon = (props) => (
+
+
+
+
+);
+
+export const BlockedIcon = (props) => (
+
+
+
+);
+
+export const SlidersHorizontalIcon = (props) => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const Recycle03Icon = (props) => (
+
+
+
+);
+
+export const CloudUploadIcon = (props) => (
+
+
+
+
+);
+
+export const ChartBarLineIcon = (props) => (
+
+
+
+
+
+
+
+);
+
+export const Notification01Icon = (props) => (
+
+
+
+);
+
+export const UserMultiple02Icon = (props) => (
+
+
+
+
+
+
+);
+
+export const Book02Icon = (props) => (
+
+
+
+);
+
+export const FileSecurityIcon = (props) => (
+
+
+
+
+);
+
+export const ScrollIcon = (props) => (
+
+
+
+);
+
+export const Menu01Icon = (props) => (
+
+
+
+
+
+);
+
+export const MailEdit01Icon = (props) => (
+
+
+
+
+
+);
+
+export const Megaphone02Icon = (props) => (
+
+
+
+);
+
+export const BookBookmark02Icon = (props) => (
+
+
+
+);
+
+export const Crown03Icon = (props) => (
+
+
+
+);
+
+export const Tick02Icon = (props) => (
+
+
+
+);
+
+export const Cancel01Icon = (props) => (
+
+
+
+);
+
+export const Alert01Icon = (props) => (
+
+
+
+);
+
+export const InformationCircleIcon = (props) => (
+
+
+
+);
+
+export const Delete02Icon = (props) => (
+
+
+
+
+);
+
+export const PlusSignCircleIcon = (props) => (
+
+
+
+);
+
+export const DollarCircleIcon = (props) => (
+
+
+
+);
+
+export const EyeIcon = (props) => (
+
+
+
+
+);
+
+export const PencilEdit01Icon = (props) => (
+
+
+
+
+);
+
+export const AlertCircleIcon = (props) => (
+
+
+
+);
+
+export const TorriGateIcon = (props) => (
+
+
+
+);
+
+export const CancelCircleIcon = (props) => (
+
+
+
+);
+
+export const Invoice04Icon = (props) => (
+
+
+
+
+);
+
+export const Link02Icon = (props) => (
+
+
+
+
+);
+
+export const UserGroupIcon = (props) => (
+
+
+
+
+
+);
+
+export const Copy01Icon = (props) => (
+
+
+
+
+
+);
+
+export const Pdf01Icon = (props) => (
+
+
+
+);
+
+export const ArrowUp01Icon = (props) => (
+
+
+
+
+);
+
+export const ArrowDown01Icon = (props) => (
+
+
+
+
+);
+
+export const Moon02Icon = (props) => (
+
+
+
+);
+
+export const Sun01Icon = (props) => (
+
+
+
+
+);
+
+export const MoonCloudIcon = (props) => (
+
+
+
+
+);
+
+export const SunCloud02Icon = (props) => (
+
+
+
+
+);
+
+export const File02Icon = (props) => (
+
+
+
+);
+
+export const Jpg02Icon = (props) => (
+
+
+
+
+);
+
+export const Png02Icon = (props) => (
+
+
+
+);
+
+export const Pdf02Icon = (props) => (
+
+
+
+);
+
+export const Mp302Icon = (props) => (
+
+
+
+);
+
+export const Ppt02Icon = (props) => (
+
+
+
+);
+
+export const Mp402Icon = (props) => (
+
+
+
+);
+
+export const Gif02Icon = (props) => (
+
+
+
+
+);
+
+export const Rar02Icon = (props) => (
+
+
+
+
+);
+
+export const Raw02Icon = (props) => (
+
+
+
+);
+
+export const Svg02Icon = (props) => (
+
+
+
+
+
+
+);
+
+export const Tiff02Icon = (props) => (
+
+
+
+
+);
+
+export const Txt02Icon = (props) => (
+
+
+
+
+);
+
+export const Typescript03Icon = (props) => (
+
+
+
+
+);
+
+export const Wav02Icon = (props) => (
+
+
+
+
+);
+
+export const Xls02Icon = (props) => (
+
+
+
+
+
+
+);
+
+export const Xml02Icon = (props) => (
+
+
+
+
+);
+
+export const Zip02Icon = (props) => (
+
+
+
+
+);
+
+export const Csv02Icon = (props) => (
+
+
+
+
+
+
+);
+
+export const Doc02Icon = (props) => (
+
+
+
+
+
+
+);
+
+export const Image01Icon = (props) => (
+
+
+
+);
+
+export const PlaySquareIcon = (props) => (
+
+
+
+);
+
+export const CloudIcon = (props) => (
+
+
+
+);
+
+
diff --git a/src/shared/components/Badge.js b/src/shared/components/Badge.js
new file mode 100644
index 0000000..55487dc
--- /dev/null
+++ b/src/shared/components/Badge.js
@@ -0,0 +1,74 @@
+'use client';
+
+import React from 'react';
+
+const Badge = ({
+ children,
+ variant = 'default',
+ size = 'md',
+ className = '',
+ ...props
+}) => {
+ const baseClassName = 'inline-flex items-center font-medium border';
+
+ const variants = {
+ default: 'bg-neutral-200/80 text-neutral-700 border-neutral-300 dark:bg-neutral-500/10 dark:text-neutral-400 dark:border-neutral-500/20',
+ primary: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/20',
+ success: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/20',
+ warning: 'bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-500/10 dark:text-yellow-400 dark:border-yellow-500/20',
+ danger: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20',
+ info: 'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-500/10 dark:text-cyan-400 dark:border-cyan-500/20',
+ purple: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/10 dark:text-purple-400 dark:border-purple-500/20',
+ pink: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-500/10 dark:text-pink-400 dark:border-pink-500/20',
+ orange: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/10 dark:text-orange-400 dark:border-orange-500/20'
+ };
+
+ const sizes = {
+ sm: 'px-2 py-0.5 rounded-full text-xs',
+ md: 'px-2.5 py-0.5 rounded-full text-xs',
+ lg: 'px-3 py-1 rounded-full text-sm'
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Predefined badge types for common use cases
+export const StatusBadge = ({ status, ...props }) => {
+ const statusConfig = {
+ active: { variant: 'success', children: 'Active' },
+ inactive: { variant: 'default', children: 'Inactive' },
+ pending: { variant: 'warning', children: 'Pending' },
+ draft: { variant: 'warning', children: 'Draft' },
+ verified: { variant: 'success', children: 'Verified' },
+ unverified: { variant: 'warning', children: 'Unverified' },
+ admin: { variant: 'purple', children: 'Admin' },
+ user: { variant: 'default', children: 'User' }
+ };
+
+ const config = statusConfig[status] || { variant: 'default', children: status };
+
+ return ;
+};
+
+export const TypeBadge = ({ type, ...props }) => {
+ const typeConfig = {
+ service: { variant: 'primary', children: 'Service' },
+ physical: { variant: 'orange', children: 'Physical Product' },
+ digital: { variant: 'purple', children: 'Digital Product' },
+ hosting: { variant: 'info', children: 'Hosting' },
+ domain: { variant: 'pink', children: 'Domain' }
+ };
+
+ const config = typeConfig[type] || { variant: 'default', children: type };
+
+ return ;
+};
+
+export default Badge;
\ No newline at end of file
diff --git a/src/shared/components/Breadcrumb.js b/src/shared/components/Breadcrumb.js
new file mode 100644
index 0000000..303fd55
--- /dev/null
+++ b/src/shared/components/Breadcrumb.js
@@ -0,0 +1,42 @@
+'use client';
+
+import React from 'react';
+
+const Breadcrumb = ({ items = [], className = '' }) => {
+ return (
+
+
+ {items.map((item, index) => (
+