From 4a06cace5d36c93596ab83fc6d6285c4accdd375 Mon Sep 17 00:00:00 2001 From: Hyko Date: Tue, 14 Apr 2026 17:27:04 -0400 Subject: [PATCH] refactor: remove modules system from core package - Remove all module-related entry points from package.json exports - Remove module source files from tsup build configuration - Clean up external dependencies related to modules - Update DEV.md to reflect modules removal from architecture - Clarify package description to specify Next.js CMS --- docs/DEV.md | 2 +- docs/modules/EXTERNAL_MODULE.md | 255 --------- docs/modules/INTERNAL_MODULE.md | 218 -------- package.json | 32 +- src/core/modules/client.js | 32 -- src/core/modules/defineModule.js | 64 --- src/core/modules/discovery.js | 314 ----------- src/core/modules/index.js | 43 -- src/core/modules/loader.js | 199 ------- src/core/modules/registry.js | 293 ---------- src/modules/PublicPagesClient.js | 54 -- src/modules/PublicPagesLayout.js | 17 - src/modules/index.js | 56 -- src/modules/init.js | 64 --- src/modules/modules.actions.js | 85 --- src/modules/modules.metadata.js | 58 -- src/modules/modules.pages.js | 167 ------ src/modules/modules.registry.js | 23 - src/modules/modules.storage.js | 61 -- src/modules/page.js | 114 ---- src/modules/pages.js | 19 - src/modules/posts/.env.example | 23 - src/modules/posts/README.md | 66 --- src/modules/posts/admin/PostCreatePage.js | 245 -------- src/modules/posts/admin/PostEditPage.js | 271 --------- src/modules/posts/admin/PostFormFields.js | 359 ------------ src/modules/posts/admin/PostsIndexPage.js | 104 ---- src/modules/posts/admin/PostsListPage.js | 316 ----------- src/modules/posts/api.js | 500 ----------------- .../categories/admin/CategoriesListPage.js | 198 ------- .../categories/admin/CategoryCreatePage.js | 124 ----- .../categories/admin/CategoryEditPage.js | 158 ------ src/modules/posts/categories/crud.js | 183 ------ src/modules/posts/config.js | 136 ----- src/modules/posts/crud.js | 526 ------------------ src/modules/posts/db.js | 129 ----- src/modules/posts/docs/admin-api.md | 19 - src/modules/posts/docs/api.md | 108 ---- src/modules/posts/docs/integration.md | 127 ----- src/modules/posts/docs/programmatic.md | 114 ---- src/modules/posts/module.config.js | 108 ---- tsup.config.js | 20 +- 42 files changed, 3 insertions(+), 6001 deletions(-) delete mode 100644 docs/modules/EXTERNAL_MODULE.md delete mode 100644 docs/modules/INTERNAL_MODULE.md delete mode 100644 src/core/modules/client.js delete mode 100644 src/core/modules/defineModule.js delete mode 100644 src/core/modules/discovery.js delete mode 100644 src/core/modules/index.js delete mode 100644 src/core/modules/loader.js delete mode 100644 src/core/modules/registry.js delete mode 100644 src/modules/PublicPagesClient.js delete mode 100644 src/modules/PublicPagesLayout.js delete mode 100644 src/modules/index.js delete mode 100644 src/modules/init.js delete mode 100644 src/modules/modules.actions.js delete mode 100644 src/modules/modules.metadata.js delete mode 100644 src/modules/modules.pages.js delete mode 100644 src/modules/modules.registry.js delete mode 100644 src/modules/modules.storage.js delete mode 100644 src/modules/page.js delete mode 100644 src/modules/pages.js delete mode 100644 src/modules/posts/.env.example delete mode 100644 src/modules/posts/README.md delete mode 100644 src/modules/posts/admin/PostCreatePage.js delete mode 100644 src/modules/posts/admin/PostEditPage.js delete mode 100644 src/modules/posts/admin/PostFormFields.js delete mode 100644 src/modules/posts/admin/PostsIndexPage.js delete mode 100644 src/modules/posts/admin/PostsListPage.js delete mode 100644 src/modules/posts/api.js delete mode 100644 src/modules/posts/categories/admin/CategoriesListPage.js delete mode 100644 src/modules/posts/categories/admin/CategoryCreatePage.js delete mode 100644 src/modules/posts/categories/admin/CategoryEditPage.js delete mode 100644 src/modules/posts/categories/crud.js delete mode 100644 src/modules/posts/config.js delete mode 100644 src/modules/posts/crud.js delete mode 100644 src/modules/posts/db.js delete mode 100644 src/modules/posts/docs/admin-api.md delete mode 100644 src/modules/posts/docs/api.md delete mode 100644 src/modules/posts/docs/integration.md delete mode 100644 src/modules/posts/docs/programmatic.md delete mode 100644 src/modules/posts/module.config.js diff --git a/docs/DEV.md b/docs/DEV.md index 57847b9..d607faf 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -4,7 +4,7 @@ Ce document couvre les conventions de code, les règles de sécurité et la proc Pour les conventions de rédaction : [LANGUE.md](./dev/LANGUE.md) et [REDACTION.md](./dev/REDACTION.md). -Pour l'architecture partagée (modules, composants, icônes) : [ARCHITECTURE.md](./dev/ARCHITECTURE.md). +Pour l'architecture partagée (composants, icônes) : [ARCHITECTURE.md](./dev/ARCHITECTURE.md). Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md). diff --git a/docs/modules/EXTERNAL_MODULE.md b/docs/modules/EXTERNAL_MODULE.md deleted file mode 100644 index 6bc3a14..0000000 --- a/docs/modules/EXTERNAL_MODULE.md +++ /dev/null @@ -1,255 +0,0 @@ -# Créer un module externe - -Un module externe est un package npm indépendant qui s'intègre dans une app qui utilise `@zen/core`. Il n'a pas besoin de modifier le code source du CMS. - ---- - -## Convention de nommage - -``` -@scope/zen-nom-du-module -``` - -Exemples : `@zen/core-invoice`, `@zen/core-nuage`. - ---- - -## Structure du package - -``` -zen-invoice/ -├── index.js # Point d'entrée — exporte defineModule() -├── admin/ # Composants React pour l'admin -│ ├── InvoiceListPage.js -│ ├── InvoiceCreatePage.js -│ └── InvoiceEditPage.js -├── db.js # createTables() et dropTables() -├── actions.js # Server actions pour pages publiques -├── metadata.js # Générateurs de métadonnées SEO -├── package.json -└── .env.example -``` - ---- - -## index.js - -On utilise `defineModule()` importé depuis `@zen/core/modules/define`. - -```js -import { lazy } from 'react'; -import { defineModule } from '@zen/core/modules/define'; - -import { createTables, dropTables } from './db.js'; -import { getInvoiceByToken } from './actions.js'; -import { generatePaymentMetadata } from './metadata.js'; - -const InvoiceListPage = lazy(() => import('./admin/InvoiceListPage.js')); -const InvoiceCreatePage = lazy(() => import('./admin/InvoiceCreatePage.js')); -const InvoiceEditPage = lazy(() => import('./admin/InvoiceEditPage.js')); - -export default defineModule({ - name: 'invoice', - displayName: 'Facturation', - version: '1.0.0', - description: 'Gestion des factures et paiements.', - - dependencies: [], - envVars: ['STRIPE_SECRET_KEY', 'ZEN_INVOICE_TAX_RATE'], - - // Navigation dans le panneau admin - navigation: [ - { - id: 'invoice', - title: 'Facturation', - icon: 'Invoice03Icon', - items: [ - { name: 'Factures', href: '/admin/invoice/list', icon: 'Invoice03Icon' }, - { name: 'Nouvelle', href: '/admin/invoice/new', icon: 'Add01Icon' }, - ], - }, - ], - - // Pages admin avec leurs composants lazy - adminPages: { - '/admin/invoice/list': InvoiceListPage, - '/admin/invoice/new': InvoiceCreatePage, - '/admin/invoice/edit': InvoiceEditPage, - }, - - // Résolveur pour les routes dynamiques (ex: /admin/invoice/edit/123) - pageResolver(path) { - const parts = path.split('/').filter(Boolean); - if (parts[0] !== 'admin' || parts[1] !== 'invoice') return null; - if (parts[2] === 'list') return InvoiceListPage; - if (parts[2] === 'new') return InvoiceCreatePage; - if (parts[2] === 'edit') return InvoiceEditPage; - return null; - }, - - publicPages: {}, - publicRoutes: [ - { pattern: ':token', description: 'Page de paiement' }, - { pattern: ':token/pdf', description: 'Télécharger la facture PDF' }, - ], - - dashboardWidgets: [], - - // Base de données - db: { createTables, dropTables }, - - // Server actions pour les pages publiques (/zen/invoice/...) - actions: { getInvoiceByToken }, - - // Générateurs de métadonnées SEO - metadata: { - payment: generatePaymentMetadata, - }, - - // Appelé une fois au démarrage du serveur - // ctx donne accès aux services du CMS - async setup(ctx) { - const stripe = await ctx.payments.then(p => p.stripe); - // Initialiser des webhooks, vérifier la config, etc. - console.log('[invoice] Stripe prêt :', !!stripe); - }, -}); -``` - ---- - -## L'objet ctx dans setup() - -`setup(ctx)` reçoit un objet qui donne accès aux services du CMS. Chaque propriété retourne une promesse vers le module correspondant. - -```js -async setup(ctx) { - // Base de données PostgreSQL - const { query, queryOne, queryAll } = await ctx.db; - const rows = await query('SELECT * FROM zen_auth_users LIMIT 1'); - - // Envoi de courriels (Resend) - const { sendEmail } = await ctx.email; - await sendEmail({ to: 'test@example.com', subject: 'Test', html: '

