Maj taxonomies
Some checks failed
Build and Push App / build (push) Failing after 11m28s

maj url photo
+ page 404
+ gestion programmation d'article
This commit is contained in:
2026-05-03 21:22:12 +02:00
parent 0e72867d4c
commit 5f421b418e
41 changed files with 1946 additions and 735 deletions

View File

@@ -47,11 +47,13 @@
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"isomorphic-dompurify": "^3.11.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"next": "^16.1.6", "next": "^16.1.6",

View File

@@ -9,119 +9,121 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
email String @unique email String @unique
password String password String
role UserRole @default(VISITOR) role UserRole @default(VISITOR)
avatar String? avatar String?
emailVerified Boolean @default(false) createdAt DateTime @default(now())
verificationToken String? @unique updatedAt DateTime @updatedAt
resetToken String? @unique bio String?
resetTokenExpiry DateTime? location String?
createdAt DateTime @default(now()) phone String?
updatedAt DateTime @updatedAt isSuspended Boolean @default(false)
bio String? suspensionReason String?
location String? countryId String?
countryId String? resetToken String? @unique
phone String? resetTokenExpiry DateTime?
isSuspended Boolean @default(false) emailVerified Boolean @default(false)
suspensionReason String? verificationToken String? @unique
businesses Business? businesses Business?
comments Comment[] comments Comment[]
conversations ConversationParticipant[] conversations ConversationParticipant[]
sentMessages Message[] sentMessages Message[]
reports MessageReport[] reports MessageReport[]
ratings Rating[] ratings Rating[]
country Country? @relation(fields: [countryId], references: [id]) country Country? @relation(fields: [countryId], references: [id])
favorites Favorite[]
} }
model Business { model Business {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
category String category String
location String
description String
logoUrl String
videoUrl String?
contactEmail String
contactPhone String?
socialLinks Json?
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
ratingCount Int @default(0)
tags String[]
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
ownerId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
isActive Boolean @default(false)
showEmail Boolean @default(true)
showPhone Boolean @default(true)
showSocials Boolean @default(true)
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
plan Plan @default(STARTER)
city String?
countryId String?
coverUrl String?
coverPosition String? @default("50% 50%")
coverZoom Float? @default(1.0)
suggestedCategory String? suggestedCategory String?
location String categoryId String?
countryId String? categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
city String? country Country? @relation(fields: [countryId], references: [id])
description String owner User @relation(fields: [ownerId], references: [id])
logoUrl String comments Comment[]
coverUrl String? conversations Conversation[]
coverPosition String? @default("50% 50%") offers Offer[]
coverZoom Float? @default(1.0) ratings Rating[]
videoUrl String? favorites Favorite[]
contactEmail String
contactPhone String?
socialLinks Json?
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
ratingCount Int @default(0)
tags String[]
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
ownerId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slug String? @unique
isActive Boolean @default(false)
showEmail Boolean @default(true)
showPhone Boolean @default(true)
showSocials Boolean @default(true)
websiteUrl String?
isSuspended Boolean @default(false)
suspensionReason String?
plan Plan @default(STARTER)
owner User @relation(fields: [ownerId], references: [id])
comments Comment[]
conversations Conversation[]
offers Offer[]
ratings Rating[]
country Country? @relation(fields: [countryId], references: [id])
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
categoryId String?
} }
model BusinessCategory { model BusinessCategory {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
slug String @unique slug String @unique
icon String? // Lucide icon name icon String?
isActive Boolean @default(true) isActive Boolean @default(true)
businesses Business[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
businesses Business[]
} }
model Country { model Country {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
code String @unique // ISO alpha-2 code String @unique
flag String? // Emoji or URL flag String?
isActive Boolean @default(true) isActive Boolean @default(true)
description String? @db.Text
businesses Business[]
users User[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
description String?
businesses Business[]
users User[]
} }
model PricingPlan { model PricingPlan {
id String @id @default(uuid()) id String @id @default(uuid())
tier Plan @unique tier Plan @unique
name String name String
priceXOF String priceXOF String
yearlyPriceXOF String? priceEUR String
priceEUR String description String
features String[]
offerLimit Int @default(1)
recommended Boolean @default(false)
color String @default("gray")
updatedAt DateTime @updatedAt
yearlyPriceEUR String? yearlyPriceEUR String?
description String yearlyPriceXOF String?
features String[]
offerLimit Int @default(1)
recommended Boolean @default(false)
color String @default("gray")
updatedAt DateTime @updatedAt
} }
model Offer { model Offer {
@@ -140,25 +142,26 @@ model Offer {
} }
model BlogPost { model BlogPost {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
slug String? @unique
excerpt String excerpt String
content String content String
author String author String
date DateTime @default(now()) date DateTime @default(now())
imageUrl String imageUrl String
tags String[] @default([]) createdAt DateTime @default(now())
metaTitle String? updatedAt DateTime @updatedAt
metaDescription String? metaDescription String?
createdAt DateTime @default(now()) metaTitle String?
updatedAt DateTime @updatedAt slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
} }
model Interview { model Interview {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
slug String? @unique
guestName String guestName String
companyName String companyName String
role String role String
@@ -169,11 +172,14 @@ model Interview {
excerpt String excerpt String
date DateTime @default(now()) date DateTime @default(now())
duration String? duration String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
} }
model Comment { model Comment {
@@ -194,40 +200,34 @@ model AnalyticsEvent {
label String? label String?
value Float? value Float?
metadata Json? metadata Json?
ip String? createdAt DateTime @default(now())
country String?
city String?
browser String? browser String?
os String? city String?
country String?
device String? device String?
ip String?
os String?
referrer String? referrer String?
userId String? userId String?
createdAt DateTime @default(now())
} }
model Rating { model Rating {
id String @id @default(uuid()) id String @id @default(uuid())
value Int value Int
userId String
businessId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comment String? comment String?
reply String? reply String?
replyAt DateTime? replyAt DateTime?
status RatingStatus @default(PENDING) status RatingStatus @default(PENDING)
userId String business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
businessId String user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
@@unique([userId, businessId]) @@unique([userId, businessId])
} }
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
model Conversation { model Conversation {
id String @id @default(uuid()) id String @id @default(uuid())
businessId String businessId String
@@ -272,6 +272,82 @@ model MessageReport {
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
} }
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
updatedAt DateTime @updatedAt
homeBlogCount Int @default(3)
homeBlogShow Boolean @default(true)
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
homeBlogTitle String @default("Derniers Articles")
homeBlogCategories String[] @default([])
homeBlogIds String[] @default([])
homeBlogSelection String @default("latest")
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>")
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>")
}
model LegalDocument {
id String @id @default(uuid())
type String @unique
title String
content String
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(uuid())
title String
description String
date DateTime
location String
thumbnailUrl String
link String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metaDescription String?
metaTitle String?
slug String? @unique
tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
}
model Favorite {
id String @id @default(uuid())
userId String
businessId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
@@unique([userId, businessId])
}
enum ContentStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum RatingStatus {
PENDING
APPROVED
REJECTED
}
enum ReportStatus { enum ReportStatus {
PENDING PENDING
RESOLVED RESOLVED
@@ -299,54 +375,3 @@ enum InterviewType {
VIDEO VIDEO
ARTICLE ARTICLE
} }
model SiteSetting {
id String @id @default("singleton")
siteName String @default("Afrohub")
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
contactEmail String @default("support@afrohub.com")
contactPhone String? @default("+225 00 00 00 00 00")
address String? @default("Abidjan, Côte d'Ivoire")
facebookUrl String?
twitterUrl String?
instagramUrl String?
linkedinUrl String?
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
homeBlogShow Boolean @default(true)
homeBlogTitle String @default("Derniers Articles")
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
homeBlogCount Int @default(3)
homeBlogSelection String @default("latest")
homeBlogIds String[] @default([])
homeBlogCategories String[] @default([])
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">&copy; 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
updatedAt DateTime @updatedAt
}
model LegalDocument {
id String @id @default(uuid())
type String @unique // 'CGU' or 'CGV'
title String
content String @db.Text
updatedAt DateTime @updatedAt
}
model Event {
id String @id @default(uuid())
title String
slug String? @unique
description String @db.Text
date DateTime
location String
thumbnailUrl String
link String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils"; import { generateSlug } from "@/lib/utils";
import { ContentStatus } from "@prisma/client";
export async function createBlogPost(data: { export async function createBlogPost(data: {
title: string; title: string;
@@ -14,6 +15,8 @@ export async function createBlogPost(data: {
tags?: string[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -23,10 +26,12 @@ export async function createBlogPost(data: {
...data, ...data,
slug, slug,
tags: data.tags || [], tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
}, },
}); });
revalidatePath("/blog"); revalidatePath("/blog");
return { success: true, data: post }; return { success: true, id: post.id };
} catch (error) { } catch (error) {
console.error("Failed to create blog post:", error); console.error("Failed to create blog post:", error);
return { success: false, error: "Erreur lors de la création de l'article" }; 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[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -56,7 +63,7 @@ export async function updateBlogPost(id: string, data: {
}, },
}); });
revalidatePath("/blog"); revalidatePath("/blog");
return { success: true, data: post }; return { success: true, id: post.id };
} catch (error) { } catch (error) {
console.error("Failed to update blog post:", error); console.error("Failed to update blog post:", error);
return { success: false, error: "Erreur lors de la mise à jour" }; return { success: false, error: "Erreur lors de la mise à jour" };

View File

@@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils"; import { generateSlug } from "@/lib/utils";
import { ContentStatus } from "@prisma/client";
export async function createEvent(data: { export async function createEvent(data: {
title: string; title: string;
@@ -15,6 +16,8 @@ export async function createEvent(data: {
tags?: string[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -24,6 +27,8 @@ export async function createEvent(data: {
...data, ...data,
slug, slug,
tags: data.tags || [], tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
}, },
}); });
revalidatePath("/blog"); revalidatePath("/blog");
@@ -46,6 +51,8 @@ export async function updateEvent(id: string, data: {
tags?: string[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);

View File

@@ -2,7 +2,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { InterviewType } from "@prisma/client"; import { InterviewType, ContentStatus } from "@prisma/client";
import { generateSlug } from "@/lib/utils"; import { generateSlug } from "@/lib/utils";
export async function createInterview(data: { export async function createInterview(data: {
@@ -20,6 +20,8 @@ export async function createInterview(data: {
tags?: string[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);
@@ -29,6 +31,8 @@ export async function createInterview(data: {
...data, ...data,
slug, slug,
tags: data.tags || [], tags: data.tags || [],
publishedAt: data.publishedAt || new Date(),
status: data.status || ContentStatus.PUBLISHED,
}, },
}); });
revalidatePath("/blog"); revalidatePath("/blog");
@@ -54,6 +58,8 @@ export async function updateInterview(id: string, data: {
tags?: string[]; tags?: string[];
metaTitle?: string; metaTitle?: string;
metaDescription?: string; metaDescription?: string;
publishedAt?: Date;
status?: ContentStatus;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title); const slug = data.slug || generateSlug(data.title);

View 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' };
}
}

View 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' };
}
}

View File

@@ -12,6 +12,15 @@ async function getData() {
return { posts, interviews, events }; 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() { export default async function BlogCMSPage() {
const { posts, interviews, events } = await getData(); const { posts, interviews, events } = await getData();
@@ -50,6 +59,7 @@ export default async function BlogCMSPage() {
<thead> <thead>
<tr> <tr>
<th className="text-left py-3 px-4">Titre</th> <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">Auteur</th>
<th className="text-left py-3 px-4">Date</th> <th className="text-left py-3 px-4">Date</th>
<th className="text-right py-3 px-4">Actions</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="font-medium text-white">{post.title}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div> <div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
</td> </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-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-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="py-4 px-4 text-right"> <td className="py-4 px-4 text-right">
@@ -90,6 +101,7 @@ export default async function BlogCMSPage() {
<thead> <thead>
<tr> <tr>
<th className="text-left py-3 px-4">Interviewé</th> <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">Entreprise / Rôle</th>
<th className="text-left py-3 px-4">Type</th> <th className="text-left py-3 px-4">Type</th>
<th className="text-right py-3 px-4">Actions</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="font-medium text-white">{interview.guestName}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div> <div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
</td> </td>
<td className="py-4 px-4">{getStatusBadge(interview.status)}</td>
<td className="py-4 px-4"> <td className="py-4 px-4">
<div className="text-sm text-slate-300">{interview.companyName}</div> <div className="text-sm text-slate-300">{interview.companyName}</div>
<div className="text-xs text-slate-500">{interview.role}</div> <div className="text-xs text-slate-500">{interview.role}</div>
@@ -137,6 +150,7 @@ export default async function BlogCMSPage() {
<thead> <thead>
<tr> <tr>
<th className="text-left py-3 px-4">Événement</th> <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-left py-3 px-4">Date & Lieu</th>
<th className="text-right py-3 px-4">Actions</th> <th className="text-right py-3 px-4">Actions</th>
</tr> </tr>
@@ -150,6 +164,7 @@ export default async function BlogCMSPage() {
{event.link ? 'Lien activé' : 'Pas de lien'} {event.link ? 'Lien activé' : 'Pas de lien'}
</div> </div>
</td> </td>
<td className="py-4 px-4">{getStatusBadge(event.status)}</td>
<td className="py-4 px-4"> <td className="py-4 px-4">
<div className="flex items-center text-sm text-slate-300 gap-2"> <div className="flex items-center text-sm text-slate-300 gap-2">
<Calendar className="w-3 h-3 text-slate-500" /> <Calendar className="w-3 h-3 text-slate-500" />

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -1,11 +1,5 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { import DashboardClient from './DashboardClient';
Users,
Store,
Clock,
MessageCircle,
Eye
} from 'lucide-react';
async function getStats() { async function getStats() {
const usersCount = await prisma.user.count(); const usersCount = await prisma.user.count();
@@ -32,51 +26,5 @@ async function getStats() {
export default async function DashboardPage() { export default async function DashboardPage() {
const stats = await getStats(); const stats = await getStats();
return <DashboardClient 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>
);
} }

View File

@@ -265,6 +265,13 @@ body {
font-style: normal; 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) */ /* Styles for the Divider (HR) */
.quill-dark-wrapper .ql-editor hr { .quill-dark-wrapper .ql-editor hr {
border: none; border: none;

View 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 é 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import TaxonomiesClient from "./TaxonomiesClient";
export default function TaxonomiesPage() {
return <TaxonomiesClient />;
}

View File

@@ -1,12 +1,16 @@
"use client"; "use client";
import { useTransition, useState } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog'; 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 Link from 'next/link';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor'; 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 { interface Props {
initialData?: { initialData?: {
@@ -20,6 +24,8 @@ interface Props {
tags?: string[]; tags?: string[];
metaTitle?: string | null; metaTitle?: string | null;
metaDescription?: 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 router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || ''); const [content, setContent] = useState(initialData?.content || '');
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
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<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data = { const data = {
title: formData.get('title') as string, title: formData.get('title') as string,
slug: formData.get('slug') as string, slug: formData.get('slug') as string,
excerpt: formData.get('excerpt') as string, excerpt: formData.get('excerpt') as string,
content: content, content: content,
author: formData.get('author') as string, author: formData.get('author') as string,
imageUrl: formData.get('imageUrl') as string, imageUrl: imageUrl, // Use state value
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''), tags: tags, // Use state value
metaTitle: formData.get('metaTitle') as string, metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') 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 () => { startTransition(async () => {
const result = initialData const result = initialData
? await updateBlogPost(initialData.id, data) ? await updateBlogPost(initialData.id, data)
: await createBlogPost(data); : await createBlogPost(data);
if (result.success) { 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.push('/blog');
router.refresh(); router.refresh();
} else { } else {
@@ -73,7 +102,21 @@ export default function BlogForm({ initialData }: Props) {
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6"> <div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2> <div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
@@ -98,15 +141,30 @@ export default function BlogForm({ initialData }: Props) {
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<label className="text-sm font-medium text-slate-400">URL de l'image</label> <div className="space-y-2">
<input <ImageUploader
name="imageUrl" label="Image de couverture"
defaultValue={initialData?.imageUrl} value={imageUrl}
required onChange={setImageUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" name="imageUrl"
placeholder="https://images.unsplash.com/..." />
/> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
<input
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -147,12 +205,12 @@ export default function BlogForm({ initialData }: Props) {
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p> <p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label> <TagInput
<input value={tags}
name="tags" onChange={setTags}
defaultValue={initialData?.tags?.join(', ') || ''} suggestions={allExistingTags}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" label="Mots-clés (tags)"
placeholder="tech, afrique, innovation" placeholder="tech, innovation, afrique..."
/> />
</div> </div>
</div> </div>
@@ -190,7 +248,7 @@ export default function BlogForm({ initialData }: Props) {
) : ( ) : (
<Save className="w-6 h-6" /> <Save className="w-6 h-6" />
)} )}
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'} {initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -1,12 +1,16 @@
"use client"; "use client";
import { useTransition, useState } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event'; 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 Link from 'next/link';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor'; 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 { interface Props {
initialData?: any; initialData?: any;
@@ -16,23 +20,46 @@ export default function EventForm({ initialData }: Props) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [description, setDescription] = useState(initialData?.description || ''); const [description, setDescription] = useState(initialData?.description || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
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<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data: any = { const data: any = {
title: formData.get('title') as string, title: formData.get('title') as string,
slug: formData.get('slug') as string, slug: formData.get('slug') as string,
location: formData.get('location') as string, location: formData.get('location') as string,
date: new Date(formData.get('date') 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, link: formData.get('link') as string || null,
description: description || '', 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, metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') 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 () => { startTransition(async () => {
const result = initialData const result = initialData
? await updateEvent(initialData.id, data) ? await updateEvent(initialData.id, data)
@@ -63,7 +90,21 @@ export default function EventForm({ initialData }: Props) {
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6"> <div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2> <div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
@@ -77,7 +118,7 @@ export default function EventForm({ initialData }: Props) {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Date de l'événement</label> <label className="text-sm font-medium text-slate-400">Date de l'événement (Début)</label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" /> <Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input <input
@@ -105,6 +146,32 @@ export default function EventForm({ initialData }: Props) {
/> />
</div> </div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
</label>
<div className="relative">
<Send className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<ImageUploader
label="Image (Affiche/Miniature)"
value={thumbnailUrl}
onChange={setThumbnailUrl}
name="thumbnailUrl"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label> <label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
<div className="relative"> <div className="relative">
@@ -119,17 +186,6 @@ export default function EventForm({ initialData }: Props) {
</div> </div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de l'image (Affiche/Miniature)</label>
<input
name="thumbnailUrl"
defaultValue={initialData?.thumbnailUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Description de l'événement</label> <label className="text-sm font-medium text-slate-400">Description de l'événement</label>
<RichTextEditor <RichTextEditor
@@ -156,12 +212,12 @@ export default function EventForm({ initialData }: Props) {
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p> <p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label> <TagInput
<input value={tags}
name="tags" onChange={setTags}
defaultValue={initialData?.tags?.join(', ') || ''} suggestions={allExistingTags}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" label="Mots-clés (tags)"
placeholder="evenement, networking, afrique" placeholder="evenement, networking, afrique..."
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,174 @@
'use client';
import React, { useState, useRef } from 'react';
import { Upload, Link as LinkIcon, X, Image as ImageIcon, Loader2 } from 'lucide-react';
import { uploadImage } from '@/app/actions/upload';
import { toast } from 'react-hot-toast';
interface Props {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
name?: string;
}
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Basic validation
if (!file.type.startsWith('image/')) {
toast.error("Le fichier doit être une image");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image ne doit pas dépasser 5 Mo");
return;
}
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const result = await uploadImage(formData);
if (result.success && result.url) {
onChange(result.url);
toast.success("Image uploadée avec succès");
} else {
toast.error(result.error || "Erreur lors de l'upload");
}
} catch (error) {
toast.error("Erreur de connexion lors de l'upload");
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const clearImage = () => {
onChange('');
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-400">{label || "Image"}</label>
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-800">
<button
type="button"
onClick={() => setMode('url')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
mode === 'url' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<LinkIcon className="w-3.5 h-3.5" />
Lien URL
</button>
<button
type="button"
onClick={() => setMode('upload')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
mode === 'upload' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
<Upload className="w-3.5 h-3.5" />
Upload
</button>
</div>
</div>
<div className="relative group">
{mode === 'url' ? (
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
type="text"
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "https://images.unsplash.com/..."}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
) : (
<div className="space-y-4">
{!value ? (
<div
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer border-2 border-dashed border-slate-700 hover:border-indigo-500 rounded-xl p-8 flex flex-col items-center justify-center gap-3 bg-slate-900/50 hover:bg-slate-900 transition-all group"
>
{isUploading ? (
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
) : (
<div className="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center group-hover:bg-indigo-500/20 group-hover:text-indigo-400 transition-colors">
<Upload className="w-6 h-6 text-slate-400 group-hover:text-indigo-400" />
</div>
)}
<div className="text-center">
<p className="text-sm font-medium text-white">
{isUploading ? "Upload en cours..." : "Cliquez pour uploader"}
</p>
<p className="text-xs text-slate-500 mt-1">PNG, JPG ou WEBP jusqu'à 5 Mo</p>
</div>
</div>
) : (
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
<img
src={value}
alt="Prévisualisation"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
>
<Upload className="w-5 h-5" />
</button>
<button
type="button"
onClick={clearImage}
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Hidden input for value to be sent via form if needed */}
<input type="hidden" name={name} value={value} />
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
</div>
)}
</div>
{value && mode === 'url' && (
<div className="mt-3 relative rounded-lg overflow-hidden aspect-video border border-slate-800">
<img src={value} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-black transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}

View File

@@ -1,13 +1,16 @@
"use client"; "use client";
import { useTransition, useState } from 'react'; import { useTransition, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview'; import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText } from 'lucide-react'; import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { InterviewType } from '@prisma/client'; import { InterviewType, ContentStatus } from '@prisma/client';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor'; import RichTextEditor from './RichTextEditor';
import ImageUploader from './ImageUploader';
import TagInput from './TagInput';
import { getAllTags } from '@/app/actions/taxonomies';
interface Props { interface Props {
initialData?: any; initialData?: any;
@@ -17,10 +20,26 @@ export default function InterviewForm({ initialData }: Props) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [content, setContent] = useState(initialData?.content || ''); const [content, setContent] = useState(initialData?.content || '');
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
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<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const publishedAtStr = formData.get('publishedAt') as string;
const data: any = { const data: any = {
title: formData.get('title') as string, title: formData.get('title') as string,
slug: formData.get('slug') as string, slug: formData.get('slug') as string,
@@ -28,16 +47,23 @@ export default function InterviewForm({ initialData }: Props) {
companyName: formData.get('companyName') as string, companyName: formData.get('companyName') as string,
role: formData.get('role') as string, role: formData.get('role') as string,
type: formData.get('type') as InterviewType, type: formData.get('type') as InterviewType,
thumbnailUrl: formData.get('thumbnailUrl') as string, thumbnailUrl: thumbnailUrl, // Use state value
videoUrl: formData.get('videoUrl') as string || null, videoUrl: formData.get('videoUrl') as string || null,
excerpt: formData.get('excerpt') as string, excerpt: formData.get('excerpt') as string,
content: content || null, content: content || null,
duration: formData.get('duration') as string || null, duration: formData.get('duration') as string || null,
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''), tags: tags, // Use state
metaTitle: formData.get('metaTitle') as string, metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') 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 miniature est requise");
return;
}
startTransition(async () => { startTransition(async () => {
const result = initialData const result = initialData
? await updateInterview(initialData.id, data) ? await updateInterview(initialData.id, data)
@@ -68,7 +94,21 @@ export default function InterviewForm({ initialData }: Props) {
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6"> <div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2> <div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
<div className="flex items-center gap-3">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
<select
name="status"
defaultValue={initialData?.status || 'PUBLISHED'}
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
>
<option value="DRAFT">Brouillon</option>
<option value="PUBLISHED">Publié</option>
<option value="ARCHIVED">Archivé</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
@@ -126,24 +166,29 @@ export default function InterviewForm({ initialData }: Props) {
</select> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label> <label className="text-sm font-medium text-slate-400">
<input Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
name="duration" </label>
defaultValue={initialData?.duration} <div className="relative">
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" <Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
placeholder="Ex: 12 min" <input
/> name="publishedAt"
type="datetime-local"
value={publishedAtValue}
onChange={(e) => setPublishedAtValue(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de la miniature (Thumbnail)</label> <ImageUploader
<input label="Miniature (Thumbnail)"
name="thumbnailUrl" value={thumbnailUrl}
defaultValue={initialData?.thumbnailUrl} onChange={setThumbnailUrl}
required name="thumbnailUrl"
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -154,6 +199,15 @@ export default function InterviewForm({ initialData }: Props) {
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://youtube.com/..." placeholder="https://youtube.com/..."
/> />
<div className="space-y-2 mt-4">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<input
name="duration"
defaultValue={initialData?.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: 12 min"
/>
</div>
</div> </div>
</div> </div>
@@ -194,12 +248,12 @@ export default function InterviewForm({ initialData }: Props) {
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p> <p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label> <TagInput
<input value={tags}
name="tags" onChange={setTags}
defaultValue={initialData?.tags?.join(', ') || ''} suggestions={allExistingTags}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" label="Mots-clés (tags)"
placeholder="interview, succes, tech" placeholder="tech, succes, interview..."
/> />
</div> </div>
</div> </div>

View File

@@ -15,7 +15,8 @@ import {
Settings, Settings,
Globe, Globe,
FileText, FileText,
Briefcase Briefcase,
Tags
} from 'lucide-react'; } from 'lucide-react';
const menuItems = [ const menuItems = [
@@ -26,7 +27,7 @@ const menuItems = [
{ name: 'Modération', icon: Flag, href: '/moderation' }, { name: 'Modération', icon: Flag, href: '/moderation' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' }, { name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' }, { name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
{ name: 'Secteurs & Catégories', icon: Briefcase, href: '/categories' }, { name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
{ name: 'Pays', icon: Globe, href: '/countries' }, { name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' }, { name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' }, { name: 'Configuration', icon: Settings, href: '/settings' },

View File

@@ -0,0 +1,180 @@
'use client';
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
import { X, Hash, Search, TrendingUp } from 'lucide-react';
interface Props {
value: string[];
onChange: (value: string[]) => void;
label?: string;
placeholder?: string;
suggestions?: string[];
}
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
const [inputValue, setInputValue] = useState('');
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputValue.trim()) {
const filtered = suggestions
.filter(tag =>
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
!value.includes(tag.toLowerCase())
)
.slice(0, 10);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else if (showSuggestions && suggestions.length > 0) {
// Show popular/all suggestions when empty but focused
const filtered = suggestions
.filter(tag => !value.includes(tag.toLowerCase()))
.slice(0, 8);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else {
setFilteredSuggestions([]);
setShowSuggestions(false);
}
setSelectedIndex(-1);
}, [inputValue, suggestions, value, showSuggestions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTag = (tagToAdd?: string) => {
const tag = (tagToAdd || inputValue).trim().toLowerCase();
if (tag && !value.includes(tag)) {
onChange([...value, tag]);
}
setInputValue('');
// Keep focus if we added via suggestion
if (tagToAdd) setShowSuggestions(true);
};
const removeTag = (tagToRemove: string) => {
onChange(value.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
addTag(filteredSuggestions[selectedIndex]);
} else {
addTag();
}
} else if (e.key === ',' || e.key === ';') {
e.preventDefault();
addTag();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!showSuggestions && suggestions.length > 0) {
setShowSuggestions(true);
} else {
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
} else if (e.key === 'Escape') {
setShowSuggestions(false);
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
removeTag(value[value.length - 1]);
}
};
return (
<div className="space-y-2 relative" ref={containerRef}>
{label && <label className="text-sm font-medium text-slate-400">{label}</label>}
<div
className="min-h-[48px] p-1.5 bg-slate-900 border border-slate-700 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-indigo-500 transition-colors cursor-text"
onClick={() => {
const input = containerRef.current?.querySelector('input');
input?.focus();
}}
>
{value.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
>
<Hash className="w-3 h-3 opacity-50" />
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="hover:text-red-400 transition-colors ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
className="flex-1 bg-transparent border-none outline-none text-white text-sm min-w-[120px] p-1 placeholder:text-slate-600"
/>
</div>
{/* Suggestions Popover */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-b border-slate-700 bg-slate-900/50 flex items-center justify-between">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest flex items-center gap-1.5">
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
{inputValue ? 'Suggestions de recherche' : 'Mots-clés suggérés'}
</p>
{!inputValue && <span className="text-[9px] text-slate-600 font-medium">Top {filteredSuggestions.length}</span>}
</div>
<div className="max-h-60 overflow-y-auto custom-scrollbar">
{filteredSuggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={(e) => {
e.stopPropagation();
addTag(suggestion);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
index === selectedIndex ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700'
}`}
>
<span className="flex items-center gap-2">
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-slate-500'}`} />
{suggestion}
</span>
{index === selectedIndex && (
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
)}
</button>
))}
</div>
</div>
)}
<p className="text-[10px] text-slate-500 italic">
Appuyez sur Entrée ou virgule pour ajouter un tag.
</p>
</div>
);
}

View File

@@ -14,16 +14,21 @@ interface Props {
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const now = new Date();
const interview = await prisma.interview.findFirst({ const interview = await prisma.interview.findFirst({
where: { where: {
OR: [{ id }, { slug: id }] OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
} }
}); });
const event = !interview ? await prisma.event.findFirst({ const event = !interview ? await prisma.event.findFirst({
where: { where: {
OR: [{ id }, { slug: id }] OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
} }
}) : null; }) : null;
@@ -47,18 +52,23 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function AfroLifeDetailPage({ params }: Props) { export default async function AfroLifeDetailPage({ params }: Props) {
const { id } = await params; const { id } = await params;
const now = new Date();
// 1. Try to find an interview // 1. Try to find an interview
const interview = await prisma.interview.findFirst({ const interview = await prisma.interview.findFirst({
where: { where: {
OR: [{ id }, { slug: id }] OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
} }
}); });
// 2. If not found, try to find an event // 2. If not found, try to find an event
const event = !interview ? await prisma.event.findFirst({ const event = !interview ? await prisma.event.findFirst({
where: { where: {
OR: [{ id }, { slug: id }] OR: [{ id }, { slug: id }],
publishedAt: { lte: now },
status: 'PUBLISHED'
} }
}) : null; }) : null;
@@ -157,7 +167,7 @@ export default async function AfroLifeDetailPage({ params }: Props) {
)} )}
{/* Description / Article Content / Event Content */} {/* Description / Article Content / Event Content */}
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600 break-words overflow-hidden"> <div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600">
{isEvent ? ( {isEvent ? (
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} /> <div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
) : ( ) : (
@@ -195,4 +205,3 @@ export default async function AfroLifeDetailPage({ params }: Props) {
</div> </div>
); );
} }

View File

@@ -19,10 +19,17 @@ export default async function AfroLifePage({ searchParams }: Props) {
const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL'; const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL';
let items: any[] = []; let items: any[] = [];
const now = new Date();
try { try {
if (filter === 'EVENT') { if (filter === 'EVENT') {
const events = await prisma.event.findMany({ const events = await prisma.event.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: now
}
},
orderBy: { date: 'desc' } orderBy: { date: 'desc' }
}); });
items = events.map(e => ({ items = events.map(e => ({
@@ -34,7 +41,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
duration: new Date(e.date).toLocaleDateString('fr-FR') duration: new Date(e.date).toLocaleDateString('fr-FR')
})); }));
} else { } else {
const where: any = {}; const where: any = {
status: 'PUBLISHED',
publishedAt: {
lte: now
}
};
if (filter !== 'ALL') { if (filter !== 'ALL') {
where.type = filter as InterviewType; where.type = filter as InterviewType;
} }
@@ -47,6 +59,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
// If 'ALL', we might want to also fetch events and mix them // If 'ALL', we might want to also fetch events and mix them
if (filter === 'ALL') { if (filter === 'ALL') {
const events = await prisma.event.findMany({ const events = await prisma.event.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: now
}
},
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
take: 10 take: 10
}); });

View File

@@ -6,6 +6,12 @@ import { generateSlug } from '@/lib/utils'
export async function GET() { export async function GET() {
try { try {
const posts = await prisma.blogPost.findMany({ const posts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: new Date()
}
},
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
}) })
return NextResponse.json(posts) return NextResponse.json(posts)

View File

@@ -5,11 +5,6 @@ import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
// GET /api/businesses — List all businesses (Mock + DB) // GET /api/businesses — List all businesses (Mock + DB)
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const user = await getAuthUser(request)
if (!user) {
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const category = searchParams.get('category') const category = searchParams.get('category')
const q = searchParams.get('q')?.toLowerCase() const q = searchParams.get('q')?.toLowerCase()

View File

@@ -5,6 +5,12 @@ import prisma from '@/lib/prisma'
export async function GET() { export async function GET() {
try { try {
const interviews = await prisma.interview.findMany({ const interviews = await prisma.interview.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: new Date()
}
},
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
}) })
return NextResponse.json(interviews) return NextResponse.json(interviews)

20
app/api/tags/route.ts Normal file
View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const blogPosts = await prisma.blogPost.findMany({ select: { tags: true } });
const interviews = await prisma.interview.findMany({ select: { tags: true } });
const events = await prisma.event.findMany({ select: { tags: true } });
const businesses = await prisma.business.findMany({ select: { tags: true } });
const allTags = new Set<string>();
[...blogPosts, ...interviews, ...events, ...businesses].forEach(item => {
item.tags.forEach(tag => allTags.add(tag.toLowerCase()));
});
return NextResponse.json(Array.from(allTags).sort());
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch tags' }, { status: 500 });
}
}

View File

@@ -19,7 +19,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
OR: [ OR: [
{ id: id }, { id: id },
{ slug: id } { slug: id }
] ],
publishedAt: {
lte: new Date()
},
status: 'PUBLISHED'
} }
}); });
@@ -46,7 +50,11 @@ export default async function BlogPostPage({ params }: Props) {
OR: [ OR: [
{ id: id }, { id: id },
{ slug: id } { slug: id }
] ],
publishedAt: {
lte: new Date()
},
status: 'PUBLISHED'
} }
}); });
@@ -102,7 +110,7 @@ export default async function BlogPostPage({ params }: Props) {
</header> </header>
{/* Content */} {/* Content */}
<div className="prose prose-lg prose-orange max-w-none text-gray-600 break-words overflow-hidden"> <div className="prose prose-lg prose-orange max-w-none text-gray-600">
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6"> <p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
{post.excerpt} {post.excerpt}
</p> </p>

View File

@@ -10,6 +10,12 @@ export default async function BlogPage() {
try { try {
posts = await prisma.blogPost.findMany({ posts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: {
lte: new Date()
}
},
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' }
}); });
} catch (error) { } catch (error) {

View File

@@ -23,4 +23,15 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
font-family: var(--font-sans); font-family: var(--font-sans);
}
.prose {
text-align: justify;
hyphens: auto;
word-break: normal;
overflow-wrap: break-word;
}
.prose p {
margin-bottom: 1.25em;
} }

50
app/not-found.tsx Normal file
View File

@@ -0,0 +1,50 @@
import React from 'react';
import Link from 'next/link';
import { Home, ArrowLeft, Search } from 'lucide-react';
export default function NotFound() {
return (
<div className="min-h-screen bg-white flex items-center justify-center px-4 sm:px-6 lg:px-8 py-24">
<div className="max-w-md w-full text-center">
{/* Animated 404 number */}
<div className="relative mb-8">
<h1 className="text-9xl font-serif font-black text-gray-100 select-none">404</h1>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl md:text-5xl font-serif font-bold text-brand-600">Oups !</span>
</div>
</div>
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-4 font-serif">
Page introuvable
</h2>
<p className="text-gray-500 mb-10 text-lg">
Il semblerait que le chemin que vous avez emprunté n'existe pas ou a été déplacé.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-full text-white bg-brand-600 hover:bg-brand-700 shadow-lg hover:shadow-xl transition-all"
>
<Home className="w-5 h-5 mr-2" />
Retour à l'accueil
</Link>
<Link
href="/annuaire"
className="inline-flex items-center justify-center px-6 py-3 border border-gray-200 text-base font-medium rounded-full text-gray-700 bg-white hover:bg-gray-50 shadow-sm transition-all"
>
<Search className="w-5 h-5 mr-2" />
Explorer l'annuaire
</Link>
</div>
<div className="mt-12 pt-12 border-t border-gray-100">
<p className="text-sm text-gray-400 italic">
"L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."
</p>
</div>
</div>
</div>
);
}

View File

@@ -62,6 +62,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// 3. Fetch all blog posts // 3. Fetch all blog posts
const blogPosts = await prisma.blogPost.findMany({ const blogPosts = await prisma.blogPost.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() }
},
select: { id: true, slug: true, updatedAt: true }, select: { id: true, slug: true, updatedAt: true },
}); });
@@ -74,6 +78,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// 4. Fetch all Afro Life (Interviews) // 4. Fetch all Afro Life (Interviews)
const interviews = await prisma.interview.findMany({ const interviews = await prisma.interview.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() }
},
select: { id: true, slug: true, updatedAt: true }, select: { id: true, slug: true, updatedAt: true },
}); });
@@ -84,7 +92,23 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7, priority: 0.7,
})); }));
return [...staticRoutes, ...businessRoutes, ...blogRoutes, ...afroLifeRoutes]; // 5. Fetch all Events
const events = await prisma.event.findMany({
where: {
status: 'PUBLISHED',
publishedAt: { lte: new Date() }
},
select: { id: true, slug: true, updatedAt: true },
});
const eventRoutes = events.map((event) => ({
url: `${baseUrl}/afrolife/${event.slug || event.id}`,
lastModified: event.updatedAt,
changeFrequency: 'monthly' as const,
priority: 0.7,
}));
return [...staticRoutes, ...businessRoutes, ...blogRoutes, ...afroLifeRoutes, ...eventRoutes];
} catch (error) { } catch (error) {
console.error("Error generating sitemap:", error); console.error("Error generating sitemap:", error);
// Fallback to only static routes if DB connection fails // Fallback to only static routes if DB connection fails

172
components/TagInput.tsx Normal file
View File

@@ -0,0 +1,172 @@
'use client';
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
import { X, Hash, Search, TrendingUp } from 'lucide-react';
interface Props {
value: string[];
onChange: (value: string[]) => void;
label?: string;
placeholder?: string;
suggestions?: string[];
}
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
const [inputValue, setInputValue] = useState('');
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputValue.trim()) {
const filtered = suggestions
.filter(tag =>
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
!value.includes(tag.toLowerCase())
)
.slice(0, 10);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else if (showSuggestions && suggestions.length > 0) {
const filtered = suggestions
.filter(tag => !value.includes(tag.toLowerCase()))
.slice(0, 8);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
} else {
setFilteredSuggestions([]);
setShowSuggestions(false);
}
setSelectedIndex(-1);
}, [inputValue, suggestions, value, showSuggestions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTag = (tagToAdd?: string) => {
const tag = (tagToAdd || inputValue).trim().toLowerCase();
if (tag && !value.includes(tag)) {
onChange([...value, tag]);
}
setInputValue('');
if (tagToAdd) setShowSuggestions(true);
};
const removeTag = (tagToRemove: string) => {
onChange(value.filter(tag => tag !== tagToRemove));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
addTag(filteredSuggestions[selectedIndex]);
} else {
addTag();
}
} else if (e.key === ',' || e.key === ';') {
e.preventDefault();
addTag();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!showSuggestions && suggestions.length > 0) {
setShowSuggestions(true);
} else {
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
} else if (e.key === 'Escape') {
setShowSuggestions(false);
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
removeTag(value[value.length - 1]);
}
};
return (
<div className="space-y-2 relative" ref={containerRef}>
{label && <label className="text-sm font-medium text-gray-700">{label}</label>}
<div
className="min-h-[48px] p-2 bg-white border border-gray-300 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 transition-all cursor-text shadow-sm"
onClick={() => {
const input = containerRef.current?.querySelector('input');
input?.focus();
}}
>
{value.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
>
<Hash className="w-3 h-3 opacity-50" />
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="hover:text-red-500 transition-colors ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setShowSuggestions(true)}
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
className="flex-1 bg-transparent border-none outline-none text-gray-900 text-sm min-w-[120px] p-1 placeholder:text-gray-400"
/>
</div>
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
<div className="p-2 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest flex items-center gap-1.5">
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
{inputValue ? 'Suggestions' : 'Tags populaires'}
</p>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredSuggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={(e) => {
e.stopPropagation();
addTag(suggestion);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
index === selectedIndex ? 'bg-brand-600 text-white' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<span className="flex items-center gap-2">
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-gray-400'}`} />
{suggestion}
</span>
{index === selectedIndex && (
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react'; import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle, Hash } from 'lucide-react';
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
import { Business, Country } from '../../types'; import { Business, Country } from '../../types';
import TagInput from '../TagInput';
import { generateBusinessDescription } from '../../lib/geminiService'; import { generateBusinessDescription } from '../../lib/geminiService';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useUser } from '../UserProvider'; import { useUser } from '../UserProvider';
@@ -53,19 +53,22 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || '')); const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [allTags, setAllTags] = useState<string[]>([]);
// Fetch countries and categories // Fetch countries, categories and tags
React.useEffect(() => { React.useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [cRes, catRes] = await Promise.all([ const [cRes, catRes, tagsRes] = await Promise.all([
fetch('/api/countries'), fetch('/api/countries'),
fetch('/api/categories') fetch('/api/categories'),
fetch('/api/tags')
]); ]);
const cData = await cRes.json(); const cData = await cRes.json();
const catData = await catRes.json(); const catData = await catRes.json();
const tagsData = await tagsRes.json();
if (!cData.error) setCountries(cData); if (!cData.error) setCountries(cData);
if (!catData.error) setCategories(catData); if (!catData.error) setCategories(catData);
if (!tagsData.error) setAllTags(tagsData);
} catch (error) { } catch (error) {
console.error('Error fetching metadata:', error); console.error('Error fetching metadata:', error);
} }
@@ -381,6 +384,16 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
</div> </div>
<textarea name="description" rows={3} value={formData.description} onChange={handleInputChange} className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" /> <textarea name="description" rows={3} value={formData.description} onChange={handleInputChange} className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" />
</div> </div>
<div className="sm:col-span-6">
<TagInput
value={formData.tags || []}
onChange={(tags) => setFormData({ ...formData, tags })}
suggestions={allTags}
label="Mots-clés & Tags"
placeholder="innovation, tech, artisanat..."
/>
<p className="mt-1 text-[10px] text-gray-500 italic">Ces tags aideront les clients à trouver votre boutique via la recherche.</p>
</div>
</div> </div>
</div> </div>

View File

@@ -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;

View File

@@ -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';

View File

@@ -142,19 +142,21 @@ model Offer {
} }
model BlogPost { model BlogPost {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
excerpt String excerpt String
content String content String
author String author String
date DateTime @default(now()) date DateTime @default(now())
imageUrl String imageUrl String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
metaDescription String? metaDescription String?
metaTitle String? metaTitle String?
slug String? @unique slug String? @unique
tags String[] @default([]) tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
} }
model Interview { model Interview {
@@ -176,6 +178,8 @@ model Interview {
metaTitle String? metaTitle String?
slug String? @unique slug String? @unique
tags String[] @default([]) tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
} }
model Comment { model Comment {
@@ -304,19 +308,21 @@ model LegalDocument {
} }
model Event { model Event {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
description String description String
date DateTime date DateTime
location String location String
thumbnailUrl String thumbnailUrl String
link String? link String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
metaDescription String? metaDescription String?
metaTitle String? metaTitle String?
slug String? @unique slug String? @unique
tags String[] @default([]) tags String[] @default([])
publishedAt DateTime @default(now())
status ContentStatus @default(PUBLISHED)
} }
model Favorite { model Favorite {
@@ -330,6 +336,12 @@ model Favorite {
@@unique([userId, businessId]) @@unique([userId, businessId])
} }
enum ContentStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum RatingStatus { enum RatingStatus {
PENDING PENDING
APPROVED APPROVED

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB