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:
+1
-1
@@ -4,7 +4,7 @@ Ce document couvre les conventions de code, les règles de sécurité et la proc
|
||||
|
||||
Pour les conventions de rédaction : [LANGUE.md](./dev/LANGUE.md) et [REDACTION.md](./dev/REDACTION.md).
|
||||
|
||||
Pour l'architecture partagée (modules, composants, icônes) : [ARCHITECTURE.md](./dev/ARCHITECTURE.md).
|
||||
Pour l'architecture partagée (composants, icônes) : [ARCHITECTURE.md](./dev/ARCHITECTURE.md).
|
||||
|
||||
Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@zen/core",
|
||||
"version": "1.3.13",
|
||||
"description": "Un CMS construit sur l'essentiel, rien de plus, rien de moins.",
|
||||
"description": "Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.hyko.cx/zen/core.git"
|
||||
@@ -115,36 +115,6 @@
|
||||
"./provider": {
|
||||
"import": "./dist/features/provider/index.js"
|
||||
},
|
||||
"./core/modules": {
|
||||
"import": "./dist/core/modules/index.js"
|
||||
},
|
||||
"./core/modules/client": {
|
||||
"import": "./dist/core/modules/client.js"
|
||||
},
|
||||
"./modules": {
|
||||
"import": "./dist/modules/index.js"
|
||||
},
|
||||
"./modules/define": {
|
||||
"import": "./dist/core/modules/defineModule.js"
|
||||
},
|
||||
"./modules/pages": {
|
||||
"import": "./dist/modules/pages.js"
|
||||
},
|
||||
"./modules/actions": {
|
||||
"import": "./dist/modules/modules.actions.js"
|
||||
},
|
||||
"./modules/storage": {
|
||||
"import": "./dist/modules/modules.storage.js"
|
||||
},
|
||||
"./modules/posts/crud": {
|
||||
"import": "./dist/modules/posts/crud.js"
|
||||
},
|
||||
"./modules/metadata": {
|
||||
"import": "./dist/modules/modules.metadata.js"
|
||||
},
|
||||
"./modules/page": {
|
||||
"import": "./dist/modules/page.js"
|
||||
},
|
||||
"./lib/metadata": {
|
||||
"import": "./dist/shared/lib/metadata/index.js"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
#################################
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' : ''} • {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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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} />
|
||||
```
|
||||
@@ -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}`] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
@@ -23,17 +23,6 @@ export default defineConfig([
|
||||
'src/core/storage/index.js',
|
||||
'src/core/toast/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/logger.js',
|
||||
],
|
||||
@@ -42,7 +31,7 @@ export default defineConfig([
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
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: [],
|
||||
bundle: true,
|
||||
banner: {
|
||||
@@ -64,9 +53,6 @@ export default defineConfig([
|
||||
'src/features/auth/page.js',
|
||||
'src/features/admin/page.js',
|
||||
'src/features/admin/navigation.server.js',
|
||||
'src/modules/page.js',
|
||||
'src/modules/modules.actions.js',
|
||||
'src/modules/modules.storage.js',
|
||||
],
|
||||
format: ['esm'],
|
||||
dts: false,
|
||||
@@ -85,10 +71,6 @@ export default defineConfig([
|
||||
'@zen/core/admin/actions',
|
||||
'@zen/core/admin/navigation',
|
||||
'@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
|
||||
esbuildOptions(options) {
|
||||
|
||||
Reference in New Issue
Block a user