ok

' }); - - // Stockage de fichiers (Cloudflare R2) - const { uploadFile, deleteFile } = await ctx.storage; - - // Stripe - const { stripe } = await ctx.payments; - - // Variables d'environnement - const apiKey = ctx.config.get('STRIPE_SECRET_KEY'); -} -``` - ---- - -## package.json du module externe - -```json -{ - "name": "@zen/core-invoice", - "version": "1.0.0", - "type": "module", - "main": "./index.js", - "exports": { - ".": "./index.js" - }, - "peerDependencies": { - "@zen/core": ">=1.0.0", - "react": ">=19.0.0" - } -} -``` - ---- - -## Intégration dans l'app consommatrice - -### 1. Installer le package - -```bash -npm install @zen/core-invoice -``` - -### 2. Créer zen.config.js à la racine de l'app - -```js -// zen.config.js -import invoiceModule from '@zen/core-invoice'; - -export default { - modules: [invoiceModule], -}; -``` - -### 3. Passer la config à initializeZen() - -```js -// instrumentation.js -import zenConfig from './zen.config.js'; - -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - const { initializeZen } = await import('@zen/core'); - await initializeZen(zenConfig); - } -} -``` - -### 4. Passer les modules à ZenProvider - -```jsx -// app/layout.js -import zenConfig from './zen.config.js'; -import { ZenProvider } from '@zen/core/provider'; - -export default function RootLayout({ children }) { - return ( - - - - {children} - - - - ); -} -``` - -### 5. Activer le module via variable d'environnement - -```bash -# .env.local -ZEN_MODULE_INVOICE=true -``` - -La convention est la même que pour les modules internes : tirets en underscores, tout en majuscules. - ---- - -## Initialiser la base de données - -Le module déclare ses tables dans `db.createTables`. Deux façons de les créer. - -**Au démarrage du serveur** (si `skipDb: false`) : - -```js -await initializeZen({ modules: zenConfig.modules, skipDb: false }); -``` - -**Via la CLI** : - -```bash -npx zen-db init -``` - ---- - -## Vérification rapide - -1. Démarrer avec `ZEN_MODULE_INVOICE=true`. -2. Ouvrir `/admin`. La section "Facturation" doit apparaître dans la navigation. -3. Naviguer vers `/admin/invoice/list`. La page du module doit se charger. -4. Appeler `getModuleActions('invoice')` côté serveur. Les actions du module doivent être retournées. diff --git a/docs/modules/INTERNAL_MODULE.md b/docs/modules/INTERNAL_MODULE.md deleted file mode 100644 index 3836528..0000000 --- a/docs/modules/INTERNAL_MODULE.md +++ /dev/null @@ -1,218 +0,0 @@ -# Créer un module interne - -Un module interne vit dans `src/modules/` et fait partie du package `@zen/core`. Il a accès direct aux services du CMS sans passer par un contexte injecté. - ---- - -## Structure d'un module - -``` -src/modules/mon-module/ -├── module.config.js # Obligatoire — navigation, pages, routes, cron, etc. -├── db.js # Optionnel — createTables() et dropTables() -├── api.js # Optionnel — routes REST -├── cron.config.js # Optionnel — tâches planifiées -├── crud.js # Optionnel — accès aux données -├── admin/ # Composants React pour l'admin -└── .env.example # Variables d'environnement requises -``` - ---- - -## module.config.js - -C'est la source de vérité du module. On utilise `defineModule()` pour déclarer la configuration. - -```js -import { lazy } from 'react'; -import { defineModule } from '../../core/modules/defineModule.js'; - -const ListPage = lazy(() => import('./admin/ListPage.js')); -const CreatePage = lazy(() => import('./admin/CreatePage.js')); -const EditPage = lazy(() => import('./admin/EditPage.js')); - -export default defineModule({ - name: 'mon-module', - displayName: 'Mon module', - version: '1.0.0', - description: 'Description courte.', - - // Modules dont celui-ci dépend (vérification au démarrage) - dependencies: [], - - // Variables d'environnement que ce module lit - envVars: ['ZEN_MON_MODULE_OPTION'], - - // Navigation admin — un ou plusieurs objets de section - navigation: [ - { - id: 'mon-module', - title: 'Mon module', - icon: 'SomeIcon', - items: [ - { name: 'Liste', href: '/admin/mon-module/list', icon: 'SomeIcon' }, - { name: 'Nouveau', href: '/admin/mon-module/new', icon: 'AddIcon' }, - ], - }, - ], - - // Pages admin — chemin exact vers composant lazy - adminPages: { - '/admin/mon-module/list': ListPage, - '/admin/mon-module/new': CreatePage, - '/admin/mon-module/edit': EditPage, - }, - - // Résolveur pour les routes dynamiques (non connues à la compilation) - // Retourner null si le chemin ne correspond pas - pageResolver(path) { - const parts = path.split('/').filter(Boolean); - if (parts[0] !== 'admin' || parts[1] !== 'mon-module') return null; - if (parts[2] === 'list') return ListPage; - if (parts[2] === 'new') return CreatePage; - if (parts[2] === 'edit') return EditPage; - return null; - }, - - // Pages publiques accessibles sans authentification (/zen/mon-module/...) - publicPages: {}, - publicRoutes: [], - - // Widgets affichés sur le dashboard admin - dashboardWidgets: [], -}); -``` - ---- - -## db.js - -Si le module crée des tables, on exporte `createTables` et `dropTables`. - -```js -import { query } from '../../core/database/index.js'; -import { tableExists } from '../../core/database/helpers.js'; - -export async function createTables() { - const created = []; - const skipped = []; - - if (!(await tableExists('zen_mon_module'))) { - await query(` - CREATE TABLE zen_mon_module ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - titre TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() - ) - `); - created.push('zen_mon_module'); - } else { - skipped.push('zen_mon_module'); - } - - return { created, skipped }; -} - -export async function dropTables() { - await query('DROP TABLE IF EXISTS zen_mon_module CASCADE'); -} -``` - ---- - -## api.js - -Les routes API sont montées automatiquement sous `/api/zen/mon-module/...`. - -```js -async function handleList(request) { - // ... - return Response.json({ items }); -} - -export default { - routes: [ - { path: '/admin/mon-module/list', method: 'GET', handler: handleList, auth: 'admin' }, - { path: '/mon-module/list', method: 'GET', handler: handleList, auth: 'public' }, - ], -}; -``` - -Valeurs acceptées pour `auth` : `'admin'` (JWT admin requis), `'user'` (JWT utilisateur requis), `'public'` (aucune auth). - ---- - -## cron.config.js - -```js -import { maFonction } from './crud.js'; - -export default { - jobs: [ - { - name: 'mon-module:nettoyage', - schedule: '0 3 * * *', // Tous les jours à 3h - handler: maFonction, - timezone: 'America/Toronto', - }, - ], -}; -``` - ---- - -## Enregistrement — 2 étapes - -Après avoir créé les fichiers, on enregistre le module dans deux endroits. - -### 1. `src/modules/modules.registry.js` - -Ajouter le nom du module à `AVAILABLE_MODULES` : - -```js -export const AVAILABLE_MODULES = [ - 'posts', - 'mon-module', // ajout -]; -``` - -### 2. `src/modules/modules.pages.js` - -Importer la config et l'ajouter à `MODULE_CONFIGS` : - -```js -import postsConfig from './posts/module.config.js'; -import monModuleConfig from './mon-module/module.config.js'; // ajout - -const MODULE_CONFIGS = { - posts: postsConfig, - 'mon-module': monModuleConfig, // ajout -}; -``` - ---- - -## Activation - -Le module est ignoré au démarrage tant que sa variable d'environnement n'est pas définie : - -```bash -# .env.local -ZEN_MODULE_MON_MODULE=true -``` - -La règle de conversion : tirets et espaces deviennent des underscores, tout en majuscules. - -``` -mon-module → ZEN_MODULE_MON_MODULE -post-types → ZEN_MODULE_POST_TYPES -``` - ---- - -## Vérification rapide - -1. Démarrer le serveur avec `ZEN_MODULE_MON_MODULE=true`. -2. Ouvrir `/admin`. La section de navigation du module doit apparaître. -3. Naviguer vers `/admin/mon-module/list`. La page doit se charger. -4. Lancer `npx zen-db init`. La table `zen_mon_module` doit être créée. diff --git a/package.json b/package.json index 0181753..88a55be 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@zen/core", "version": "1.3.13", - "description": "Un CMS construit sur l'essentiel, rien de plus, rien de moins.", + "description": "Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.", "repository": { "type": "git", "url": "https://git.hyko.cx/zen/core.git" @@ -115,36 +115,6 @@ "./provider": { "import": "./dist/features/provider/index.js" }, - "./core/modules": { - "import": "./dist/core/modules/index.js" - }, - "./core/modules/client": { - "import": "./dist/core/modules/client.js" - }, - "./modules": { - "import": "./dist/modules/index.js" - }, - "./modules/define": { - "import": "./dist/core/modules/defineModule.js" - }, - "./modules/pages": { - "import": "./dist/modules/pages.js" - }, - "./modules/actions": { - "import": "./dist/modules/modules.actions.js" - }, - "./modules/storage": { - "import": "./dist/modules/modules.storage.js" - }, - "./modules/posts/crud": { - "import": "./dist/modules/posts/crud.js" - }, - "./modules/metadata": { - "import": "./dist/modules/modules.metadata.js" - }, - "./modules/page": { - "import": "./dist/modules/page.js" - }, "./lib/metadata": { "import": "./dist/shared/lib/metadata/index.js" }, diff --git a/src/core/modules/client.js b/src/core/modules/client.js deleted file mode 100644 index e4bc4f2..0000000 --- a/src/core/modules/client.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Client-Safe Module Registry Access - * - * This file ONLY exports functions that are safe to use in client components. - * It does NOT export discovery, loader, or initialization functions that - * might import server-only modules like database code. - * - * NOTE: Most registry functions return empty results on the client because - * the registry is populated on the server during discovery. For client-side - * module page loading, use the loaders from modules.pages.js instead. - */ - -// Only export registry getter functions (no discovery/loader functions) -export { - getModule, - getAllModules, - getEnabledModules, - isModuleRegistered, - isModuleEnabled, - getAllApiRoutes, - getAllAdminNavigation, - getAdminPage, - getAllCronJobs, - getAllPublicRoutes, - getAllDatabaseSchemas, - getModuleMetadataGenerator, - getAllModuleMetadata, -} from './registry.js'; - -// NOTE: getModulePublicPages is NOT exported here because it relies on the -// server-side registry which is empty on the client. Use getModulePublicPageLoader() -// from '@zen/core/modules/pages' instead for client-side public page loading. diff --git a/src/core/modules/defineModule.js b/src/core/modules/defineModule.js deleted file mode 100644 index c5ee3cf..0000000 --- a/src/core/modules/defineModule.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * defineModule — helper to declare a ZEN module. - * - * Used for both internal modules (src/modules/) and external npm packages. - * - * @param {Object} config - Module configuration - * @returns {Object} Normalized module configuration - */ -export function defineModule(config) { - if (!config || typeof config !== 'object') { - throw new Error('[defineModule] Config must be an object.'); - } - - if (!config.name || typeof config.name !== 'string') { - throw new Error('[defineModule] Field "name" is required (e.g. "invoice").'); - } - - return { - // Identity - version: '1.0.0', - displayName: config.name.charAt(0).toUpperCase() + config.name.slice(1), - description: '', - - // Dependencies and environment variables - dependencies: [], - envVars: [], - - // Admin UI - navigation: null, - adminPages: {}, - pageResolver: null, - - // Public pages - publicPages: {}, - publicRoutes: [], - dashboardWidgets: [], - - // Server actions for public pages - actions: {}, - - // SEO metadata generators - metadata: {}, - - // Storage prefixes served publicly (no auth). Format: 'posts/blogue', 'assets/docs' - storagePublicPrefixes: [], - - // Storage access policies for private paths. Each entry: { prefix, type } - // type 'owner' — pathParts[1] must match session.user.id, or role is 'admin' - // type 'admin' — session.user.role must be 'admin' - storageAccessPolicies: [], - - // Database (optional) — { createTables, dropTables } - db: null, - - // Initialization callback (optional) — setup(ctx) - setup: null, - - // Spread last so all fields above can be overridden - ...config, - - // Internal marker — do not override - __isZenModule: true, - }; -} diff --git a/src/core/modules/discovery.js b/src/core/modules/discovery.js deleted file mode 100644 index 0c6e87d..0000000 --- a/src/core/modules/discovery.js +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Module Discovery System - * Auto-discovers and registers modules from the modules directory. - * Also handles registration of external modules passed via zen.config.js. - */ - -import { registerModule, clearRegistry } from './registry.js'; -import { getAvailableModules } from '../../modules/modules.registry.js'; -import { step, done, warn, fail, info } from '../../shared/lib/logger.js'; - -/** - * Check if a module is enabled via environment variable - * @param {string} moduleName - Module name - * @returns {boolean} - */ -export function isModuleEnabledInEnv(moduleName) { - const envVar = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`; - return process.env[envVar] === 'true'; -} - -/** - * Discover and register all modules - * @param {Object} options - Discovery options - * @param {boolean} options.force - Force re-discovery - * @returns {Promise} Discovery result - */ -export async function discoverModules(options = {}) { - const { force = false } = options; - - const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__'); - - if (globalThis[DISCOVERY_KEY] && !force) { - warn('modules already discovered, skipping'); - return { alreadyDiscovered: true }; - } - - if (force) { - clearRegistry(); - } - - step('Discovering modules...'); - - const discovered = []; - const enabled = []; - const skipped = []; - const errors = []; - - const knownModules = getAvailableModules(); - - for (const moduleName of knownModules) { - try { - const isEnabled = isModuleEnabledInEnv(moduleName); - - if (!isEnabled) { - skipped.push(moduleName); - continue; - } - - // Load module configuration - const moduleConfig = await loadModuleConfig(moduleName); - - if (moduleConfig) { - // Load additional components (db, cron, api) - const components = await loadModuleComponents(moduleName); - - // Register the module - registerModule(moduleName, { - ...moduleConfig, - ...components, - enabled: true - }); - - discovered.push(moduleName); - enabled.push(moduleName); - info(`Registered ${moduleName}`); - } - } catch (error) { - errors.push({ module: moduleName, error: error.message }); - fail(`Error loading ${moduleName}: ${error.message}`); - } - } - - globalThis[DISCOVERY_KEY] = true; - - return { discovered, enabled, skipped, errors }; -} - -/** - * Load module configuration from module.config.js - * @param {string} moduleName - Module name - * @returns {Promise} Module configuration - */ -async function loadModuleConfig(moduleName) { - try { - const config = await import(`../../modules/${moduleName}/module.config.js`); - const moduleConfig = config.default || config; - - return { - name: moduleConfig.name || moduleName, - displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1), - version: moduleConfig.version || '1.0.0', - description: moduleConfig.description || `${moduleName} module`, - dependencies: moduleConfig.dependencies || [], - envVars: moduleConfig.envVars || [], - // Admin config: navigation + page path markers (components loaded client-side) - admin: buildAdminConfig(moduleConfig), - // Public routes metadata (components loaded client-side) - public: moduleConfig.publicRoutes?.length - ? { routes: moduleConfig.publicRoutes } - : undefined, - storagePublicPrefixes: moduleConfig.storagePublicPrefixes || [], - storageAccessPolicies: moduleConfig.storageAccessPolicies || [], - }; - } catch (error) { - // No module.config.js — use defaults silently - return { - name: moduleName, - displayName: moduleName.charAt(0).toUpperCase() + moduleName.slice(1), - version: '1.0.0', - description: `${moduleName} module` - }; - } -} - -/** - * Load additional module components (db, cron, api) - * Note: Metadata is loaded from modules.metadata.js (static registry) - * @param {string} moduleName - Module name - * @returns {Promise} Module components - */ -async function loadModuleComponents(moduleName) { - const components = {}; - - // Load API routes - try { - const api = await import(`../../modules/${moduleName}/api.js`); - components.api = api.default || api; - } catch (error) { - // API is optional - } - - // Load cron configuration - try { - const cron = await import(`../../modules/${moduleName}/cron.config.js`); - components.cron = cron.default || cron; - } catch (error) { - // Cron is optional - } - - // Load database configuration - try { - const db = await import(`../../modules/${moduleName}/db.js`); - if (db.createTables) { - components.db = { - init: db.createTables, - drop: db.dropTables - }; - } - } catch (error) { - // DB is optional - } - - return components; -} - -/** - * Register external modules provided via zen.config.js. - * Skips any module whose ZEN_MODULE_=true env var is not set. - * - * @param {Array} modules - Array of module configs created with defineModule() - * @returns {Promise} { registered, skipped, errors } - */ -export async function registerExternalModules(modules = []) { - const registered = []; - const skipped = []; - const errors = []; - - for (const moduleConfig of modules) { - const moduleName = moduleConfig?.name; - - if (!moduleName) { - errors.push({ module: '(unknown)', error: 'Missing "name" field.' }); - continue; - } - - try { - if (!isModuleEnabledInEnv(moduleName)) { - skipped.push(moduleName); - continue; - } - - // Build registry entry from the external module config - const adminConfig = buildAdminConfig(moduleConfig); - - const moduleData = { - name: moduleName, - displayName: moduleConfig.displayName || moduleName, - version: moduleConfig.version || '1.0.0', - description: moduleConfig.description || '', - dependencies: moduleConfig.dependencies || [], - envVars: moduleConfig.envVars || [], - admin: adminConfig, - public: moduleConfig.publicRoutes?.length - ? { routes: moduleConfig.publicRoutes } - : undefined, - actions: moduleConfig.actions || {}, - metadata: moduleConfig.metadata || {}, - db: moduleConfig.db - ? { init: moduleConfig.db.createTables, drop: moduleConfig.db.dropTables } - : undefined, - cron: moduleConfig.cron || undefined, - api: moduleConfig.api || undefined, - storagePublicPrefixes: moduleConfig.storagePublicPrefixes || [], - storageAccessPolicies: moduleConfig.storageAccessPolicies || [], - enabled: true, - external: true, - }; - - registerModule(moduleName, moduleData); - - // Call setup(ctx) if provided. - // Pass the module's declared envVars so the restricted config.get() - // enforces least-privilege access to environment variables. - if (typeof moduleConfig.setup === 'function') { - const ctx = await buildModuleContext(moduleConfig.envVars || []); - await moduleConfig.setup(ctx); - } - - registered.push(moduleName); - info(`Registered external ${moduleName}`); - } catch (error) { - errors.push({ module: moduleName, error: error.message }); - fail(`Error registering external ${moduleName}: ${error.message}`); - } - } - - return { registered, skipped, errors }; -} - -/** - * Build admin config object from a module config (shared with loadModuleConfig). - * @param {Object} moduleConfig - * @returns {Object|undefined} - */ -function buildAdminConfig(moduleConfig) { - if (!moduleConfig.navigation && !moduleConfig.adminPages) return undefined; - - const adminConfig = {}; - - if (moduleConfig.navigation) { - adminConfig.navigation = moduleConfig.navigation; - } - - if (moduleConfig.adminPages) { - adminConfig.pages = {}; - for (const path of Object.keys(moduleConfig.adminPages)) { - adminConfig.pages[path] = true; - } - } - - return adminConfig; -} - -/** - * Build the context object injected into module setup() callbacks. - * All services are lazy-initialized internally — importing them is safe. - * - * The config.get accessor is intentionally restricted: a module may only read - * environment variables that it has declared in its own envVars list. This - * prevents a malicious or compromised third-party module from reading unrelated - * secrets (e.g. STRIPE_SECRET_KEY, ZEN_DATABASE_URL) via ctx.config.get(). - * - * @param {string[]} [allowedKeys=[]] - env var names this module declared - * @returns {Promise} ctx - */ -async function buildModuleContext(allowedKeys = []) { - const [db, email, storage, payments] = await Promise.all([ - import('../../core/database/index.js'), - import('../../core/email/index.js'), - import('../../core/storage/index.js'), - import('../../core/payments/index.js'), - ]); - - const allowedSet = new Set(allowedKeys); - - return { - db, - email, - storage, - payments, - config: { - /** - * Read an env var — only variables declared in the module's envVars list - * are accessible. Any other key returns undefined and logs a violation. - */ - get: (key) => { - if (!allowedSet.has(key)) { - fail(`[Security] Module attempted to read undeclared env var "${key}" — access denied`); - return undefined; - } - return process.env[key]; - }, - }, - }; -} - -/** - * Reset module discovery (useful for testing) - */ -export function resetDiscovery() { - const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__'); - globalThis[DISCOVERY_KEY] = false; - clearRegistry(); -} diff --git a/src/core/modules/index.js b/src/core/modules/index.js deleted file mode 100644 index 887ddec..0000000 --- a/src/core/modules/index.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Module System Entry Point - * Exports all module-related functionality - */ - -// Discovery -export { - discoverModules, - registerExternalModules, - isModuleEnabledInEnv, - resetDiscovery -} from './discovery.js'; - -// Registry (server-side only - these functions rely on the registry populated during discovery) -export { - registerModule, - getModule, - getAllModules, - getEnabledModules, - isModuleRegistered, - isModuleEnabled, - clearRegistry, - getAllApiRoutes, - getAllAdminNavigation, - getAdminPage, - getAllCronJobs, - getAllPublicRoutes, - getAllDatabaseSchemas, - getModuleMetadataGenerator, - getAllModuleMetadata, - getModulePublicPages // returns route metadata only, use modules.pages.js for components -} from './registry.js'; - -// Loader -export { - initializeModules, - initializeModuleDatabases, - startModuleCronJobs, - stopModuleCronJobs, - getCronJobStatus, - resetModuleLoader, - getModuleStatus -} from './loader.js'; diff --git a/src/core/modules/loader.js b/src/core/modules/loader.js deleted file mode 100644 index b239943..0000000 --- a/src/core/modules/loader.js +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Module Loader - * Handles loading and initializing modules - */ - -import { discoverModules, resetDiscovery } from './discovery.js'; -import { - getAllModules, - getEnabledModules, - getAllCronJobs, - getAllDatabaseSchemas, - isModuleEnabled -} from './registry.js'; -import { schedule, stopAll, getStatus } from '@zen/core/cron'; -import { step, done, warn, fail, info } from '../../shared/lib/logger.js'; - -// Use globalThis to track initialization state -const INIT_KEY = Symbol.for('__ZEN_MODULES_INITIALIZED__'); - -/** - * Initialize all modules - * Discovers modules, initializes databases, and starts cron jobs - * @param {Object} options - Initialization options - * @param {boolean} options.skipCron - Skip starting cron jobs - * @param {boolean} options.skipDb - Skip database initialization - * @param {boolean} options.force - Force re-initialization - * @returns {Promise} Initialization result - */ -export async function initializeModules(options = {}) { - const { skipCron = false, skipDb = false, force = false } = options; - - // Prevent multiple initializations - if (globalThis[INIT_KEY] && !force) { - warn('modules already initialized, skipping'); - return { alreadyInitialized: true }; - } - - step('Initializing modules...'); - - const result = { - discovery: null, - database: { created: [], skipped: [], errors: [] }, - cron: { started: [], errors: [] } - }; - - try { - // Step 1: Discover modules - result.discovery = await discoverModules({ force }); - - // Step 2: Initialize databases - if (!skipDb) { - result.database = await initializeModuleDatabases(); - } - - // Step 3: Start cron jobs - if (!skipCron) { - result.cron = await startModuleCronJobs(); - } - - globalThis[INIT_KEY] = true; - done('Modules initialized'); - - } catch (error) { - fail(`Module initialization failed: ${error.message}`); - result.error = error.message; - } - - return result; -} - -/** - * Initialize databases for all enabled modules - * @returns {Promise} Database initialization result - */ -export async function initializeModuleDatabases() { - step('Initializing module databases...'); - - const schemas = getAllDatabaseSchemas(); - const result = { - created: [], - skipped: [], - errors: [] - }; - - for (const schema of schemas) { - try { - if (schema.init && typeof schema.init === 'function') { - const initResult = await schema.init(); - - if (initResult?.created) { - result.created.push(...initResult.created); - } - if (initResult?.skipped) { - result.skipped.push(...initResult.skipped); - } - - info(`DB ready: ${schema.module}`); - } - } catch (error) { - result.errors.push({ - module: schema.module, - error: error.message - }); - fail(`DB init error for ${schema.module}: ${error.message}`); - } - } - - return result; -} - -/** - * Start cron jobs for all enabled modules. - * Delegates scheduling to core/cron so all jobs share a single registry. - * @returns {Promise} Cron job start result - */ -export async function startModuleCronJobs() { - step('Starting cron jobs...'); - - // Clear any jobs registered by a previous init cycle - stopModuleCronJobs(); - - const jobs = getAllCronJobs(); - const result = { started: [], errors: [] }; - - for (const job of jobs) { - try { - if (typeof job.handler !== 'function') continue; - - schedule(job.name, job.schedule, job.handler, { - timezone: job.timezone - }); - result.started.push(job.name); - } catch (error) { - result.errors.push({ job: job.name, module: job.module, error: error.message }); - fail(`Cron error for ${job.name}: ${error.message}`); - } - } - - return result; -} - -/** - * Stop all module cron jobs. - * Delegates to core/cron which owns the shared registry. - */ -export function stopModuleCronJobs() { - stopAll(); -} - -/** - * Get status of all cron jobs. - * @returns {Object} Cron job status (from core/cron) - */ -export function getCronJobStatus() { - return getStatus(); -} - -/** - * Reset module loader (useful for testing) - */ -export function resetModuleLoader() { - stopModuleCronJobs(); - resetDiscovery(); - globalThis[INIT_KEY] = false; -} - -/** - * Get module status - * @returns {Object} Status of all modules - */ -export function getModuleStatus() { - const modules = getAllModules(); - const enabled = getEnabledModules(); - const cronStatus = getCronJobStatus(); - - return { - totalModules: modules.size, - enabledModules: enabled.length, - modules: Array.from(modules.entries()).map(([name, data]) => ({ - name, - enabled: data.enabled, - displayName: data.displayName, - version: data.version, - hasApi: !!data.api, - hasAdmin: !!data.admin, - hasCron: !!data.cron, - hasDb: !!data.db, - hasPublic: !!data.public - })), - cronJobs: cronStatus - }; -} - -// Re-export useful functions from registry -export { - isModuleEnabled, - getAllModules, - getEnabledModules -} from './registry.js'; diff --git a/src/core/modules/registry.js b/src/core/modules/registry.js deleted file mode 100644 index e00d774..0000000 --- a/src/core/modules/registry.js +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Module Registry - * Stores and manages all discovered modules - */ - -// Use globalThis to persist registry across module reloads -const REGISTRY_KEY = Symbol.for('__ZEN_MODULE_REGISTRY__'); - -/** - * Initialize or get the module registry - * @returns {Map} Module registry map - */ -function getRegistry() { - if (!globalThis[REGISTRY_KEY]) { - globalThis[REGISTRY_KEY] = new Map(); - } - return globalThis[REGISTRY_KEY]; -} - -/** - * Register a module in the registry - * @param {string} name - Module name - * @param {Object} moduleData - Module configuration and components - */ -export function registerModule(name, moduleData) { - const registry = getRegistry(); - registry.set(name, { - ...moduleData, - registeredAt: new Date().toISOString() - }); -} - -/** - * Get a registered module by name - * @param {string} name - Module name - * @returns {Object|null} Module data or null - */ -export function getModule(name) { - const registry = getRegistry(); - return registry.get(name) || null; -} - -/** - * Get all registered modules - * @returns {Map} All registered modules - */ -export function getAllModules() { - return getRegistry(); -} - -/** - * Get all enabled modules - * @returns {Array} Array of enabled module data - */ -export function getEnabledModules() { - const registry = getRegistry(); - const enabled = []; - - for (const [name, data] of registry.entries()) { - if (data.enabled) { - enabled.push({ name, ...data }); - } - } - - return enabled; -} - -/** - * Check if a module is registered - * @param {string} name - Module name - * @returns {boolean} - */ -export function isModuleRegistered(name) { - const registry = getRegistry(); - return registry.has(name); -} - -/** - * Check if a module is enabled - * @param {string} name - Module name - * @returns {boolean} - */ -export function isModuleEnabled(name) { - const module = getModule(name); - return module?.enabled === true; -} - -/** - * Clear the module registry (useful for testing) - */ -export function clearRegistry() { - const registry = getRegistry(); - registry.clear(); -} - -/** - * Get all API routes from enabled modules - * @returns {Array} Array of route definitions - */ -export function getAllApiRoutes() { - const routes = []; - const registry = getRegistry(); - - for (const [name, data] of registry.entries()) { - if (data.enabled && data.api?.routes) { - routes.push(...data.api.routes.map(route => ({ - ...route, - module: name - }))); - } - } - - return routes; -} - -/** - * Get all admin navigation sections from enabled modules - * @param {string} pathname - Current pathname for active state - * @returns {Array} Array of navigation sections - */ -export function getAllAdminNavigation(pathname) { - const sections = []; - const registry = getRegistry(); - - for (const [name, data] of registry.entries()) { - if (data.enabled && data.admin?.navigation) { - const nav = data.admin.navigation; - - // Handle function or object navigation - const section = typeof nav === 'function' ? nav(pathname) : nav; - - if (section) { - // Support array of sections (e.g. one per post type) - const sectionList = Array.isArray(section) ? section : [section]; - for (const s of sectionList) { - if (s.items) { - s.items = s.items.map(item => ({ - ...item, - current: pathname.startsWith(item.href) - })); - } - sections.push({ ...s, module: name }); - } - } - } - } - - return sections; -} - -/** - * Get admin page info for a given path - * - * Returns module info if the path is registered as an admin page. - * The actual component is loaded client-side via modules.pages.js - * - * @param {string} path - Page path (e.g., '/admin/invoice/invoices') - * @returns {Object|null} Object with { module, path } or null - */ -export function getAdminPage(path) { - const registry = getRegistry(); - - for (const [name, data] of registry.entries()) { - if (data.enabled && data.admin?.pages) { - if (data.admin.pages[path]) { - return { module: name, path }; - } - } - } - - return null; -} - -/** - * Get all cron jobs from enabled modules - * @returns {Array} Array of cron job definitions - */ -export function getAllCronJobs() { - const jobs = []; - const registry = getRegistry(); - - for (const [name, data] of registry.entries()) { - if (data.enabled && data.cron?.jobs) { - jobs.push(...data.cron.jobs.map(job => ({ - ...job, - module: name - }))); - } - } - - return jobs; -} - -/** - * Get public routes from enabled modules - * @returns {Array} Array of public route definitions - */ -export function getAllPublicRoutes() { - const routes = []; - const registry = getRegistry(); - - for (const [name, data] of registry.entries()) { - if (data.enabled && data.public?.routes) { - routes.push(...data.public.routes.map(route => ({ - ...route, - module: name - }))); - } - } - - return routes; -} - -/** - * Get database schemas from all enabled modules - * @returns {Array} Array of database schema definitions - */ -export function getAllDatabaseSchemas() { - const schemas = []; - const registry = getRegistry(); - - for (const [name, data] of registry.entries()) { - if (data.enabled && data.db) { - schemas.push({ - module: name, - ...data.db - }); - } - } - - return schemas; -} - -/** - * Get a specific metadata generator function from a module. - * Use this when you need to call a generator directly (e.g. for Next.js generateMetadata). - * - * To get the full metadata object for a module, use getModuleMetadata() from modules.metadata.js. - * - * @param {string} moduleName - Module name (e.g., 'invoice') - * @param {string} type - Metadata type key (e.g., 'payment', 'pdf', 'receipt') - * @returns {Function|null} Metadata generator function or null if not found - */ -export function getModuleMetadataGenerator(moduleName, type) { - const module = getModule(moduleName); - - if (module?.enabled && module?.metadata) { - // If type is specified, return the specific generator - if (type && module.metadata[type]) { - return module.metadata[type]; - } - // If no type, return the default (first one or 'payment') - return module.metadata.payment || module.metadata[Object.keys(module.metadata)[0]] || null; - } - - return null; -} - -/** - * Get all metadata configurations from enabled modules - * @returns {Object} Object mapping module names to their metadata configs - */ -export function getAllModuleMetadata() { - const metadata = {}; - const registry = getRegistry(); - - for (const [name, data] of registry.entries()) { - if (data.enabled && data.metadata) { - metadata[name] = data.metadata; - } - } - - return metadata; -} - -/** - * Get public routes configuration from a module - * - * NOTE: This function only returns route metadata, not components. - * For loading public page components, use getModulePublicPageLoader() from modules.pages.js - * - * @param {string} moduleName - Module name - * @returns {Object|null} Public routes config or null - */ -export function getModulePublicPages(moduleName) { - const module = getModule(moduleName); - - if (module?.enabled && module?.public) { - return module.public; - } - - return null; -} diff --git a/src/modules/PublicPagesClient.js b/src/modules/PublicPagesClient.js deleted file mode 100644 index f43b513..0000000 --- a/src/modules/PublicPagesClient.js +++ /dev/null @@ -1,54 +0,0 @@ -'use client'; - -import React, { Suspense } from 'react'; -import { getModulePublicPageLoader } from './modules.pages.js'; -import { Loading } from '../shared/components'; - -/** - * Not Found Message Component - */ -function NotFoundMessage() { - return ( -
-
-

