272 lines
9.4 KiB
JavaScript
272 lines
9.4 KiB
JavaScript
'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;
|