chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
+245
View File
@@ -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;
+271
View File
@@ -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;
+359
View File
@@ -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;
+104
View File
@@ -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' : ''} &bull; {type.fields.map(f => f.type).join(', ')}
</p>
<div className="flex gap-2 mt-1">
<Button
size="sm"
variant="secondary"
onClick={() => router.push(`/admin/posts/${type.key}/list`)}
icon={<Book02Icon className="w-4 h-4" />}
>
Posts
</Button>
{type.hasCategory && (
<Button
size="sm"
variant="ghost"
onClick={() => router.push(`/admin/posts/${type.key}/categories`)}
icon={<Layers01Icon className="w-4 h-4" />}
>
Catégories
</Button>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
};
export default PostsIndexPage;
+316
View File
@@ -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;