Page non trouvée

-

- La page que vous recherchez n'existe pas. -

-
-
- ); -} - -/** - * Public Module Pages Router - * Handles routing for all public module pages dynamically - * - * Uses the client-side page loader from modules.pages.js instead of - * the server-side registry (which is empty on the client). - */ -const PublicPagesClient = ({ - path = [], - moduleActions = {}, - ...additionalProps -}) => { - const moduleName = path[0]; - const PublicPage = getModulePublicPageLoader(moduleName); - - if (PublicPage) { - return ( - }> - - - ); - } - - // Module not found or no public pages - return ; -}; - -export default PublicPagesClient; diff --git a/src/modules/PublicPagesLayout.js b/src/modules/PublicPagesLayout.js deleted file mode 100644 index b88c4b9..0000000 --- a/src/modules/PublicPagesLayout.js +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -import React from 'react'; - -/** - * Public Module Pages Layout - * Simple layout for public module pages like invoice payment - */ -const PublicPagesLayout = ({ children }) => { - return ( -
- {children} -
- ); -}; - -export default PublicPagesLayout; diff --git a/src/modules/index.js b/src/modules/index.js deleted file mode 100644 index 7a4e8a1..0000000 --- a/src/modules/index.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Modules Entry Point - * Export all module-related functionality - * - * NOTE: Individual modules (like invoice) should NOT be exported from here. - * Access module functionality directly via: - * - import { ... } from '@zen/core/modules/invoice' - * - Or use the dynamic registry functions from @zen/core/core/modules - */ - -// Module registry -export { - getAvailableModules, - AVAILABLE_MODULES -} from './modules.registry.js'; - -// Dynamic module system exports -export { - // Discovery & initialization - discoverModules, - registerExternalModules, - initializeModules, - initializeModuleDatabases, - startModuleCronJobs, - stopModuleCronJobs, - getModuleStatus, - - // Registry getters - getAllApiRoutes, - getAllAdminNavigation, - getAdminPage, - getAllCronJobs, - getAllPublicRoutes, - getAllDatabaseSchemas, - getModule, - getAllModules, - getEnabledModules, - - // Module-specific getters - getModuleMetadataGenerator, - getAllModuleMetadata, - getModulePublicPages, - - // Status & checks - isModuleEnabled, - isModuleRegistered, -} from '../core/modules/index.js'; - -// Module metadata (server-side object getters) -export { getModuleMetadata, getMetadataGenerator } from './modules.metadata.js'; - -// Client-side module pages registry -export { registerExternalModulePages } from './modules.pages.js'; - -// Public pages system -export * from './pages.js'; diff --git a/src/modules/init.js b/src/modules/init.js deleted file mode 100644 index ab513dc..0000000 --- a/src/modules/init.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Module Database Initialization (CLI) - * - * Initializes DB tables for each enabled module. - * Modules are auto-discovered from AVAILABLE_MODULES — - * no manual registration needed when adding a new module. - */ - -import { AVAILABLE_MODULES } from './modules.registry.js'; -import { step, done, warn, fail, info } from '../shared/lib/logger.js'; - -/** - * Check if a module is enabled via environment variable - * @param {string} moduleName - Module name - * @returns {boolean} - */ -function isModuleEnabled(moduleName) { - const envKey = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`; - return process.env[envKey] === 'true'; -} - -/** - * Initialize all enabled module databases - * @returns {Promise} Result with created and skipped tables - */ -export async function initModules() { - const created = []; - const skipped = []; - - step('Initializing module databases...'); - - for (const moduleName of AVAILABLE_MODULES) { - if (!isModuleEnabled(moduleName)) { - info(`Skipped ${moduleName} (not enabled)`); - continue; - } - - try { - step(`Initializing ${moduleName}...`); - const db = await import(`./${moduleName}/db.js`); - - if (typeof db.createTables === 'function') { - const result = await db.createTables(); - - if (result?.created) created.push(...result.created); - if (result?.skipped) skipped.push(...result.skipped); - - done(`${moduleName} initialized`); - } else { - info(`${moduleName} has no createTables function`); - } - } catch (error) { - if (error.code === 'ERR_MODULE_NOT_FOUND' || error.message?.includes('Cannot find module')) { - info(`${moduleName} has no db.js (skipped)`); - } else { - fail(`${moduleName}: ${error.message}`); - } - } - } - - return { created, skipped }; -} - -export default initModules; diff --git a/src/modules/modules.actions.js b/src/modules/modules.actions.js deleted file mode 100644 index 9a3a640..0000000 --- a/src/modules/modules.actions.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Module Actions Registry (Server-Side) - * - * Static registry for internal module server actions. - * External modules registered via zen.config.js are resolved through the runtime registry. - * - * Usage: - * import { getModuleActions } from '@zen/core/modules/actions'; - * const { getInvoiceByToken } = getModuleActions('invoice'); - */ - -import { getModule, getEnabledModules } from '@zen/core/core/modules'; -import { fail } from '../shared/lib/logger.js'; - -// Static actions for internal modules (add entries here for new internal modules) -export const MODULE_ACTIONS = { - posts: {}, -}; - -// Static dashboard stats actions for internal modules -export const MODULE_DASHBOARD_ACTIONS = {}; - -/** - * Get actions for a specific module. - * Checks the static registry first, then the runtime registry for external modules. - * - * @param {string} moduleName - Module name - * @returns {Object} Module actions object or empty object - */ -export function getModuleActions(moduleName) { - if (MODULE_ACTIONS[moduleName]) return MODULE_ACTIONS[moduleName]; - - // External modules declare their actions in their defineModule() config - return getModule(moduleName)?.actions ?? {}; -} - -/** - * Get dashboard stats action for a specific module - * @param {string} moduleName - Module name - * @returns {Function|null} Dashboard stats function or null - */ -export function getModuleDashboardAction(moduleName) { - return MODULE_DASHBOARD_ACTIONS[moduleName] || null; -} - -/** - * Get all dashboard stats from all modules (internal static + external runtime). - * @returns {Promise} Object with module names as keys and stats as values - */ -export async function getAllModuleDashboardStats() { - const stats = {}; - - // Internal modules — static action map - for (const [moduleName, getStats] of Object.entries(MODULE_DASHBOARD_ACTIONS)) { - const envKey = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`; - if (process.env[envKey] !== 'true') continue; - - try { - const result = await getStats(); - if (result.success) { - stats[moduleName] = result.stats; - } - } catch (error) { - fail(`Error getting dashboard stats for ${moduleName}: ${error.message}`); - } - } - - // External modules — runtime registry - for (const mod of getEnabledModules()) { - if (mod.external && typeof mod.actions?.getDashboardStats === 'function') { - try { - const result = await mod.actions.getDashboardStats(); - if (result.success) { - stats[mod.name] = result.stats; - } - } catch (error) { - fail(`Error getting dashboard stats for ${mod.name}: ${error.message}`); - } - } - } - - return stats; -} - -export default MODULE_ACTIONS; diff --git a/src/modules/modules.metadata.js b/src/modules/modules.metadata.js deleted file mode 100644 index 12ec6e0..0000000 --- a/src/modules/modules.metadata.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Module Metadata Registry (Server-Side) - * - * Static registry for internal module SEO metadata generators. - * External modules registered via zen.config.js are resolved through the runtime registry. - * - * Usage: - * import { getMetadataGenerator } from '@zen/core/modules/metadata'; - * const fn = getMetadataGenerator('invoice', 'payment'); - * const meta = await fn(params.token); - */ - -import { getModule } from '../core/modules/registry.js'; - -// Static metadata for internal modules (add entries here for new internal modules) -export const MODULE_METADATA = {}; - -/** - * Get metadata generators for a specific module. - * Checks the static registry first, then the runtime registry for external modules. - * - * @param {string} moduleName - Module name - * @returns {Object|null} Module metadata object or null - */ -export function getModuleMetadata(moduleName) { - return MODULE_METADATA[moduleName] || getModule(moduleName)?.metadata || null; -} - -/** - * Get a specific metadata generator function. - * Checks the static registry first, then the runtime registry for external modules. - * - * @param {string} moduleName - Module name - * @param {string} generatorName - Metadata generator key (e.g., 'payment', 'pdf') - * @returns {Function|null} Metadata generator function or null - */ -export function getMetadataGenerator(moduleName, generatorName) { - const metadata = MODULE_METADATA[moduleName]; - - if (metadata) { - if (metadata.default && typeof metadata.default[generatorName] === 'function') { - return metadata.default[generatorName]; - } - if (typeof metadata[generatorName] === 'function') { - return metadata[generatorName]; - } - } - - // External modules declare metadata in their defineModule() config - const externalMetadata = getModule(moduleName)?.metadata; - if (externalMetadata && typeof externalMetadata[generatorName] === 'function') { - return externalMetadata[generatorName]; - } - - return null; -} - -export default MODULE_METADATA; diff --git a/src/modules/modules.pages.js b/src/modules/modules.pages.js deleted file mode 100644 index 0bcb09d..0000000 --- a/src/modules/modules.pages.js +++ /dev/null @@ -1,167 +0,0 @@ -'use client'; - -/** - * Module Pages Registry (Client-Side) - * - * Static registry for internal modules (imported explicitly for proper code splitting). - * External modules are registered at runtime via registerExternalModulePages(). - * - * To add an internal module: - * 1. Import its config below - * 2. Add it to MODULE_CONFIGS - */ - -// Import module configs — add new internal modules here -import postsConfig from './posts/module.config.js'; - -// Internal module configs — add new modules here -const MODULE_CONFIGS = { - posts: postsConfig, -}; - -// Runtime registry for external modules (populated by ZenProvider via registerExternalModulePages) -const EXTERNAL_MODULE_CONFIGS = new Map(); - -/** - * Register external module configs at runtime. - * Called by ZenProvider when the app starts. - * Idempotent — safe to call multiple times with the same modules. - * - * @param {Array} modules - Array of module configs created with defineModule() - */ -export function registerExternalModulePages(modules = []) { - for (const mod of modules) { - if (mod?.name) { - EXTERNAL_MODULE_CONFIGS.set(mod.name, mod); - } - } -} - -/** - * Build admin pages from all module configs - */ -export const MODULE_ADMIN_PAGES = Object.entries(MODULE_CONFIGS).reduce((acc, [moduleName, config]) => { - if (config.adminPages) { - acc[moduleName] = config.adminPages; - } - return acc; -}, {}); - -/** - * Build public pages from all module configs - */ -export const MODULE_PUBLIC_PAGES = Object.entries(MODULE_CONFIGS).reduce((acc, [moduleName, config]) => { - if (config.publicPages) { - acc[moduleName] = config.publicPages; - } - return acc; -}, {}); - -/** - * Build dashboard widgets from all module configs - */ -export const MODULE_DASHBOARD_WIDGETS = Object.entries(MODULE_CONFIGS).reduce((acc, [moduleName, config]) => { - if (config.dashboardWidgets) { - acc[moduleName] = config.dashboardWidgets; - } - return acc; -}, {}); - -/** - * Get admin page loader for a specific module and path. - * Checks external modules first, then internal modules. - * - * @param {string} moduleName - Module name - * @param {string} path - Admin path - * @returns {React.LazyExoticComponent|null} Lazy component or null - */ -export function getModulePageLoader(moduleName, path) { - // Check external modules first - const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName); - if (externalConfig) { - if (typeof externalConfig.pageResolver === 'function') { - const resolved = externalConfig.pageResolver(path); - if (resolved) return resolved; - } - if (externalConfig.adminPages?.[path]) { - return externalConfig.adminPages[path]; - } - } - - // Fall back to internal modules - const config = MODULE_CONFIGS[moduleName]; - if (config?.pageResolver) { - const resolved = config.pageResolver(path); - if (resolved) return resolved; - } - - const modulePages = MODULE_ADMIN_PAGES[moduleName]; - if (modulePages?.[path]) { - return modulePages[path]; - } - - return null; -} - -/** - * Get public page loader for a specific module. - * Checks external modules first, then internal modules. - * - * @param {string} moduleName - Module name - * @returns {React.LazyExoticComponent|null} Lazy component or null - */ -export function getModulePublicPageLoader(moduleName) { - const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName); - if (externalConfig?.publicPages?.default) { - return externalConfig.publicPages.default; - } - - const modulePages = MODULE_PUBLIC_PAGES[moduleName]; - if (modulePages?.default) { - return modulePages.default; - } - - return null; -} - -/** - * Get module navigation config. - * Checks external modules first, then internal modules. - * - * @param {string} moduleName - Module name - * @returns {Object|null} Navigation config or null - */ -export function getModuleNavigation(moduleName) { - const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName); - if (externalConfig?.navigation) return externalConfig.navigation; - - const config = MODULE_CONFIGS[moduleName]; - return config?.navigation || null; -} - -/** - * Get all module configs - * @returns {Object} All module configs - */ -export function getAllModuleConfigs() { - return MODULE_CONFIGS; -} - -/** - * Get all dashboard widgets from all modules (internal + external). - * @returns {Object} Object with module names as keys and arrays of lazy widgets - */ -export function getModuleDashboardWidgets() { - const widgets = { ...MODULE_DASHBOARD_WIDGETS }; - for (const [name, config] of EXTERNAL_MODULE_CONFIGS) { - if (config.dashboardWidgets?.length) { - widgets[name] = config.dashboardWidgets; - } - } - return widgets; -} - -// Legacy export for backward compatibility -export const MODULE_PAGES = MODULE_ADMIN_PAGES; - -export default MODULE_ADMIN_PAGES; diff --git a/src/modules/modules.registry.js b/src/modules/modules.registry.js deleted file mode 100644 index 5e3ac5d..0000000 --- a/src/modules/modules.registry.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Available Modules Registry - * - * Add the module name here to make it discoverable by the server. - * See docs/modules/INTERNAL_MODULE.md for the full creation guide. - * - * Required steps when adding an internal module: - * 1. modules.registry.js → Add name to AVAILABLE_MODULES (this file) - * 2. modules.pages.js → Import module.config.js, add to MODULE_CONFIGS - * - * Optional steps (only when the capability is needed): - * 3. modules.actions.js → Add to MODULE_ACTIONS (public page server actions) - * 4. modules.metadata.js → Add to MODULE_METADATA (Next.js SEO generators) - */ -export const AVAILABLE_MODULES = [ - 'posts', -]; - -export function getAvailableModules() { - return [...AVAILABLE_MODULES]; -} - -export default AVAILABLE_MODULES; diff --git a/src/modules/modules.storage.js b/src/modules/modules.storage.js deleted file mode 100644 index 95c3892..0000000 --- a/src/modules/modules.storage.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Module Storage Registry (Server-Side) - * - * Aggregates storage public prefixes and private access policies declared by - * each module via defineModule(). - * - * Public prefixes are served without authentication. - * Access policies control auth requirements for private paths. - * - * Usage: - * import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage'; - */ - -import { getEnabledModules } from '@zen/core/core/modules'; - -/** - * Get all storage public prefixes from every enabled module. - * @returns {string[]} Deduplicated list of public storage prefixes - */ -export function getAllStoragePublicPrefixes() { - const prefixes = new Set(); - - for (const mod of getEnabledModules()) { - for (const prefix of mod.storagePublicPrefixes ?? []) { - prefixes.add(prefix); - } - } - - return [...prefixes]; -} - -/** - * Get all storage access policies from every enabled module. - * - * Built-in: user files at users/{id}/... are always owner-scoped. - * The auth feature is an always-on core feature with no module registration; - * its policy is declared here as the single built-in entry. - * - * Additional policies are contributed by enabled modules via their - * `storageAccessPolicies` defineModule field. - * - * Policy shape: { prefix: string, type: 'owner' | 'admin' } - * 'owner' — pathParts[1] must match session.user.id, or role is 'admin' - * 'admin' — session.user.role must be 'admin' - * - * @returns {{ prefix: string, type: string }[]} - */ -export function getAllStorageAccessPolicies() { - const policies = [ - // Built-in: user files are owner-scoped (auth feature, always enabled) - { prefix: 'users', type: 'owner' }, - ]; - - for (const mod of getEnabledModules()) { - for (const policy of mod.storageAccessPolicies ?? []) { - policies.push(policy); - } - } - - return policies; -} diff --git a/src/modules/page.js b/src/modules/page.js deleted file mode 100644 index 4540df6..0000000 --- a/src/modules/page.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * 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 '@zen/core/modules/page'; - * ``` - * - * Module actions are loaded from the static modules.actions.js registry. - */ - -import { PublicPagesLayout, PublicPagesClient } from '@zen/core/modules/pages'; -import { getMetadataGenerator } from '@zen/core/modules/metadata'; -import { getAppConfig } from '@zen/core'; -import { getModuleActions } from '@zen/core/modules/actions'; -import { fail } from '../shared/lib/logger.js'; - -/** - * 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) { - fail(`Error generating metadata for ${moduleName}/${metadataType}: ${error.message}`); - } - } - } - - // Default metadata - return { - title: process.env.ZEN_NAME || 'ZEN', - }; -} - -/** - * 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 deleted file mode 100644 index 14a37da..0000000 --- a/src/modules/pages.js +++ /dev/null @@ -1,19 +0,0 @@ -'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 deleted file mode 100644 index ff84001..0000000 --- a/src/modules/posts/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -################################# -# 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, datetime, color, 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 - -# Public storage access per type (optional, default: false) -# When true, images of that type are served without authentication. -# Files are stored at posts/{type}/{id}/{filename} and accessible via /zen/api/storage/posts/{type}/... -ZEN_MODULE_POSTS_TYPE_BLOGUE_PUBLIC=true -# ZEN_MODULE_POSTS_TYPE_CVE_PUBLIC=false -# ZEN_MODULE_POSTS_TYPE_EMPLOI_PUBLIC=false -################################# diff --git a/src/modules/posts/README.md b/src/modules/posts/README.md deleted file mode 100644 index 6d7501b..0000000 --- a/src/modules/posts/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Module Posts - -Types de contenus configurables via variables d'environnement. Chaque projet déclare ses propres types (blogue, CVE, emploi, événement...) avec les champs dont il a besoin, sans toucher au code. - ---- - -## Configuration - -Copier les variables de [`.env.example`](.env.example) dans votre `.env`. - -Si aucun label n'est fourni (`ZEN_MODULE_POSTS_TYPES=blogue`), le nom affiché sera la clé avec la première lettre en majuscule. - -### Types de champs - -| Type | Syntaxe `.env` | Description | -|---|---|---| -| `title` | `nom:title` | Champ texte principal, génère le slug automatiquement | -| `slug` | `nom:slug` | Slug unique par type, pré-rempli depuis le titre | -| `text` | `nom:text` | Zone de texte libre | -| `markdown` | `nom:markdown` | Éditeur Markdown avec prévisualisation | -| `date` | `nom:date` | Sélecteur de date (YYYY-MM-DD) | -| `datetime` | `nom:datetime` | Date et heure (ISO 8601, UTC) | -| `color` | `nom:color` | Sélecteur de couleur, stocke un code hex `#rrggbb` | -| `category` | `nom:category` | Menu déroulant lié à la table des catégories | -| `image` | `nom:image` | Upload d'image vers le stockage Zen | -| `relation` | `nom:relation:type_cible` | Sélection multiple vers des posts d'un autre type | - -Chaque type doit avoir au moins un champ `title` et un champ `slug`. - -Si un type utilise le champ `image`, configurer le stockage Zen dans le `.env` principal : `ZEN_STORAGE_REGION`, `ZEN_STORAGE_ACCESS_KEY`, `ZEN_STORAGE_SECRET_KEY`, `ZEN_STORAGE_BUCKET`. - -### Accès public aux images - -Par défaut, les images d'un type nécessitent une session authentifiée. Pour les rendre accessibles publiquement (ex. images de blogue affichées sur le site) : - -```env -ZEN_MODULE_POSTS_TYPE_BLOGUE_PUBLIC=true -``` - -Les images sont stockées sous `posts/{type}/{id}/{filename}` et servies via `/zen/api/storage/posts/{type}/...`. L'accès public est déclaré dans le module. Aucune variable d'environnement globale n'est nécessaire. - ---- - -## Base de données - -Les tables sont créées automatiquement avec `npx zen-db init`. - ---- - -## Interface d'administration - -| Page | URL | -|---|---| -| Liste des posts | `/admin/posts/{type}/list` | -| Créer un post | `/admin/posts/{type}/new` | -| Modifier un post | `/admin/posts/{type}/edit/{id}` | -| Liste des catégories | `/admin/posts/{type}/categories` | - ---- - -## Documentation - -- [API publique](docs/api.md) — endpoints, paramètres, réponses JSON -- [API d'administration](docs/admin-api.md) — routes authentifiées -- [Intégration Next.js](docs/integration.md) — liste, détail, SEO -- [Usage programmatique](docs/programmatic.md) — `upsertPost`, cron jobs, imports diff --git a/src/modules/posts/admin/PostCreatePage.js b/src/modules/posts/admin/PostCreatePage.js deleted file mode 100644 index 1f1dc65..0000000 --- a/src/modules/posts/admin/PostCreatePage.js +++ /dev/null @@ -1,245 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { useRouter, usePathname } from 'next/navigation'; -import { Button, Card } from '../../../shared/components'; -import { useToast } from '@zen/core/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.message || 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.message || data.error || 'É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

-
- -
- - {loading ? ( -
- ) : ( -
- -
-

{label}

- setSlugTouched(true)} - categories={categories} - uploading={uploading} - onImageChange={handleImageChange} - /> -
-
- -
- - -
-
- )} -
- ); -}; - -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 deleted file mode 100644 index 63e575f..0000000 --- a/src/modules/posts/admin/PostEditPage.js +++ /dev/null @@ -1,271 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { useRouter, usePathname } from 'next/navigation'; -import { Button, Card } from '../../../shared/components'; -import { useToast } from '@zen/core/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.message || 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.message || data.error || 'É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

-
- -
- - {loading ? ( -
- ) : ( -
- -
-

{label}

- setSlugTouched(true)} - categories={categories} - uploading={uploading} - onImageChange={handleImageChange} - /> -
-
- -
- - -
-
- )} -
- ); -}; - -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 deleted file mode 100644 index be01c39..0000000 --- a/src/modules/posts/admin/PostFormFields.js +++ /dev/null @@ -1,359 +0,0 @@ -'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 ( -
- -