diff --git a/admin/package-lock.json b/admin/package-lock.json index 22892bb..a5ee6c2 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -47,11 +47,13 @@ "@tailwindcss/postcss": "^4.2.2", "@tailwindcss/typography": "^0.5.19", "@types/bcryptjs": "^2.4.6", + "@types/dompurify": "^3.0.5", "@types/node": "^22.14.0", "@types/pg": "^8.16.0", "autoprefixer": "^10.4.27", "bcryptjs": "^3.0.3", "dotenv": "^17.3.1", + "isomorphic-dompurify": "^3.11.0", "jiti": "^2.6.1", "lucide-react": "^0.554.0", "next": "^16.1.6", diff --git a/admin/prisma/schema.prisma b/admin/prisma/schema.prisma index d63fff3..ca723e3 100644 --- a/admin/prisma/schema.prisma +++ b/admin/prisma/schema.prisma @@ -9,119 +9,121 @@ datasource db { } model User { - id String @id @default(uuid()) - name String - email String @unique - password String - role UserRole @default(VISITOR) - avatar String? - emailVerified Boolean @default(false) - verificationToken String? @unique - resetToken String? @unique - resetTokenExpiry DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - bio String? - location String? - countryId String? - phone String? - isSuspended Boolean @default(false) - suspensionReason String? - businesses Business? - comments Comment[] - conversations ConversationParticipant[] - sentMessages Message[] - reports MessageReport[] - ratings Rating[] - country Country? @relation(fields: [countryId], references: [id]) + id String @id @default(uuid()) + name String + email String @unique + password String + role UserRole @default(VISITOR) + avatar String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + bio String? + location String? + phone String? + isSuspended Boolean @default(false) + suspensionReason String? + countryId String? + resetToken String? @unique + resetTokenExpiry DateTime? + emailVerified Boolean @default(false) + verificationToken String? @unique + businesses Business? + comments Comment[] + conversations ConversationParticipant[] + sentMessages Message[] + reports MessageReport[] + ratings Rating[] + country Country? @relation(fields: [countryId], references: [id]) + favorites Favorite[] } model Business { - id String @id @default(uuid()) - name String - category String + id String @id @default(uuid()) + name 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? - location String - countryId String? - city String? - description String - logoUrl String - coverUrl String? - coverPosition String? @default("50% 50%") - coverZoom Float? @default(1.0) - 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) - 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? + categoryId String? + categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id]) + country Country? @relation(fields: [countryId], references: [id]) + owner User @relation(fields: [ownerId], references: [id]) + comments Comment[] + conversations Conversation[] + offers Offer[] + ratings Rating[] + favorites Favorite[] } model BusinessCategory { id String @id @default(uuid()) name String @unique slug String @unique - icon String? // Lucide icon name + icon String? isActive Boolean @default(true) - businesses Business[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + businesses Business[] } model Country { id String @id @default(uuid()) name String @unique - code String @unique // ISO alpha-2 - flag String? // Emoji or URL + code String @unique + flag String? isActive Boolean @default(true) - description String? @db.Text - businesses Business[] - users User[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + description String? + businesses Business[] + users User[] } model PricingPlan { - id String @id @default(uuid()) - tier Plan @unique - name String - priceXOF String - yearlyPriceXOF String? - priceEUR String + id String @id @default(uuid()) + tier Plan @unique + name String + priceXOF String + priceEUR String + description String + features String[] + offerLimit Int @default(1) + recommended Boolean @default(false) + color String @default("gray") + updatedAt DateTime @updatedAt yearlyPriceEUR String? - description String - features String[] - offerLimit Int @default(1) - recommended Boolean @default(false) - color String @default("gray") - updatedAt DateTime @updatedAt + yearlyPriceXOF String? } model Offer { @@ -140,25 +142,26 @@ model Offer { } model BlogPost { - id String @id @default(uuid()) + id String @id @default(uuid()) title String - slug String? @unique excerpt String content String author String - date DateTime @default(now()) + date DateTime @default(now()) imageUrl String - tags String[] @default([]) - metaTitle String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt metaDescription String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + metaTitle String? + slug String? @unique + tags String[] @default([]) + publishedAt DateTime @default(now()) + status ContentStatus @default(PUBLISHED) } model Interview { id String @id @default(uuid()) title String - slug String? @unique guestName String companyName String role String @@ -169,11 +172,14 @@ model Interview { excerpt String date DateTime @default(now()) duration String? - tags String[] @default([]) - metaTitle String? - metaDescription 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 Comment { @@ -194,40 +200,34 @@ model AnalyticsEvent { label String? value Float? metadata Json? - ip String? - country String? - city String? + createdAt DateTime @default(now()) browser String? - os String? + city String? + country String? device String? + ip String? + os String? referrer String? userId String? - createdAt DateTime @default(now()) } model Rating { - id String @id @default(uuid()) + id String @id @default(uuid()) value Int + userId String + businessId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt comment String? reply String? replyAt DateTime? status RatingStatus @default(PENDING) - userId String - businessId String - 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) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, businessId]) } -enum RatingStatus { - PENDING - APPROVED - REJECTED -} - model Conversation { id String @id @default(uuid()) businessId String @@ -272,6 +272,82 @@ model MessageReport { 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("

{siteName}

Bonjour {name},

Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :

Réinitialiser mon mot de passe

Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.


© 2025 {siteName}. Tous droits réservés.

") + verifyEmailSubject String @default("Vérification de votre compte - Afrohub") + verifyEmailTemplate String @default("

{siteName}

Bonjour {name},

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 :

Vérifier mon compte

Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.


© 2025 {siteName}. Tous droits réservés.

") +} + +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 { PENDING RESOLVED @@ -299,54 +375,3 @@ enum InterviewType { VIDEO 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("

{siteName}

Bonjour {name},

Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :

Réinitialiser mon mot de passe

Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.


© 2025 {siteName}. Tous droits réservés.

") @db.Text - verifyEmailSubject String @default("Vérification de votre compte - Afrohub") - verifyEmailTemplate String @default("

{siteName}

Bonjour {name},

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 :

Vérifier mon compte

Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.


© 2025 {siteName}. Tous droits réservés.

") @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 -} diff --git a/admin/public/uploads/1777833997337-98dkw92.gif b/admin/public/uploads/1777833997337-98dkw92.gif new file mode 100644 index 0000000..161894d Binary files /dev/null and b/admin/public/uploads/1777833997337-98dkw92.gif differ diff --git a/admin/public/uploads/1777834233872-lz35qcr.jpg b/admin/public/uploads/1777834233872-lz35qcr.jpg new file mode 100644 index 0000000..fee095f Binary files /dev/null and b/admin/public/uploads/1777834233872-lz35qcr.jpg differ diff --git a/admin/src/app/actions/blog.ts b/admin/src/app/actions/blog.ts index ef89234..32601f5 100644 --- a/admin/src/app/actions/blog.ts +++ b/admin/src/app/actions/blog.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; import { generateSlug } from "@/lib/utils"; +import { ContentStatus } from "@prisma/client"; export async function createBlogPost(data: { title: string; @@ -14,6 +15,8 @@ export async function createBlogPost(data: { tags?: string[]; metaTitle?: string; metaDescription?: string; + publishedAt?: Date; + status?: ContentStatus; }) { try { const slug = data.slug || generateSlug(data.title); @@ -23,10 +26,12 @@ export async function createBlogPost(data: { ...data, slug, tags: data.tags || [], + publishedAt: data.publishedAt || new Date(), + status: data.status || ContentStatus.PUBLISHED, }, }); revalidatePath("/blog"); - return { success: true, data: post }; + return { success: true, id: post.id }; } catch (error) { console.error("Failed to create blog post:", error); return { success: false, error: "Erreur lors de la création de l'article" }; @@ -43,6 +48,8 @@ export async function updateBlogPost(id: string, data: { tags?: string[]; metaTitle?: string; metaDescription?: string; + publishedAt?: Date; + status?: ContentStatus; }) { try { const slug = data.slug || generateSlug(data.title); @@ -56,7 +63,7 @@ export async function updateBlogPost(id: string, data: { }, }); revalidatePath("/blog"); - return { success: true, data: post }; + return { success: true, id: post.id }; } catch (error) { console.error("Failed to update blog post:", error); return { success: false, error: "Erreur lors de la mise à jour" }; diff --git a/admin/src/app/actions/event.ts b/admin/src/app/actions/event.ts index 07b5b47..a2598cd 100644 --- a/admin/src/app/actions/event.ts +++ b/admin/src/app/actions/event.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; import { generateSlug } from "@/lib/utils"; +import { ContentStatus } from "@prisma/client"; export async function createEvent(data: { title: string; @@ -15,6 +16,8 @@ export async function createEvent(data: { tags?: string[]; metaTitle?: string; metaDescription?: string; + publishedAt?: Date; + status?: ContentStatus; }) { try { const slug = data.slug || generateSlug(data.title); @@ -24,6 +27,8 @@ export async function createEvent(data: { ...data, slug, tags: data.tags || [], + publishedAt: data.publishedAt || new Date(), + status: data.status || ContentStatus.PUBLISHED, }, }); revalidatePath("/blog"); @@ -46,6 +51,8 @@ export async function updateEvent(id: string, data: { tags?: string[]; metaTitle?: string; metaDescription?: string; + publishedAt?: Date; + status?: ContentStatus; }) { try { const slug = data.slug || generateSlug(data.title); diff --git a/admin/src/app/actions/interview.ts b/admin/src/app/actions/interview.ts index e9fba53..16c02e8 100644 --- a/admin/src/app/actions/interview.ts +++ b/admin/src/app/actions/interview.ts @@ -2,7 +2,7 @@ import { prisma } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; -import { InterviewType } from "@prisma/client"; +import { InterviewType, ContentStatus } from "@prisma/client"; import { generateSlug } from "@/lib/utils"; export async function createInterview(data: { @@ -20,6 +20,8 @@ export async function createInterview(data: { tags?: string[]; metaTitle?: string; metaDescription?: string; + publishedAt?: Date; + status?: ContentStatus; }) { try { const slug = data.slug || generateSlug(data.title); @@ -29,6 +31,8 @@ export async function createInterview(data: { ...data, slug, tags: data.tags || [], + publishedAt: data.publishedAt || new Date(), + status: data.status || ContentStatus.PUBLISHED, }, }); revalidatePath("/blog"); @@ -54,6 +58,8 @@ export async function updateInterview(id: string, data: { tags?: string[]; metaTitle?: string; metaDescription?: string; + publishedAt?: Date; + status?: ContentStatus; }) { try { const slug = data.slug || generateSlug(data.title); diff --git a/admin/src/app/actions/taxonomies.ts b/admin/src/app/actions/taxonomies.ts new file mode 100644 index 0000000..4b537ae --- /dev/null +++ b/admin/src/app/actions/taxonomies.ts @@ -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(); + 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' }; + } +} diff --git a/admin/src/app/actions/upload.ts b/admin/src/app/actions/upload.ts new file mode 100644 index 0000000..838f798 --- /dev/null +++ b/admin/src/app/actions/upload.ts @@ -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' }; + } +} diff --git a/admin/src/app/blog/page.tsx b/admin/src/app/blog/page.tsx index 40edfd1..4d1ef79 100644 --- a/admin/src/app/blog/page.tsx +++ b/admin/src/app/blog/page.tsx @@ -12,6 +12,15 @@ async function getData() { return { posts, interviews, events }; } +const getStatusBadge = (status: string) => { + switch (status) { + case 'DRAFT': return BROUILLON; + case 'PUBLISHED': return PUBLIÉ; + case 'ARCHIVED': return ARCHIVÉ; + default: return null; + } +} + export default async function BlogCMSPage() { const { posts, interviews, events } = await getData(); @@ -50,6 +59,7 @@ export default async function BlogCMSPage() { Titre + Statut Auteur Date Actions @@ -62,6 +72,7 @@ export default async function BlogCMSPage() {
{post.title}
{post.excerpt}
+ {getStatusBadge(post.status)} {post.author} {new Date(post.createdAt).toLocaleDateString('fr-FR')} @@ -90,6 +101,7 @@ export default async function BlogCMSPage() { Interviewé + Statut Entreprise / Rôle Type Actions @@ -102,6 +114,7 @@ export default async function BlogCMSPage() {
{interview.guestName}
{interview.title}
+ {getStatusBadge(interview.status)}
{interview.companyName}
{interview.role}
@@ -137,6 +150,7 @@ export default async function BlogCMSPage() { Événement + Statut Date & Lieu Actions @@ -150,6 +164,7 @@ export default async function BlogCMSPage() { {event.link ? 'Lien activé' : 'Pas de lien'} + {getStatusBadge(event.status)}
diff --git a/admin/src/app/categories/page.tsx b/admin/src/app/categories/page.tsx deleted file mode 100644 index 8bbc77a..0000000 --- a/admin/src/app/categories/page.tsx +++ /dev/null @@ -1,404 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useTransition } from 'react'; -import { - Plus, - Edit2, - Trash2, - Check, - X, - Loader2, - Briefcase, - AlertCircle, - Clock, - Sparkles, - Cpu, - Sprout, - Shirt, - Utensils, - HardHat, - Stethoscope, - GraduationCap, - Palette, - Plane, - Truck, - Wallet, - Zap, - Leaf, - Camera, - Music, - ShoppingBag, - Heart, - Home -} from 'lucide-react'; -import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories'; -import { toast } from 'react-hot-toast'; - -const ICON_LIST = [ - { name: 'Briefcase', icon: Briefcase }, - { name: 'Cpu', icon: Cpu }, - { name: 'Sprout', icon: Sprout }, - { name: 'Shirt', icon: Shirt }, - { name: 'Sparkles', icon: Sparkles }, - { name: 'Utensils', icon: Utensils }, - { name: 'HardHat', icon: HardHat }, - { name: 'Stethoscope', icon: Stethoscope }, - { name: 'GraduationCap', icon: GraduationCap }, - { name: 'Palette', icon: Palette }, - { name: 'Plane', icon: Plane }, - { name: 'Truck', icon: Truck }, - { name: 'Wallet', icon: Wallet }, - { name: 'Zap', icon: Zap }, - { name: 'Leaf', icon: Leaf }, - { name: 'Camera', icon: Camera }, - { name: 'Music', icon: Music }, - { name: 'ShoppingBag', icon: ShoppingBag }, - { name: 'Heart', icon: Heart }, - { name: 'Home', icon: Home }, -]; - -export default function CategoriesPage() { - const [categories, setCategories] = useState([]); - const [suggestions, setSuggestions] = useState([]); - const [loading, setLoading] = useState(true); - const [isPending, startTransition] = useTransition(); - const [showAddModal, setShowAddModal] = useState(false); - const [editingCategory, setEditingCategory] = useState(null); - const [selectedSuggestion, setSelectedSuggestion] = useState(null); - - const renderIcon = (iconName: string) => { - const found = ICON_LIST.find(i => i.name === iconName); - if (found) { - const IconComp = found.icon; - return ; - } - return ; - }; - - const [formData, setFormData] = useState({ - name: '', - slug: '', - icon: 'Briefcase', - isActive: true - }); - - useEffect(() => { - loadCategories(); - }, []); - - async function loadCategories() { - setLoading(true); - try { - const [catData, suggData] = await Promise.all([ - getCategories(), - getSuggestedCategories() - ]); - setCategories(catData); - setSuggestions(suggData); - } catch (e) { - toast.error("Erreur de chargement"); - } finally { - setLoading(false); - } - } - - const handleAddCategory = async (e: React.FormEvent) => { - e.preventDefault(); - startTransition(async () => { - const result = await createCategory({ ...formData, suggestionId: selectedSuggestion?.id }); - if (result.success) { - toast.success("Catégorie créée !"); - setShowAddModal(false); - setSelectedSuggestion(null); - setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); - loadCategories(); - } else { - toast.error(result.error || "Erreur"); - } - }); - }; - - const handleUpdateCategory = async (e: React.FormEvent) => { - e.preventDefault(); - if (!editingCategory) return; - - startTransition(async () => { - const result = await updateCategory(editingCategory.id, formData); - if (result.success) { - toast.success("Catégorie mise à jour !"); - setEditingCategory(null); - loadCategories(); - } else { - toast.error(result.error || "Erreur"); - } - }); - }; - - const handleDelete = async (id: string) => { - if (!confirm("Supprimer cette catégorie ?")) return; - - const result = await deleteCategory(id); - if (result.success) { - toast.success("Supprimée !"); - loadCategories(); - } else { - toast.error(result.error || "Erreur"); - } - }; - - const openEdit = (cat: any) => { - setEditingCategory(cat); - setFormData({ - name: cat.name, - slug: cat.slug, - icon: cat.icon || 'Briefcase', - isActive: cat.isActive - }); - }; - - if (loading) { - return ( -
- -
- ); - } - - return ( -
-
-
-

Secteurs & Catégories

-

Gérez les secteurs d'activité disponibles pour les entrepreneurs.

-
- -
- -
-
-
-
-

Catégories Officielles

- {categories.length} secteurs -
-
- - - - - - - - - - {categories.map((cat) => ( - - - - - - ))} - -
NomStatutActions
-
-
- {renderIcon(cat.icon)} -
-
-

{cat.name}

-

{cat.slug}

-
-
-
- {cat.isActive ? ( - - Actif - - ) : ( - - Inactif - - )} - -
- - -
-
-
-
-
- -
-
-
- -

Suggestions

-
-
- {suggestions.length === 0 ? ( -
-
- -
-

Aucune suggestion en attente.

-
- ) : ( - suggestions.map((sugg) => ( -
-
- Proposé - {new Date(sugg.createdAt).toLocaleDateString()} -
-

{sugg.suggestedCategory}

-
- - Par : {sugg.name} -
- -
- )) - )} -
-
-
-
- - {(showAddModal || editingCategory) && ( -
-
-
-
- {selectedSuggestion && } -

- {editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'} -

-
- -
- -
-
-
-
- - { - 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" - /> -
-
- - 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" - /> -
- -
- 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" - /> - -
- - {selectedSuggestion && ( -
-

✨ **Note :** En créant cette catégorie, l'entreprise **{selectedSuggestion.name}** y sera automatiquement rattachée.

-
- )} -
- -
- -
- {ICON_LIST.map((item) => ( - - ))} -
-
-
- -
- - -
-
-
-
- )} -
- ); -} diff --git a/admin/src/app/dashboard/DashboardClient.tsx b/admin/src/app/dashboard/DashboardClient.tsx new file mode 100644 index 0000000..a2abfaa --- /dev/null +++ b/admin/src/app/dashboard/DashboardClient.tsx @@ -0,0 +1,68 @@ +"use client"; + +import React from 'react'; +import { + Users, + Store, + Clock, + MessageCircle, + Eye +} from 'lucide-react'; + +interface Stats { + usersCount: number; + businessCount: number; + pendingCount: number; + commentsCount: number; + totalViews: number; + uniqueVisitors: number; +} + +export default function DashboardClient({ stats }: { stats: Stats }) { + const statCards = [ + { label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' }, + { label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' }, + { label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' }, + { label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' }, + { label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' }, + { label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' }, + ]; + + return ( +
+
+

Tableau de bord

+

Vue d'ensemble de l'activité sur la plateforme.

+
+ +
+ {statCards.map((card) => ( +
+
+
+ +
+ {card.value} +
+

{card.label}

+
+ ))} +
+ +
+
+

Derniers entrepreneurs

+
+

Bientôt disponible...

+
+
+
+

Activité récente

+
+

Bientôt disponible...

+
+
+
+
+ ); +} diff --git a/admin/src/app/dashboard/page.tsx b/admin/src/app/dashboard/page.tsx index 3e68c1d..91f83d4 100644 --- a/admin/src/app/dashboard/page.tsx +++ b/admin/src/app/dashboard/page.tsx @@ -1,11 +1,5 @@ import { prisma } from '@/lib/prisma'; -import { - Users, - Store, - Clock, - MessageCircle, - Eye -} from 'lucide-react'; +import DashboardClient from './DashboardClient'; async function getStats() { const usersCount = await prisma.user.count(); @@ -32,51 +26,5 @@ async function getStats() { export default async function DashboardPage() { const stats = await getStats(); - - const statCards = [ - { label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' }, - { label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' }, - { label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' }, - { label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' }, - { label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' }, - { label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' }, - ]; - - return ( -
-
-

Tableau de bord

-

Vue d'ensemble de l'activité sur la plateforme.

-
- -
- {statCards.map((card) => ( -
-
-
- -
- {card.value} -
-

{card.label}

-
- ))} -
- -
-
-

Derniers entrepreneurs

-
-

Bientôt disponible...

-
-
-
-

Activité récente

-
-

Bientôt disponible...

-
-
-
-
- ); + return ; } diff --git a/admin/src/app/globals.css b/admin/src/app/globals.css index 2619389..2761b25 100644 --- a/admin/src/app/globals.css +++ b/admin/src/app/globals.css @@ -265,6 +265,13 @@ body { font-style: normal; } +.quill-dark-wrapper .ql-editor { + text-align: justify; + hyphens: auto; + word-break: normal; + line-height: 1.6; +} + /* Styles for the Divider (HR) */ .quill-dark-wrapper .ql-editor hr { border: none; diff --git a/admin/src/app/not-found.tsx b/admin/src/app/not-found.tsx new file mode 100644 index 0000000..8d53c7d --- /dev/null +++ b/admin/src/app/not-found.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React from 'react'; +import Link from 'next/link'; +import { LayoutDashboard, ArrowLeft } from 'lucide-react'; + +export default function NotFound() { + return ( +
+
+
+
+ 404 +
+

Page d'administration introuvable

+

La ressource que vous recherchez n'existe pas ou a été déplacée.

+
+ +
+ + + Retour au Dashboard + + +
+
+
+ ); +} diff --git a/admin/src/app/taxonomies/TaxonomiesClient.tsx b/admin/src/app/taxonomies/TaxonomiesClient.tsx new file mode 100644 index 0000000..e38eedb --- /dev/null +++ b/admin/src/app/taxonomies/TaxonomiesClient.tsx @@ -0,0 +1,415 @@ +"use client"; + +import React, { useState, useEffect, useTransition } from 'react'; +import { + Plus, + Edit2, + Trash2, + Check, + X, + Loader2, + Briefcase, + AlertCircle, + Clock, + Sparkles, + Cpu, + Sprout, + Shirt, + Utensils, + HardHat, + Stethoscope, + GraduationCap, + Palette, + Plane, + Truck, + Wallet, + Zap, + Leaf, + Camera, + Music, + ShoppingBag, + Heart, + Home, + Hash, + Tags, + Search, + ExternalLink, + Eye, + FileText, + User as UserIcon, + MapPin, + BookOpen, + Save +} from 'lucide-react'; +import Link from 'next/link'; +import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories'; +import { getAllTags, deleteTagGlobally, renameTagGlobally, getTagUsage } from '@/app/actions/taxonomies'; +import { toast } from 'react-hot-toast'; + +const ICON_LIST = [ + { name: 'Briefcase', icon: Briefcase }, + { name: 'Cpu', icon: Cpu }, + { name: 'Sprout', icon: Sprout }, + { name: 'Shirt', icon: Shirt }, + { name: 'Sparkles', icon: Sparkles }, + { name: 'Utensils', icon: Utensils }, + { name: 'HardHat', icon: HardHat }, + { name: 'Stethoscope', icon: Stethoscope }, + { name: 'GraduationCap', icon: GraduationCap }, + { name: 'Palette', icon: Palette }, + { name: 'Plane', icon: Plane }, + { name: 'Truck', icon: Truck }, + { name: 'Wallet', icon: Wallet }, + { name: 'Zap', icon: Zap }, + { name: 'Leaf', icon: Leaf }, + { name: 'Camera', icon: Camera }, + { name: 'Music', icon: Music }, + { name: 'ShoppingBag', icon: ShoppingBag }, + { name: 'Heart', icon: Heart }, + { name: 'Home', icon: Home }, + { name: 'Tags', icon: Tags }, +]; + +type Tab = 'categories' | 'tags'; + +export default function TaxonomiesClient() { + const [activeTab, setActiveTab] = useState('categories'); + const [categories, setCategories] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(true); + const [isPending, startTransition] = useTransition(); + + // Category Form State + const [showAddModal, setShowAddModal] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [formData, setFormData] = useState({ + name: '', + slug: '', + icon: 'Briefcase', + isActive: true + }); + + // Tag Form State + const [newTagName, setNewTagName] = useState(''); + const [isEditingUsageName, setIsEditingUsageName] = useState(false); + const [usagePreview, setUsagePreview] = useState<{ tag: string, data: any } | null>(null); + const [loadingUsage, setLoadingUsage] = useState(false); + + const loadData = async () => { + setLoading(true); + try { + const [catsRes, tagsRes, sugRes] = await Promise.all([ + getCategories(), + getAllTags(), + getSuggestedCategories() + ]); + setCategories(catsRes); + setTags(tagsRes); + setSuggestions(sugRes); + } catch (err) { + toast.error("Erreur de chargement"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleCategorySubmit = async (e: React.FormEvent) => { + e.preventDefault(); + startTransition(async () => { + const result = editingCategory + ? await updateCategory(editingCategory.id, formData) + : await createCategory(formData); + + if (result.success) { + toast.success(editingCategory ? "Catégorie mise à jour" : "Catégorie créée"); + setShowAddModal(false); + setEditingCategory(null); + setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); + loadData(); + } else { + toast.error(result.error || "Erreur"); + } + }); + }; + + const handleTagDelete = async (tag: string) => { + if (!confirm(`Supprimer le tag "${tag}" globalement ?`)) return; + const result = await deleteTagGlobally(tag); + if (result.success) { + toast.success("Tag supprimé partout"); + loadData(); + } else { + toast.error(result.error || "Erreur"); + } + }; + + const handleShowUsage = async (tag: string) => { + setLoadingUsage(true); + const result = await getTagUsage(tag); + if (result.success) { + setUsagePreview({ tag, data: result.data }); + } + setLoadingUsage(false); + }; + + if (loading) return
Chargement...
; + + return ( +
+
+
+

Taxonomies

+

Gérez les catégories et les mots-clés du site.

+
+
+ + +
+
+ + {activeTab === 'categories' ? ( +
+
+

+ + Liste des catégories ({categories.length}) +

+ +
+ +
+ {categories.map((cat) => { + const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase; + return ( +
+
+
+ +
+
+

{cat.name}

+

/{cat.slug}

+
+
+
+ + +
+
+ ); + })} +
+
+ ) : ( +
+
+

+ + Tags utilisés ({tags.length}) +

+
+ +
+ {tags.map((tag) => ( +
handleShowUsage(tag)} + className="group flex items-center justify-between bg-slate-950 border border-slate-800 rounded-xl p-3 hover:border-indigo-500/50 transition-all hover:bg-indigo-500/5 cursor-pointer" + > + {tag} +
+ { e.stopPropagation(); handleTagDelete(tag); }} + /> +
+
+ ))} +
+
+ )} + + {/* Modal Ajout/Modif Catégorie */} + {showAddModal && ( +
+
+

{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}

+
+
+ + 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" + /> +
+
+ {ICON_LIST.map((item) => ( + + ))} +
+
+ + +
+
+
+
+ )} + + {/* Usage Modal */} + {usagePreview && ( +
+
+
+
+ + {isEditingUsageName ? ( +
+ setNewTagName(e.target.value)} + className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold" + /> + +
+ ) : ( +

Tag : {usagePreview.tag}

+ )} +
+
+ {!isEditingUsageName && ( + + )} + +
+
+ +
+ {/* Blog Posts */} +
+
+
+ +
+

Articles de Blog ({usagePreview.data.blogPosts.length})

+
+
+ {usagePreview.data.blogPosts.length === 0 ? ( +

Aucun article

+ ) : usagePreview.data.blogPosts.map((post: any) => ( +
+ {post.title} + + + +
+ ))} +
+
+ + {/* Interviews */} +
+
+
+ +
+

Interviews ({usagePreview.data.interviews.length})

+
+
+ {usagePreview.data.interviews.length === 0 ? ( +

Aucune interview

+ ) : usagePreview.data.interviews.map((item: any) => ( +
+ {item.title} + + + +
+ ))} +
+
+ + {/* Events */} +
+
+
+ +
+

Événements ({usagePreview.data.events.length})

+
+
+ {usagePreview.data.events.length === 0 ? ( +

Aucun événement

+ ) : usagePreview.data.events.map((item: any) => ( +
+ {item.title} + + + +
+ ))} +
+
+
+
+
+ )} +
+ ); +} diff --git a/admin/src/app/taxonomies/page.tsx b/admin/src/app/taxonomies/page.tsx new file mode 100644 index 0000000..c3afad9 --- /dev/null +++ b/admin/src/app/taxonomies/page.tsx @@ -0,0 +1,5 @@ +import TaxonomiesClient from "./TaxonomiesClient"; + +export default function TaxonomiesPage() { + return ; +} diff --git a/admin/src/components/BlogForm.tsx b/admin/src/components/BlogForm.tsx index 87d94d7..fd63dc7 100644 --- a/admin/src/components/BlogForm.tsx +++ b/admin/src/components/BlogForm.tsx @@ -1,12 +1,16 @@ "use client"; -import { useTransition, useState } from 'react'; +import { useTransition, useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { createBlogPost, updateBlogPost } from '@/app/actions/blog'; -import { Loader2, ArrowLeft, Save } from 'lucide-react'; +import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react'; import Link from 'next/link'; import { toast } from 'react-hot-toast'; import RichTextEditor from './RichTextEditor'; +import ImageUploader from './ImageUploader'; +import TagInput from './TagInput'; +import { ContentStatus } from '@prisma/client'; +import { getAllTags } from '@/app/actions/taxonomies'; interface Props { initialData?: { @@ -20,6 +24,8 @@ interface Props { tags?: string[]; metaTitle?: string | null; metaDescription?: string | null; + publishedAt?: Date | string | null; + status?: ContentStatus; }; } @@ -27,29 +33,52 @@ export default function BlogForm({ initialData }: Props) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [content, setContent] = useState(initialData?.content || ''); + const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || ''); + const [tags, setTags] = useState(initialData?.tags || []); + const [allExistingTags, setAllExistingTags] = useState([]); + const [publishedAtValue, setPublishedAtValue] = useState( + initialData?.publishedAt + ? new Date(initialData.publishedAt).toISOString().slice(0, 16) + : new Date().toISOString().slice(0, 16) + ); + + const isPublished = new Date(publishedAtValue) <= new Date(); + + useEffect(() => { + getAllTags().then(setAllExistingTags); + }, []); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); const formData = new FormData(event.currentTarget); + const publishedAtStr = formData.get('publishedAt') as string; + const data = { title: formData.get('title') as string, slug: formData.get('slug') as string, excerpt: formData.get('excerpt') as string, content: content, author: formData.get('author') as string, - imageUrl: formData.get('imageUrl') as string, - tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''), + imageUrl: imageUrl, // Use state value + tags: tags, // Use state value metaTitle: formData.get('metaTitle') as string, metaDescription: formData.get('metaDescription') as string, + publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined, + status: formData.get('status') as ContentStatus, }; + if (!data.imageUrl) { + toast.error("Une image est requise"); + return; + } + startTransition(async () => { const result = initialData ? await updateBlogPost(initialData.id, data) : await createBlogPost(data); if (result.success) { - toast.success(initialData ? "Article mis à jour" : "Article publié avec succès"); + toast.success(initialData ? "Article mis à jour" : "Article créé avec succès"); router.push('/blog'); router.refresh(); } else { @@ -73,7 +102,21 @@ export default function BlogForm({ initialData }: Props) {
-

Informations Générales

+
+

Informations Générales

+
+ + +
+
@@ -98,15 +141,30 @@ export default function BlogForm({ initialData }: Props) {
-
- - +
+
+ +
+
+ +
+ + 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" + /> +
+
@@ -147,12 +205,12 @@ export default function BlogForm({ initialData }: Props) {

Laissez vide pour générer automatiquement à partir du titre.

- -
@@ -190,7 +248,7 @@ export default function BlogForm({ initialData }: Props) { ) : ( )} - {initialData ? 'Enregistrer les modifications' : 'Publier l\'article'} + {initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
diff --git a/admin/src/components/EventForm.tsx b/admin/src/components/EventForm.tsx index 284587d..e8dd639 100644 --- a/admin/src/components/EventForm.tsx +++ b/admin/src/components/EventForm.tsx @@ -1,12 +1,16 @@ "use client"; -import { useTransition, useState } from 'react'; +import { useTransition, useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { createEvent, updateEvent } from '@/app/actions/event'; -import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon } from 'lucide-react'; +import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react'; import Link from 'next/link'; import { toast } from 'react-hot-toast'; import RichTextEditor from './RichTextEditor'; +import ImageUploader from './ImageUploader'; +import TagInput from './TagInput'; +import { ContentStatus } from '@prisma/client'; +import { getAllTags } from '@/app/actions/taxonomies'; interface Props { initialData?: any; @@ -16,23 +20,46 @@ export default function EventForm({ initialData }: Props) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [description, setDescription] = useState(initialData?.description || ''); + const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || ''); + const [tags, setTags] = useState(initialData?.tags || []); + const [allExistingTags, setAllExistingTags] = useState([]); + const [publishedAtValue, setPublishedAtValue] = useState( + initialData?.publishedAt + ? new Date(initialData.publishedAt).toISOString().slice(0, 16) + : new Date().toISOString().slice(0, 16) + ); + + const isPublished = new Date(publishedAtValue) <= new Date(); + + useEffect(() => { + getAllTags().then(setAllExistingTags); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); + const publishedAtStr = formData.get('publishedAt') as string; + const data: any = { title: formData.get('title') as string, slug: formData.get('slug') as string, location: formData.get('location') as string, date: new Date(formData.get('date') as string), - thumbnailUrl: formData.get('thumbnailUrl') as string, + thumbnailUrl: thumbnailUrl, // Use state value link: formData.get('link') as string || null, description: description || '', - tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''), + tags: tags, // Use state metaTitle: formData.get('metaTitle') as string, metaDescription: formData.get('metaDescription') as string, + publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined, + status: formData.get('status') as ContentStatus, }; + if (!data.thumbnailUrl) { + toast.error("Une image est requise"); + return; + } + startTransition(async () => { const result = initialData ? await updateEvent(initialData.id, data) @@ -63,7 +90,21 @@ export default function EventForm({ initialData }: Props) {
-

Informations Générales

+
+

Informations Générales

+
+ + +
+
@@ -77,7 +118,7 @@ export default function EventForm({ initialData }: Props) { />
- +
+
+ +
+ + 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" + /> +
+
+
+ +
+
+ +
@@ -119,17 +186,6 @@ export default function EventForm({ initialData }: Props) {
-
- - -
-
Laissez vide pour générer automatiquement à partir du titre.

- -
diff --git a/admin/src/components/ImageUploader.tsx b/admin/src/components/ImageUploader.tsx new file mode 100644 index 0000000..4c4dd5d --- /dev/null +++ b/admin/src/components/ImageUploader.tsx @@ -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(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + 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 ( +
+
+ +
+ + +
+
+ +
+ {mode === 'url' ? ( +
+ + 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" + /> +
+ ) : ( +
+ {!value ? ( +
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 ? ( + + ) : ( +
+ +
+ )} +
+

+ {isUploading ? "Upload en cours..." : "Cliquez pour uploader"} +

+

PNG, JPG ou WEBP jusqu'à 5 Mo

+
+
+ ) : ( +
+ Prévisualisation +
+ + +
+ {/* Hidden input for value to be sent via form if needed */} + +
+ )} + +
+ )} +
+ + {value && mode === 'url' && ( +
+ Preview + +
+ )} +
+ ); +} diff --git a/admin/src/components/InterviewForm.tsx b/admin/src/components/InterviewForm.tsx index 1fcccb9..028d947 100644 --- a/admin/src/components/InterviewForm.tsx +++ b/admin/src/components/InterviewForm.tsx @@ -1,13 +1,16 @@ "use client"; -import { useTransition, useState } from 'react'; +import { useTransition, useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; 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 { InterviewType } from '@prisma/client'; +import { InterviewType, ContentStatus } from '@prisma/client'; import { toast } from 'react-hot-toast'; import RichTextEditor from './RichTextEditor'; +import ImageUploader from './ImageUploader'; +import TagInput from './TagInput'; +import { getAllTags } from '@/app/actions/taxonomies'; interface Props { initialData?: any; @@ -17,10 +20,26 @@ export default function InterviewForm({ initialData }: Props) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [content, setContent] = useState(initialData?.content || ''); + const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || ''); + const [tags, setTags] = useState(initialData?.tags || []); + const [allExistingTags, setAllExistingTags] = useState([]); + const [publishedAtValue, setPublishedAtValue] = useState( + initialData?.publishedAt + ? new Date(initialData.publishedAt).toISOString().slice(0, 16) + : new Date().toISOString().slice(0, 16) + ); + + const isPublished = new Date(publishedAtValue) <= new Date(); + + useEffect(() => { + getAllTags().then(setAllExistingTags); + }, []); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); const formData = new FormData(event.currentTarget); + const publishedAtStr = formData.get('publishedAt') as string; + const data: any = { title: formData.get('title') as string, slug: formData.get('slug') as string, @@ -28,16 +47,23 @@ export default function InterviewForm({ initialData }: Props) { companyName: formData.get('companyName') as string, role: formData.get('role') as string, type: formData.get('type') as InterviewType, - thumbnailUrl: formData.get('thumbnailUrl') as string, + thumbnailUrl: thumbnailUrl, // Use state value videoUrl: formData.get('videoUrl') as string || null, excerpt: formData.get('excerpt') as string, content: content || 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, 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 () => { const result = initialData ? await updateInterview(initialData.id, data) @@ -68,7 +94,21 @@ export default function InterviewForm({ initialData }: Props) {
-

Informations Générales

+
+

Informations Générales

+
+ + +
+
@@ -126,24 +166,29 @@ export default function InterviewForm({ initialData }: Props) {
- - + +
+ + 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" + /> +
- -
@@ -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" placeholder="https://youtube.com/..." /> +
+ + +
@@ -194,12 +248,12 @@ export default function InterviewForm({ initialData }: Props) {

Laissez vide pour générer automatiquement à partir du titre.

- -
diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index 2dabf05..3c2688a 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -15,7 +15,8 @@ import { Settings, Globe, FileText, - Briefcase + Briefcase, + Tags } from 'lucide-react'; const menuItems = [ @@ -26,7 +27,7 @@ const menuItems = [ { name: 'Modération', icon: Flag, href: '/moderation' }, { name: 'Blog & Interviews', icon: BookOpen, href: '/blog' }, { 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: 'Documents Légaux', icon: FileText, href: '/legal' }, { name: 'Configuration', icon: Settings, href: '/settings' }, diff --git a/admin/src/components/TagInput.tsx b/admin/src/components/TagInput.tsx new file mode 100644 index 0000000..9b4db20 --- /dev/null +++ b/admin/src/components/TagInput.tsx @@ -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([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(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) => { + 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 ( +
+ {label && } + +
{ + const input = containerRef.current?.querySelector('input'); + input?.focus(); + }} + > + {value.map((tag) => ( + + + {tag} + + + ))} + 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" + /> +
+ + {/* Suggestions Popover */} + {showSuggestions && filteredSuggestions.length > 0 && ( +
+
+

+ {inputValue ? : } + {inputValue ? 'Suggestions de recherche' : 'Mots-clés suggérés'} +

+ {!inputValue && Top {filteredSuggestions.length}} +
+
+ {filteredSuggestions.map((suggestion, index) => ( + + ))} +
+
+ )} + +

+ Appuyez sur Entrée ou virgule pour ajouter un tag. +

+
+ ); +} diff --git a/app/afrolife/[id]/page.tsx b/app/afrolife/[id]/page.tsx index 41d2cb7..e348bef 100644 --- a/app/afrolife/[id]/page.tsx +++ b/app/afrolife/[id]/page.tsx @@ -14,16 +14,21 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { id } = await params; + const now = new Date(); const interview = await prisma.interview.findFirst({ where: { - OR: [{ id }, { slug: id }] + OR: [{ id }, { slug: id }], + publishedAt: { lte: now }, + status: 'PUBLISHED' } }); const event = !interview ? await prisma.event.findFirst({ where: { - OR: [{ id }, { slug: id }] + OR: [{ id }, { slug: id }], + publishedAt: { lte: now }, + status: 'PUBLISHED' } }) : null; @@ -47,18 +52,23 @@ export async function generateMetadata({ params }: Props): Promise { export default async function AfroLifeDetailPage({ params }: Props) { const { id } = await params; + const now = new Date(); // 1. Try to find an interview const interview = await prisma.interview.findFirst({ where: { - OR: [{ id }, { slug: id }] + OR: [{ id }, { slug: id }], + publishedAt: { lte: now }, + status: 'PUBLISHED' } }); // 2. If not found, try to find an event const event = !interview ? await prisma.event.findFirst({ where: { - OR: [{ id }, { slug: id }] + OR: [{ id }, { slug: id }], + publishedAt: { lte: now }, + status: 'PUBLISHED' } }) : null; @@ -157,7 +167,7 @@ export default async function AfroLifeDetailPage({ params }: Props) { )} {/* Description / Article Content / Event Content */} -
+
{isEvent ? (
) : ( @@ -195,4 +205,3 @@ export default async function AfroLifeDetailPage({ params }: Props) {
); } - diff --git a/app/afrolife/page.tsx b/app/afrolife/page.tsx index 90d563c..d9392d9 100644 --- a/app/afrolife/page.tsx +++ b/app/afrolife/page.tsx @@ -19,10 +19,17 @@ export default async function AfroLifePage({ searchParams }: Props) { const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL'; let items: any[] = []; + const now = new Date(); try { if (filter === 'EVENT') { const events = await prisma.event.findMany({ + where: { + status: 'PUBLISHED', + publishedAt: { + lte: now + } + }, orderBy: { date: 'desc' } }); items = events.map(e => ({ @@ -34,7 +41,12 @@ export default async function AfroLifePage({ searchParams }: Props) { duration: new Date(e.date).toLocaleDateString('fr-FR') })); } else { - const where: any = {}; + const where: any = { + status: 'PUBLISHED', + publishedAt: { + lte: now + } + }; if (filter !== 'ALL') { 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 (filter === 'ALL') { const events = await prisma.event.findMany({ + where: { + status: 'PUBLISHED', + publishedAt: { + lte: now + } + }, orderBy: { date: 'desc' }, take: 10 }); diff --git a/app/api/blog/route.ts b/app/api/blog/route.ts index f720f32..cc03ddd 100644 --- a/app/api/blog/route.ts +++ b/app/api/blog/route.ts @@ -6,6 +6,12 @@ import { generateSlug } from '@/lib/utils' export async function GET() { try { const posts = await prisma.blogPost.findMany({ + where: { + status: 'PUBLISHED', + publishedAt: { + lte: new Date() + } + }, orderBy: { date: 'desc' }, }) return NextResponse.json(posts) diff --git a/app/api/businesses/route.ts b/app/api/businesses/route.ts index aa5e5dc..751b5fc 100644 --- a/app/api/businesses/route.ts +++ b/app/api/businesses/route.ts @@ -5,11 +5,6 @@ import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils' // GET /api/businesses — List all businesses (Mock + DB) export async function GET(request: NextRequest) { try { - const user = await getAuthUser(request) - if (!user) { - return NextResponse.json({ error: 'Authentification requise' }, { status: 401 }) - } - const { searchParams } = new URL(request.url) const category = searchParams.get('category') const q = searchParams.get('q')?.toLowerCase() diff --git a/app/api/interviews/route.ts b/app/api/interviews/route.ts index 8b302e2..39ca58f 100644 --- a/app/api/interviews/route.ts +++ b/app/api/interviews/route.ts @@ -5,6 +5,12 @@ import prisma from '@/lib/prisma' export async function GET() { try { const interviews = await prisma.interview.findMany({ + where: { + status: 'PUBLISHED', + publishedAt: { + lte: new Date() + } + }, orderBy: { date: 'desc' }, }) return NextResponse.json(interviews) diff --git a/app/api/tags/route.ts b/app/api/tags/route.ts new file mode 100644 index 0000000..bb57239 --- /dev/null +++ b/app/api/tags/route.ts @@ -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(); + [...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 }); + } +} diff --git a/app/blog/[id]/page.tsx b/app/blog/[id]/page.tsx index 417be86..1c214b1 100644 --- a/app/blog/[id]/page.tsx +++ b/app/blog/[id]/page.tsx @@ -19,7 +19,11 @@ export async function generateMetadata({ params }: Props): Promise { OR: [ { id: id }, { slug: id } - ] + ], + publishedAt: { + lte: new Date() + }, + status: 'PUBLISHED' } }); @@ -46,7 +50,11 @@ export default async function BlogPostPage({ params }: Props) { OR: [ { id: id }, { slug: id } - ] + ], + publishedAt: { + lte: new Date() + }, + status: 'PUBLISHED' } }); @@ -102,7 +110,7 @@ export default async function BlogPostPage({ params }: Props) { {/* Content */} -
+

{post.excerpt}

diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 0b18803..d334b77 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -10,6 +10,12 @@ export default async function BlogPage() { try { posts = await prisma.blogPost.findMany({ + where: { + status: 'PUBLISHED', + publishedAt: { + lte: new Date() + } + }, orderBy: { createdAt: 'desc' } }); } catch (error) { diff --git a/app/globals.css b/app/globals.css index a9bbbb5..5be014f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -23,4 +23,15 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-family: var(--font-sans); +} + +.prose { + text-align: justify; + hyphens: auto; + word-break: normal; + overflow-wrap: break-word; +} + +.prose p { + margin-bottom: 1.25em; } \ No newline at end of file diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..95c57bd --- /dev/null +++ b/app/not-found.tsx @@ -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 ( +
+
+ {/* Animated 404 number */} +
+

404

+
+ Oups ! +
+
+ +

+ Page introuvable +

+ +

+ Il semblerait que le chemin que vous avez emprunté n'existe pas ou a été déplacé. +

+ +
+ + + Retour à l'accueil + + + + Explorer l'annuaire + +
+ +
+

+ "L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse." +

+
+
+
+ ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 8290fbe..dce355f 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -62,6 +62,10 @@ export default async function sitemap(): Promise { // 3. Fetch all blog posts const blogPosts = await prisma.blogPost.findMany({ + where: { + status: 'PUBLISHED', + publishedAt: { lte: new Date() } + }, select: { id: true, slug: true, updatedAt: true }, }); @@ -74,6 +78,10 @@ export default async function sitemap(): Promise { // 4. Fetch all Afro Life (Interviews) const interviews = await prisma.interview.findMany({ + where: { + status: 'PUBLISHED', + publishedAt: { lte: new Date() } + }, select: { id: true, slug: true, updatedAt: true }, }); @@ -84,7 +92,23 @@ export default async function sitemap(): Promise { 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) { console.error("Error generating sitemap:", error); // Fallback to only static routes if DB connection fails diff --git a/components/TagInput.tsx b/components/TagInput.tsx new file mode 100644 index 0000000..db83437 --- /dev/null +++ b/components/TagInput.tsx @@ -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([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(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) => { + 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 ( +
+ {label && } + +
{ + const input = containerRef.current?.querySelector('input'); + input?.focus(); + }} + > + {value.map((tag) => ( + + + {tag} + + + ))} + 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" + /> +
+ + {showSuggestions && filteredSuggestions.length > 0 && ( +
+
+

+ {inputValue ? : } + {inputValue ? 'Suggestions' : 'Tags populaires'} +

+
+
+ {filteredSuggestions.map((suggestion, index) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/components/dashboard/DashboardProfile.tsx b/components/dashboard/DashboardProfile.tsx index 7bb2ad8..38d7f0f 100644 --- a/components/dashboard/DashboardProfile.tsx +++ b/components/dashboard/DashboardProfile.tsx @@ -1,9 +1,9 @@ "use client"; - -import React, { useState } from 'react'; -import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle, Hash } from 'lucide-react'; import { Business, Country } from '../../types'; +import TagInput from '../TagInput'; import { generateBusinessDescription } from '../../lib/geminiService'; import { toast } from 'react-hot-toast'; import { useUser } from '../UserProvider'; @@ -53,19 +53,22 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu const [videoPreviewId, setVideoPreviewId] = useState(getYouTubeId(business.videoUrl || '')); const [isGenerating, setIsGenerating] = useState(false); const [isMounted, setIsMounted] = useState(false); - - // Fetch countries and categories + const [allTags, setAllTags] = useState([]); + // Fetch countries, categories and tags React.useEffect(() => { const fetchData = async () => { try { - const [cRes, catRes] = await Promise.all([ + const [cRes, catRes, tagsRes] = await Promise.all([ fetch('/api/countries'), - fetch('/api/categories') + fetch('/api/categories'), + fetch('/api/tags') ]); const cData = await cRes.json(); const catData = await catRes.json(); + const tagsData = await tagsRes.json(); if (!cData.error) setCountries(cData); if (!catData.error) setCategories(catData); + if (!tagsData.error) setAllTags(tagsData); } catch (error) { console.error('Error fetching metadata:', error); } @@ -381,6 +384,16 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu