From 5f421b418e5ac07ec3880c3bbf7e1772948cd2e4 Mon Sep 17 00:00:00 2001 From: streaper2 Date: Sun, 3 May 2026 21:22:12 +0200 Subject: [PATCH] Maj taxonomies maj url photo + page 404 + gestion programmation d'article --- admin/package-lock.json | 2 + admin/prisma/schema.prisma | 361 ++++++++------- .../public/uploads/1777833997337-98dkw92.gif | Bin 0 -> 1605 bytes .../public/uploads/1777834233872-lz35qcr.jpg | Bin 0 -> 35102 bytes admin/src/app/actions/blog.ts | 11 +- admin/src/app/actions/event.ts | 7 + admin/src/app/actions/interview.ts | 8 +- admin/src/app/actions/taxonomies.ts | 138 ++++++ admin/src/app/actions/upload.ts | 42 ++ admin/src/app/blog/page.tsx | 15 + admin/src/app/categories/page.tsx | 404 ----------------- admin/src/app/dashboard/DashboardClient.tsx | 68 +++ admin/src/app/dashboard/page.tsx | 56 +-- admin/src/app/globals.css | 7 + admin/src/app/not-found.tsx | 38 ++ admin/src/app/taxonomies/TaxonomiesClient.tsx | 415 ++++++++++++++++++ admin/src/app/taxonomies/page.tsx | 5 + admin/src/components/BlogForm.tsx | 102 ++++- admin/src/components/EventForm.tsx | 102 ++++- admin/src/components/ImageUploader.tsx | 174 ++++++++ admin/src/components/InterviewForm.tsx | 104 +++-- admin/src/components/Sidebar.tsx | 5 +- admin/src/components/TagInput.tsx | 180 ++++++++ app/afrolife/[id]/page.tsx | 21 +- app/afrolife/page.tsx | 20 +- app/api/blog/route.ts | 6 + app/api/businesses/route.ts | 5 - app/api/interviews/route.ts | 6 + app/api/tags/route.ts | 20 + app/blog/[id]/page.tsx | 14 +- app/blog/page.tsx | 6 + app/globals.css | 11 + app/not-found.tsx | 50 +++ app/sitemap.ts | 26 +- components/TagInput.tsx | 172 ++++++++ components/dashboard/DashboardProfile.tsx | 27 +- .../migration.sql | 8 + .../migration.sql | 11 + prisma/schema.prisma | 34 +- public/uploads/1777833997337-98dkw92.gif | Bin 0 -> 1605 bytes public/uploads/1777834233872-lz35qcr.jpg | Bin 0 -> 35102 bytes 41 files changed, 1946 insertions(+), 735 deletions(-) create mode 100644 admin/public/uploads/1777833997337-98dkw92.gif create mode 100644 admin/public/uploads/1777834233872-lz35qcr.jpg create mode 100644 admin/src/app/actions/taxonomies.ts create mode 100644 admin/src/app/actions/upload.ts delete mode 100644 admin/src/app/categories/page.tsx create mode 100644 admin/src/app/dashboard/DashboardClient.tsx create mode 100644 admin/src/app/not-found.tsx create mode 100644 admin/src/app/taxonomies/TaxonomiesClient.tsx create mode 100644 admin/src/app/taxonomies/page.tsx create mode 100644 admin/src/components/ImageUploader.tsx create mode 100644 admin/src/components/TagInput.tsx create mode 100644 app/api/tags/route.ts create mode 100644 app/not-found.tsx create mode 100644 components/TagInput.tsx create mode 100644 prisma/migrations/20260503171918_add_published_at/migration.sql create mode 100644 prisma/migrations/20260503172958_add_content_status/migration.sql create mode 100644 public/uploads/1777833997337-98dkw92.gif create mode 100644 public/uploads/1777834233872-lz35qcr.jpg 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 0000000000000000000000000000000000000000..161894da0e9392a2a13370d8f3ed128fadcc699f GIT binary patch literal 1605 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�A>a0@K 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 IMHB)5*+jT&#Q*>R literal 0 HcmV?d00001 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