chore: import codes
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user