chore: import codes
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
#################################
|
||||
# MODULE POSTS
|
||||
ZEN_MODULE_POSTS=true
|
||||
|
||||
# List of post types (pipe-separated, lowercase)
|
||||
# Optional display label: key:Label (e.g. actu:Actualités)
|
||||
ZEN_MODULE_ZEN_MODULE_POSTS_TYPES=blogue:Blogue|cve:CVE|emploi:Emplois
|
||||
|
||||
# Fields for each type: name:type|name:type|...
|
||||
# Supported field types: title, slug, text, markdown, date, category, image
|
||||
# Relation field: name:relation:target_post_type (e.g. keywords:relation:mots-cle)
|
||||
ZEN_MODULE_POSTS_TYPE_BLOGUE=title:title|slug:slug|category:category|date:date|resume:text|content:markdown|image:image
|
||||
ZEN_MODULE_POSTS_TYPE_CVE=title:title|slug:slug|cve_id:text|severity:text|description:markdown|date:date|keywords:relation:mots-cle
|
||||
ZEN_MODULE_POSTS_TYPE_EMPLOI=title:title|slug:slug|date:date|description:markdown
|
||||
ZEN_MODULE_POSTS_TYPE_MOTS-CLE=title:title|slug:slug
|
||||
#################################
|
||||
@@ -0,0 +1,547 @@
|
||||
# Posts Module
|
||||
|
||||
Configurable Custom Post Types via environment variables. Inspired by the WordPress CPT concept: each project declares its own content types (blog, CVE, job, event, etc.) with the fields it needs, without modifying code.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple post types** in a single module (blog, CVE, job...)
|
||||
- **Dynamic fields** per type: title, slug, text, markdown, date, datetime, color, category, image, relation
|
||||
- **Generic admin**: forms adapt automatically to the config
|
||||
- **Public API** per type for integration in a Next.js site
|
||||
- **Optional categories** per type (enabled if a `category` field is defined)
|
||||
- **Relations** between types (many-to-many, e.g. CVE → Tags)
|
||||
- **Unique slugs** per type (scoped: `blogue/mon-article` ≠ `cve/mon-article`)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Environment variables
|
||||
|
||||
Copy variables from [`.env.example`](.env.example) into your `.env`:
|
||||
|
||||
> If no label is provided (`ZEN_MODULE_POSTS_TYPES=blogue`), the display name will be the key with the first letter capitalized (`Blogue`).
|
||||
|
||||
**Optional (images):**
|
||||
|
||||
If one of your types uses the `image` field, configure Zen storage in your main `.env` (`ZEN_STORAGE_REGION`, `ZEN_STORAGE_ACCESS_KEY`, `ZEN_STORAGE_SECRET_KEY`, `ZEN_STORAGE_BUCKET`).
|
||||
|
||||
### 2. Available field types
|
||||
|
||||
| Type | `.env` syntax | Description |
|
||||
|---|---|---|
|
||||
| `title` | `name:title` | Main text field — auto-generates the slug |
|
||||
| `slug` | `name:slug` | Unique URL slug per type — auto-filled from title |
|
||||
| `text` | `name:text` | Free text area (textarea) |
|
||||
| `markdown` | `name:markdown` | Markdown editor with preview |
|
||||
| `date` | `name:date` | Date picker only (YYYY-MM-DD) |
|
||||
| `datetime` | `name:datetime` | Date **and time** picker (YYYY-MM-DDTHH:MM) |
|
||||
| `color` | `name:color` | Color picker — stores a hex code `#rrggbb` |
|
||||
| `category` | `name:category` | Dropdown linked to the category table |
|
||||
| `image` | `name:image` | Image upload to Zen storage |
|
||||
| `relation` | `name:relation:target_type` | Multi-select to posts of another type |
|
||||
|
||||
> **Rule:** each type must have at least one `title` field and one `slug` field. The `category` field automatically creates the `zen_posts_category` table. The `relation` field automatically creates the `zen_posts_relations` table.
|
||||
|
||||
#### `date` vs `datetime`
|
||||
|
||||
- `date` → stores `"2026-03-14"` — sufficient for blog posts, events
|
||||
- `datetime` → stores `"2026-03-14T10:30:00.000Z"` (ISO 8601, UTC) — needed for CVEs, security bulletins, precise schedules
|
||||
|
||||
### `relation` field — Linking posts together
|
||||
|
||||
The `relation` field associates multiple posts of another type (many-to-many). Example: news posts referencing a source and tags.
|
||||
|
||||
```bash
|
||||
ZEN_MODULE_POSTS_TYPE_ACTUALITE=title:title|slug:slug|date:datetime|resume:text|content:markdown|image:image|source:relation:source|tags:relation:tag
|
||||
ZEN_MODULE_POSTS_TYPE_TAG=title:title|slug:slug
|
||||
ZEN_MODULE_POSTS_TYPE_SOURCE=title:title|slug:slug
|
||||
```
|
||||
|
||||
- The name before `relation` (`source`, `tags`) is the field name in the form and in the API response
|
||||
- The value after `relation` (`source`, `tag`) is the target post type
|
||||
- Selection is **multiple** (multi-select with real-time search)
|
||||
- Relations are stored in `zen_posts_relations` (junction table)
|
||||
- In the API, relations are returned as an array of objects containing **all fields** of the linked post: `tags: [{ id, slug, title, color, ... }]`
|
||||
|
||||
### 3. Database tables
|
||||
|
||||
Tables are created automatically with `npx zen-db init`. For reference, here are the module tables:
|
||||
|
||||
| Table | Description |
|
||||
|---|---|
|
||||
| `zen_posts` | Posts (all types — custom fields in `data JSONB`) |
|
||||
| `zen_posts_category` | Categories per type (if a `category` field is defined) |
|
||||
| `zen_posts_relations` | Relations between posts (if a `relation` field is defined) |
|
||||
|
||||
> **Design:** all custom fields are stored in the `data JSONB` column. Adding or removing a field in `.env` requires no SQL migration.
|
||||
|
||||
---
|
||||
|
||||
## Admin interface
|
||||
|
||||
| Page | URL |
|
||||
|---|---|
|
||||
| Post list for a type | `/admin/posts/{type}/list` |
|
||||
| Create a post | `/admin/posts/{type}/new` |
|
||||
| Edit a post | `/admin/posts/{type}/edit/{id}` |
|
||||
| Category list for a type | `/admin/posts/{type}/categories` |
|
||||
| Create a category | `/admin/posts/{type}/categories/new` |
|
||||
| Edit a category | `/admin/posts/{type}/categories/edit/{id}` |
|
||||
|
||||
---
|
||||
|
||||
## Public API (no authentication)
|
||||
|
||||
### Config
|
||||
|
||||
```
|
||||
GET /zen/api/posts/config
|
||||
```
|
||||
|
||||
Returns the list of all configured types with their fields.
|
||||
|
||||
### Post list
|
||||
|
||||
```
|
||||
GET /zen/api/posts/{type}
|
||||
```
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `page` | `1` | Current page |
|
||||
| `limit` | `20` | Results per page |
|
||||
| `category_id` | — | Filter by category |
|
||||
| `sortBy` | `created_at` | Sort by (field name of the type) |
|
||||
| `sortOrder` | `DESC` | `ASC` or `DESC` |
|
||||
| `withRelations` | `false` | `true` to include relation fields in each post |
|
||||
|
||||
> **Performance:** `withRelations=true` runs an additional SQL query per post. Use with a reasonable `limit` (≤ 20). On a detail page, prefer `/posts/{type}/{slug}` which always loads relations.
|
||||
|
||||
**Response without `withRelations` (default):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"posts": [
|
||||
{
|
||||
"id": 1,
|
||||
"post_type": "actualite",
|
||||
"slug": "faille-critique-openssh",
|
||||
"title": "Faille critique dans OpenSSH",
|
||||
"date": "2026-03-14T10:30:00.000Z",
|
||||
"resume": "Une faille critique...",
|
||||
"image": "blog/1234567890-image.webp",
|
||||
"created_at": "2026-03-14T12:00:00Z",
|
||||
"updated_at": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"totalPages": 3,
|
||||
"page": 1,
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
**Response with `withRelations=true`:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"posts": [
|
||||
{
|
||||
"id": 1,
|
||||
"slug": "faille-critique-openssh",
|
||||
"title": "Faille critique dans OpenSSH",
|
||||
"date": "2026-03-14T10:30:00.000Z",
|
||||
"source": [
|
||||
{ "id": 3, "slug": "cert-fr", "post_type": "source", "title": "CERT-FR" }
|
||||
],
|
||||
"tags": [
|
||||
{ "id": 7, "slug": "openssh", "post_type": "tag", "title": "OpenSSH" },
|
||||
{ "id": 8, "slug": "vulnerabilite", "post_type": "tag", "title": "Vulnérabilité" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Single post by slug
|
||||
|
||||
```
|
||||
GET /zen/api/posts/{type}/{slug}
|
||||
```
|
||||
|
||||
Relations are **always included** on a single post.
|
||||
|
||||
**Response for a news post:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"post": {
|
||||
"id": 1,
|
||||
"post_type": "actualite",
|
||||
"slug": "faille-critique-openssh",
|
||||
"title": "Faille critique dans OpenSSH",
|
||||
"date": "2026-03-14T10:30:00.000Z",
|
||||
"resume": "Une faille critique a été découverte...",
|
||||
"content": "# Détails\n\n...",
|
||||
"image": "blog/1234567890-image.webp",
|
||||
|
||||
"source": [
|
||||
{ "id": 3, "slug": "cert-fr", "post_type": "source", "title": "CERT-FR" }
|
||||
],
|
||||
"tags": [
|
||||
{ "id": 7, "slug": "openssh", "post_type": "tag", "title": "OpenSSH" },
|
||||
{ "id": 8, "slug": "vulnerabilite", "post_type": "tag", "title": "Vulnérabilité" }
|
||||
],
|
||||
|
||||
"created_at": "2026-03-14T12:00:00Z",
|
||||
"updated_at": "2026-03-14T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Categories
|
||||
|
||||
```
|
||||
GET /zen/api/posts/{type}/categories
|
||||
```
|
||||
|
||||
Returns the list of active categories for the type (to populate a filter).
|
||||
|
||||
### Images
|
||||
|
||||
Image keys are built as follows: `/zen/api/storage/{image_field_value}`
|
||||
|
||||
```jsx
|
||||
<img src={`/zen/api/storage/${post.image}`} alt={post.title} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next.js integration examples
|
||||
|
||||
### News list (without relations)
|
||||
|
||||
```js
|
||||
// app/actualites/page.js
|
||||
export default async function ActualitesPage() {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&sortBy=date&sortOrder=DESC`
|
||||
);
|
||||
const { posts, total, totalPages } = await res.json();
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### News list with tags and source (withRelations)
|
||||
|
||||
```js
|
||||
// app/actualites/page.js
|
||||
export default async function ActualitesPage() {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&withRelations=true`
|
||||
);
|
||||
const { posts } = await res.json();
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<a href={`/actualites/${post.slug}`}>{post.title}</a>
|
||||
|
||||
{/* Source (array, usually 1 element) */}
|
||||
{post.source?.[0] && (
|
||||
<span>Source : {post.source[0].title}</span>
|
||||
)}
|
||||
|
||||
{/* Tags (array of 0..N elements) */}
|
||||
<div>
|
||||
{post.tags?.map(tag => (
|
||||
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### News detail page (relations always included)
|
||||
|
||||
```js
|
||||
// app/actualites/[slug]/page.js
|
||||
export default async function ActualiteDetailPage({ params }) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}`
|
||||
);
|
||||
const { post } = await res.json();
|
||||
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
|
||||
{/* datetime field: display with time */}
|
||||
<time dateTime={post.date}>
|
||||
{new Date(post.date).toLocaleString('fr-FR')}
|
||||
</time>
|
||||
|
||||
{/* Source — array even with a single element */}
|
||||
{post.source?.[0] && (
|
||||
<p>
|
||||
Source : <a href={`/sources/${post.source[0].slug}`}>{post.source[0].title}</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### CVE detail page
|
||||
|
||||
```js
|
||||
// app/cve/[slug]/page.js
|
||||
export default async function CVEDetailPage({ params }) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/cve/${params.slug}`
|
||||
);
|
||||
const { post } = await res.json();
|
||||
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
<p>ID : {post.cve_id}</p>
|
||||
<p>Sévérité : {post.severity} — Score : {post.score}</p>
|
||||
<p>Produit : {post.product}</p>
|
||||
|
||||
{/* Disclosure date and time */}
|
||||
<time dateTime={post.date}>
|
||||
{new Date(post.date).toLocaleString('fr-FR')}
|
||||
</time>
|
||||
|
||||
{/* Associated tags */}
|
||||
{post.tags?.length > 0 && (
|
||||
<div>
|
||||
{post.tags.map(tag => (
|
||||
<span key={tag.id}>{tag.title}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>{post.description}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic SEO metadata
|
||||
|
||||
```js
|
||||
// app/actualites/[slug]/page.js
|
||||
export async function generateMetadata({ params }) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}`
|
||||
);
|
||||
const { post } = await res.json();
|
||||
if (!post) return {};
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.resume,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.resume,
|
||||
images: post.image ? [`/zen/api/storage/${post.image}`] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a new post type
|
||||
|
||||
Edit `.env` only — no database restart needed:
|
||||
|
||||
```bash
|
||||
# Before
|
||||
ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités
|
||||
|
||||
# After — adding the 'evenement' type
|
||||
ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités|evenement:Événements
|
||||
ZEN_MODULE_POSTS_TYPE_EVENEMENT=title:title|slug:slug|date:datetime|location:text|description:markdown|image:image
|
||||
```
|
||||
|
||||
Restart the server. Tables are unchanged (new fields use the existing JSONB).
|
||||
|
||||
## Modifying fields on an existing type
|
||||
|
||||
Update the `ZEN_MODULE_POSTS_TYPE_*` variable and restart. Existing posts keep their data in JSONB even if a field is removed from the config — it simply won't appear in the form anymore.
|
||||
|
||||
---
|
||||
|
||||
## Programmatic usage (importers / fetchers)
|
||||
|
||||
CRUD functions are directly importable server-side. No need to go through the HTTP API — ideal for cron jobs, import scripts, or automated fetchers.
|
||||
|
||||
### Available functions
|
||||
|
||||
```js
|
||||
import {
|
||||
createPost, // Create a post
|
||||
updatePost, // Update a post
|
||||
getPostBySlug, // Find by slug
|
||||
getPostByField, // Find by any JSONB field
|
||||
upsertPost, // Create or update (idempotent, for importers)
|
||||
getPosts, // List with pagination
|
||||
deletePost, // Delete
|
||||
} from '@hykocx/zen/modules/posts/crud';
|
||||
```
|
||||
|
||||
### `upsertPost(postType, rawData, uniqueField)`
|
||||
|
||||
Key function for importers: creates the post if it doesn't exist, updates it otherwise.
|
||||
|
||||
- `postType`: the post type (`'cve'`, `'actualite'`, etc.)
|
||||
- `rawData`: post data (same fields as for `createPost`)
|
||||
- `uniqueField`: the deduplication key field (`'slug'` by default, or `'cve_id'`, etc.)
|
||||
|
||||
Returns `{ post, created: boolean }`.
|
||||
|
||||
### Example — CVE fetcher (cron job)
|
||||
|
||||
```js
|
||||
// src/cron/fetch-cves.js
|
||||
import { upsertPost } from '@hykocx/zen/modules/posts/crud';
|
||||
|
||||
export async function fetchAndImportCVEs() {
|
||||
// 1. Fetch data from an external source
|
||||
const response = await fetch('https://api.example.com/cves/recent');
|
||||
const { cves } = await response.json();
|
||||
|
||||
const results = { created: 0, updated: 0, errors: 0 };
|
||||
|
||||
for (const cve of cves) {
|
||||
try {
|
||||
// 2. Resolve relations — ensure tags exist
|
||||
const tagIds = [];
|
||||
for (const tagName of (cve.tags || [])) {
|
||||
const { post: tag } = await upsertPost('tag', { title: tagName }, 'slug');
|
||||
tagIds.push(tag.id);
|
||||
}
|
||||
|
||||
// 3. Upsert the CVE (deduplicated on cve_id)
|
||||
const { created } = await upsertPost('cve', {
|
||||
title: cve.title, // title field
|
||||
cve_id: cve.id, // text field
|
||||
severity: cve.severity, // text field
|
||||
score: String(cve.cvssScore), // text field
|
||||
product: cve.affectedProduct, // text field
|
||||
date: cve.publishedAt, // datetime field (ISO 8601)
|
||||
description: cve.description, // markdown field
|
||||
tags: tagIds, // relation:tag field — array of IDs
|
||||
}, 'cve_id'); // deduplicate on cve_id
|
||||
|
||||
created ? results.created++ : results.updated++;
|
||||
} catch (err) {
|
||||
console.error(`[CVE import] Error for ${cve.id}:`, err.message);
|
||||
results.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CVE import] Done — created: ${results.created}, updated: ${results.updated}, errors: ${results.errors}`);
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
### Example — News fetcher with source
|
||||
|
||||
```js
|
||||
import { upsertPost, getPostByField } from '@hykocx/zen/modules/posts/crud';
|
||||
|
||||
export async function fetchAndImportActualites(sourceName, sourceUrl, articles) {
|
||||
// 1. Ensure the source exists
|
||||
const { post: source } = await upsertPost('source', {
|
||||
title: sourceName,
|
||||
color: '#3b82f6',
|
||||
}, 'slug');
|
||||
|
||||
for (const article of articles) {
|
||||
await upsertPost('actualite', {
|
||||
title: article.title,
|
||||
date: article.publishedAt,
|
||||
resume: article.summary,
|
||||
content: article.content,
|
||||
source: [source.id], // relation:source — array of IDs
|
||||
tags: [], // relation:tag
|
||||
}, 'slug');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rules for relation fields in `rawData`
|
||||
|
||||
`relation` fields must receive an **array of IDs** of existing posts:
|
||||
|
||||
```js
|
||||
// Correct
|
||||
{ tags: [7, 8, 12], source: [3] }
|
||||
|
||||
// Incorrect — no slugs or objects
|
||||
{ tags: ['openssh', 'vuln'], source: { id: 3 } }
|
||||
```
|
||||
|
||||
If the linked posts don't exist yet, create them first with `upsertPost` then use their IDs.
|
||||
|
||||
---
|
||||
|
||||
## Admin API (authentication required)
|
||||
|
||||
These routes require an active admin session.
|
||||
|
||||
| Method | Route | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/zen/api/admin/posts/config` | Full config for all types |
|
||||
| `GET` | `/zen/api/admin/posts/search?type={type}&q={query}` | Search posts for the relation picker |
|
||||
| `GET` | `/zen/api/admin/posts/posts?type={type}` | Post list for a type |
|
||||
| `GET` | `/zen/api/admin/posts/posts?type={type}&withRelations=true` | List with relations included |
|
||||
| `GET` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Post by ID (relations always included) |
|
||||
| `POST` | `/zen/api/admin/posts/posts?type={type}` | Create a post |
|
||||
| `PUT` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Update a post |
|
||||
| `DELETE` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Delete a post |
|
||||
| `POST` | `/zen/api/admin/posts/upload-image` | Upload an image |
|
||||
| `GET` | `/zen/api/admin/posts/categories?type={type}` | Category list |
|
||||
| `POST` | `/zen/api/admin/posts/categories?type={type}` | Create a category |
|
||||
| `PUT` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Update a category |
|
||||
| `DELETE` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Delete a category |
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Button, Card } from '../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
import { getTodayString } from '../../../shared/lib/dates.js';
|
||||
import PostFormFields from './PostFormFields.js';
|
||||
|
||||
function slugifyTitle(title) {
|
||||
if (!title || typeof title !== 'string') return '';
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function getPostTypeFromPath(pathname) {
|
||||
const segments = (pathname || '').split('/').filter(Boolean);
|
||||
// /admin/posts/{type}/new → segments[2]
|
||||
return segments[2] || '';
|
||||
}
|
||||
|
||||
const PostCreatePage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const toast = useToast();
|
||||
|
||||
const postType = getPostTypeFromPath(pathname);
|
||||
|
||||
const [typeConfig, setTypeConfig] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
// Only sync title → slug when title content changes (not when slug is cleared)
|
||||
useEffect(() => {
|
||||
if (!typeConfig || slugTouched) return;
|
||||
const titleField = typeConfig.titleField;
|
||||
const slugField = typeConfig.slugField;
|
||||
if (titleField && slugField && formData[titleField]) {
|
||||
setFormData(prev => ({ ...prev, [slugField]: slugifyTitle(prev[titleField]) }));
|
||||
}
|
||||
}, [formData[typeConfig?.titleField], typeConfig]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/zen/api/admin/posts/config', { credentials: 'include' });
|
||||
const data = await response.json();
|
||||
if (data.success && data.config.types[postType]) {
|
||||
const config = data.config.types[postType];
|
||||
setTypeConfig(config);
|
||||
|
||||
// Initialize form data with defaults
|
||||
const defaults = {};
|
||||
for (const field of config.fields) {
|
||||
if (field.type === 'date') defaults[field.name] = getTodayString();
|
||||
else if (field.type === 'relation') defaults[field.name] = [];
|
||||
else defaults[field.name] = '';
|
||||
}
|
||||
setFormData(defaults);
|
||||
|
||||
if (config.hasCategory) loadCategories();
|
||||
} else {
|
||||
toast.error('Type de post introuvable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading config:', error);
|
||||
toast.error('Impossible de charger la configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/zen/api/admin/posts/categories?type=${postType}&limit=1000&is_active=true`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) setCategories(data.categories || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (field === typeConfig?.slugField) setSlugTouched(true);
|
||||
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
||||
};
|
||||
|
||||
const handleImageChange = async (fieldName, e) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const response = await fetch('/zen/api/admin/posts/upload-image', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.key) {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
|
||||
toast.success('Image téléchargée');
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du téléchargement');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
toast.error('Échec du téléchargement');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (typeConfig?.titleField && !formData[typeConfig.titleField]?.trim()) {
|
||||
newErrors[typeConfig.titleField] = 'Ce champ est requis';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = { ...formData };
|
||||
// Convert category to integer or null
|
||||
if (typeConfig?.hasCategory) {
|
||||
const catField = typeConfig.fields.find(f => f.type === 'category');
|
||||
if (catField) {
|
||||
payload[catField.name] = payload[catField.name] ? parseInt(payload[catField.name]) : null;
|
||||
}
|
||||
}
|
||||
// Convert relation fields to arrays of IDs
|
||||
for (const field of (typeConfig?.fields || []).filter(f => f.type === 'relation')) {
|
||||
const items = payload[field.name];
|
||||
payload[field.name] = Array.isArray(items) ? items.map(i => (typeof i === 'object' ? i.id : i)) : [];
|
||||
}
|
||||
// Convert datetime fields to ISO 8601 UTC
|
||||
for (const field of (typeConfig?.fields || []).filter(f => f.type === 'datetime')) {
|
||||
const val = payload[field.name];
|
||||
if (val && !val.includes('Z')) payload[field.name] = val + ':00.000Z';
|
||||
}
|
||||
|
||||
const response = await fetch(`/zen/api/admin/posts/posts?type=${postType}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Post créé avec succès');
|
||||
router.push(`/admin/posts/${postType}/list`);
|
||||
} else {
|
||||
toast.error(data.error || data.message || 'Échec de la création');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating post:', error);
|
||||
toast.error('Échec de la création');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const label = typeConfig?.label || capitalize(postType);
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Button, Card } from '../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
import { formatDateForInput, formatDateTimeForInput } from '../../../shared/lib/dates.js';
|
||||
import PostFormFields from './PostFormFields.js';
|
||||
|
||||
function slugifyTitle(title) {
|
||||
if (!title || typeof title !== 'string') return '';
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function getParamsFromPath(pathname) {
|
||||
const segments = (pathname || '').split('/').filter(Boolean);
|
||||
// /admin/posts/{type}/edit/{id} → segments[2], segments[4]
|
||||
return { postType: segments[2] || '', postId: segments[4] || '' };
|
||||
}
|
||||
|
||||
const PostEditPage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const toast = useToast();
|
||||
|
||||
const { postType, postId } = getParamsFromPath(pathname);
|
||||
|
||||
const [typeConfig, setTypeConfig] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (postType && postId) loadConfig();
|
||||
}, [postType, postId]);
|
||||
|
||||
// Only sync title → slug when title content changes (not when slug is cleared)
|
||||
useEffect(() => {
|
||||
if (!typeConfig || slugTouched) return;
|
||||
const titleField = typeConfig.titleField;
|
||||
const slugField = typeConfig.slugField;
|
||||
if (titleField && slugField && formData[titleField]) {
|
||||
setFormData(prev => ({ ...prev, [slugField]: slugifyTitle(prev[titleField]) }));
|
||||
}
|
||||
}, [formData[typeConfig?.titleField], typeConfig]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const [configRes, postRes] = await Promise.all([
|
||||
fetch('/zen/api/admin/posts/config', { credentials: 'include' }),
|
||||
fetch(`/zen/api/admin/posts/posts?type=${postType}&id=${postId}`, { credentials: 'include' })
|
||||
]);
|
||||
|
||||
const configData = await configRes.json();
|
||||
const postData = await postRes.json();
|
||||
|
||||
if (!configData.success || !configData.config.types[postType]) {
|
||||
toast.error('Type de post introuvable');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = configData.config.types[postType];
|
||||
setTypeConfig(config);
|
||||
|
||||
if (!postData.success || !postData.post) {
|
||||
toast.error('Post introuvable');
|
||||
router.push(`/admin/posts/${postType}/list`);
|
||||
return;
|
||||
}
|
||||
|
||||
const post = postData.post;
|
||||
|
||||
// Populate form data from post
|
||||
const initial = {};
|
||||
for (const field of config.fields) {
|
||||
if (field.type === 'slug') {
|
||||
initial[field.name] = post.slug || '';
|
||||
} else if (field.type === 'category') {
|
||||
initial[field.name] = post.category_id ? String(post.category_id) : '';
|
||||
} else if (field.type === 'date') {
|
||||
initial[field.name] = post[field.name] ? formatDateForInput(post[field.name]) : '';
|
||||
} else if (field.type === 'datetime') {
|
||||
initial[field.name] = post[field.name] ? formatDateTimeForInput(post[field.name]) : '';
|
||||
} else if (field.type === 'relation') {
|
||||
// Relations come as [{ id, title, slug }] from getPostById
|
||||
initial[field.name] = Array.isArray(post[field.name]) ? post[field.name] : [];
|
||||
} else {
|
||||
initial[field.name] = post[field.name] || '';
|
||||
}
|
||||
}
|
||||
setFormData(initial);
|
||||
setSlugTouched(true); // Don't auto-generate slug on edit
|
||||
|
||||
if (config.hasCategory) loadCategories();
|
||||
} catch (error) {
|
||||
console.error('Error loading post:', error);
|
||||
toast.error('Impossible de charger le post');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/zen/api/admin/posts/categories?type=${postType}&limit=1000&is_active=true`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) setCategories(data.categories || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (field === typeConfig?.slugField) setSlugTouched(value !== '');
|
||||
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
||||
};
|
||||
|
||||
const handleImageChange = async (fieldName, e) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const response = await fetch('/zen/api/admin/posts/upload-image', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.key) {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: data.key }));
|
||||
toast.success('Image téléchargée');
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du téléchargement');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
toast.error('Échec du téléchargement');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (typeConfig?.titleField && !formData[typeConfig.titleField]?.trim()) {
|
||||
newErrors[typeConfig.titleField] = 'Ce champ est requis';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = { ...formData };
|
||||
if (typeConfig?.hasCategory) {
|
||||
const catField = typeConfig.fields.find(f => f.type === 'category');
|
||||
if (catField) {
|
||||
payload[catField.name] = payload[catField.name] ? parseInt(payload[catField.name]) : null;
|
||||
}
|
||||
}
|
||||
// Convert relation fields to arrays of IDs
|
||||
for (const field of (typeConfig?.fields || []).filter(f => f.type === 'relation')) {
|
||||
const items = payload[field.name];
|
||||
payload[field.name] = Array.isArray(items) ? items.map(i => (typeof i === 'object' ? i.id : i)) : [];
|
||||
}
|
||||
// Convert datetime fields to ISO 8601 UTC
|
||||
for (const field of (typeConfig?.fields || []).filter(f => f.type === 'datetime')) {
|
||||
const val = payload[field.name];
|
||||
if (val && !val.includes('Z')) payload[field.name] = val + ':00.000Z';
|
||||
}
|
||||
|
||||
const response = await fetch(`/zen/api/admin/posts/posts?type=${postType}&id=${postId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Post mis à jour avec succès');
|
||||
router.push(`/admin/posts/${postType}/list`);
|
||||
} else {
|
||||
toast.error(data.error || data.message || 'Échec de la mise à jour');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating post:', error);
|
||||
toast.error('Échec de la mise à jour');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const label = typeConfig?.label || capitalize(postType);
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -0,0 +1,359 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Input, Select, Textarea, MarkdownEditor } from '../../../shared/components';
|
||||
|
||||
/**
|
||||
* Dynamic field renderer for post forms.
|
||||
*
|
||||
* Relation fields expect formData[fieldName] = [{ id, title }]
|
||||
* (array of objects for display, converted to IDs on submit by the parent).
|
||||
*/
|
||||
const PostFormFields = ({
|
||||
fields = [],
|
||||
formData = {},
|
||||
onChange,
|
||||
errors = {},
|
||||
slugValue,
|
||||
onSlugFocus,
|
||||
categories = [],
|
||||
uploading = false,
|
||||
onImageChange,
|
||||
}) => {
|
||||
return (
|
||||
<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;
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Book02Icon, Layers01Icon } from '../../../shared/Icons.js';
|
||||
import { Card, Button } from '../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
/**
|
||||
* Posts index page — shows all configured post types.
|
||||
* The user selects a type to navigate to its list.
|
||||
*/
|
||||
const PostsIndexPage = () => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/zen/api/admin/posts/config', { credentials: 'include' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setConfig(data.config);
|
||||
} else {
|
||||
toast.error('Impossible de charger la configuration des posts');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading posts config:', error);
|
||||
toast.error('Impossible de charger la configuration des posts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const types = config ? Object.values(config.types) : [];
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon, Layers01Icon } from '../../../shared/Icons.js';
|
||||
import { Table, Button, Card, Pagination } from '../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from '../../../shared/lib/dates.js';
|
||||
|
||||
/**
|
||||
* Generic posts list page.
|
||||
* Reads postType from the URL path: /admin/posts/{type}/list
|
||||
*/
|
||||
const PostsListPage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const toast = useToast();
|
||||
|
||||
const postType = getPostTypeFromPath(pathname);
|
||||
|
||||
const [typeConfig, setTypeConfig] = useState(null);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [sortBy, setSortBy] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState('desc');
|
||||
|
||||
useEffect(() => {
|
||||
setTypeConfig(null);
|
||||
setPosts([]);
|
||||
setPagination({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
loadConfig();
|
||||
}, [postType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeConfig) loadPosts();
|
||||
}, [typeConfig, sortBy, sortOrder, pagination.page, pagination.limit]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/zen/api/admin/posts/config', { credentials: 'include' });
|
||||
const data = await response.json();
|
||||
if (data.success && data.config.types[postType]) {
|
||||
setTypeConfig(data.config.types[postType]);
|
||||
} else {
|
||||
toast.error('Type de post introuvable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading config:', error);
|
||||
toast.error('Impossible de charger la configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const searchParams = new URLSearchParams({
|
||||
type: postType,
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
const response = await fetch(`/zen/api/admin/posts/posts?${searchParams}`, { credentials: 'include' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setPosts(data.posts || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.total || 0,
|
||||
totalPages: data.totalPages || 0,
|
||||
page: data.page || 1
|
||||
}));
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du chargement des posts');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading posts:', error);
|
||||
toast.error('Échec du chargement des posts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePost = async (post) => {
|
||||
const titleField = typeConfig?.titleField;
|
||||
const title = titleField ? post[titleField] : `#${post.id}`;
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer "${title}" ?`)) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
const response = await fetch(`/zen/api/admin/posts/posts?type=${postType}&id=${post.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
toast.success('Post supprimé avec succès');
|
||||
loadPosts();
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la suppression');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting post:', error);
|
||||
toast.error('Échec de la suppression');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildColumns = () => {
|
||||
if (!typeConfig) return [];
|
||||
|
||||
const cols = [];
|
||||
|
||||
for (const field of typeConfig.fields) {
|
||||
if (field.type === 'slug') continue; // shown under title
|
||||
if (field.type === 'markdown') continue; // body content — edit page only
|
||||
|
||||
if (field.type === 'title') {
|
||||
cols.push({
|
||||
key: field.name,
|
||||
label: capitalize(field.name),
|
||||
sortable: true,
|
||||
render: (post) => (
|
||||
<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;
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Posts Module - API Routes
|
||||
*/
|
||||
|
||||
import {
|
||||
createPost,
|
||||
getPostById,
|
||||
getPostBySlug,
|
||||
getPosts,
|
||||
searchPosts,
|
||||
updatePost,
|
||||
deletePost
|
||||
} from './crud.js';
|
||||
|
||||
import {
|
||||
createCategory,
|
||||
getCategoryById,
|
||||
getCategories,
|
||||
getActiveCategories,
|
||||
updateCategory,
|
||||
deleteCategory
|
||||
} from './categories/crud.js';
|
||||
|
||||
import {
|
||||
uploadImage,
|
||||
deleteFile,
|
||||
generateBlogFilePath,
|
||||
generateUniqueFilename,
|
||||
validateUpload,
|
||||
FILE_TYPE_PRESETS,
|
||||
FILE_SIZE_LIMITS
|
||||
} from '@hykocx/zen/storage';
|
||||
|
||||
import { getPostsConfig, getPostType } from './config.js';
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
// ============================================================================
|
||||
|
||||
async function handleGetConfig() {
|
||||
try {
|
||||
const config = getPostsConfig();
|
||||
return { success: true, config };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message || 'Failed to get config' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Posts (admin)
|
||||
// ============================================================================
|
||||
|
||||
async function handleGetPosts(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
||||
|
||||
const id = url.searchParams.get('id');
|
||||
if (id) {
|
||||
const post = await getPostById(postType, parseInt(id));
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
return { success: true, post };
|
||||
}
|
||||
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
||||
const search = url.searchParams.get('search') || '';
|
||||
const category_id = url.searchParams.get('category_id') || null;
|
||||
const sortBy = url.searchParams.get('sortBy') || 'created_at';
|
||||
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
|
||||
const withRelations = url.searchParams.get('withRelations') === 'true';
|
||||
|
||||
const result = await getPosts(postType, {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
category_id: category_id ? parseInt(category_id) : null,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
withRelations
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
posts: result.posts,
|
||||
total: result.pagination.total,
|
||||
totalPages: result.pagination.totalPages,
|
||||
page: result.pagination.page,
|
||||
limit: result.pagination.limit
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error GET posts:', error);
|
||||
return { success: false, error: error.message || 'Failed to fetch posts' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePost(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
|
||||
const body = await request.json();
|
||||
const postData = body.post || body;
|
||||
if (!postData || Object.keys(postData).length === 0) {
|
||||
return { success: false, error: 'Post data is required' };
|
||||
}
|
||||
|
||||
const post = await createPost(postType, postData);
|
||||
return { success: true, post, message: 'Post created successfully' };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error creating post:', error);
|
||||
return { success: false, error: error.message || 'Failed to create post' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdatePost(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const body = await request.json();
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id') || body.id;
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Post ID is required' };
|
||||
|
||||
const updates = body.post || (({ id: _i, ...rest }) => rest)(body);
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'Update data is required' };
|
||||
}
|
||||
|
||||
const typeConfig = getPostType(postType);
|
||||
|
||||
// Handle old image cleanup
|
||||
const existing = await getPostById(postType, parseInt(id));
|
||||
if (!existing) return { success: false, error: 'Post not found' };
|
||||
|
||||
const post = await updatePost(postType, parseInt(id), updates);
|
||||
|
||||
// Clean up replaced images
|
||||
if (typeConfig) {
|
||||
for (const field of typeConfig.fields.filter(f => f.type === 'image')) {
|
||||
const oldKey = existing._data?.[field.name] || null;
|
||||
const newKey = updates[field.name];
|
||||
if (oldKey && newKey !== undefined && newKey !== oldKey) {
|
||||
try {
|
||||
await deleteFile(oldKey);
|
||||
} catch (err) {
|
||||
console.warn(`[Posts] Error deleting old image ${oldKey}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, post, message: 'Post updated successfully' };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error updating post:', error);
|
||||
return { success: false, error: error.message || 'Failed to update post' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePost(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id');
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Post ID is required' };
|
||||
|
||||
const deleted = await deletePost(postType, parseInt(id));
|
||||
if (!deleted) return { success: false, error: 'Post not found' };
|
||||
return { success: true, message: 'Post deleted successfully' };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error deleting post:', error);
|
||||
return { success: false, error: 'Failed to delete post' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Image upload (admin)
|
||||
// ============================================================================
|
||||
|
||||
async function handleUploadImage(request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!file) return { success: false, error: 'No file provided' };
|
||||
|
||||
const validation = validateUpload({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||
maxSize: FILE_SIZE_LIMITS.IMAGE
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.errors.join(', ') };
|
||||
}
|
||||
|
||||
const uniqueFilename = generateUniqueFilename(file.name);
|
||||
const key = generateBlogFilePath(Date.now(), uniqueFilename);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const uploadResult = await uploadImage({
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
metadata: { originalName: file.name }
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error || 'Upload failed' };
|
||||
}
|
||||
|
||||
return { success: true, key: uploadResult.data.key };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error uploading image:', error);
|
||||
return { success: false, error: error.message || 'Upload failed' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Categories (admin)
|
||||
// ============================================================================
|
||||
|
||||
async function handleGetCategories(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
|
||||
const id = url.searchParams.get('id');
|
||||
if (id) {
|
||||
const category = await getCategoryById(postType, parseInt(id));
|
||||
if (!category) return { success: false, error: 'Category not found' };
|
||||
return { success: true, category };
|
||||
}
|
||||
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
||||
const search = url.searchParams.get('search') || '';
|
||||
const is_active = url.searchParams.get('is_active');
|
||||
const sortBy = url.searchParams.get('sortBy') || 'title';
|
||||
const sortOrder = url.searchParams.get('sortOrder') || 'ASC';
|
||||
|
||||
const result = await getCategories(postType, {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
is_active: is_active === 'true' ? true : is_active === 'false' ? false : null,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
categories: result.categories,
|
||||
total: result.pagination.total,
|
||||
totalPages: result.pagination.totalPages,
|
||||
page: result.pagination.page,
|
||||
limit: result.pagination.limit
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error GET categories:', error);
|
||||
return { success: false, error: error.message || 'Failed to fetch categories' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateCategory(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
|
||||
const body = await request.json();
|
||||
const categoryData = body.category || body;
|
||||
if (!categoryData || Object.keys(categoryData).length === 0) {
|
||||
return { success: false, error: 'Category data is required' };
|
||||
}
|
||||
|
||||
const category = await createCategory(postType, categoryData);
|
||||
return { success: true, category, message: 'Category created successfully' };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error creating category:', error);
|
||||
return { success: false, error: error.message || 'Failed to create category' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateCategory(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const body = await request.json();
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id') || body.id;
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Category ID is required' };
|
||||
|
||||
const updates = body.category || (({ id: _i, ...rest }) => rest)(body);
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'Update data is required' };
|
||||
}
|
||||
|
||||
const existing = await getCategoryById(postType, parseInt(id));
|
||||
if (!existing) return { success: false, error: 'Category not found' };
|
||||
|
||||
const category = await updateCategory(postType, parseInt(id), updates);
|
||||
return { success: true, category, message: 'Category updated successfully' };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error updating category:', error);
|
||||
return { success: false, error: error.message || 'Failed to update category' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCategory(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
const id = url.searchParams.get('id');
|
||||
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!id) return { success: false, error: 'Category ID is required' };
|
||||
|
||||
await deleteCategory(postType, parseInt(id));
|
||||
return { success: true, message: 'Category deleted successfully' };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error deleting category:', error);
|
||||
if (error.message.includes('Cannot delete')) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
return { success: false, error: 'Failed to delete category' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Relation search (admin — used by RelationSelector picker)
|
||||
// ============================================================================
|
||||
|
||||
async function handleSearchPosts(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const postType = url.searchParams.get('type');
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
||||
|
||||
const q = url.searchParams.get('q') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
||||
|
||||
const posts = await searchPosts(postType, q, limit);
|
||||
return { success: true, posts };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error searching posts:', error);
|
||||
return { success: false, error: error.message || 'Failed to search posts' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
async function handlePublicGetConfig() {
|
||||
try {
|
||||
const config = getPostsConfig();
|
||||
return { success: true, config };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message || 'Failed to get config' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublicGetPosts(request, params) {
|
||||
try {
|
||||
const postType = params?.type;
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
if (!getPostType(postType)) return { success: false, error: `Unknown post type: ${postType}` };
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
||||
const category_id = url.searchParams.get('category_id') || null;
|
||||
const sortBy = url.searchParams.get('sortBy') || 'created_at';
|
||||
const sortOrder = (url.searchParams.get('sortOrder') || 'DESC').toUpperCase();
|
||||
const withRelations = url.searchParams.get('withRelations') === 'true';
|
||||
|
||||
const result = await getPosts(postType, {
|
||||
page,
|
||||
limit,
|
||||
category_id: category_id ? parseInt(category_id) : null,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
withRelations
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
posts: result.posts,
|
||||
total: result.pagination.total,
|
||||
totalPages: result.pagination.totalPages,
|
||||
page: result.pagination.page,
|
||||
limit: result.pagination.limit
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error public GET posts:', error);
|
||||
return { success: false, error: error.message || 'Failed to fetch posts' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublicGetPostBySlug(request, params) {
|
||||
try {
|
||||
const postType = params?.type;
|
||||
const slug = params?.slug;
|
||||
if (!postType || !slug) return { success: false, error: 'Post type and slug are required' };
|
||||
|
||||
const post = await getPostBySlug(postType, slug);
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
return { success: true, post };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error public GET post by slug:', error);
|
||||
return { success: false, error: error.message || 'Failed to fetch post' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublicGetCategories(request, params) {
|
||||
try {
|
||||
const postType = params?.type;
|
||||
if (!postType) return { success: false, error: 'Post type is required' };
|
||||
|
||||
const categories = await getActiveCategories(postType);
|
||||
return { success: true, categories };
|
||||
} catch (error) {
|
||||
console.error('[Posts] Error public GET categories:', error);
|
||||
return { success: false, error: error.message || 'Failed to fetch categories' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Route Definitions
|
||||
// ============================================================================
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
// Admin config
|
||||
{ path: '/admin/posts/config', method: 'GET', handler: handleGetConfig, auth: 'admin' },
|
||||
|
||||
// Admin posts
|
||||
{ path: '/admin/posts/posts', method: 'GET', handler: handleGetPosts, auth: 'admin' },
|
||||
{ path: '/admin/posts/posts', method: 'POST', handler: handleCreatePost, auth: 'admin' },
|
||||
{ path: '/admin/posts/posts', method: 'PUT', handler: handleUpdatePost, auth: 'admin' },
|
||||
{ path: '/admin/posts/posts', method: 'DELETE', handler: handleDeletePost, auth: 'admin' },
|
||||
|
||||
// Admin image upload
|
||||
{ path: '/admin/posts/upload-image', method: 'POST', handler: handleUploadImage, auth: 'admin' },
|
||||
|
||||
// Admin relation search (for RelationSelector picker)
|
||||
{ path: '/admin/posts/search', method: 'GET', handler: handleSearchPosts, auth: 'admin' },
|
||||
|
||||
// Admin categories
|
||||
{ path: '/admin/posts/categories', method: 'GET', handler: handleGetCategories, auth: 'admin' },
|
||||
{ path: '/admin/posts/categories', method: 'POST', handler: handleCreateCategory, auth: 'admin' },
|
||||
{ path: '/admin/posts/categories', method: 'PUT', handler: handleUpdateCategory, auth: 'admin' },
|
||||
{ path: '/admin/posts/categories', method: 'DELETE', handler: handleDeleteCategory, auth: 'admin' },
|
||||
|
||||
// Public
|
||||
{ path: '/posts/config', method: 'GET', handler: handlePublicGetConfig, auth: 'public' },
|
||||
{ path: '/posts/:type', method: 'GET', handler: handlePublicGetPosts, auth: 'public' },
|
||||
{ path: '/posts/:type/:slug', method: 'GET', handler: handlePublicGetPostBySlug, auth: 'public' },
|
||||
{ path: '/posts/:type/categories', method: 'GET', handler: handlePublicGetCategories, auth: 'public' },
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon } from '../../../../shared/Icons.js';
|
||||
import { Table, Button, StatusBadge, Card, Pagination } from '../../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
function getPostTypeFromPath(pathname) {
|
||||
const segments = (pathname || '').split('/').filter(Boolean);
|
||||
// /admin/posts/{type}/categories → segments[2]
|
||||
return segments[2] || '';
|
||||
}
|
||||
|
||||
const CategoriesListPage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const toast = useToast();
|
||||
|
||||
const postType = getPostTypeFromPath(pathname);
|
||||
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [sortBy, setSortBy] = useState('title');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Titre',
|
||||
sortable: true,
|
||||
render: (cat) => <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.error || 'Échec du chargement des catégories');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
toast.error('Échec du chargement des catégories');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCategory = async (category) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer la catégorie "${category.title}" ?`)) return;
|
||||
try {
|
||||
setDeleting(true);
|
||||
const response = await fetch(`/zen/api/admin/posts/categories?type=${postType}&id=${category.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
toast.success('Catégorie supprimée avec succès');
|
||||
loadCategories();
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la suppression de la catégorie');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
toast.error('Échec de la suppression de la catégorie');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Button, Card, Input, Textarea } from '../../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
function getPostTypeFromPath(pathname) {
|
||||
const segments = (pathname || '').split('/').filter(Boolean);
|
||||
// /admin/posts/{type}/categories/new → segments[2]
|
||||
return segments[2] || '';
|
||||
}
|
||||
|
||||
const CategoryCreatePage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const toast = useToast();
|
||||
|
||||
const postType = getPostTypeFromPath(pathname);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState({ title: '', description: '', is_active: true });
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.title.trim()) newErrors.title = 'Le titre est requis';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await fetch(`/zen/api/admin/posts/categories?type=${postType}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Catégorie créée avec succès');
|
||||
router.push(`/admin/posts/${postType}/categories`);
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la création');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating category:', error);
|
||||
toast.error('Échec de la création');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Button, Card, Input, Textarea } from '../../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
function getParamsFromPath(pathname) {
|
||||
const segments = (pathname || '').split('/').filter(Boolean);
|
||||
// /admin/posts/{type}/categories/edit/{id} → segments[2], segments[5]
|
||||
return { postType: segments[2] || '', categoryId: segments[5] || '' };
|
||||
}
|
||||
|
||||
const CategoryEditPage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const toast = useToast();
|
||||
|
||||
const { postType, categoryId } = getParamsFromPath(pathname);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState({ title: '', description: '', is_active: true });
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (postType && categoryId) loadCategory();
|
||||
}, [postType, categoryId]);
|
||||
|
||||
const loadCategory = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/zen/api/admin/posts/categories?type=${postType}&id=${categoryId}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success && data.category) {
|
||||
setFormData({
|
||||
title: data.category.title || '',
|
||||
description: data.category.description || '',
|
||||
is_active: data.category.is_active ?? true
|
||||
});
|
||||
} else {
|
||||
toast.error('Catégorie introuvable');
|
||||
router.push(`/admin/posts/${postType}/categories`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading category:', error);
|
||||
toast.error('Impossible de charger la catégorie');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.title.trim()) newErrors.title = 'Le titre est requis';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await fetch(`/zen/api/admin/posts/categories?type=${postType}&id=${categoryId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Catégorie mise à jour avec succès');
|
||||
router.push(`/admin/posts/${postType}/categories`);
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la mise à jour');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error);
|
||||
toast.error('Échec de la mise à jour');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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;
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Posts Module - Categories CRUD
|
||||
* Categories are scoped by post_type.
|
||||
*/
|
||||
|
||||
import { query } from '@hykocx/zen/database';
|
||||
|
||||
/**
|
||||
* Create a new category for a post type.
|
||||
* @param {string} postType
|
||||
* @param {Object} categoryData
|
||||
* @returns {Promise<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];
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Posts Module - Config Parser
|
||||
* Parses ZEN_MODULE_ZEN_MODULE_POSTS_TYPES and ZEN_MODULE_POSTS_TYPE_* environment variables into a structured config.
|
||||
*
|
||||
* .env format:
|
||||
* ZEN_MODULE_ZEN_MODULE_POSTS_TYPES=blogue|cve|emploi
|
||||
* ZEN_MODULE_POSTS_TYPE_BLOGUE=title:title|slug:slug|category:category|date:date|resume:text|content:markdown|image:image
|
||||
* ZEN_MODULE_POSTS_TYPE_CVE=title:title|slug:slug|cve_id:text|severity:text|description:markdown|date:date
|
||||
*
|
||||
* Supported field types: title, slug, text, markdown, date, datetime, color, category, image, relation
|
||||
*
|
||||
* Relation field format: name:relation:target_post_type
|
||||
* e.g. keywords:relation:mots-cle
|
||||
*/
|
||||
|
||||
const VALID_FIELD_TYPES = ['title', 'slug', 'text', 'markdown', 'date', 'datetime', 'color', 'category', 'image', 'relation'];
|
||||
|
||||
let _cachedConfig = null;
|
||||
|
||||
/**
|
||||
* Parse a single type's field string into an array of field definitions.
|
||||
* Format: "name:type" or "name:type:param" (param used for relation target)
|
||||
* e.g. "title:title|slug:slug|date:date|keywords:relation:mots-cle"
|
||||
* -> [{ name: 'title', type: 'title' }, ..., { name: 'keywords', type: 'relation', target: 'mots-cle' }]
|
||||
* @param {string} fieldString
|
||||
* @returns {Array<{name: string, type: string, target?: string}>}
|
||||
*/
|
||||
function parseFields(fieldString) {
|
||||
if (!fieldString) return [];
|
||||
|
||||
return fieldString
|
||||
.split('|')
|
||||
.map(part => {
|
||||
const segments = part.trim().split(':');
|
||||
const name = segments[0];
|
||||
const type = segments[1];
|
||||
const param = segments[2] || null;
|
||||
if (!name) return null;
|
||||
const resolvedType = VALID_FIELD_TYPES.includes(type) ? type : 'text';
|
||||
const field = { name: name.trim(), type: resolvedType };
|
||||
if (resolvedType === 'relation' && param) {
|
||||
field.target = param.trim().toLowerCase();
|
||||
}
|
||||
return field;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all ZEN_MODULE_POSTS_TYPE_* env vars and build the config object.
|
||||
* @returns {Object} Parsed config
|
||||
*/
|
||||
function buildConfig() {
|
||||
const enabled = process.env.ZEN_MODULE_POSTS === 'true';
|
||||
|
||||
if (!enabled) {
|
||||
return { enabled: false, types: {} };
|
||||
}
|
||||
|
||||
const typesRaw = process.env.ZEN_MODULE_ZEN_MODULE_POSTS_TYPES || '';
|
||||
// Each entry can be "key" or "key:Label" — only lowercase the key part
|
||||
const typeKeys = typesRaw
|
||||
.split('|')
|
||||
.map(k => {
|
||||
const [key, ...rest] = k.trim().split(':');
|
||||
const label = rest.join(':'); // preserve colons in label if any
|
||||
return label ? `${key.toLowerCase()}:${label}` : key.toLowerCase();
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const types = {};
|
||||
|
||||
for (const entry of typeKeys) {
|
||||
// Support "key:Label" format (label is optional)
|
||||
const [key, customLabel] = entry.split(':');
|
||||
const envKey = `ZEN_MODULE_POSTS_TYPE_${key.toUpperCase()}`;
|
||||
const fieldString = process.env[envKey] || '';
|
||||
const fields = parseFields(fieldString);
|
||||
|
||||
const titleField = fields.find(f => f.type === 'title')?.name || null;
|
||||
const slugField = fields.find(f => f.type === 'slug')?.name || null;
|
||||
const hasCategory = fields.some(f => f.type === 'category');
|
||||
const hasRelations = fields.some(f => f.type === 'relation');
|
||||
const label = customLabel || (key.charAt(0).toUpperCase() + key.slice(1));
|
||||
|
||||
types[key] = {
|
||||
key,
|
||||
label,
|
||||
fields,
|
||||
hasCategory,
|
||||
hasRelations,
|
||||
titleField,
|
||||
slugField,
|
||||
};
|
||||
}
|
||||
|
||||
return { enabled: true, types };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parsed posts config (cached after first call).
|
||||
* @returns {{ enabled: boolean, types: Object }}
|
||||
*/
|
||||
export function getPostsConfig() {
|
||||
if (!_cachedConfig) {
|
||||
_cachedConfig = buildConfig();
|
||||
}
|
||||
return _cachedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single post type config by key.
|
||||
* @param {string} key - Type key (e.g. 'blogue')
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function getPostType(key) {
|
||||
const config = getPostsConfig();
|
||||
return config.types[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the posts module is enabled.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPostsEnabled() {
|
||||
return process.env.ZEN_MODULE_POSTS === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cached config (useful for testing).
|
||||
*/
|
||||
export function resetConfig() {
|
||||
_cachedConfig = null;
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* Posts Module - CRUD Operations
|
||||
* Uses a single zen_posts table with JSONB for custom fields.
|
||||
* Relation fields are stored in zen_posts_relations (many-to-many).
|
||||
*/
|
||||
|
||||
import { query } from '@hykocx/zen/database';
|
||||
import { deleteFile } from '@hykocx/zen/storage';
|
||||
import { getPostType } from './config.js';
|
||||
|
||||
function slugify(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
async function ensureUniqueSlug(postType, baseSlug, excludeId = null) {
|
||||
let slug = baseSlug || 'post';
|
||||
let n = 1;
|
||||
for (;;) {
|
||||
let result;
|
||||
if (excludeId != null) {
|
||||
result = await query(
|
||||
`SELECT id FROM zen_posts WHERE post_type = $1 AND slug = $2 AND id != $3`,
|
||||
[postType, slug, excludeId]
|
||||
);
|
||||
} else {
|
||||
result = await query(
|
||||
`SELECT id FROM zen_posts WHERE post_type = $1 AND slug = $2`,
|
||||
[postType, slug]
|
||||
);
|
||||
}
|
||||
if (result.rows.length === 0) return slug;
|
||||
n++;
|
||||
slug = `${baseSlug}-${n}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getImageKeys(typeConfig, data) {
|
||||
if (!typeConfig || !data) return [];
|
||||
return typeConfig.fields
|
||||
.filter(f => f.type === 'image')
|
||||
.map(f => data[f.name])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save relation field values for a post.
|
||||
* Replaces all existing relations for the given field names.
|
||||
* @param {number} postId
|
||||
* @param {Object} relationUpdates - { fieldName: [id, id, ...] }
|
||||
*/
|
||||
async function saveRelations(postId, relationUpdates) {
|
||||
for (const [fieldName, ids] of Object.entries(relationUpdates)) {
|
||||
// Delete existing relations for this field
|
||||
await query(
|
||||
`DELETE FROM zen_posts_relations WHERE post_id = $1 AND field_name = $2`,
|
||||
[postId, fieldName]
|
||||
);
|
||||
|
||||
if (!ids || ids.length === 0) continue;
|
||||
|
||||
// Insert new relations
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const relatedId = parseInt(ids[i]);
|
||||
if (!relatedId) continue;
|
||||
await query(
|
||||
`INSERT INTO zen_posts_relations (post_id, field_name, related_post_id, sort_order)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (post_id, field_name, related_post_id) DO UPDATE SET sort_order = $4`,
|
||||
[postId, fieldName, relatedId, i]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load relation fields for a post, grouped by field name.
|
||||
* Returns { fieldName: [{ id, slug, title }] }
|
||||
* @param {number} postId
|
||||
* @param {Object} typeConfig
|
||||
* @returns {Promise<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) {
|
||||
console.warn(`[Posts] Failed to delete image from storage: ${imageKey}`, deleteResult.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Posts] Error deleting image from storage: ${imageKey}`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post by a specific field value stored in JSONB data.
|
||||
* Useful for deduplication in importers (e.g. find by cve_id).
|
||||
* @param {string} postType
|
||||
* @param {string} fieldName
|
||||
* @param {string} fieldValue
|
||||
* @returns {Promise<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 };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Posts Module - Database
|
||||
* Creates zen_posts and zen_posts_category tables.
|
||||
*/
|
||||
|
||||
import { query } from '@hykocx/zen/database';
|
||||
import { getPostsConfig } from './config.js';
|
||||
|
||||
async function tableExists(tableName) {
|
||||
const result = await query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
)`,
|
||||
[tableName]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
async function createPostsCategoryTable() {
|
||||
const tableName = 'zen_posts_category';
|
||||
const exists = await tableExists(tableName);
|
||||
|
||||
if (exists) {
|
||||
console.log(`- Table already exists: ${tableName}`);
|
||||
return { created: false, tableName };
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE zen_posts_category (
|
||||
id SERIAL PRIMARY KEY,
|
||||
post_type VARCHAR(100) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await query(`CREATE INDEX idx_zen_posts_category_post_type ON zen_posts_category(post_type)`);
|
||||
await query(`CREATE INDEX idx_zen_posts_category_is_active ON zen_posts_category(is_active)`);
|
||||
|
||||
console.log(`✓ Created table: ${tableName}`);
|
||||
return { created: true, tableName };
|
||||
}
|
||||
|
||||
async function createPostsTable() {
|
||||
const tableName = 'zen_posts';
|
||||
const exists = await tableExists(tableName);
|
||||
|
||||
if (exists) {
|
||||
console.log(`- Table already exists: ${tableName}`);
|
||||
return { created: false, tableName };
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE zen_posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
post_type VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(500) NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
category_id INTEGER REFERENCES zen_posts_category(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(post_type, slug)
|
||||
)
|
||||
`);
|
||||
|
||||
await query(`CREATE INDEX idx_zen_posts_post_type ON zen_posts(post_type)`);
|
||||
await query(`CREATE INDEX idx_zen_posts_post_type_slug ON zen_posts(post_type, slug)`);
|
||||
await query(`CREATE INDEX idx_zen_posts_category_id ON zen_posts(category_id)`);
|
||||
await query(`CREATE INDEX idx_zen_posts_data_gin ON zen_posts USING GIN (data)`);
|
||||
|
||||
console.log(`✓ Created table: ${tableName}`);
|
||||
return { created: true, tableName };
|
||||
}
|
||||
|
||||
async function createPostsRelationsTable() {
|
||||
const tableName = 'zen_posts_relations';
|
||||
const exists = await tableExists(tableName);
|
||||
|
||||
if (exists) {
|
||||
console.log(`- Table already exists: ${tableName}`);
|
||||
return { created: false, tableName };
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE zen_posts_relations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
post_id INTEGER NOT NULL REFERENCES zen_posts(id) ON DELETE CASCADE,
|
||||
field_name VARCHAR(100) NOT NULL,
|
||||
related_post_id INTEGER NOT NULL REFERENCES zen_posts(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
UNIQUE(post_id, field_name, related_post_id)
|
||||
)
|
||||
`);
|
||||
|
||||
await query(`CREATE INDEX idx_zen_posts_relations_post_id ON zen_posts_relations(post_id)`);
|
||||
await query(`CREATE INDEX idx_zen_posts_relations_related ON zen_posts_relations(related_post_id)`);
|
||||
|
||||
console.log(`✓ Created table: ${tableName}`);
|
||||
return { created: true, tableName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all posts-related tables.
|
||||
* zen_posts_category is only created if at least one type uses the 'category' field.
|
||||
* zen_posts_relations is only created if at least one type uses the 'relation' field.
|
||||
* @returns {Promise<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
|
||||
console.log('\n--- Posts Categories ---');
|
||||
const catResult = await createPostsCategoryTable();
|
||||
if (catResult.created) created.push(catResult.tableName);
|
||||
else skipped.push(catResult.tableName);
|
||||
|
||||
console.log('\n--- Posts ---');
|
||||
const postResult = await createPostsTable();
|
||||
if (postResult.created) created.push(postResult.tableName);
|
||||
else skipped.push(postResult.tableName);
|
||||
|
||||
if (needsRelations) {
|
||||
console.log('\n--- Posts Relations ---');
|
||||
const relResult = await createPostsRelationsTable();
|
||||
if (relResult.created) created.push(relResult.tableName);
|
||||
else skipped.push(relResult.tableName);
|
||||
}
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Posts Module Configuration
|
||||
* Navigation and adminPages are generated dynamically from ZEN_MODULE_ZEN_MODULE_POSTS_TYPES env var.
|
||||
*/
|
||||
|
||||
import { lazy } from 'react';
|
||||
import { getPostsConfig } from './config.js';
|
||||
|
||||
// Lazy components — shared across all post types
|
||||
const PostsListPage = lazy(() => import('./admin/PostsListPage.js'));
|
||||
const PostCreatePage = lazy(() => import('./admin/PostCreatePage.js'));
|
||||
const PostEditPage = lazy(() => import('./admin/PostEditPage.js'));
|
||||
const CategoriesListPage = lazy(() => import('./categories/admin/CategoriesListPage.js'));
|
||||
const CategoryCreatePage = lazy(() => import('./categories/admin/CategoryCreatePage.js'));
|
||||
const CategoryEditPage = lazy(() => import('./categories/admin/CategoryEditPage.js'));
|
||||
|
||||
const postsConfig = getPostsConfig();
|
||||
|
||||
// Build adminPages and navigation dynamically from configured post types
|
||||
const adminPages = {};
|
||||
const navigationSections = [];
|
||||
|
||||
for (const type of Object.values(postsConfig.types)) {
|
||||
// Register routes for this post type
|
||||
adminPages[`/admin/posts/${type.key}/list`] = PostsListPage;
|
||||
adminPages[`/admin/posts/${type.key}/new`] = PostCreatePage;
|
||||
adminPages[`/admin/posts/${type.key}/edit`] = PostEditPage;
|
||||
|
||||
const navItems = [
|
||||
{ name: type.label, href: `/admin/posts/${type.key}/list`, icon: 'Book02Icon' },
|
||||
];
|
||||
|
||||
if (type.hasCategory) {
|
||||
adminPages[`/admin/posts/${type.key}/categories`] = CategoriesListPage;
|
||||
adminPages[`/admin/posts/${type.key}/categories/new`] = CategoryCreatePage;
|
||||
adminPages[`/admin/posts/${type.key}/categories/edit`] = CategoryEditPage;
|
||||
navItems.push({ name: 'Catégories', href: `/admin/posts/${type.key}/categories`, icon: 'Layers01Icon' });
|
||||
}
|
||||
|
||||
navigationSections.push({
|
||||
id: `posts-${type.key}`,
|
||||
title: type.label,
|
||||
icon: 'Book02Icon',
|
||||
items: navItems,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side page resolver using path patterns (no env vars needed).
|
||||
* Called by getModulePageLoader in modules.pages.js.
|
||||
*
|
||||
* URL patterns:
|
||||
* /admin/posts/{type}/list
|
||||
* /admin/posts/{type}/new
|
||||
* /admin/posts/{type}/edit
|
||||
* /admin/posts/{type}/categories
|
||||
* /admin/posts/{type}/categories/new
|
||||
* /admin/posts/{type}/categories/edit
|
||||
*/
|
||||
function pageResolver(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
// ['admin', 'posts', type, action, ...]
|
||||
if (parts[0] !== 'admin' || parts[1] !== 'posts' || parts.length < 4) return null;
|
||||
const action = parts[3];
|
||||
if (action === 'list') return PostsListPage;
|
||||
if (action === 'new') return PostCreatePage;
|
||||
if (action === 'edit') return PostEditPage;
|
||||
if (action === 'categories') {
|
||||
if (parts[4] === 'new') return CategoryCreatePage;
|
||||
if (parts[4] === 'edit') return CategoryEditPage;
|
||||
return CategoriesListPage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'posts',
|
||||
displayName: 'Posts',
|
||||
version: '1.0.0',
|
||||
description: 'Multi-type custom post system configurable via environment variables',
|
||||
|
||||
dependencies: [],
|
||||
|
||||
envVars: ['ZEN_MODULE_ZEN_MODULE_POSTS_TYPES'],
|
||||
|
||||
// Array of sections — one per post type (server-side, env vars available)
|
||||
navigation: navigationSections,
|
||||
|
||||
// Used server-side by discovery.js to register paths in the registry
|
||||
adminPages,
|
||||
|
||||
// Used client-side by getModulePageLoader — pattern-based, no env vars needed
|
||||
pageResolver,
|
||||
|
||||
publicPages: {},
|
||||
publicRoutes: [],
|
||||
dashboardWidgets: [],
|
||||
};
|
||||
Reference in New Issue
Block a user