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
This commit is contained in:
2026-04-14 17:27:04 -04:00
parent 936d21fdec
commit 4a06cace5d
42 changed files with 3 additions and 6001 deletions
+1 -1
View File
@@ -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 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). Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md).
-255
View File
@@ -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: '<p>ok</p>' });
// 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 (
<html>
<body>
<ZenProvider modules={zenConfig.modules}>
{children}
</ZenProvider>
</body>
</html>
);
}
```
### 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.
-218
View File
@@ -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.
+1 -31
View File
@@ -1,7 +1,7 @@
{ {
"name": "@zen/core", "name": "@zen/core",
"version": "1.3.13", "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": { "repository": {
"type": "git", "type": "git",
"url": "https://git.hyko.cx/zen/core.git" "url": "https://git.hyko.cx/zen/core.git"
@@ -115,36 +115,6 @@
"./provider": { "./provider": {
"import": "./dist/features/provider/index.js" "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": { "./lib/metadata": {
"import": "./dist/shared/lib/metadata/index.js" "import": "./dist/shared/lib/metadata/index.js"
}, },
-32
View File
@@ -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.
-64
View File
@@ -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,
};
}
-314
View File
@@ -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<Object>} 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<Object|null>} 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<Object>} 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_<NAME>=true env var is not set.
*
* @param {Array} modules - Array of module configs created with defineModule()
* @returns {Promise<Object>} { 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<Object>} 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();
}
-43
View File
@@ -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';
-199
View File
@@ -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<Object>} 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<Object>} 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<Object>} 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';
-293
View File
@@ -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;
}
-54
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
<h1 className="text-2xl font-semibold text-white mb-4">Page non trouvée</h1>
<p className="text-neutral-400">
La page que vous recherchez n'existe pas.
</p>
</div>
</div>
);
}
/**
* 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 (
<Suspense fallback={<Loading />}>
<PublicPage
path={path}
{...moduleActions}
{...additionalProps}
/>
</Suspense>
);
}
// Module not found or no public pages
return <NotFoundMessage />;
};
export default PublicPagesClient;
-17
View File
@@ -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 (
<div className="min-h-screen">
{children}
</div>
);
};
export default PublicPagesLayout;
-56
View File
@@ -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';
-64
View File
@@ -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<Object>} 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;
-85
View File
@@ -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>} 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;
-58
View File
@@ -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;
-167
View File
@@ -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;
-23
View File
@@ -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;
-61
View File
@@ -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;
}
-114
View File
@@ -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 (
<PublicPagesLayout>
<PublicPagesClient
path={path}
moduleActions={moduleActions}
{...additionalProps}
/>
</PublicPagesLayout>
);
}
-19
View File
@@ -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';
-23
View File
@@ -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
#################################
-66
View File
@@ -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
-245
View File
@@ -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 (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer {label}</h1>
<p className="mt-1 text-xs text-neutral-400">Ajouter un nouvel élément</p>
</div>
<Button variant="secondary" size="sm" onClick={() => router.push(`/admin/posts/${postType}/list`)}>
Retour
</Button>
</div>
{loading ? (
<Card><div className="animate-pulse h-40 bg-neutral-200 dark:bg-neutral-800 rounded" /></Card>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">{label}</h2>
<PostFormFields
fields={typeConfig?.fields || []}
formData={formData}
onChange={handleChange}
errors={errors}
slugValue={typeConfig?.slugField ? formData[typeConfig.slugField] : undefined}
onSlugFocus={() => setSlugTouched(true)}
categories={categories}
uploading={uploading}
onImageChange={handleImageChange}
/>
</div>
</Card>
<div className="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => router.push(`/admin/posts/${postType}/list`)} disabled={saving}>
Annuler
</Button>
<Button type="submit" variant="success" loading={saving} disabled={saving}>
{saving ? 'Création...' : 'Créer'}
</Button>
</div>
</form>
)}
</div>
);
};
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
export default PostCreatePage;
-271
View File
@@ -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 (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier {label}</h1>
<p className="mt-1 text-xs text-neutral-400">Modifier un élément existant</p>
</div>
<Button variant="secondary" size="sm" onClick={() => router.push(`/admin/posts/${postType}/list`)}>
Retour
</Button>
</div>
{loading ? (
<Card><div className="animate-pulse h-40 bg-neutral-200 dark:bg-neutral-800 rounded" /></Card>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">{label}</h2>
<PostFormFields
fields={typeConfig?.fields || []}
formData={formData}
onChange={handleChange}
errors={errors}
slugValue={typeConfig?.slugField ? formData[typeConfig.slugField] : undefined}
onSlugFocus={() => setSlugTouched(true)}
categories={categories}
uploading={uploading}
onImageChange={handleImageChange}
/>
</div>
</Card>
<div className="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => router.push(`/admin/posts/${postType}/list`)} disabled={saving}>
Annuler
</Button>
<Button type="submit" variant="success" loading={saving} disabled={saving}>
{saving ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</div>
</form>
)}
</div>
);
};
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
export default PostEditPage;
-359
View File
@@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{fields.map((field) => {
switch (field.type) {
case 'title':
return (
<div key={field.name} className="md:col-span-2">
<Input
label={`${capitalize(field.name)} *`}
value={formData[field.name] || ''}
onChange={(value) => onChange(field.name, value)}
placeholder={`${capitalize(field.name)}...`}
error={errors[field.name]}
/>
</div>
);
case 'slug':
return (
<div key={field.name} className="md:col-span-2">
<Input
label="Slug"
value={slugValue ?? formData[field.name] ?? ''}
onChange={(value) => onChange(field.name, value)}
onFocus={onSlugFocus}
placeholder="url-slug (généré depuis le titre)"
/>
</div>
);
case 'text':
return (
<div key={field.name} className="md:col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{capitalize(field.name)}
</label>
<Textarea
value={formData[field.name] || ''}
onChange={(value) => onChange(field.name, value)}
rows={3}
placeholder={`${capitalize(field.name)}...`}
error={errors[field.name]}
/>
</div>
);
case 'markdown':
return (
<div key={field.name} className="md:col-span-2">
<MarkdownEditor
label={capitalize(field.name)}
value={formData[field.name] || ''}
onChange={(value) => onChange(field.name, value)}
rows={14}
placeholder={`${capitalize(field.name)} en Markdown...`}
/>
</div>
);
case 'date':
return (
<div key={field.name}>
<Input
label={capitalize(field.name)}
type="date"
value={formData[field.name] || ''}
onChange={(value) => onChange(field.name, value)}
error={errors[field.name]}
/>
</div>
);
case 'datetime':
return (
<div key={field.name}>
<Input
label={capitalize(field.name)}
type="datetime-local"
value={formData[field.name] || ''}
onChange={(value) => onChange(field.name, value)}
error={errors[field.name]}
/>
</div>
);
case 'color':
return (
<div key={field.name}>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{capitalize(field.name)}
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={formData[field.name] || '#000000'}
onChange={(e) => 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"
/>
<span className="text-sm font-mono text-neutral-600 dark:text-neutral-400">
{formData[field.name] || '#000000'}
</span>
{formData[field.name] && (
<button
type="button"
onClick={() => onChange(field.name, '')}
className="text-xs text-neutral-500 hover:text-red-400"
>
Réinitialiser
</button>
)}
</div>
{errors[field.name] && (
<p className="mt-1 text-xs text-red-400">{errors[field.name]}</p>
)}
</div>
);
case 'category':
return (
<div key={field.name}>
<Select
label="Catégorie"
value={formData[field.name] || ''}
onChange={(value) => onChange(field.name, value)}
options={[
{ value: '', label: 'Aucune' },
...categories.map(c => ({ value: c.id, label: c.title }))
]}
/>
</div>
);
case 'image':
return (
<div key={field.name} className="md:col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{capitalize(field.name)}
</label>
<input
type="file"
accept="image/*"
onChange={(e) => 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] && (
<div className="mt-2 flex items-center gap-2">
<img
src={`/zen/api/storage/${formData[field.name]}`}
alt=""
className="w-20 h-20 object-cover rounded"
/>
<button
type="button"
onClick={() => onChange(field.name, '')}
className="text-sm text-red-400 hover:text-red-300"
>
Supprimer
</button>
</div>
)}
</div>
);
case 'relation':
return (
<div key={field.name} className="md:col-span-2">
<RelationSelector
label={capitalize(field.name)}
targetType={field.target}
value={formData[field.name] || []}
onChange={(items) => onChange(field.name, items)}
/>
</div>
);
default:
return (
<div key={field.name}>
<Input
label={capitalize(field.name)}
value={formData[field.name] || ''}
onChange={(value) => onChange(field.name, value)}
error={errors[field.name]}
/>
</div>
);
}
})}
</div>
);
};
// ============================================================================
// 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 (
<div>
{label && (
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{label}</label>
)}
{/* Selected chips */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{value.map((item) => (
<span
key={item.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-neutral-200 dark:bg-neutral-700 text-sm text-neutral-800 dark:text-neutral-100"
>
{item.title}
<button
type="button"
onClick={() => handleRemove(item.id)}
className="text-neutral-400 hover:text-red-400 ml-1 leading-none"
aria-label="Retirer"
>
×
</button>
</span>
))}
</div>
)}
{/* Search input + dropdown */}
<div className="relative" ref={dropdownRef}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
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 && (
<div className="absolute z-50 mt-1 w-full max-h-60 overflow-y-auto rounded border border-neutral-200 dark:border-neutral-600 bg-white dark:bg-neutral-900 shadow-lg">
{loading ? (
<div className="px-3 py-2 text-sm text-neutral-400">Chargement</div>
) : results.length === 0 ? (
<div className="px-3 py-2 text-sm text-neutral-500">
{query ? 'Aucun résultat' : 'Tapez pour rechercher'}
</div>
) : (
results.map((item) => (
<button
key={item.id}
type="button"
onClick={() => 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}
<span className="ml-2 text-xs text-neutral-500">{item.slug}</span>
</button>
))
)}
</div>
)}
</div>
</div>
);
};
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
}
export default PostFormFields;
-104
View File
@@ -1,104 +0,0 @@
'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 '@zen/core/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 (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Posts</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez vos types de contenu</p>
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2].map(i => (
<div key={i} className="h-28 rounded-lg bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
))}
</div>
) : types.length === 0 ? (
<Card>
<p className="text-neutral-400 text-sm">
Aucun type de post configuré. Ajoutez <code className="text-neutral-300">ZEN_MODULE_ZEN_MODULE_POSTS_TYPES</code> dans votre fichier <code className="text-neutral-300">.env</code>.
</p>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{types.map(type => (
<Card key={type.key} className="hover:border-neutral-600 transition-colors">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Book02Icon className="w-5 h-5 text-neutral-400" />
<span className="font-semibold text-neutral-900 dark:text-white">{type.label}</span>
</div>
<p className="text-xs text-neutral-500">
{type.fields.length} champ{type.fields.length !== 1 ? 's' : ''} &bull; {type.fields.map(f => f.type).join(', ')}
</p>
<div className="flex gap-2 mt-1">
<Button
size="sm"
variant="secondary"
onClick={() => router.push(`/admin/posts/${type.key}/list`)}
icon={<Book02Icon className="w-4 h-4" />}
>
Posts
</Button>
{type.hasCategory && (
<Button
size="sm"
variant="ghost"
onClick={() => router.push(`/admin/posts/${type.key}/categories`)}
icon={<Layers01Icon className="w-4 h-4" />}
>
Catégories
</Button>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
};
export default PostsIndexPage;
-316
View File
@@ -1,316 +0,0 @@
'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 '@zen/core/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.message || 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.message || 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) => (
<div>
<div className="text-sm font-semibold text-neutral-900 dark:text-white">{post[field.name] || '-'}</div>
{typeConfig.slugField && (
<div className="text-xs text-neutral-500 dark:text-gray-400 font-mono">{post.slug}</div>
)}
</div>
),
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) => (
<span className="text-sm text-neutral-600 dark:text-gray-300">
{post[field.name] ? formatDateForDisplay(post[field.name], 'fr-FR') : '-'}
</span>
),
skeleton: { height: 'h-4', width: '100px' }
});
} else if (field.type === 'datetime') {
cols.push({
key: field.name,
label: capitalize(field.name),
sortable: true,
render: (post) => (
<span className="text-sm text-neutral-600 dark:text-gray-300">
{post[field.name] ? formatDateTimeForDisplay(post[field.name]) : '-'}
</span>
),
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] ? (
<div className="flex items-center gap-2">
<span
className="inline-block w-5 h-5 rounded border border-neutral-600 shrink-0"
style={{ backgroundColor: post[field.name] }}
/>
<span className="text-xs font-mono text-neutral-500 dark:text-gray-400">{post[field.name]}</span>
</div>
) : <span className="text-sm text-neutral-400 dark:text-gray-500">-</span>,
skeleton: { height: 'h-5', width: '80px' }
});
} else if (field.type === 'category') {
cols.push({
key: 'category_title',
label: 'Catégorie',
sortable: false,
render: (post) => (
<span className="text-sm text-neutral-600 dark:text-gray-300">{post.category_title || '-'}</span>
),
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] ? (
<img src={`/zen/api/storage/${post[field.name]}`} alt="" className="w-10 h-10 object-cover rounded" />
) : (
<span className="text-sm text-neutral-400 dark:text-gray-500">-</span>
),
skeleton: { height: 'h-10', width: '40px' }
});
} else if (field.type === 'text') {
cols.push({
key: field.name,
label: capitalize(field.name),
sortable: false,
render: (post) => (
<div className="text-sm text-neutral-600 dark:text-gray-300 max-w-xs truncate">
{post[field.name] || <span className="text-neutral-400 dark:text-gray-500">-</span>}
</div>
),
skeleton: { height: 'h-4', width: '35%' }
});
}
}
cols.push({
key: 'actions',
label: 'Actions',
render: (post) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/posts/${postType}/edit/${post.id}`)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-4 h-4" />}
className="p-2"
/>
<Button
variant="danger"
size="sm"
onClick={() => handleDeletePost(post)}
disabled={deleting}
icon={<Delete02Icon className="w-4 h-4" />}
className="p-2"
/>
</div>
),
skeleton: { height: 'h-8', width: '80px' }
});
return cols;
};
const label = typeConfig?.label || capitalize(postType);
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">{label}</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez vos {label.toLowerCase()}s</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => router.push(`/admin/posts/${postType}/new`)}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer un {label.toLowerCase()}
</Button>
{typeConfig?.hasCategory && (
<Button
variant="secondary"
size="sm"
onClick={() => router.push(`/admin/posts/${postType}/categories`)}
icon={<Layers01Icon className="w-4 h-4" />}
>
Catégories
</Button>
)}
</div>
</div>
<Card variant="default" padding="none">
<Table
columns={buildColumns()}
data={posts}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={(newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
}}
emptyMessage={`Aucun ${label.toLowerCase()} trouvé`}
emptyDescription={`Créez votre premier ${label.toLowerCase()}`}
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={(p) => 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}
/>
</Card>
</div>
);
};
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;
-500
View File
@@ -1,500 +0,0 @@
/**
* 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,
generateUniqueFilename,
validateUpload,
getFileExtension,
FILE_TYPE_PRESETS,
FILE_SIZE_LIMITS
} from '@zen/core/storage';
const generatePostFilePath = (typeKey, postIdOrSlug, filename) => `posts/${typeKey}/${postIdOrSlug}/${filename}`;
/**
* Extension → MIME type map derived from the validated file extension.
* The client-supplied file.type is NEVER trusted — it is an attacker-controlled
* multipart field with no server-side enforcement.
*/
const EXTENSION_TO_MIME = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
};
import { getPostsConfig, getPostType } from './config.js';
import { fail, warn } from '../../shared/lib/logger.js';
import { defineApiRoutes, apiSuccess, apiError } from '@zen/core/api';
// ============================================================================
// Config
// ============================================================================
async function handleGetConfig() {
try {
const config = getPostsConfig();
return apiSuccess({ success: true, config });
} catch (error) {
fail(`Posts: error getting config: ${error.message}`);
return apiError('Internal Server Error', '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 apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
const id = url.searchParams.get('id');
if (id) {
const post = await getPostById(postType, parseInt(id));
if (!post) return apiError('Not Found', 'Post not found');
return apiSuccess({ 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 apiSuccess({
success: true,
posts: result.posts,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
});
} catch (error) {
fail(`Posts: error GET posts: ${error.message}`);
return apiError('Internal Server Error', 'Failed to fetch posts');
}
}
async function handleCreatePost(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return apiError('Bad Request', 'Post type is required');
const body = await request.json();
const postData = body.post || body;
if (!postData || Object.keys(postData).length === 0) {
return apiError('Bad Request', 'Post data is required');
}
const post = await createPost(postType, postData);
return apiSuccess({ success: true, post, message: 'Post created successfully' });
} catch (error) {
fail(`Posts: error creating post: ${error.message}`);
return apiError('Internal Server Error', '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 apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Post ID is required');
const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
if (!updates || Object.keys(updates).length === 0) {
return apiError('Bad Request', 'Update data is required');
}
const typeConfig = getPostType(postType);
const existing = await getPostById(postType, parseInt(id));
if (!existing) return apiError('Not Found', '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) {
warn(`Posts: error deleting old image ${oldKey}: ${err.message}`);
}
}
}
}
return apiSuccess({ success: true, post, message: 'Post updated successfully' });
} catch (error) {
fail(`Posts: error updating post: ${error.message}`);
return apiError('Internal Server Error', '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 apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Post ID is required');
const deleted = await deletePost(postType, parseInt(id));
if (!deleted) return apiError('Not Found', 'Post not found');
return apiSuccess({ success: true, message: 'Post deleted successfully' });
} catch (error) {
fail(`Posts: error deleting post: ${error.message}`);
return apiError('Internal Server Error', 'Failed to delete post');
}
}
// ============================================================================
// Image upload (admin)
// ============================================================================
async function handleUploadImage(request) {
try {
const formData = await request.formData();
const file = formData.get('file');
const postType = formData.get('type');
if (!file) return apiError('Bad Request', 'No file provided');
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `Unknown post type: ${postType}`);
// Read the buffer before validation so both magic-byte assertion and
// dangerous-pattern inspection (HTML/SVG/XML denylist) are executed
// against actual file content, not merely the client-supplied metadata.
const buffer = Buffer.from(await file.arrayBuffer());
const validation = validateUpload({
filename: file.name,
size: file.size,
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
maxSize: FILE_SIZE_LIMITS.IMAGE,
buffer,
});
if (!validation.valid) {
return apiError('Bad Request', validation.errors.join(', '));
}
const uniqueFilename = generateUniqueFilename(file.name);
const key = generatePostFilePath(postType, Date.now(), uniqueFilename);
// Derive content-type from the validated extension — never from file.type,
// which is fully attacker-controlled.
const ext = getFileExtension(file.name).toLowerCase();
const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
const uploadResult = await uploadImage({
key,
body: buffer,
contentType,
metadata: { originalName: file.name }
});
if (!uploadResult.success) {
return apiError('Internal Server Error', 'Upload failed');
}
return apiSuccess({ success: true, key: uploadResult.data.key });
} catch (error) {
fail(`Posts: error uploading image: ${error.message}`);
return apiError('Internal Server Error', 'Upload failed');
}
}
// ============================================================================
// Categories (admin)
// ============================================================================
async function handleGetCategories(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return apiError('Bad Request', 'Post type is required');
const id = url.searchParams.get('id');
if (id) {
const category = await getCategoryById(postType, parseInt(id));
if (!category) return apiError('Not Found', 'Category not found');
return apiSuccess({ 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 apiSuccess({
success: true,
categories: result.categories,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
});
} catch (error) {
fail(`Posts: error GET categories: ${error.message}`);
return apiError('Internal Server Error', 'Failed to fetch categories');
}
}
async function handleCreateCategory(request) {
try {
const url = new URL(request.url);
const postType = url.searchParams.get('type');
if (!postType) return apiError('Bad Request', 'Post type is required');
const body = await request.json();
const categoryData = body.category || body;
if (!categoryData || Object.keys(categoryData).length === 0) {
return apiError('Bad Request', 'Category data is required');
}
const category = await createCategory(postType, categoryData);
return apiSuccess({ success: true, category, message: 'Category created successfully' });
} catch (error) {
fail(`Posts: error creating category: ${error.message}`);
return apiError('Internal Server Error', '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 apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Category ID is required');
const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
if (!updates || Object.keys(updates).length === 0) {
return apiError('Bad Request', 'Update data is required');
}
const existing = await getCategoryById(postType, parseInt(id));
if (!existing) return apiError('Not Found', 'Category not found');
const category = await updateCategory(postType, parseInt(id), updates);
return apiSuccess({ success: true, category, message: 'Category updated successfully' });
} catch (error) {
fail(`Posts: error updating category: ${error.message}`);
return apiError('Internal Server Error', '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 apiError('Bad Request', 'Post type is required');
if (!id) return apiError('Bad Request', 'Category ID is required');
await deleteCategory(postType, parseInt(id));
return apiSuccess({ success: true, message: 'Category deleted successfully' });
} catch (error) {
fail(`Posts: error deleting category: ${error.message}`);
if (error.message.includes('Cannot delete')) {
return apiError('Bad Request', error.message);
}
return apiError('Internal Server 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 apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `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 apiSuccess({ success: true, posts });
} catch (error) {
fail(`Posts: error searching posts: ${error.message}`);
return apiError('Internal Server Error', 'Failed to search posts');
}
}
// ============================================================================
// Public API
// ============================================================================
async function handlePublicGetConfig() {
try {
const config = getPostsConfig();
return apiSuccess({ success: true, config });
} catch (error) {
fail(`Posts: error getting public config: ${error.message}`);
return apiError('Internal Server Error', 'Failed to get config');
}
}
async function handlePublicGetPosts(request, params) {
try {
const postType = params?.type;
if (!postType) return apiError('Bad Request', 'Post type is required');
if (!getPostType(postType)) return apiError('Bad Request', `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 apiSuccess({
success: true,
posts: result.posts,
total: result.pagination.total,
totalPages: result.pagination.totalPages,
page: result.pagination.page,
limit: result.pagination.limit
});
} catch (error) {
fail(`Posts: error public GET posts: ${error.message}`);
return apiError('Internal Server Error', 'Failed to fetch posts');
}
}
async function handlePublicGetPostBySlug(request, params) {
try {
const postType = params?.type;
const slug = params?.slug;
if (!postType || !slug) return apiError('Bad Request', 'Post type and slug are required');
const post = await getPostBySlug(postType, slug);
if (!post) return apiError('Not Found', 'Post not found');
return apiSuccess({ success: true, post });
} catch (error) {
fail(`Posts: error public GET post by slug: ${error.message}`);
return apiError('Internal Server Error', 'Failed to fetch post');
}
}
async function handlePublicGetCategories(request, params) {
try {
const postType = params?.type;
if (!postType) return apiError('Bad Request', 'Post type is required');
const categories = await getActiveCategories(postType);
return apiSuccess({ success: true, categories });
} catch (error) {
fail(`Posts: error public GET categories: ${error.message}`);
return apiError('Internal Server Error', 'Failed to fetch categories');
}
}
// ============================================================================
// Route Definitions
// ============================================================================
export default {
routes: defineApiRoutes([
// 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' },
])
};
@@ -1,198 +0,0 @@
'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 '@zen/core/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) => <div className="text-sm font-semibold text-neutral-900 dark:text-white">{cat.title}</div>,
skeleton: { height: 'h-4', width: '40%' }
},
{
key: 'description',
label: 'Description',
sortable: false,
render: (cat) => (
<div className="text-sm text-neutral-600 dark:text-gray-300 max-w-xs truncate">
{cat.description || <span className="text-neutral-400 dark:text-gray-500">-</span>}
</div>
),
skeleton: { height: 'h-4', width: '60%' }
},
{
key: 'posts_count',
label: 'Posts',
sortable: false,
render: (cat) => <div className="text-sm text-neutral-600 dark:text-gray-300">{cat.posts_count || 0}</div>,
skeleton: { height: 'h-4', width: '30px' }
},
{
key: 'is_active',
label: 'Statut',
sortable: true,
render: (cat) => (
<StatusBadge variant={cat.is_active ? 'success' : 'default'}>
{cat.is_active ? 'Actif' : 'Inactif'}
</StatusBadge>
),
skeleton: { height: 'h-6', width: '70px', className: 'rounded-full' }
},
{
key: 'actions',
label: 'Actions',
render: (cat) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/posts/${postType}/categories/edit/${cat.id}`)}
disabled={deleting}
icon={<PencilEdit01Icon className="w-4 h-4" />}
className="p-2"
/>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteCategory(cat)}
disabled={deleting}
icon={<Delete02Icon className="w-4 h-4" />}
className="p-2"
/>
</div>
),
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.message || 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.message || 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 (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Catégories</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez les catégories de {postType || 'posts'}</p>
</div>
<Button
onClick={() => router.push(`/admin/posts/${postType}/categories/new`)}
icon={<PlusSignCircleIcon className="w-4 h-4" />}
>
Créer une catégorie
</Button>
</div>
<Card variant="default" padding="none">
<Table
columns={columns}
data={categories}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={(newSortBy) => {
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"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={(p) => 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}
/>
</Card>
</div>
);
};
export default CategoriesListPage;
@@ -1,124 +0,0 @@
'use client';
import React, { useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Button, Card, Input, Textarea } from '../../../../shared/components';
import { useToast } from '@zen/core/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.message || 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 (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Créer une catégorie</h1>
<p className="mt-1 text-xs text-neutral-400">Ajouter une nouvelle catégorie</p>
</div>
<Button variant="secondary" size="sm" onClick={() => router.push(`/admin/posts/${postType}/categories`)}>
Retour
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Catégorie</h2>
<div className="grid grid-cols-1 gap-4">
<Input
label="Titre *"
value={formData.title}
onChange={(value) => handleChange('title', value)}
placeholder="Titre de la catégorie..."
error={errors.title}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Description</label>
<Textarea
value={formData.description}
onChange={(value) => handleChange('description', value)}
rows={3}
placeholder="Description de la catégorie..."
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Active</span>
</label>
</div>
</div>
</Card>
<div className="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => router.push(`/admin/posts/${postType}/categories`)} disabled={saving}>
Annuler
</Button>
<Button type="submit" variant="success" loading={saving} disabled={saving}>
{saving ? 'Création...' : 'Créer'}
</Button>
</div>
</form>
</div>
);
};
export default CategoryCreatePage;
@@ -1,158 +0,0 @@
'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 '@zen/core/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.message || 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 (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier la catégorie</h1>
<p className="mt-1 text-xs text-neutral-400">Modifier une catégorie existante</p>
</div>
<Button variant="secondary" size="sm" onClick={() => router.push(`/admin/posts/${postType}/categories`)}>
Retour
</Button>
</div>
{loading ? (
<Card><div className="animate-pulse h-40 bg-neutral-200 dark:bg-neutral-800 rounded" /></Card>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Catégorie</h2>
<div className="grid grid-cols-1 gap-4">
<Input
label="Titre *"
value={formData.title}
onChange={(value) => handleChange('title', value)}
placeholder="Titre de la catégorie..."
error={errors.title}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Description</label>
<Textarea
value={formData.description}
onChange={(value) => handleChange('description', value)}
rows={3}
placeholder="Description de la catégorie..."
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Active</span>
</label>
</div>
</div>
</Card>
<div className="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => router.push(`/admin/posts/${postType}/categories`)} disabled={saving}>
Annuler
</Button>
<Button type="submit" variant="success" loading={saving} disabled={saving}>
{saving ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</div>
</form>
)}
</div>
);
};
export default CategoryEditPage;
-183
View File
@@ -1,183 +0,0 @@
/**
* Posts Module - Categories CRUD
* Categories are scoped by post_type.
*/
import { query } from '@zen/core/database';
/**
* Create a new category for a post type.
* @param {string} postType
* @param {Object} categoryData
* @returns {Promise<Object>}
*/
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<Object|null>}
*/
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<Object>}
*/
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<Array>}
*/
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<Object>}
*/
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<Object>}
*/
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];
}
-136
View File
@@ -1,136 +0,0 @@
/**
* 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));
const isPublic = process.env[`ZEN_MODULE_POSTS_TYPE_${key.toUpperCase()}_PUBLIC`] === 'true';
types[key] = {
key,
label,
fields,
hasCategory,
hasRelations,
titleField,
slugField,
public: isPublic,
};
}
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;
}
-526
View File
@@ -1,526 +0,0 @@
/**
* 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 '@zen/core/database';
import { deleteFile } from '@zen/core/storage';
import { warn } from '../../shared/lib/logger.js';
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<Object>}
*/
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<Object>}
*/
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<Object|null>}
*/
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<Object|null>}
*/
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<Object>}
*/
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<Array>}
*/
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<Object>}
*/
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<boolean>}
*/
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) {
warn(`Posts: failed to delete image from storage: ${imageKey}${deleteResult.error}`);
}
} catch (err) {
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<Object|null>}
*/
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 };
}
-129
View File
@@ -1,129 +0,0 @@
/**
* Posts Module - Database
* Creates zen_posts and zen_posts_category tables.
*/
import { query, tableExists } from '@zen/core/database';
import { getPostsConfig } from './config.js';
import { done, info, step } from '../../shared/lib/logger.js';
async function createPostsCategoryTable() {
const tableName = 'zen_posts_category';
const exists = await tableExists(tableName);
if (exists) {
info(`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)`);
done(`Created table: ${tableName}`);
return { created: true, tableName };
}
async function createPostsTable() {
const tableName = 'zen_posts';
const exists = await tableExists(tableName);
if (exists) {
info(`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)`);
done(`Created table: ${tableName}`);
return { created: true, tableName };
}
async function createPostsRelationsTable() {
const tableName = 'zen_posts_relations';
const exists = await tableExists(tableName);
if (exists) {
info(`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)`);
done(`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<Object>}
*/
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
step('Posts Categories');
const catResult = await createPostsCategoryTable();
if (catResult.created) created.push(catResult.tableName);
else skipped.push(catResult.tableName);
step('Posts');
const postResult = await createPostsTable();
if (postResult.created) created.push(postResult.tableName);
else skipped.push(postResult.tableName);
if (needsRelations) {
step('Posts Relations');
const relResult = await createPostsRelationsTable();
if (relResult.created) created.push(relResult.tableName);
else skipped.push(relResult.tableName);
}
return { created, skipped };
}
-19
View File
@@ -1,19 +0,0 @@
# API d'administration — Module Posts
Authentification requise.
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/posts/config` | Config complète de tous les types |
| `GET` | `/zen/api/admin/posts/search?type={type}&q={query}` | Recherche pour le sélecteur de relation |
| `GET` | `/zen/api/admin/posts/posts?type={type}` | Liste des posts d'un type |
| `GET` | `/zen/api/admin/posts/posts?type={type}&withRelations=true` | Liste avec relations |
| `GET` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Post par ID (relations toujours incluses) |
| `POST` | `/zen/api/admin/posts/posts?type={type}` | Créer un post |
| `PUT` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Modifier un post |
| `DELETE` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Supprimer un post |
| `POST` | `/zen/api/admin/posts/upload-image` | Upload d'image (`multipart/form-data` : `file`, `type`) |
| `GET` | `/zen/api/admin/posts/categories?type={type}` | Liste des catégories |
| `POST` | `/zen/api/admin/posts/categories?type={type}` | Créer une catégorie |
| `PUT` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Modifier une catégorie |
| `DELETE` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Supprimer une catégorie |
-108
View File
@@ -1,108 +0,0 @@
# API publique — Module Posts
Pas d'authentification requise.
## Config
```
GET /zen/api/posts/config
```
Retourne la liste de tous les types configurés avec leurs champs.
---
## Liste de posts
```
GET /zen/api/posts/{type}
```
| Paramètre | Défaut | Description |
|---|---|---|
| `page` | `1` | Page courante |
| `limit` | `20` | Résultats par page |
| `category_id` | — | Filtrer par catégorie |
| `sortBy` | `created_at` | Trier par (nom de champ du type) |
| `sortOrder` | `DESC` | `ASC` ou `DESC` |
| `withRelations` | `false` | `true` pour inclure les champs relation |
`withRelations=true` exécute une requête SQL supplémentaire par post. Garder un `limit` raisonnable (20 maximum). Sur une page de détail, préférer `/posts/{type}/{slug}` qui charge toujours les relations.
**Réponse sans relations (défaut) :**
```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
}
```
**Réponse avec `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é" }
]
}
]
}
```
---
## Post par slug
```
GET /zen/api/posts/{type}/{slug}
```
Les relations sont toujours incluses sur un post individuel.
---
## Catégories
```
GET /zen/api/posts/{type}/categories
```
Retourne les catégories actives du type (pour alimenter un filtre).
---
## Images
Les clés d'image s'utilisent avec la route de stockage :
```jsx
<img src={`/zen/api/storage/${post.image}`} alt={post.title} />
```
-127
View File
@@ -1,127 +0,0 @@
# Intégration Next.js — Module Posts
> **Images** — Les images sont stockées sous `posts/{type}/{id}/{filename}` et servies via `/zen/api/storage/posts/{type}/...`. Pour qu'elles soient accessibles sans authentification (nécessaire pour l'affichage public), activer `ZEN_MODULE_POSTS_TYPE_{TYPE}_PUBLIC=true` dans le `.env`.
## Liste de posts
```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 (
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/actualites/${post.slug}`}>{post.title}</a>
<p>{post.resume}</p>
{post.image && <img src={`/zen/api/storage/${post.image}`} alt="" />}
</li>
))}
</ul>
);
}
```
---
## Liste avec 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&withRelations=true`
);
const { posts } = await res.json();
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/actualites/${post.slug}`}>{post.title}</a>
{post.source?.[0] && (
<span>Source : {post.source[0].title}</span>
)}
<div>
{post.tags?.map(tag => (
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
))}
</div>
</li>
))}
</ul>
);
}
```
---
## Page de détail
```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 (
<article>
<h1>{post.title}</h1>
<time dateTime={post.date}>
{new Date(post.date).toLocaleString('fr-FR')}
</time>
{post.source?.[0] && (
<p>Source : <a href={`/sources/${post.source[0].slug}`}>{post.source[0].title}</a></p>
)}
{post.tags?.length > 0 && (
<div>
{post.tags.map(tag => (
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
))}
</div>
)}
{post.image && <img src={`/zen/api/storage/${post.image}`} alt="" />}
<div>{post.content}</div>
</article>
);
}
```
---
## Métadonnées SEO dynamiques
```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}`] : [],
},
};
}
```
-114
View File
@@ -1,114 +0,0 @@
# Usage programmatique — Module Posts
Idéal pour les cron jobs, scripts d'import ou fetchers automatisés.
## Fonctions disponibles
```js
import {
createPost, // Créer un post
updatePost, // Modifier un post
getPostBySlug, // Chercher par slug
getPostByField, // Chercher par n'importe quel champ JSONB
upsertPost, // Créer ou mettre à jour (idempotent)
getPosts, // Liste avec pagination
deletePost, // Supprimer
} from '@zen/core/modules/posts/crud';
```
---
## `upsertPost(postType, rawData, uniqueField)`
Crée le post s'il n'existe pas, le met à jour sinon.
- `postType` : le type de post (`'cve'`, `'actualite'`...)
- `rawData` : les données du post (mêmes champs que pour `createPost`)
- `uniqueField` : le champ de déduplication (`'slug'` par défaut)
Retourne `{ post, created: boolean }`.
### Champs `relation` dans `rawData`
Les champs `relation` reçoivent un **tableau d'IDs** de posts existants.
```js
// Correct
{ tags: [7, 8, 12], source: [3] }
// Incorrect
{ tags: ['openssh', 'vuln'], source: { id: 3 } }
```
Si les posts liés n'existent pas encore, les créer d'abord avec `upsertPost` puis utiliser leurs IDs.
---
## Exemple : fetcher de CVE
```js
// src/cron/fetch-cves.js
import { upsertPost } from '@zen/core/modules/posts/crud';
export async function fetchAndImportCVEs() {
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 {
// Résoudre les relations : s'assurer que les tags existent
const tagIds = [];
for (const tagName of (cve.tags || [])) {
const { post: tag } = await upsertPost('tag', { title: tagName }, 'slug');
tagIds.push(tag.id);
}
// Upsert du CVE, dédupliqué sur cve_id
const { created } = await upsertPost('cve', {
title: cve.title,
cve_id: cve.id,
severity: cve.severity,
score: String(cve.cvssScore),
product: cve.affectedProduct,
date: cve.publishedAt,
description: cve.description,
tags: tagIds,
}, '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;
}
```
---
## Exemple : fetcher d'actualités avec source
```js
import { upsertPost } from '@zen/core/modules/posts/crud';
export async function fetchAndImportActualites(sourceName, articles) {
// S'assurer que la source existe
const { post: source } = await upsertPost('source', { title: sourceName }, 'slug');
for (const article of articles) {
await upsertPost('actualite', {
title: article.title,
date: article.publishedAt,
resume: article.summary,
content: article.content,
source: [source.id],
tags: [],
}, 'slug');
}
}
```
-108
View File
@@ -1,108 +0,0 @@
/**
* Posts Module Configuration
* Navigation and adminPages are generated dynamically from ZEN_MODULE_POSTS_TYPES env var.
*/
import { lazy } from 'react';
import { defineModule } from '../../core/modules/defineModule.js';
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, navigation and public storage prefixes dynamically from configured post types
const adminPages = {};
const navigationSections = [];
const storagePublicPrefixes = [];
for (const type of Object.values(postsConfig.types)) {
// Register public storage prefix for this type if marked public
if (type.public) {
storagePublicPrefixes.push(`posts/${type.key}`);
}
// 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 defineModule({
name: 'posts',
displayName: 'Posts',
version: '1.0.0',
description: 'Multi-type custom post system configurable via environment variables',
dependencies: [],
envVars: ['ZEN_MODULE_POSTS_TYPES'],
storagePublicPrefixes,
storageAccessPolicies: [{ prefix: 'posts', type: 'admin' }],
// 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: [],
});
+1 -19
View File
@@ -23,17 +23,6 @@ export default defineConfig([
'src/core/storage/index.js', 'src/core/storage/index.js',
'src/core/toast/index.js', 'src/core/toast/index.js',
'src/features/provider/index.js', 'src/features/provider/index.js',
'src/core/modules/index.js',
'src/core/modules/client.js',
'src/modules/index.js',
'src/modules/init.js',
'src/modules/pages.js',
'src/modules/modules.metadata.js',
// Module actions, API, CRUD and server-only config (bundled to resolve relative imports)
'src/modules/*/actions.js',
'src/modules/*/api.js',
'src/modules/*/crud.js',
'src/modules/*/config.js',
'src/shared/lib/metadata/index.js', 'src/shared/lib/metadata/index.js',
'src/shared/lib/logger.js', 'src/shared/lib/logger.js',
], ],
@@ -42,7 +31,7 @@ export default defineConfig([
splitting: false, splitting: false,
sourcemap: false, sourcemap: false,
clean: true, clean: true,
external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/api', '@zen/core/cron', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@zen/core/modules/actions', '@zen/core/modules/storage', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'], external: ['react', 'react-dom', 'next', 'pg', 'dotenv', 'dotenv/config', 'resend', '@react-email/components', 'node-cron', 'readline', 'crypto', 'url', 'fs', 'path', 'net', 'dns', 'tls', '@zen/core/api', '@zen/core/cron', '@zen/core/database', '@zen/core/email', '@zen/core/email/templates', '@zen/core/storage', '@zen/core/toast', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
noExternal: [], noExternal: [],
bundle: true, bundle: true,
banner: { banner: {
@@ -64,9 +53,6 @@ export default defineConfig([
'src/features/auth/page.js', 'src/features/auth/page.js',
'src/features/admin/page.js', 'src/features/admin/page.js',
'src/features/admin/navigation.server.js', 'src/features/admin/navigation.server.js',
'src/modules/page.js',
'src/modules/modules.actions.js',
'src/modules/modules.storage.js',
], ],
format: ['esm'], format: ['esm'],
dts: false, dts: false,
@@ -85,10 +71,6 @@ export default defineConfig([
'@zen/core/admin/actions', '@zen/core/admin/actions',
'@zen/core/admin/navigation', '@zen/core/admin/navigation',
'@zen/core/toast', '@zen/core/toast',
'@zen/core/core/modules',
'@zen/core/modules/pages',
'@zen/core/modules/actions',
'@zen/core/modules/metadata',
], ],
bundle: false, // Don't bundle these files bundle: false, // Don't bundle these files
esbuildOptions(options) { esbuildOptions(options) {