Titre
+ Statut
Auteur
Date
Actions
@@ -62,6 +72,7 @@ export default async function BlogCMSPage() {
{post.title}
{post.excerpt}
+ {getStatusBadge(post.status)}
{post.author}
{new Date(post.createdAt).toLocaleDateString('fr-FR')}
@@ -90,6 +101,7 @@ export default async function BlogCMSPage() {
Interviewé
+ Statut
Entreprise / Rôle
Type
Actions
@@ -102,6 +114,7 @@ export default async function BlogCMSPage() {
{interview.guestName}
{interview.title}
+ {getStatusBadge(interview.status)}
{interview.companyName}
{interview.role}
@@ -137,6 +150,7 @@ export default async function BlogCMSPage() {
Événement
+ Statut
Date & Lieu
Actions
@@ -150,6 +164,7 @@ export default async function BlogCMSPage() {
{event.link ? 'Lien activé' : 'Pas de lien'}
+ {getStatusBadge(event.status)}
diff --git a/admin/src/app/categories/page.tsx b/admin/src/app/categories/page.tsx
deleted file mode 100644
index 8bbc77a..0000000
--- a/admin/src/app/categories/page.tsx
+++ /dev/null
@@ -1,404 +0,0 @@
-"use client";
-
-import React, { useState, useEffect, useTransition } from 'react';
-import {
- Plus,
- Edit2,
- Trash2,
- Check,
- X,
- Loader2,
- Briefcase,
- AlertCircle,
- Clock,
- Sparkles,
- Cpu,
- Sprout,
- Shirt,
- Utensils,
- HardHat,
- Stethoscope,
- GraduationCap,
- Palette,
- Plane,
- Truck,
- Wallet,
- Zap,
- Leaf,
- Camera,
- Music,
- ShoppingBag,
- Heart,
- Home
-} from 'lucide-react';
-import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
-import { toast } from 'react-hot-toast';
-
-const ICON_LIST = [
- { name: 'Briefcase', icon: Briefcase },
- { name: 'Cpu', icon: Cpu },
- { name: 'Sprout', icon: Sprout },
- { name: 'Shirt', icon: Shirt },
- { name: 'Sparkles', icon: Sparkles },
- { name: 'Utensils', icon: Utensils },
- { name: 'HardHat', icon: HardHat },
- { name: 'Stethoscope', icon: Stethoscope },
- { name: 'GraduationCap', icon: GraduationCap },
- { name: 'Palette', icon: Palette },
- { name: 'Plane', icon: Plane },
- { name: 'Truck', icon: Truck },
- { name: 'Wallet', icon: Wallet },
- { name: 'Zap', icon: Zap },
- { name: 'Leaf', icon: Leaf },
- { name: 'Camera', icon: Camera },
- { name: 'Music', icon: Music },
- { name: 'ShoppingBag', icon: ShoppingBag },
- { name: 'Heart', icon: Heart },
- { name: 'Home', icon: Home },
-];
-
-export default function CategoriesPage() {
- const [categories, setCategories] = useState
([]);
- const [suggestions, setSuggestions] = useState([]);
- const [loading, setLoading] = useState(true);
- const [isPending, startTransition] = useTransition();
- const [showAddModal, setShowAddModal] = useState(false);
- const [editingCategory, setEditingCategory] = useState(null);
- const [selectedSuggestion, setSelectedSuggestion] = useState(null);
-
- const renderIcon = (iconName: string) => {
- const found = ICON_LIST.find(i => i.name === iconName);
- if (found) {
- const IconComp = found.icon;
- return ;
- }
- return ;
- };
-
- const [formData, setFormData] = useState({
- name: '',
- slug: '',
- icon: 'Briefcase',
- isActive: true
- });
-
- useEffect(() => {
- loadCategories();
- }, []);
-
- async function loadCategories() {
- setLoading(true);
- try {
- const [catData, suggData] = await Promise.all([
- getCategories(),
- getSuggestedCategories()
- ]);
- setCategories(catData);
- setSuggestions(suggData);
- } catch (e) {
- toast.error("Erreur de chargement");
- } finally {
- setLoading(false);
- }
- }
-
- const handleAddCategory = async (e: React.FormEvent) => {
- e.preventDefault();
- startTransition(async () => {
- const result = await createCategory({ ...formData, suggestionId: selectedSuggestion?.id });
- if (result.success) {
- toast.success("Catégorie créée !");
- setShowAddModal(false);
- setSelectedSuggestion(null);
- setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
- loadCategories();
- } else {
- toast.error(result.error || "Erreur");
- }
- });
- };
-
- const handleUpdateCategory = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!editingCategory) return;
-
- startTransition(async () => {
- const result = await updateCategory(editingCategory.id, formData);
- if (result.success) {
- toast.success("Catégorie mise à jour !");
- setEditingCategory(null);
- loadCategories();
- } else {
- toast.error(result.error || "Erreur");
- }
- });
- };
-
- const handleDelete = async (id: string) => {
- if (!confirm("Supprimer cette catégorie ?")) return;
-
- const result = await deleteCategory(id);
- if (result.success) {
- toast.success("Supprimée !");
- loadCategories();
- } else {
- toast.error(result.error || "Erreur");
- }
- };
-
- const openEdit = (cat: any) => {
- setEditingCategory(cat);
- setFormData({
- name: cat.name,
- slug: cat.slug,
- icon: cat.icon || 'Briefcase',
- isActive: cat.isActive
- });
- };
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
Secteurs & Catégories
-
Gérez les secteurs d'activité disponibles pour les entrepreneurs.
-
-
{ setShowAddModal(true); setEditingCategory(null); setSelectedSuggestion(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); }}
- className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl flex items-center gap-2 transition-all shadow-lg shadow-indigo-600/20"
- >
- Nouvelle Catégorie
-
-
-
-
-
-
-
-
Catégories Officielles
- {categories.length} secteurs
-
-
-
-
-
- Nom
- Statut
- Actions
-
-
-
- {categories.map((cat) => (
-
-
-
-
- {renderIcon(cat.icon)}
-
-
-
{cat.name}
-
{cat.slug}
-
-
-
-
- {cat.isActive ? (
-
- Actif
-
- ) : (
-
- Inactif
-
- )}
-
-
-
- openEdit(cat)}
- className="p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-all"
- >
-
-
- handleDelete(cat.id)}
- className="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
- >
-
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
Suggestions
-
-
- {suggestions.length === 0 ? (
-
-
-
-
-
Aucune suggestion en attente.
-
- ) : (
- suggestions.map((sugg) => (
-
-
- Proposé
- {new Date(sugg.createdAt).toLocaleDateString()}
-
-
{sugg.suggestedCategory}
-
-
- Par : {sugg.name}
-
-
{
- setSelectedSuggestion(sugg);
- setFormData({
- name: sugg.suggestedCategory,
- slug: sugg.suggestedCategory.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, ''),
- icon: 'Briefcase',
- isActive: true
- });
- setShowAddModal(true);
- }}
- className="w-full mt-3 text-xs bg-indigo-600/10 text-indigo-400 hover:bg-indigo-600 hover:text-white font-bold py-2.5 rounded-lg transition-all border border-indigo-600/20"
- >
- Convertir en catégorie officielle
-
-
- ))
- )}
-
-
-
-
-
- {(showAddModal || editingCategory) && (
-
-
-
-
- {selectedSuggestion && }
-
- {editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'}
-
-
-
{ setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
- className="text-slate-400 hover:text-white transition-colors"
- >
-
-
-
-
-
-
-
- )}
-
- );
-}
diff --git a/admin/src/app/dashboard/DashboardClient.tsx b/admin/src/app/dashboard/DashboardClient.tsx
new file mode 100644
index 0000000..a2abfaa
--- /dev/null
+++ b/admin/src/app/dashboard/DashboardClient.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import React from 'react';
+import {
+ Users,
+ Store,
+ Clock,
+ MessageCircle,
+ Eye
+} from 'lucide-react';
+
+interface Stats {
+ usersCount: number;
+ businessCount: number;
+ pendingCount: number;
+ commentsCount: number;
+ totalViews: number;
+ uniqueVisitors: number;
+}
+
+export default function DashboardClient({ stats }: { stats: Stats }) {
+ const statCards = [
+ { label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
+ { label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
+ { label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
+ { label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
+ { label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
+ { label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
+ ];
+
+ return (
+
+
+
Tableau de bord
+
Vue d'ensemble de l'activité sur la plateforme.
+
+
+
+ {statCards.map((card) => (
+
+ ))}
+
+
+
+
+
Derniers entrepreneurs
+
+
Bientôt disponible...
+
+
+
+
Activité récente
+
+
Bientôt disponible...
+
+
+
+
+ );
+}
diff --git a/admin/src/app/dashboard/page.tsx b/admin/src/app/dashboard/page.tsx
index 3e68c1d..91f83d4 100644
--- a/admin/src/app/dashboard/page.tsx
+++ b/admin/src/app/dashboard/page.tsx
@@ -1,11 +1,5 @@
import { prisma } from '@/lib/prisma';
-import {
- Users,
- Store,
- Clock,
- MessageCircle,
- Eye
-} from 'lucide-react';
+import DashboardClient from './DashboardClient';
async function getStats() {
const usersCount = await prisma.user.count();
@@ -32,51 +26,5 @@ async function getStats() {
export default async function DashboardPage() {
const stats = await getStats();
-
- const statCards = [
- { label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
- { label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
- { label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
- { label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
- { label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
- { label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
- ];
-
- return (
-
-
-
Tableau de bord
-
Vue d'ensemble de l'activité sur la plateforme.
-
-
-
- {statCards.map((card) => (
-
- ))}
-
-
-
-
-
Derniers entrepreneurs
-
-
Bientôt disponible...
-
-
-
-
Activité récente
-
-
Bientôt disponible...
-
-
-
-
- );
+ return ;
}
diff --git a/admin/src/app/globals.css b/admin/src/app/globals.css
index 2619389..2761b25 100644
--- a/admin/src/app/globals.css
+++ b/admin/src/app/globals.css
@@ -265,6 +265,13 @@ body {
font-style: normal;
}
+.quill-dark-wrapper .ql-editor {
+ text-align: justify;
+ hyphens: auto;
+ word-break: normal;
+ line-height: 1.6;
+}
+
/* Styles for the Divider (HR) */
.quill-dark-wrapper .ql-editor hr {
border: none;
diff --git a/admin/src/app/not-found.tsx b/admin/src/app/not-found.tsx
new file mode 100644
index 0000000..8d53c7d
--- /dev/null
+++ b/admin/src/app/not-found.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import React from 'react';
+import Link from 'next/link';
+import { LayoutDashboard, ArrowLeft } from 'lucide-react';
+
+export default function NotFound() {
+ return (
+
+
+
+
+ 404
+
+
Page d'administration introuvable
+
La ressource que vous recherchez n'existe pas ou a été déplacée.
+
+
+
+
+
+ Retour au Dashboard
+
+
window.history.back()}
+ className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-slate-300 border border-slate-800 rounded-xl font-semibold hover:bg-slate-800 transition-colors"
+ >
+
+ Page précédente
+
+
+
+
+ );
+}
diff --git a/admin/src/app/taxonomies/TaxonomiesClient.tsx b/admin/src/app/taxonomies/TaxonomiesClient.tsx
new file mode 100644
index 0000000..e38eedb
--- /dev/null
+++ b/admin/src/app/taxonomies/TaxonomiesClient.tsx
@@ -0,0 +1,415 @@
+"use client";
+
+import React, { useState, useEffect, useTransition } from 'react';
+import {
+ Plus,
+ Edit2,
+ Trash2,
+ Check,
+ X,
+ Loader2,
+ Briefcase,
+ AlertCircle,
+ Clock,
+ Sparkles,
+ Cpu,
+ Sprout,
+ Shirt,
+ Utensils,
+ HardHat,
+ Stethoscope,
+ GraduationCap,
+ Palette,
+ Plane,
+ Truck,
+ Wallet,
+ Zap,
+ Leaf,
+ Camera,
+ Music,
+ ShoppingBag,
+ Heart,
+ Home,
+ Hash,
+ Tags,
+ Search,
+ ExternalLink,
+ Eye,
+ FileText,
+ User as UserIcon,
+ MapPin,
+ BookOpen,
+ Save
+} from 'lucide-react';
+import Link from 'next/link';
+import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
+import { getAllTags, deleteTagGlobally, renameTagGlobally, getTagUsage } from '@/app/actions/taxonomies';
+import { toast } from 'react-hot-toast';
+
+const ICON_LIST = [
+ { name: 'Briefcase', icon: Briefcase },
+ { name: 'Cpu', icon: Cpu },
+ { name: 'Sprout', icon: Sprout },
+ { name: 'Shirt', icon: Shirt },
+ { name: 'Sparkles', icon: Sparkles },
+ { name: 'Utensils', icon: Utensils },
+ { name: 'HardHat', icon: HardHat },
+ { name: 'Stethoscope', icon: Stethoscope },
+ { name: 'GraduationCap', icon: GraduationCap },
+ { name: 'Palette', icon: Palette },
+ { name: 'Plane', icon: Plane },
+ { name: 'Truck', icon: Truck },
+ { name: 'Wallet', icon: Wallet },
+ { name: 'Zap', icon: Zap },
+ { name: 'Leaf', icon: Leaf },
+ { name: 'Camera', icon: Camera },
+ { name: 'Music', icon: Music },
+ { name: 'ShoppingBag', icon: ShoppingBag },
+ { name: 'Heart', icon: Heart },
+ { name: 'Home', icon: Home },
+ { name: 'Tags', icon: Tags },
+];
+
+type Tab = 'categories' | 'tags';
+
+export default function TaxonomiesClient() {
+ const [activeTab, setActiveTab] = useState('categories');
+ const [categories, setCategories] = useState([]);
+ const [suggestions, setSuggestions] = useState([]);
+ const [tags, setTags] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [isPending, startTransition] = useTransition();
+
+ // Category Form State
+ const [showAddModal, setShowAddModal] = useState(false);
+ const [editingCategory, setEditingCategory] = useState(null);
+ const [formData, setFormData] = useState({
+ name: '',
+ slug: '',
+ icon: 'Briefcase',
+ isActive: true
+ });
+
+ // Tag Form State
+ const [newTagName, setNewTagName] = useState('');
+ const [isEditingUsageName, setIsEditingUsageName] = useState(false);
+ const [usagePreview, setUsagePreview] = useState<{ tag: string, data: any } | null>(null);
+ const [loadingUsage, setLoadingUsage] = useState(false);
+
+ const loadData = async () => {
+ setLoading(true);
+ try {
+ const [catsRes, tagsRes, sugRes] = await Promise.all([
+ getCategories(),
+ getAllTags(),
+ getSuggestedCategories()
+ ]);
+ setCategories(catsRes);
+ setTags(tagsRes);
+ setSuggestions(sugRes);
+ } catch (err) {
+ toast.error("Erreur de chargement");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const handleCategorySubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ startTransition(async () => {
+ const result = editingCategory
+ ? await updateCategory(editingCategory.id, formData)
+ : await createCategory(formData);
+
+ if (result.success) {
+ toast.success(editingCategory ? "Catégorie mise à jour" : "Catégorie créée");
+ setShowAddModal(false);
+ setEditingCategory(null);
+ setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
+ loadData();
+ } else {
+ toast.error(result.error || "Erreur");
+ }
+ });
+ };
+
+ const handleTagDelete = async (tag: string) => {
+ if (!confirm(`Supprimer le tag "${tag}" globalement ?`)) return;
+ const result = await deleteTagGlobally(tag);
+ if (result.success) {
+ toast.success("Tag supprimé partout");
+ loadData();
+ } else {
+ toast.error(result.error || "Erreur");
+ }
+ };
+
+ const handleShowUsage = async (tag: string) => {
+ setLoadingUsage(true);
+ const result = await getTagUsage(tag);
+ if (result.success) {
+ setUsagePreview({ tag, data: result.data });
+ }
+ setLoadingUsage(false);
+ };
+
+ if (loading) return Chargement...
;
+
+ return (
+
+
+
+
Taxonomies
+
Gérez les catégories et les mots-clés du site.
+
+
+ setActiveTab('categories')}
+ className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'categories' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
+ >
+ Catégories
+
+ setActiveTab('tags')}
+ className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'tags' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
+ >
+ Tags / Mots-clés
+
+
+
+
+ {activeTab === 'categories' ? (
+
+
+
+
+ Liste des catégories ({categories.length})
+
+
{ setEditingCategory(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); setShowAddModal(true); }}
+ className="btn-verify flex items-center gap-2"
+ >
+ Nouvelle catégorie
+
+
+
+
+ {categories.map((cat) => {
+ const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase;
+ return (
+
+
+
+
+
+
+
{cat.name}
+
/{cat.slug}
+
+
+
+ { setEditingCategory(cat); setFormData({ name: cat.name, slug: cat.slug, icon: cat.icon, isActive: cat.isActive }); setShowAddModal(true); }}
+ className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
+ >
+
+
+ { if (confirm("Supprimer ?")) await deleteCategory(cat.id); loadData(); }}
+ className="p-2 text-slate-400 hover:text-red-400 transition-colors"
+ >
+
+
+
+
+ );
+ })}
+
+
+ ) : (
+
+
+
+
+ Tags utilisés ({tags.length})
+
+
+
+
+ {tags.map((tag) => (
+
handleShowUsage(tag)}
+ className="group flex items-center justify-between bg-slate-950 border border-slate-800 rounded-xl p-3 hover:border-indigo-500/50 transition-all hover:bg-indigo-500/5 cursor-pointer"
+ >
+
{tag}
+
+ { e.stopPropagation(); handleTagDelete(tag); }}
+ />
+
+
+ ))}
+
+
+ )}
+
+ {/* Modal Ajout/Modif Catégorie */}
+ {showAddModal && (
+
+
+
{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}
+
+
+
+ )}
+
+ {/* Usage Modal */}
+ {usagePreview && (
+
+
+
+
+
+ {isEditingUsageName ? (
+
+ setNewTagName(e.target.value)}
+ className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold"
+ />
+ {
+ const res = await renameTagGlobally(usagePreview.tag, newTagName);
+ if (res.success) {
+ toast.success("Renommé");
+ setUsagePreview({ ...usagePreview, tag: newTagName });
+ setIsEditingUsageName(false);
+ loadData();
+ }
+ }}
+ className="p-2 bg-emerald-600 text-white rounded-lg"
+ >
+
+
+
+ ) : (
+
Tag : {usagePreview.tag}
+ )}
+
+
+ {!isEditingUsageName && (
+ { setNewTagName(usagePreview.tag); setIsEditingUsageName(true); }} className="p-2 text-slate-400 hover:text-indigo-400">
+ )}
+ setUsagePreview(null)} className="p-2 text-slate-400 hover:text-white">
+
+
+
+
+ {/* Blog Posts */}
+
+
+
+
+
+
Articles de Blog ({usagePreview.data.blogPosts.length})
+
+
+ {usagePreview.data.blogPosts.length === 0 ? (
+
Aucun article
+ ) : usagePreview.data.blogPosts.map((post: any) => (
+
+ {post.title}
+
+
+
+
+ ))}
+
+
+
+ {/* Interviews */}
+
+
+
+
+
+
Interviews ({usagePreview.data.interviews.length})
+
+
+ {usagePreview.data.interviews.length === 0 ? (
+
Aucune interview
+ ) : usagePreview.data.interviews.map((item: any) => (
+
+ {item.title}
+
+
+
+
+ ))}
+
+
+
+ {/* Events */}
+
+
+
+
+
+
Événements ({usagePreview.data.events.length})
+
+
+ {usagePreview.data.events.length === 0 ? (
+
Aucun événement
+ ) : usagePreview.data.events.map((item: any) => (
+
+ {item.title}
+
+
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/admin/src/app/taxonomies/page.tsx b/admin/src/app/taxonomies/page.tsx
new file mode 100644
index 0000000..c3afad9
--- /dev/null
+++ b/admin/src/app/taxonomies/page.tsx
@@ -0,0 +1,5 @@
+import TaxonomiesClient from "./TaxonomiesClient";
+
+export default function TaxonomiesPage() {
+ return ;
+}
diff --git a/admin/src/components/BlogForm.tsx b/admin/src/components/BlogForm.tsx
index 87d94d7..fd63dc7 100644
--- a/admin/src/components/BlogForm.tsx
+++ b/admin/src/components/BlogForm.tsx
@@ -1,12 +1,16 @@
"use client";
-import { useTransition, useState } from 'react';
+import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
-import { Loader2, ArrowLeft, Save } from 'lucide-react';
+import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
+import ImageUploader from './ImageUploader';
+import TagInput from './TagInput';
+import { ContentStatus } from '@prisma/client';
+import { getAllTags } from '@/app/actions/taxonomies';
interface Props {
initialData?: {
@@ -20,6 +24,8 @@ interface Props {
tags?: string[];
metaTitle?: string | null;
metaDescription?: string | null;
+ publishedAt?: Date | string | null;
+ status?: ContentStatus;
};
}
@@ -27,29 +33,52 @@ export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || '');
+ const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
+ const [tags, setTags] = useState(initialData?.tags || []);
+ const [allExistingTags, setAllExistingTags] = useState([]);
+ const [publishedAtValue, setPublishedAtValue] = useState(
+ initialData?.publishedAt
+ ? new Date(initialData.publishedAt).toISOString().slice(0, 16)
+ : new Date().toISOString().slice(0, 16)
+ );
+
+ const isPublished = new Date(publishedAtValue) <= new Date();
+
+ useEffect(() => {
+ getAllTags().then(setAllExistingTags);
+ }, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
+ const publishedAtStr = formData.get('publishedAt') as string;
+
const data = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
excerpt: formData.get('excerpt') as string,
content: content,
author: formData.get('author') as string,
- imageUrl: formData.get('imageUrl') as string,
- tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
+ imageUrl: imageUrl, // Use state value
+ tags: tags, // Use state value
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
+ publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
+ status: formData.get('status') as ContentStatus,
};
+ if (!data.imageUrl) {
+ toast.error("Une image est requise");
+ return;
+ }
+
startTransition(async () => {
const result = initialData
? await updateBlogPost(initialData.id, data)
: await createBlogPost(data);
if (result.success) {
- toast.success(initialData ? "Article mis à jour" : "Article publié avec succès");
+ toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
router.push('/blog');
router.refresh();
} else {
@@ -73,7 +102,21 @@ export default function BlogForm({ initialData }: Props) {
diff --git a/admin/src/components/EventForm.tsx b/admin/src/components/EventForm.tsx
index 284587d..e8dd639 100644
--- a/admin/src/components/EventForm.tsx
+++ b/admin/src/components/EventForm.tsx
@@ -1,12 +1,16 @@
"use client";
-import { useTransition, useState } from 'react';
+import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event';
-import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon } from 'lucide-react';
+import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
+import ImageUploader from './ImageUploader';
+import TagInput from './TagInput';
+import { ContentStatus } from '@prisma/client';
+import { getAllTags } from '@/app/actions/taxonomies';
interface Props {
initialData?: any;
@@ -16,23 +20,46 @@ export default function EventForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [description, setDescription] = useState(initialData?.description || '');
+ const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
+ const [tags, setTags] = useState(initialData?.tags || []);
+ const [allExistingTags, setAllExistingTags] = useState([]);
+ const [publishedAtValue, setPublishedAtValue] = useState(
+ initialData?.publishedAt
+ ? new Date(initialData.publishedAt).toISOString().slice(0, 16)
+ : new Date().toISOString().slice(0, 16)
+ );
+
+ const isPublished = new Date(publishedAtValue) <= new Date();
+
+ useEffect(() => {
+ getAllTags().then(setAllExistingTags);
+ }, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
+ const publishedAtStr = formData.get('publishedAt') as string;
+
const data: any = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
location: formData.get('location') as string,
date: new Date(formData.get('date') as string),
- thumbnailUrl: formData.get('thumbnailUrl') as string,
+ thumbnailUrl: thumbnailUrl, // Use state value
link: formData.get('link') as string || null,
description: description || '',
- tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
+ tags: tags, // Use state
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
+ publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
+ status: formData.get('status') as ContentStatus,
};
+ if (!data.thumbnailUrl) {
+ toast.error("Une image est requise");
+ return;
+ }
+
startTransition(async () => {
const result = initialData
? await updateEvent(initialData.id, data)
@@ -63,7 +90,21 @@ export default function EventForm({ initialData }: Props) {
diff --git a/prisma/migrations/20260503171918_add_published_at/migration.sql b/prisma/migrations/20260503171918_add_published_at/migration.sql
new file mode 100644
index 0000000..5c2a930
--- /dev/null
+++ b/prisma/migrations/20260503171918_add_published_at/migration.sql
@@ -0,0 +1,8 @@
+-- AlterTable
+ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+
+-- AlterTable
+ALTER TABLE "Event" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+
+-- AlterTable
+ALTER TABLE "Interview" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
diff --git a/prisma/migrations/20260503172958_add_content_status/migration.sql b/prisma/migrations/20260503172958_add_content_status/migration.sql
new file mode 100644
index 0000000..f70ae9d
--- /dev/null
+++ b/prisma/migrations/20260503172958_add_content_status/migration.sql
@@ -0,0 +1,11 @@
+-- CreateEnum
+CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
+
+-- AlterTable
+ALTER TABLE "BlogPost" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
+
+-- AlterTable
+ALTER TABLE "Event" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
+
+-- AlterTable
+ALTER TABLE "Interview" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index cb53eb1..ca723e3 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -142,19 +142,21 @@ model Offer {
}
model BlogPost {
- id String @id @default(uuid())
+ id String @id @default(uuid())
title String
excerpt String
content String
author String
- date DateTime @default(now())
+ date DateTime @default(now())
imageUrl String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
- slug String? @unique
- tags String[] @default([])
+ slug String? @unique
+ tags String[] @default([])
+ publishedAt DateTime @default(now())
+ status ContentStatus @default(PUBLISHED)
}
model Interview {
@@ -176,6 +178,8 @@ model Interview {
metaTitle String?
slug String? @unique
tags String[] @default([])
+ publishedAt DateTime @default(now())
+ status ContentStatus @default(PUBLISHED)
}
model Comment {
@@ -304,19 +308,21 @@ model LegalDocument {
}
model Event {
- id String @id @default(uuid())
+ id String @id @default(uuid())
title String
description String
date DateTime
location String
thumbnailUrl String
link String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
- slug String? @unique
- tags String[] @default([])
+ slug String? @unique
+ tags String[] @default([])
+ publishedAt DateTime @default(now())
+ status ContentStatus @default(PUBLISHED)
}
model Favorite {
@@ -330,6 +336,12 @@ model Favorite {
@@unique([userId, businessId])
}
+enum ContentStatus {
+ DRAFT
+ PUBLISHED
+ ARCHIVED
+}
+
enum RatingStatus {
PENDING
APPROVED
diff --git a/public/uploads/1777833997337-98dkw92.gif b/public/uploads/1777833997337-98dkw92.gif
new file mode 100644
index 0000000..161894d
Binary files /dev/null and b/public/uploads/1777833997337-98dkw92.gif differ
diff --git a/public/uploads/1777834233872-lz35qcr.jpg b/public/uploads/1777834233872-lz35qcr.jpg
new file mode 100644
index 0000000..fee095f
Binary files /dev/null and b/public/uploads/1777834233872-lz35qcr.jpg differ