maj url photo + page 404 + gestion programmation d'article
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { generateSlug } from "@/lib/utils";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
|
||||
export async function createBlogPost(data: {
|
||||
title: string;
|
||||
@@ -14,6 +15,8 @@ export async function createBlogPost(data: {
|
||||
tags?: string[];
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
publishedAt?: Date;
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
@@ -23,10 +26,12 @@ export async function createBlogPost(data: {
|
||||
...data,
|
||||
slug,
|
||||
tags: data.tags || [],
|
||||
publishedAt: data.publishedAt || new Date(),
|
||||
status: data.status || ContentStatus.PUBLISHED,
|
||||
},
|
||||
});
|
||||
revalidatePath("/blog");
|
||||
return { success: true, data: post };
|
||||
return { success: true, id: post.id };
|
||||
} catch (error) {
|
||||
console.error("Failed to create blog post:", error);
|
||||
return { success: false, error: "Erreur lors de la création de l'article" };
|
||||
@@ -43,6 +48,8 @@ export async function updateBlogPost(id: string, data: {
|
||||
tags?: string[];
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
publishedAt?: Date;
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
@@ -56,7 +63,7 @@ export async function updateBlogPost(id: string, data: {
|
||||
},
|
||||
});
|
||||
revalidatePath("/blog");
|
||||
return { success: true, data: post };
|
||||
return { success: true, id: post.id };
|
||||
} catch (error) {
|
||||
console.error("Failed to update blog post:", error);
|
||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { generateSlug } from "@/lib/utils";
|
||||
import { ContentStatus } from "@prisma/client";
|
||||
|
||||
export async function createEvent(data: {
|
||||
title: string;
|
||||
@@ -15,6 +16,8 @@ export async function createEvent(data: {
|
||||
tags?: string[];
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
publishedAt?: Date;
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
@@ -24,6 +27,8 @@ export async function createEvent(data: {
|
||||
...data,
|
||||
slug,
|
||||
tags: data.tags || [],
|
||||
publishedAt: data.publishedAt || new Date(),
|
||||
status: data.status || ContentStatus.PUBLISHED,
|
||||
},
|
||||
});
|
||||
revalidatePath("/blog");
|
||||
@@ -46,6 +51,8 @@ export async function updateEvent(id: string, data: {
|
||||
tags?: string[];
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
publishedAt?: Date;
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { InterviewType } from "@prisma/client";
|
||||
import { InterviewType, ContentStatus } from "@prisma/client";
|
||||
import { generateSlug } from "@/lib/utils";
|
||||
|
||||
export async function createInterview(data: {
|
||||
@@ -20,6 +20,8 @@ export async function createInterview(data: {
|
||||
tags?: string[];
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
publishedAt?: Date;
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
@@ -29,6 +31,8 @@ export async function createInterview(data: {
|
||||
...data,
|
||||
slug,
|
||||
tags: data.tags || [],
|
||||
publishedAt: data.publishedAt || new Date(),
|
||||
status: data.status || ContentStatus.PUBLISHED,
|
||||
},
|
||||
});
|
||||
revalidatePath("/blog");
|
||||
@@ -54,6 +58,8 @@ export async function updateInterview(id: string, data: {
|
||||
tags?: string[];
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
publishedAt?: Date;
|
||||
status?: ContentStatus;
|
||||
}) {
|
||||
try {
|
||||
const slug = data.slug || generateSlug(data.title);
|
||||
|
||||
138
admin/src/app/actions/taxonomies.ts
Normal file
138
admin/src/app/actions/taxonomies.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function getAllTags() {
|
||||
try {
|
||||
const [blogPosts, interviews, events] = await Promise.all([
|
||||
prisma.blogPost.findMany({ select: { tags: true } }),
|
||||
prisma.interview.findMany({ select: { tags: true } }),
|
||||
prisma.event.findMany({ select: { tags: true } }),
|
||||
]);
|
||||
|
||||
const allTags = new Set<string>();
|
||||
blogPosts.forEach(p => p.tags.forEach(t => allTags.add(t)));
|
||||
interviews.forEach(i => i.tags.forEach(t => allTags.add(t)));
|
||||
events.forEach(e => e.tags.forEach(t => allTags.add(t)));
|
||||
|
||||
return Array.from(allTags).sort();
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTagGlobally(tagName: string) {
|
||||
try {
|
||||
// This is expensive as we need to update all records
|
||||
// In a real app, a Tag model would be better
|
||||
|
||||
const [blogPosts, interviews, events] = await Promise.all([
|
||||
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
|
||||
prisma.interview.findMany({ where: { tags: { has: tagName } } }),
|
||||
prisma.event.findMany({ where: { tags: { has: tagName } } }),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
...blogPosts.map(p => prisma.blogPost.update({
|
||||
where: { id: p.id },
|
||||
data: { tags: { set: p.tags.filter(t => t !== tagName) } }
|
||||
})),
|
||||
...interviews.map(i => prisma.interview.update({
|
||||
where: { id: i.id },
|
||||
data: { tags: { set: i.tags.filter(t => t !== tagName) } }
|
||||
})),
|
||||
...events.map(e => prisma.event.update({
|
||||
where: { id: e.id },
|
||||
data: { tags: { set: e.tags.filter(t => t !== tagName) } }
|
||||
}))
|
||||
]);
|
||||
|
||||
revalidatePath('/taxonomies');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting tag globally:', error);
|
||||
return { success: false, error: 'Erreur lors de la suppression du tag' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameTagGlobally(oldName: string, newName: string) {
|
||||
try {
|
||||
const cleanNewName = newName.trim().toLowerCase();
|
||||
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
|
||||
|
||||
const [blogPosts, interviews, events] = await Promise.all([
|
||||
prisma.blogPost.findMany({ where: { tags: { has: oldName } } }),
|
||||
prisma.interview.findMany({ where: { tags: { has: oldName } } }),
|
||||
prisma.event.findMany({ where: { tags: { has: oldName } } }),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
...blogPosts.map(p => prisma.blogPost.update({
|
||||
where: { id: p.id },
|
||||
data: { tags: { set: p.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||
})),
|
||||
...interviews.map(i => prisma.interview.update({
|
||||
where: { id: i.id },
|
||||
data: { tags: { set: i.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||
})),
|
||||
...events.map(e => prisma.event.update({
|
||||
where: { id: e.id },
|
||||
data: { tags: { set: e.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||
}))
|
||||
]);
|
||||
|
||||
revalidatePath('/taxonomies');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error renaming tag globally:', error);
|
||||
return { success: false, error: 'Erreur lors du renommage du tag' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTagUsage(tagName: string) {
|
||||
try {
|
||||
const [blogPosts, interviews, events] = await Promise.all([
|
||||
prisma.blogPost.findMany({
|
||||
where: { tags: { has: tagName } },
|
||||
select: { id: true, title: true, status: true, author: true }
|
||||
}),
|
||||
prisma.interview.findMany({
|
||||
where: { tags: { has: tagName } },
|
||||
select: { id: true, title: true, status: true, guestName: true }
|
||||
}),
|
||||
prisma.event.findMany({
|
||||
where: { tags: { has: tagName } },
|
||||
select: { id: true, title: true, status: true, location: true }
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
blogPosts: blogPosts.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
type: 'blog'
|
||||
})),
|
||||
interviews: interviews.map(i => ({
|
||||
id: i.id,
|
||||
title: i.title,
|
||||
status: i.status,
|
||||
type: 'interview'
|
||||
})),
|
||||
events: events.map(e => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
status: e.status,
|
||||
type: 'event'
|
||||
})),
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching tag usage:', error);
|
||||
return { success: false, error: 'Erreur lors de la récupération de l\'usage' };
|
||||
}
|
||||
}
|
||||
42
admin/src/app/actions/upload.ts
Normal file
42
admin/src/app/actions/upload.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
'use server';
|
||||
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function uploadImage(formData: FormData) {
|
||||
try {
|
||||
const file = formData.get('file') as File;
|
||||
if (!file) {
|
||||
return { success: false, error: 'Aucun fichier fourni' };
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Create a unique filename
|
||||
const fileExtension = file.name.split('.').pop();
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
||||
|
||||
// Path for the ROOT public/uploads (where the main site will look)
|
||||
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
|
||||
// Path for the ADMIN public/uploads (where the admin app can see it for preview)
|
||||
const adminPublicDir = join(process.cwd(), 'public', 'uploads');
|
||||
|
||||
// Ensure both directories exist
|
||||
await mkdir(rootPublicDir, { recursive: true });
|
||||
await mkdir(adminPublicDir, { recursive: true });
|
||||
|
||||
// Save to both
|
||||
await writeFile(join(rootPublicDir, fileName), buffer);
|
||||
await writeFile(join(adminPublicDir, fileName), buffer);
|
||||
|
||||
// Return the relative URL
|
||||
return {
|
||||
success: true,
|
||||
url: `/uploads/${fileName}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error during image upload:', error);
|
||||
return { success: false, error: 'Erreur lors de l\'upload de l\'image' };
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,15 @@ async function getData() {
|
||||
return { posts, interviews, events };
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
|
||||
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
|
||||
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20">ARCHIVÉ</span>;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogCMSPage() {
|
||||
const { posts, interviews, events } = await getData();
|
||||
|
||||
@@ -50,6 +59,7 @@ export default async function BlogCMSPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4">Titre</th>
|
||||
<th className="text-left py-3 px-4">Statut</th>
|
||||
<th className="text-left py-3 px-4">Auteur</th>
|
||||
<th className="text-left py-3 px-4">Date</th>
|
||||
<th className="text-right py-3 px-4">Actions</th>
|
||||
@@ -62,6 +72,7 @@ export default async function BlogCMSPage() {
|
||||
<div className="font-medium text-white">{post.title}</div>
|
||||
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">{getStatusBadge(post.status)}</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
|
||||
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
||||
<td className="py-4 px-4 text-right">
|
||||
@@ -90,6 +101,7 @@ export default async function BlogCMSPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4">Interviewé</th>
|
||||
<th className="text-left py-3 px-4">Statut</th>
|
||||
<th className="text-left py-3 px-4">Entreprise / Rôle</th>
|
||||
<th className="text-left py-3 px-4">Type</th>
|
||||
<th className="text-right py-3 px-4">Actions</th>
|
||||
@@ -102,6 +114,7 @@ export default async function BlogCMSPage() {
|
||||
<div className="font-medium text-white">{interview.guestName}</div>
|
||||
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">{getStatusBadge(interview.status)}</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="text-sm text-slate-300">{interview.companyName}</div>
|
||||
<div className="text-xs text-slate-500">{interview.role}</div>
|
||||
@@ -137,6 +150,7 @@ export default async function BlogCMSPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4">Événement</th>
|
||||
<th className="text-left py-3 px-4">Statut</th>
|
||||
<th className="text-left py-3 px-4">Date & Lieu</th>
|
||||
<th className="text-right py-3 px-4">Actions</th>
|
||||
</tr>
|
||||
@@ -150,6 +164,7 @@ export default async function BlogCMSPage() {
|
||||
{event.link ? 'Lien activé' : 'Pas de lien'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">{getStatusBadge(event.status)}</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center text-sm text-slate-300 gap-2">
|
||||
<Calendar className="w-3 h-3 text-slate-500" />
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState<any>(null);
|
||||
|
||||
const renderIcon = (iconName: string) => {
|
||||
const found = ICON_LIST.find(i => i.name === iconName);
|
||||
if (found) {
|
||||
const IconComp = found.icon;
|
||||
return <IconComp className="w-4 h-4 text-indigo-400" />;
|
||||
}
|
||||
return <Briefcase className="w-4 h-4 text-indigo-400" />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Secteurs & Catégories</h1>
|
||||
<p className="text-slate-400">Gérez les secteurs d'activité disponibles pour les entrepreneurs.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Nouvelle Catégorie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-white">Catégories Officielles</h2>
|
||||
<span className="text-xs text-slate-400">{categories.length} secteurs</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Nom</th>
|
||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Statut</th>
|
||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{categories.map((cat) => (
|
||||
<tr key={cat.id} className="hover:bg-slate-800/30 transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-slate-800 rounded-lg flex items-center justify-center">
|
||||
{renderIcon(cat.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{cat.name}</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono">{cat.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{cat.isActive ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500">
|
||||
<Check className="w-3 h-3" /> Actif
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-500">
|
||||
<X className="w-3 h-3" /> Inactif
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openEdit(cat)}
|
||||
className="p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-all"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cat.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-yellow-400" />
|
||||
<h2 className="font-semibold text-white">Suggestions</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Check className="w-6 h-6 text-slate-600" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">Aucune suggestion en attente.</p>
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((sugg) => (
|
||||
<div key={sugg.id} className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2 hover:border-indigo-500/30 transition-colors group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-wider bg-indigo-400/10 px-2 py-0.5 rounded">Proposé</span>
|
||||
<span className="text-[10px] text-slate-500">{new Date(sugg.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p className="text-white font-bold text-lg">{sugg.suggestedCategory}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Briefcase className="w-3 h-3" />
|
||||
<span>Par : <span className="text-slate-300 font-medium">{sugg.name}</span></span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showAddModal || editingCategory) && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/30">
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedSuggestion && <Sparkles className="w-5 h-5 text-yellow-400" />}
|
||||
<h3 className="text-xl font-bold text-white">
|
||||
{editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={editingCategory ? handleUpdateCategory : handleAddCategory} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Nom de la catégorie</label>
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
const slug = name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, '');
|
||||
setFormData({...formData, name, slug});
|
||||
}}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Slug (URL)</label>
|
||||
<input
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({...formData, slug: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-950 rounded-xl border border-slate-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({...formData, isActive: e.target.checked})}
|
||||
className="w-5 h-5 rounded border-slate-700 bg-slate-800 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-slate-300">Rendre active immédiatement</label>
|
||||
</div>
|
||||
|
||||
{selectedSuggestion && (
|
||||
<div className="p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-xs text-indigo-300">
|
||||
<p>✨ **Note :** En créant cette catégorie, l'entreprise **{selectedSuggestion.name}** y sera automatiquement rattachée.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400 block mb-2">Choisir une icône</label>
|
||||
<div className="grid grid-cols-5 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{ICON_LIST.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, icon: item.name})}
|
||||
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${
|
||||
formData.icon === item.name
|
||||
? 'bg-indigo-600 border-indigo-500 text-white shadow-lg shadow-indigo-600/30 scale-105'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
|
||||
}`}
|
||||
title={item.name}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
|
||||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all border border-slate-700"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
|
||||
>
|
||||
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{editingCategory ? 'Sauvegarder les modifications' : selectedSuggestion ? 'Valider et Créer' : 'Créer la catégorie'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
admin/src/app/dashboard/DashboardClient.tsx
Normal file
68
admin/src/app/dashboard/DashboardClient.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
|
||||
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
{statCards.map((card) => (
|
||||
<div key={card.label} className="card">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
|
||||
<card.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">{card.value}</span>
|
||||
</div>
|
||||
<h3 className="text-slate-400 font-medium">{card.label}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
|
||||
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
{statCards.map((card) => (
|
||||
<div key={card.label} className="card">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
|
||||
<card.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">{card.value}</span>
|
||||
</div>
|
||||
<h3 className="text-slate-400 font-medium">{card.label}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <DashboardClient stats={stats} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
38
admin/src/app/not-found.tsx
Normal file
38
admin/src/app/not-found.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 mb-6">
|
||||
<span className="text-4xl font-bold text-indigo-400">404</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1>
|
||||
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a été déplacée.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
Retour au Dashboard
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Page précédente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
415
admin/src/app/taxonomies/TaxonomiesClient.tsx
Normal file
415
admin/src/app/taxonomies/TaxonomiesClient.tsx
Normal file
@@ -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<Tab>('categories');
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Category Form State
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<any>(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 <div className="p-8 text-center text-slate-500">Chargement...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Taxonomies</h1>
|
||||
<p className="text-slate-400">Gérez les catégories et les mots-clés du site.</p>
|
||||
</div>
|
||||
<div className="flex bg-slate-900 p-1 rounded-xl border border-slate-800">
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'categories' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Tags className="w-5 h-5 text-indigo-400" />
|
||||
Liste des catégories ({categories.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => { setEditingCategory(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); setShowAddModal(true); }}
|
||||
className="btn-verify flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Nouvelle catégorie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categories.map((cat) => {
|
||||
const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase;
|
||||
return (
|
||||
<div key={cat.id} className="card p-4 flex items-center justify-between group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-slate-800 rounded-lg text-indigo-400">
|
||||
<IconComp className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-bold">{cat.name}</h3>
|
||||
<p className="text-xs text-slate-500">/{cat.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { if (confirm("Supprimer ?")) await deleteCategory(cat.id); loadData(); }}
|
||||
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Hash className="w-5 h-5 text-indigo-400" />
|
||||
Tags utilisés ({tags.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="text-sm font-bold text-slate-300 truncate group-hover:text-white">{tag}</span>
|
||||
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||
<Trash2
|
||||
className="w-3.5 h-3.5 text-slate-500 hover:text-red-400"
|
||||
onClick={(e) => { e.stopPropagation(); handleTagDelete(tag); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Ajout/Modif Catégorie */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-md w-full">
|
||||
<h3 className="text-xl font-bold text-white mb-6">{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}</h3>
|
||||
<form onSubmit={handleCategorySubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-400 mb-2 uppercase tracking-wider">Nom</label>
|
||||
<input
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:border-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-1">
|
||||
{ICON_LIST.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, icon: item.name })}
|
||||
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${formData.icon === item.name ? 'bg-indigo-600/20 border-indigo-500 text-indigo-400' : 'bg-slate-950 border-slate-800 text-slate-500 hover:border-slate-700'}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-8">
|
||||
<button type="button" onClick={() => setShowAddModal(false)} className="flex-1 px-4 py-3 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-all">Annuler</button>
|
||||
<button type="submit" disabled={isPending} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all flex items-center justify-center gap-2">
|
||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||
{editingCategory ? 'Mettre à jour' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Modal */}
|
||||
{usagePreview && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl">
|
||||
<div className="p-6 border-b border-slate-700 bg-slate-800/30 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Hash className="w-6 h-6 text-indigo-500" />
|
||||
{isEditingUsageName ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
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"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-xl font-bold text-white uppercase tracking-tight">Tag : {usagePreview.tag}</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{!isEditingUsageName && (
|
||||
<button onClick={() => { setNewTagName(usagePreview.tag); setIsEditingUsageName(true); }} className="p-2 text-slate-400 hover:text-indigo-400"><Edit2 className="w-4 h-4" /></button>
|
||||
)}
|
||||
<button onClick={() => setUsagePreview(null)} className="p-2 text-slate-400 hover:text-white"><X className="w-6 h-6" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 grid grid-cols-1 md:grid-cols-3 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||
{/* Blog Posts */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="p-1.5 bg-indigo-500/10 rounded-lg">
|
||||
<BookOpen className="w-3.5 h-3.5 text-indigo-400" />
|
||||
</div>
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Articles de Blog ({usagePreview.data.blogPosts.length})</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{usagePreview.data.blogPosts.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-600 italic px-2">Aucun article</p>
|
||||
) : usagePreview.data.blogPosts.map((post: any) => (
|
||||
<div key={post.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-indigo-500/30 transition-all">
|
||||
<span className="text-sm font-medium text-slate-200 truncate pr-2">{post.title}</span>
|
||||
<Link href={`/blog/edit/${post.id}`} className="p-1.5 bg-slate-800 hover:bg-indigo-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interviews */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="p-1.5 bg-emerald-500/10 rounded-lg">
|
||||
<UserIcon className="w-3.5 h-3.5 text-emerald-400" />
|
||||
</div>
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Interviews ({usagePreview.data.interviews.length})</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{usagePreview.data.interviews.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-600 italic px-2">Aucune interview</p>
|
||||
) : usagePreview.data.interviews.map((item: any) => (
|
||||
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-emerald-500/30 transition-all">
|
||||
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||
<Link href={`/interview/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-emerald-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="p-1.5 bg-amber-500/10 rounded-lg">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
</div>
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Événements ({usagePreview.data.events.length})</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{usagePreview.data.events.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-600 italic px-2">Aucun événement</p>
|
||||
) : usagePreview.data.events.map((item: any) => (
|
||||
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-amber-500/30 transition-all">
|
||||
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||
<Link href={`/event/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-amber-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
admin/src/app/taxonomies/page.tsx
Normal file
5
admin/src/app/taxonomies/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TaxonomiesClient from "./TaxonomiesClient";
|
||||
|
||||
export default function TaxonomiesPage() {
|
||||
return <TaxonomiesClient />;
|
||||
}
|
||||
Reference in New Issue
Block a user