maj url photo + page 404 + gestion programmation d'article
This commit is contained in:
2
admin/package-lock.json
generated
2
admin/package-lock.json
generated
@@ -47,11 +47,13 @@
|
|||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
|
"isomorphic-dompurify": "^3.11.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
|||||||
@@ -9,119 +9,121 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
role UserRole @default(VISITOR)
|
role UserRole @default(VISITOR)
|
||||||
avatar String?
|
avatar String?
|
||||||
emailVerified Boolean @default(false)
|
createdAt DateTime @default(now())
|
||||||
verificationToken String? @unique
|
updatedAt DateTime @updatedAt
|
||||||
resetToken String? @unique
|
bio String?
|
||||||
resetTokenExpiry DateTime?
|
location String?
|
||||||
createdAt DateTime @default(now())
|
phone String?
|
||||||
updatedAt DateTime @updatedAt
|
isSuspended Boolean @default(false)
|
||||||
bio String?
|
suspensionReason String?
|
||||||
location String?
|
countryId String?
|
||||||
countryId String?
|
resetToken String? @unique
|
||||||
phone String?
|
resetTokenExpiry DateTime?
|
||||||
isSuspended Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
suspensionReason String?
|
verificationToken String? @unique
|
||||||
businesses Business?
|
businesses Business?
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
conversations ConversationParticipant[]
|
conversations ConversationParticipant[]
|
||||||
sentMessages Message[]
|
sentMessages Message[]
|
||||||
reports MessageReport[]
|
reports MessageReport[]
|
||||||
ratings Rating[]
|
ratings Rating[]
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
|
favorites Favorite[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Business {
|
model Business {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
category String
|
category String
|
||||||
|
location String
|
||||||
|
description String
|
||||||
|
logoUrl String
|
||||||
|
videoUrl String?
|
||||||
|
contactEmail String
|
||||||
|
contactPhone String?
|
||||||
|
socialLinks Json?
|
||||||
|
verified Boolean @default(false)
|
||||||
|
viewCount Int @default(0)
|
||||||
|
rating Float @default(0.0)
|
||||||
|
ratingCount Int @default(0)
|
||||||
|
tags String[]
|
||||||
|
isFeatured Boolean @default(false)
|
||||||
|
founderName String?
|
||||||
|
founderImageUrl String?
|
||||||
|
keyMetric String?
|
||||||
|
ownerId String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
slug String? @unique
|
||||||
|
isActive Boolean @default(false)
|
||||||
|
showEmail Boolean @default(true)
|
||||||
|
showPhone Boolean @default(true)
|
||||||
|
showSocials Boolean @default(true)
|
||||||
|
websiteUrl String?
|
||||||
|
isSuspended Boolean @default(false)
|
||||||
|
suspensionReason String?
|
||||||
|
plan Plan @default(STARTER)
|
||||||
|
city String?
|
||||||
|
countryId String?
|
||||||
|
coverUrl String?
|
||||||
|
coverPosition String? @default("50% 50%")
|
||||||
|
coverZoom Float? @default(1.0)
|
||||||
suggestedCategory String?
|
suggestedCategory String?
|
||||||
location String
|
categoryId String?
|
||||||
countryId String?
|
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
|
||||||
city String?
|
country Country? @relation(fields: [countryId], references: [id])
|
||||||
description String
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
logoUrl String
|
comments Comment[]
|
||||||
coverUrl String?
|
conversations Conversation[]
|
||||||
coverPosition String? @default("50% 50%")
|
offers Offer[]
|
||||||
coverZoom Float? @default(1.0)
|
ratings Rating[]
|
||||||
videoUrl String?
|
favorites Favorite[]
|
||||||
contactEmail String
|
|
||||||
contactPhone String?
|
|
||||||
socialLinks Json?
|
|
||||||
verified Boolean @default(false)
|
|
||||||
viewCount Int @default(0)
|
|
||||||
rating Float @default(0.0)
|
|
||||||
ratingCount Int @default(0)
|
|
||||||
tags String[]
|
|
||||||
isFeatured Boolean @default(false)
|
|
||||||
founderName String?
|
|
||||||
founderImageUrl String?
|
|
||||||
keyMetric String?
|
|
||||||
ownerId String @unique
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
slug String? @unique
|
|
||||||
isActive Boolean @default(false)
|
|
||||||
showEmail Boolean @default(true)
|
|
||||||
showPhone Boolean @default(true)
|
|
||||||
showSocials Boolean @default(true)
|
|
||||||
websiteUrl String?
|
|
||||||
isSuspended Boolean @default(false)
|
|
||||||
suspensionReason String?
|
|
||||||
plan Plan @default(STARTER)
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
|
||||||
comments Comment[]
|
|
||||||
conversations Conversation[]
|
|
||||||
offers Offer[]
|
|
||||||
ratings Rating[]
|
|
||||||
country Country? @relation(fields: [countryId], references: [id])
|
|
||||||
categoryRef BusinessCategory? @relation(fields: [categoryId], references: [id])
|
|
||||||
categoryId String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model BusinessCategory {
|
model BusinessCategory {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
slug String @unique
|
slug String @unique
|
||||||
icon String? // Lucide icon name
|
icon String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
businesses Business[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
businesses Business[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Country {
|
model Country {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
code String @unique // ISO alpha-2
|
code String @unique
|
||||||
flag String? // Emoji or URL
|
flag String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
description String? @db.Text
|
|
||||||
businesses Business[]
|
|
||||||
users User[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
description String?
|
||||||
|
businesses Business[]
|
||||||
|
users User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model PricingPlan {
|
model PricingPlan {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tier Plan @unique
|
tier Plan @unique
|
||||||
name String
|
name String
|
||||||
priceXOF String
|
priceXOF String
|
||||||
yearlyPriceXOF String?
|
priceEUR String
|
||||||
priceEUR String
|
description String
|
||||||
|
features String[]
|
||||||
|
offerLimit Int @default(1)
|
||||||
|
recommended Boolean @default(false)
|
||||||
|
color String @default("gray")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
yearlyPriceEUR String?
|
yearlyPriceEUR String?
|
||||||
description String
|
yearlyPriceXOF String?
|
||||||
features String[]
|
|
||||||
offerLimit Int @default(1)
|
|
||||||
recommended Boolean @default(false)
|
|
||||||
color String @default("gray")
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Offer {
|
model Offer {
|
||||||
@@ -140,25 +142,26 @@ model Offer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model BlogPost {
|
model BlogPost {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
slug String? @unique
|
|
||||||
excerpt String
|
excerpt String
|
||||||
content String
|
content String
|
||||||
author String
|
author String
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
imageUrl String
|
imageUrl String
|
||||||
tags String[] @default([])
|
createdAt DateTime @default(now())
|
||||||
metaTitle String?
|
updatedAt DateTime @updatedAt
|
||||||
metaDescription String?
|
metaDescription String?
|
||||||
createdAt DateTime @default(now())
|
metaTitle String?
|
||||||
updatedAt DateTime @updatedAt
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Interview {
|
model Interview {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
slug String? @unique
|
|
||||||
guestName String
|
guestName String
|
||||||
companyName String
|
companyName String
|
||||||
role String
|
role String
|
||||||
@@ -169,11 +172,14 @@ model Interview {
|
|||||||
excerpt String
|
excerpt String
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
duration String?
|
duration String?
|
||||||
tags String[] @default([])
|
|
||||||
metaTitle String?
|
|
||||||
metaDescription String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -194,40 +200,34 @@ model AnalyticsEvent {
|
|||||||
label String?
|
label String?
|
||||||
value Float?
|
value Float?
|
||||||
metadata Json?
|
metadata Json?
|
||||||
ip String?
|
createdAt DateTime @default(now())
|
||||||
country String?
|
|
||||||
city String?
|
|
||||||
browser String?
|
browser String?
|
||||||
os String?
|
city String?
|
||||||
|
country String?
|
||||||
device String?
|
device String?
|
||||||
|
ip String?
|
||||||
|
os String?
|
||||||
referrer String?
|
referrer String?
|
||||||
userId String?
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Rating {
|
model Rating {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
value Int
|
value Int
|
||||||
|
userId String
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
comment String?
|
comment String?
|
||||||
reply String?
|
reply String?
|
||||||
replyAt DateTime?
|
replyAt DateTime?
|
||||||
status RatingStatus @default(PENDING)
|
status RatingStatus @default(PENDING)
|
||||||
userId String
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
businessId String
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, businessId])
|
@@unique([userId, businessId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RatingStatus {
|
|
||||||
PENDING
|
|
||||||
APPROVED
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
model Conversation {
|
model Conversation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
businessId String
|
businessId String
|
||||||
@@ -272,6 +272,82 @@ model MessageReport {
|
|||||||
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SiteSetting {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
siteName String @default("Afrohub")
|
||||||
|
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
|
||||||
|
contactEmail String @default("support@afrohub.com")
|
||||||
|
contactPhone String? @default("+225 00 00 00 00 00")
|
||||||
|
address String? @default("Abidjan, Côte d'Ivoire")
|
||||||
|
facebookUrl String?
|
||||||
|
twitterUrl String?
|
||||||
|
instagramUrl String?
|
||||||
|
linkedinUrl String?
|
||||||
|
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
homeBlogCount Int @default(3)
|
||||||
|
homeBlogShow Boolean @default(true)
|
||||||
|
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
|
||||||
|
homeBlogTitle String @default("Derniers Articles")
|
||||||
|
homeBlogCategories String[] @default([])
|
||||||
|
homeBlogIds String[] @default([])
|
||||||
|
homeBlogSelection String @default("latest")
|
||||||
|
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
||||||
|
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
|
||||||
|
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>")
|
||||||
|
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
|
||||||
|
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LegalDocument {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String @unique
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
date DateTime
|
||||||
|
location String
|
||||||
|
thumbnailUrl String
|
||||||
|
link String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
metaDescription String?
|
||||||
|
metaTitle String?
|
||||||
|
slug String? @unique
|
||||||
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorite {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
businessId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, businessId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContentStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RatingStatus {
|
||||||
|
PENDING
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
enum ReportStatus {
|
enum ReportStatus {
|
||||||
PENDING
|
PENDING
|
||||||
RESOLVED
|
RESOLVED
|
||||||
@@ -299,54 +375,3 @@ enum InterviewType {
|
|||||||
VIDEO
|
VIDEO
|
||||||
ARTICLE
|
ARTICLE
|
||||||
}
|
}
|
||||||
|
|
||||||
model SiteSetting {
|
|
||||||
id String @id @default("singleton")
|
|
||||||
siteName String @default("Afrohub")
|
|
||||||
siteSlogan String @default("La plateforme de référence pour l'entrepreneuriat africain.")
|
|
||||||
contactEmail String @default("support@afrohub.com")
|
|
||||||
contactPhone String? @default("+225 00 00 00 00 00")
|
|
||||||
address String? @default("Abidjan, Côte d'Ivoire")
|
|
||||||
facebookUrl String?
|
|
||||||
twitterUrl String?
|
|
||||||
instagramUrl String?
|
|
||||||
linkedinUrl String?
|
|
||||||
footerText String? @default("© 2025 Afrohub. Tous droits réservés.")
|
|
||||||
homeBlogShow Boolean @default(true)
|
|
||||||
homeBlogTitle String @default("Derniers Articles")
|
|
||||||
homeBlogSubtitle String @default("Toutes les actualités de l'entrepreneuriat africain.")
|
|
||||||
homeBlogCount Int @default(3)
|
|
||||||
homeBlogSelection String @default("latest")
|
|
||||||
homeBlogIds String[] @default([])
|
|
||||||
homeBlogCategories String[] @default([])
|
|
||||||
homeCategories String[] @default(["Technologie & IT", "Agriculture & Agrobusiness", "Mode & Textile", "Cosmétique & Beauté"])
|
|
||||||
resetPasswordSubject String @default("Réinitialisation de votre mot de passe - Afrohub")
|
|
||||||
resetPasswordTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{resetUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Réinitialiser mon mot de passe</a></div><p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
|
|
||||||
verifyEmailSubject String @default("Vérification de votre compte - Afrohub")
|
|
||||||
verifyEmailTemplate String @default("<div style=\"font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;\"><h2 style=\"color: #0d9488; text-align: center;\">{siteName}</h2><p>Bonjour {name},</p><p>Merci de vous être inscrit sur {siteName}. Pour finaliser la création de votre compte, merci de vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p><div style=\"text-align: center; margin: 30px 0;\"><a href=\"{verifyUrl}\" style=\"background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;\">Vérifier mon compte</a></div><p>Si vous n'avez pas créé de compte sur notre plateforme, vous pouvez ignorer cet email.</p><hr style=\"border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;\"><p style=\"font-size: 12px; color: #6b7280; text-align: center;\">© 2025 {siteName}. Tous droits réservés.</p></div>") @db.Text
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model LegalDocument {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
type String @unique // 'CGU' or 'CGV'
|
|
||||||
title String
|
|
||||||
content String @db.Text
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Event {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
|
||||||
slug String? @unique
|
|
||||||
description String @db.Text
|
|
||||||
date DateTime
|
|
||||||
location String
|
|
||||||
thumbnailUrl String
|
|
||||||
link String?
|
|
||||||
tags String[] @default([])
|
|
||||||
metaTitle String?
|
|
||||||
metaDescription String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|||||||
BIN
admin/public/uploads/1777833997337-98dkw92.gif
Normal file
BIN
admin/public/uploads/1777833997337-98dkw92.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
admin/public/uploads/1777834233872-lz35qcr.jpg
Normal file
BIN
admin/public/uploads/1777834233872-lz35qcr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -3,6 +3,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
export async function createBlogPost(data: {
|
export async function createBlogPost(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,6 +15,8 @@ export async function createBlogPost(data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -23,10 +26,12 @@ export async function createBlogPost(data: {
|
|||||||
...data,
|
...data,
|
||||||
slug,
|
slug,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/blog");
|
||||||
return { success: true, data: post };
|
return { success: true, id: post.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create blog post:", error);
|
console.error("Failed to create blog post:", error);
|
||||||
return { success: false, error: "Erreur lors de la création de l'article" };
|
return { success: false, error: "Erreur lors de la création de l'article" };
|
||||||
@@ -43,6 +48,8 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -56,7 +63,7 @@ export async function updateBlogPost(id: string, data: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/blog");
|
||||||
return { success: true, data: post };
|
return { success: true, id: post.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update blog post:", error);
|
console.error("Failed to update blog post:", error);
|
||||||
return { success: false, error: "Erreur lors de la mise à jour" };
|
return { success: false, error: "Erreur lors de la mise à jour" };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { ContentStatus } from "@prisma/client";
|
||||||
|
|
||||||
export async function createEvent(data: {
|
export async function createEvent(data: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,6 +16,8 @@ export async function createEvent(data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -24,6 +27,8 @@ export async function createEvent(data: {
|
|||||||
...data,
|
...data,
|
||||||
slug,
|
slug,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/blog");
|
||||||
@@ -46,6 +51,8 @@ export async function updateEvent(id: string, data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { InterviewType } from "@prisma/client";
|
import { InterviewType, ContentStatus } from "@prisma/client";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
|
||||||
export async function createInterview(data: {
|
export async function createInterview(data: {
|
||||||
@@ -20,6 +20,8 @@ export async function createInterview(data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
@@ -29,6 +31,8 @@ export async function createInterview(data: {
|
|||||||
...data,
|
...data,
|
||||||
slug,
|
slug,
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
|
publishedAt: data.publishedAt || new Date(),
|
||||||
|
status: data.status || ContentStatus.PUBLISHED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
revalidatePath("/blog");
|
revalidatePath("/blog");
|
||||||
@@ -54,6 +58,8 @@ export async function updateInterview(id: string, data: {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string;
|
metaTitle?: string;
|
||||||
metaDescription?: string;
|
metaDescription?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
status?: ContentStatus;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const slug = data.slug || generateSlug(data.title);
|
const slug = data.slug || generateSlug(data.title);
|
||||||
|
|||||||
138
admin/src/app/actions/taxonomies.ts
Normal file
138
admin/src/app/actions/taxonomies.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function getAllTags() {
|
||||||
|
try {
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ select: { tags: true } }),
|
||||||
|
prisma.interview.findMany({ select: { tags: true } }),
|
||||||
|
prisma.event.findMany({ select: { tags: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
blogPosts.forEach(p => p.tags.forEach(t => allTags.add(t)));
|
||||||
|
interviews.forEach(i => i.tags.forEach(t => allTags.add(t)));
|
||||||
|
events.forEach(e => e.tags.forEach(t => allTags.add(t)));
|
||||||
|
|
||||||
|
return Array.from(allTags).sort();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tags:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTagGlobally(tagName: string) {
|
||||||
|
try {
|
||||||
|
// This is expensive as we need to update all records
|
||||||
|
// In a real app, a Tag model would be better
|
||||||
|
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
prisma.interview.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
prisma.event.findMany({ where: { tags: { has: tagName } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...blogPosts.map(p => prisma.blogPost.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { tags: { set: p.tags.filter(t => t !== tagName) } }
|
||||||
|
})),
|
||||||
|
...interviews.map(i => prisma.interview.update({
|
||||||
|
where: { id: i.id },
|
||||||
|
data: { tags: { set: i.tags.filter(t => t !== tagName) } }
|
||||||
|
})),
|
||||||
|
...events.map(e => prisma.event.update({
|
||||||
|
where: { id: e.id },
|
||||||
|
data: { tags: { set: e.tags.filter(t => t !== tagName) } }
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidatePath('/taxonomies');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting tag globally:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la suppression du tag' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameTagGlobally(oldName: string, newName: string) {
|
||||||
|
try {
|
||||||
|
const cleanNewName = newName.trim().toLowerCase();
|
||||||
|
if (!cleanNewName) return { success: false, error: 'Nouveau nom invalide' };
|
||||||
|
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
prisma.interview.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
prisma.event.findMany({ where: { tags: { has: oldName } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...blogPosts.map(p => prisma.blogPost.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { tags: { set: p.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
})),
|
||||||
|
...interviews.map(i => prisma.interview.update({
|
||||||
|
where: { id: i.id },
|
||||||
|
data: { tags: { set: i.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
})),
|
||||||
|
...events.map(e => prisma.event.update({
|
||||||
|
where: { id: e.id },
|
||||||
|
data: { tags: { set: e.tags.map(t => t === oldName ? cleanNewName : t) } }
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidatePath('/taxonomies');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error renaming tag globally:', error);
|
||||||
|
return { success: false, error: 'Erreur lors du renommage du tag' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTagUsage(tagName: string) {
|
||||||
|
try {
|
||||||
|
const [blogPosts, interviews, events] = await Promise.all([
|
||||||
|
prisma.blogPost.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, author: true }
|
||||||
|
}),
|
||||||
|
prisma.interview.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, guestName: true }
|
||||||
|
}),
|
||||||
|
prisma.event.findMany({
|
||||||
|
where: { tags: { has: tagName } },
|
||||||
|
select: { id: true, title: true, status: true, location: true }
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
blogPosts: blogPosts.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
status: p.status,
|
||||||
|
type: 'blog'
|
||||||
|
})),
|
||||||
|
interviews: interviews.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
title: i.title,
|
||||||
|
status: i.status,
|
||||||
|
type: 'interview'
|
||||||
|
})),
|
||||||
|
events: events.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
status: e.status,
|
||||||
|
type: 'event'
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tag usage:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de la récupération de l\'usage' };
|
||||||
|
}
|
||||||
|
}
|
||||||
42
admin/src/app/actions/upload.ts
Normal file
42
admin/src/app/actions/upload.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function uploadImage(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: 'Aucun fichier fourni' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
// Create a unique filename
|
||||||
|
const fileExtension = file.name.split('.').pop();
|
||||||
|
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}.${fileExtension}`;
|
||||||
|
|
||||||
|
// Path for the ROOT public/uploads (where the main site will look)
|
||||||
|
const rootPublicDir = join(process.cwd(), '..', 'public', 'uploads');
|
||||||
|
// Path for the ADMIN public/uploads (where the admin app can see it for preview)
|
||||||
|
const adminPublicDir = join(process.cwd(), 'public', 'uploads');
|
||||||
|
|
||||||
|
// Ensure both directories exist
|
||||||
|
await mkdir(rootPublicDir, { recursive: true });
|
||||||
|
await mkdir(adminPublicDir, { recursive: true });
|
||||||
|
|
||||||
|
// Save to both
|
||||||
|
await writeFile(join(rootPublicDir, fileName), buffer);
|
||||||
|
await writeFile(join(adminPublicDir, fileName), buffer);
|
||||||
|
|
||||||
|
// Return the relative URL
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: `/uploads/${fileName}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during image upload:', error);
|
||||||
|
return { success: false, error: 'Erreur lors de l\'upload de l\'image' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,15 @@ async function getData() {
|
|||||||
return { posts, interviews, events };
|
return { posts, interviews, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/10 text-slate-400 border border-slate-500/20">BROUILLON</span>;
|
||||||
|
case 'PUBLISHED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">PUBLIÉ</span>;
|
||||||
|
case 'ARCHIVED': return <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20">ARCHIVÉ</span>;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function BlogCMSPage() {
|
export default async function BlogCMSPage() {
|
||||||
const { posts, interviews, events } = await getData();
|
const { posts, interviews, events } = await getData();
|
||||||
|
|
||||||
@@ -50,6 +59,7 @@ export default async function BlogCMSPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4">Titre</th>
|
<th className="text-left py-3 px-4">Titre</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
<th className="text-left py-3 px-4">Auteur</th>
|
<th className="text-left py-3 px-4">Auteur</th>
|
||||||
<th className="text-left py-3 px-4">Date</th>
|
<th className="text-left py-3 px-4">Date</th>
|
||||||
<th className="text-right py-3 px-4">Actions</th>
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
@@ -62,6 +72,7 @@ export default async function BlogCMSPage() {
|
|||||||
<div className="font-medium text-white">{post.title}</div>
|
<div className="font-medium text-white">{post.title}</div>
|
||||||
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
|
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(post.status)}</td>
|
||||||
<td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
|
<td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
|
||||||
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
<td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
|
||||||
<td className="py-4 px-4 text-right">
|
<td className="py-4 px-4 text-right">
|
||||||
@@ -90,6 +101,7 @@ export default async function BlogCMSPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4">Interviewé</th>
|
<th className="text-left py-3 px-4">Interviewé</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
<th className="text-left py-3 px-4">Entreprise / Rôle</th>
|
<th className="text-left py-3 px-4">Entreprise / Rôle</th>
|
||||||
<th className="text-left py-3 px-4">Type</th>
|
<th className="text-left py-3 px-4">Type</th>
|
||||||
<th className="text-right py-3 px-4">Actions</th>
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
@@ -102,6 +114,7 @@ export default async function BlogCMSPage() {
|
|||||||
<div className="font-medium text-white">{interview.guestName}</div>
|
<div className="font-medium text-white">{interview.guestName}</div>
|
||||||
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
|
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(interview.status)}</td>
|
||||||
<td className="py-4 px-4">
|
<td className="py-4 px-4">
|
||||||
<div className="text-sm text-slate-300">{interview.companyName}</div>
|
<div className="text-sm text-slate-300">{interview.companyName}</div>
|
||||||
<div className="text-xs text-slate-500">{interview.role}</div>
|
<div className="text-xs text-slate-500">{interview.role}</div>
|
||||||
@@ -137,6 +150,7 @@ export default async function BlogCMSPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4">Événement</th>
|
<th className="text-left py-3 px-4">Événement</th>
|
||||||
|
<th className="text-left py-3 px-4">Statut</th>
|
||||||
<th className="text-left py-3 px-4">Date & Lieu</th>
|
<th className="text-left py-3 px-4">Date & Lieu</th>
|
||||||
<th className="text-right py-3 px-4">Actions</th>
|
<th className="text-right py-3 px-4">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -150,6 +164,7 @@ export default async function BlogCMSPage() {
|
|||||||
{event.link ? 'Lien activé' : 'Pas de lien'}
|
{event.link ? 'Lien activé' : 'Pas de lien'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-4 px-4">{getStatusBadge(event.status)}</td>
|
||||||
<td className="py-4 px-4">
|
<td className="py-4 px-4">
|
||||||
<div className="flex items-center text-sm text-slate-300 gap-2">
|
<div className="flex items-center text-sm text-slate-300 gap-2">
|
||||||
<Calendar className="w-3 h-3 text-slate-500" />
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useTransition } from 'react';
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
Loader2,
|
|
||||||
Briefcase,
|
|
||||||
AlertCircle,
|
|
||||||
Clock,
|
|
||||||
Sparkles,
|
|
||||||
Cpu,
|
|
||||||
Sprout,
|
|
||||||
Shirt,
|
|
||||||
Utensils,
|
|
||||||
HardHat,
|
|
||||||
Stethoscope,
|
|
||||||
GraduationCap,
|
|
||||||
Palette,
|
|
||||||
Plane,
|
|
||||||
Truck,
|
|
||||||
Wallet,
|
|
||||||
Zap,
|
|
||||||
Leaf,
|
|
||||||
Camera,
|
|
||||||
Music,
|
|
||||||
ShoppingBag,
|
|
||||||
Heart,
|
|
||||||
Home
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
|
|
||||||
const ICON_LIST = [
|
|
||||||
{ name: 'Briefcase', icon: Briefcase },
|
|
||||||
{ name: 'Cpu', icon: Cpu },
|
|
||||||
{ name: 'Sprout', icon: Sprout },
|
|
||||||
{ name: 'Shirt', icon: Shirt },
|
|
||||||
{ name: 'Sparkles', icon: Sparkles },
|
|
||||||
{ name: 'Utensils', icon: Utensils },
|
|
||||||
{ name: 'HardHat', icon: HardHat },
|
|
||||||
{ name: 'Stethoscope', icon: Stethoscope },
|
|
||||||
{ name: 'GraduationCap', icon: GraduationCap },
|
|
||||||
{ name: 'Palette', icon: Palette },
|
|
||||||
{ name: 'Plane', icon: Plane },
|
|
||||||
{ name: 'Truck', icon: Truck },
|
|
||||||
{ name: 'Wallet', icon: Wallet },
|
|
||||||
{ name: 'Zap', icon: Zap },
|
|
||||||
{ name: 'Leaf', icon: Leaf },
|
|
||||||
{ name: 'Camera', icon: Camera },
|
|
||||||
{ name: 'Music', icon: Music },
|
|
||||||
{ name: 'ShoppingBag', icon: ShoppingBag },
|
|
||||||
{ name: 'Heart', icon: Heart },
|
|
||||||
{ name: 'Home', icon: Home },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
|
||||||
const [categories, setCategories] = useState<any[]>([]);
|
|
||||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
|
||||||
const [editingCategory, setEditingCategory] = useState<any>(null);
|
|
||||||
const [selectedSuggestion, setSelectedSuggestion] = useState<any>(null);
|
|
||||||
|
|
||||||
const renderIcon = (iconName: string) => {
|
|
||||||
const found = ICON_LIST.find(i => i.name === iconName);
|
|
||||||
if (found) {
|
|
||||||
const IconComp = found.icon;
|
|
||||||
return <IconComp className="w-4 h-4 text-indigo-400" />;
|
|
||||||
}
|
|
||||||
return <Briefcase className="w-4 h-4 text-indigo-400" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
slug: '',
|
|
||||||
icon: 'Briefcase',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCategories();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadCategories() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [catData, suggData] = await Promise.all([
|
|
||||||
getCategories(),
|
|
||||||
getSuggestedCategories()
|
|
||||||
]);
|
|
||||||
setCategories(catData);
|
|
||||||
setSuggestions(suggData);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Erreur de chargement");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddCategory = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await createCategory({ ...formData, suggestionId: selectedSuggestion?.id });
|
|
||||||
if (result.success) {
|
|
||||||
toast.success("Catégorie créée !");
|
|
||||||
setShowAddModal(false);
|
|
||||||
setSelectedSuggestion(null);
|
|
||||||
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
|
|
||||||
loadCategories();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Erreur");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateCategory = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!editingCategory) return;
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await updateCategory(editingCategory.id, formData);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success("Catégorie mise à jour !");
|
|
||||||
setEditingCategory(null);
|
|
||||||
loadCategories();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Erreur");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm("Supprimer cette catégorie ?")) return;
|
|
||||||
|
|
||||||
const result = await deleteCategory(id);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success("Supprimée !");
|
|
||||||
loadCategories();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Erreur");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (cat: any) => {
|
|
||||||
setEditingCategory(cat);
|
|
||||||
setFormData({
|
|
||||||
name: cat.name,
|
|
||||||
slug: cat.slug,
|
|
||||||
icon: cat.icon || 'Briefcase',
|
|
||||||
isActive: cat.isActive
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-20">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-7xl mx-auto">
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-white">Secteurs & Catégories</h1>
|
|
||||||
<p className="text-slate-400">Gérez les secteurs d'activité disponibles pour les entrepreneurs.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowAddModal(true); setEditingCategory(null); setSelectedSuggestion(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); }}
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-xl flex items-center gap-2 transition-all shadow-lg shadow-indigo-600/20"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" /> Nouvelle Catégorie
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
|
||||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center justify-between">
|
|
||||||
<h2 className="font-semibold text-white">Catégories Officielles</h2>
|
|
||||||
<span className="text-xs text-slate-400">{categories.length} secteurs</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-700">
|
|
||||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Nom</th>
|
|
||||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">Statut</th>
|
|
||||||
<th className="p-4 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<tr key={cat.id} className="hover:bg-slate-800/30 transition-colors">
|
|
||||||
<td className="p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-slate-800 rounded-lg flex items-center justify-center">
|
|
||||||
{renderIcon(cat.icon)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-white">{cat.name}</p>
|
|
||||||
<p className="text-[10px] text-slate-500 font-mono">{cat.slug}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-4">
|
|
||||||
{cat.isActive ? (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500">
|
|
||||||
<Check className="w-3 h-3" /> Actif
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/10 text-slate-500">
|
|
||||||
<X className="w-3 h-3" /> Inactif
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => openEdit(cat)}
|
|
||||||
className="p-2 text-slate-400 hover:text-indigo-400 hover:bg-indigo-400/10 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(cat.id)}
|
|
||||||
className="p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
|
|
||||||
<div className="p-4 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
|
|
||||||
<Clock className="w-5 h-5 text-yellow-400" />
|
|
||||||
<h2 className="font-semibold text-white">Suggestions</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{suggestions.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="w-12 h-12 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
||||||
<Check className="w-6 h-6 text-slate-600" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-500">Aucune suggestion en attente.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
suggestions.map((sugg) => (
|
|
||||||
<div key={sugg.id} className="p-4 bg-slate-950 rounded-xl border border-slate-800 space-y-2 hover:border-indigo-500/30 transition-colors group">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-wider bg-indigo-400/10 px-2 py-0.5 rounded">Proposé</span>
|
|
||||||
<span className="text-[10px] text-slate-500">{new Date(sugg.createdAt).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-white font-bold text-lg">{sugg.suggestedCategory}</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
|
||||||
<Briefcase className="w-3 h-3" />
|
|
||||||
<span>Par : <span className="text-slate-300 font-medium">{sugg.name}</span></span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedSuggestion(sugg);
|
|
||||||
setFormData({
|
|
||||||
name: sugg.suggestedCategory,
|
|
||||||
slug: sugg.suggestedCategory.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, ''),
|
|
||||||
icon: 'Briefcase',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
setShowAddModal(true);
|
|
||||||
}}
|
|
||||||
className="w-full mt-3 text-xs bg-indigo-600/10 text-indigo-400 hover:bg-indigo-600 hover:text-white font-bold py-2.5 rounded-lg transition-all border border-indigo-600/20"
|
|
||||||
>
|
|
||||||
Convertir en catégorie officielle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(showAddModal || editingCategory) && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
|
||||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
|
||||||
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/30">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{selectedSuggestion && <Sparkles className="w-5 h-5 text-yellow-400" />}
|
|
||||||
<h3 className="text-xl font-bold text-white">
|
|
||||||
{editingCategory ? 'Modifier la catégorie' : selectedSuggestion ? 'Valider la suggestion' : 'Nouvelle catégorie'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
|
|
||||||
className="text-slate-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={editingCategory ? handleUpdateCategory : handleAddCategory} className="p-6 space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Nom de la catégorie</label>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const name = e.target.value;
|
|
||||||
const slug = name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ /g, '-').replace(/[^\w-]/g, '');
|
|
||||||
setFormData({...formData, name, slug});
|
|
||||||
}}
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Slug (URL)</label>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
value={formData.slug}
|
|
||||||
onChange={(e) => setFormData({...formData, slug: e.target.value})}
|
|
||||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-slate-950 rounded-xl border border-slate-800">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="isActive"
|
|
||||||
checked={formData.isActive}
|
|
||||||
onChange={(e) => setFormData({...formData, isActive: e.target.checked})}
|
|
||||||
className="w-5 h-5 rounded border-slate-700 bg-slate-800 text-indigo-600 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
<label htmlFor="isActive" className="text-sm font-medium text-slate-300">Rendre active immédiatement</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedSuggestion && (
|
|
||||||
<div className="p-4 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-xs text-indigo-300">
|
|
||||||
<p>✨ **Note :** En créant cette catégorie, l'entreprise **{selectedSuggestion.name}** y sera automatiquement rattachée.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400 block mb-2">Choisir une icône</label>
|
|
||||||
<div className="grid grid-cols-5 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
|
||||||
{ICON_LIST.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData({...formData, icon: item.name})}
|
|
||||||
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${
|
|
||||||
formData.icon === item.name
|
|
||||||
? 'bg-indigo-600 border-indigo-500 text-white shadow-lg shadow-indigo-600/30 scale-105'
|
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
|
|
||||||
}`}
|
|
||||||
title={item.name}
|
|
||||||
>
|
|
||||||
<item.icon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setShowAddModal(false); setEditingCategory(null); setSelectedSuggestion(null); }}
|
|
||||||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all border border-slate-700"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="flex-1 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white font-bold py-3 rounded-xl transition-all flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20"
|
|
||||||
>
|
|
||||||
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
||||||
{editingCategory ? 'Sauvegarder les modifications' : selectedSuggestion ? 'Valider et Créer' : 'Créer la catégorie'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
68
admin/src/app/dashboard/DashboardClient.tsx
Normal file
68
admin/src/app/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Store,
|
||||||
|
Clock,
|
||||||
|
MessageCircle,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
usersCount: number;
|
||||||
|
businessCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
commentsCount: number;
|
||||||
|
totalViews: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardClient({ stats }: { stats: Stats }) {
|
||||||
|
const statCards = [
|
||||||
|
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||||
|
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
||||||
|
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
|
||||||
|
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
||||||
|
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
|
||||||
|
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
|
||||||
|
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="card">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
|
||||||
|
<card.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-white">{card.value}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-slate-400 font-medium">{card.label}</h3>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import {
|
import DashboardClient from './DashboardClient';
|
||||||
Users,
|
|
||||||
Store,
|
|
||||||
Clock,
|
|
||||||
MessageCircle,
|
|
||||||
Eye
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
async function getStats() {
|
async function getStats() {
|
||||||
const usersCount = await prisma.user.count();
|
const usersCount = await prisma.user.count();
|
||||||
@@ -32,51 +26,5 @@ async function getStats() {
|
|||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const stats = await getStats();
|
const stats = await getStats();
|
||||||
|
return <DashboardClient stats={stats} />;
|
||||||
const statCards = [
|
|
||||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
|
||||||
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
|
||||||
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
|
|
||||||
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
|
|
||||||
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
|
|
||||||
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
|
|
||||||
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid">
|
|
||||||
{statCards.map((card) => (
|
|
||||||
<div key={card.label} className="card">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
|
|
||||||
<card.icon className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold text-white">{card.value}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-slate-400 font-medium">{card.label}</h3>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,13 @@ body {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quill-dark-wrapper .ql-editor {
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
word-break: normal;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Styles for the Divider (HR) */
|
/* Styles for the Divider (HR) */
|
||||||
.quill-dark-wrapper .ql-editor hr {
|
.quill-dark-wrapper .ql-editor hr {
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
38
admin/src/app/not-found.tsx
Normal file
38
admin/src/app/not-found.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { LayoutDashboard, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 mb-6">
|
||||||
|
<span className="text-4xl font-bold text-indigo-400">404</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Page d'administration introuvable</h1>
|
||||||
|
<p className="text-slate-400">La ressource que vous recherchez n'existe pas ou a été déplacée.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
|
Retour au Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-slate-300 border border-slate-800 rounded-xl font-semibold hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
Page précédente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
415
admin/src/app/taxonomies/TaxonomiesClient.tsx
Normal file
415
admin/src/app/taxonomies/TaxonomiesClient.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Briefcase,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
Sparkles,
|
||||||
|
Cpu,
|
||||||
|
Sprout,
|
||||||
|
Shirt,
|
||||||
|
Utensils,
|
||||||
|
HardHat,
|
||||||
|
Stethoscope,
|
||||||
|
GraduationCap,
|
||||||
|
Palette,
|
||||||
|
Plane,
|
||||||
|
Truck,
|
||||||
|
Wallet,
|
||||||
|
Zap,
|
||||||
|
Leaf,
|
||||||
|
Camera,
|
||||||
|
Music,
|
||||||
|
ShoppingBag,
|
||||||
|
Heart,
|
||||||
|
Home,
|
||||||
|
Hash,
|
||||||
|
Tags,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
User as UserIcon,
|
||||||
|
MapPin,
|
||||||
|
BookOpen,
|
||||||
|
Save
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { getCategories, createCategory, updateCategory, deleteCategory, getSuggestedCategories } from '@/app/actions/categories';
|
||||||
|
import { getAllTags, deleteTagGlobally, renameTagGlobally, getTagUsage } from '@/app/actions/taxonomies';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
const ICON_LIST = [
|
||||||
|
{ name: 'Briefcase', icon: Briefcase },
|
||||||
|
{ name: 'Cpu', icon: Cpu },
|
||||||
|
{ name: 'Sprout', icon: Sprout },
|
||||||
|
{ name: 'Shirt', icon: Shirt },
|
||||||
|
{ name: 'Sparkles', icon: Sparkles },
|
||||||
|
{ name: 'Utensils', icon: Utensils },
|
||||||
|
{ name: 'HardHat', icon: HardHat },
|
||||||
|
{ name: 'Stethoscope', icon: Stethoscope },
|
||||||
|
{ name: 'GraduationCap', icon: GraduationCap },
|
||||||
|
{ name: 'Palette', icon: Palette },
|
||||||
|
{ name: 'Plane', icon: Plane },
|
||||||
|
{ name: 'Truck', icon: Truck },
|
||||||
|
{ name: 'Wallet', icon: Wallet },
|
||||||
|
{ name: 'Zap', icon: Zap },
|
||||||
|
{ name: 'Leaf', icon: Leaf },
|
||||||
|
{ name: 'Camera', icon: Camera },
|
||||||
|
{ name: 'Music', icon: Music },
|
||||||
|
{ name: 'ShoppingBag', icon: ShoppingBag },
|
||||||
|
{ name: 'Heart', icon: Heart },
|
||||||
|
{ name: 'Home', icon: Home },
|
||||||
|
{ name: 'Tags', icon: Tags },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Tab = 'categories' | 'tags';
|
||||||
|
|
||||||
|
export default function TaxonomiesClient() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('categories');
|
||||||
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Category Form State
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tag Form State
|
||||||
|
const [newTagName, setNewTagName] = useState('');
|
||||||
|
const [isEditingUsageName, setIsEditingUsageName] = useState(false);
|
||||||
|
const [usagePreview, setUsagePreview] = useState<{ tag: string, data: any } | null>(null);
|
||||||
|
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [catsRes, tagsRes, sugRes] = await Promise.all([
|
||||||
|
getCategories(),
|
||||||
|
getAllTags(),
|
||||||
|
getSuggestedCategories()
|
||||||
|
]);
|
||||||
|
setCategories(catsRes);
|
||||||
|
setTags(tagsRes);
|
||||||
|
setSuggestions(sugRes);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Erreur de chargement");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCategorySubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = editingCategory
|
||||||
|
? await updateCategory(editingCategory.id, formData)
|
||||||
|
: await createCategory(formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(editingCategory ? "Catégorie mise à jour" : "Catégorie créée");
|
||||||
|
setShowAddModal(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true });
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagDelete = async (tag: string) => {
|
||||||
|
if (!confirm(`Supprimer le tag "${tag}" globalement ?`)) return;
|
||||||
|
const result = await deleteTagGlobally(tag);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Tag supprimé partout");
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowUsage = async (tag: string) => {
|
||||||
|
setLoadingUsage(true);
|
||||||
|
const result = await getTagUsage(tag);
|
||||||
|
if (result.success) {
|
||||||
|
setUsagePreview({ tag, data: result.data });
|
||||||
|
}
|
||||||
|
setLoadingUsage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-slate-500">Chargement...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Taxonomies</h1>
|
||||||
|
<p className="text-slate-400">Gérez les catégories et les mots-clés du site.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex bg-slate-900 p-1 rounded-xl border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('categories')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'categories' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Catégories
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tags')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeTab === 'tags' ? 'bg-indigo-600 text-white shadow-lg' : 'text-slate-400 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
Tags / Mots-clés
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'categories' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Tags className="w-5 h-5 text-indigo-400" />
|
||||||
|
Liste des catégories ({categories.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCategory(null); setFormData({ name: '', slug: '', icon: 'Briefcase', isActive: true }); setShowAddModal(true); }}
|
||||||
|
className="btn-verify flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Nouvelle catégorie
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const IconComp = ICON_LIST.find(i => i.name === cat.icon)?.icon || Briefcase;
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="card p-4 flex items-center justify-between group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-slate-800 rounded-lg text-indigo-400">
|
||||||
|
<IconComp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-bold">{cat.name}</h3>
|
||||||
|
<p className="text-xs text-slate-500">/{cat.slug}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingCategory(cat); setFormData({ name: cat.name, slug: cat.slug, icon: cat.icon, isActive: cat.isActive }); setShowAddModal(true); }}
|
||||||
|
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { if (confirm("Supprimer ?")) await deleteCategory(cat.id); loadData(); }}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Hash className="w-5 h-5 text-indigo-400" />
|
||||||
|
Tags utilisés ({tags.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
onClick={() => handleShowUsage(tag)}
|
||||||
|
className="group flex items-center justify-between bg-slate-950 border border-slate-800 rounded-xl p-3 hover:border-indigo-500/50 transition-all hover:bg-indigo-500/5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-slate-300 truncate group-hover:text-white">{tag}</span>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
|
||||||
|
<Trash2
|
||||||
|
className="w-3.5 h-3.5 text-slate-500 hover:text-red-400"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleTagDelete(tag); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal Ajout/Modif Catégorie */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-md w-full">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-6">{editingCategory ? 'Modifier la catégorie' : 'Nouvelle catégorie'}</h3>
|
||||||
|
<form onSubmit={handleCategorySubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-slate-400 mb-2 uppercase tracking-wider">Nom</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:border-indigo-500 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-1">
|
||||||
|
{ICON_LIST.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, icon: item.name })}
|
||||||
|
className={`p-3 rounded-xl border flex items-center justify-center transition-all ${formData.icon === item.name ? 'bg-indigo-600/20 border-indigo-500 text-indigo-400' : 'bg-slate-950 border-slate-800 text-slate-500 hover:border-slate-700'}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-8">
|
||||||
|
<button type="button" onClick={() => setShowAddModal(false)} className="flex-1 px-4 py-3 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-all">Annuler</button>
|
||||||
|
<button type="submit" disabled={isPending} className="flex-1 px-4 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all flex items-center justify-center gap-2">
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
|
{editingCategory ? 'Mettre à jour' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage Modal */}
|
||||||
|
{usagePreview && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl">
|
||||||
|
<div className="p-6 border-b border-slate-700 bg-slate-800/30 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Hash className="w-6 h-6 text-indigo-500" />
|
||||||
|
{isEditingUsageName ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-indigo-500/50 rounded-xl px-4 py-2 text-white font-bold"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await renameTagGlobally(usagePreview.tag, newTagName);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("Renommé");
|
||||||
|
setUsagePreview({ ...usagePreview, tag: newTagName });
|
||||||
|
setIsEditingUsageName(false);
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 bg-emerald-600 text-white rounded-lg"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xl font-bold text-white uppercase tracking-tight">Tag : {usagePreview.tag}</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{!isEditingUsageName && (
|
||||||
|
<button onClick={() => { setNewTagName(usagePreview.tag); setIsEditingUsageName(true); }} className="p-2 text-slate-400 hover:text-indigo-400"><Edit2 className="w-4 h-4" /></button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setUsagePreview(null)} className="p-2 text-slate-400 hover:text-white"><X className="w-6 h-6" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 grid grid-cols-1 md:grid-cols-3 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||||
|
{/* Blog Posts */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-indigo-500/10 rounded-lg">
|
||||||
|
<BookOpen className="w-3.5 h-3.5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Articles de Blog ({usagePreview.data.blogPosts.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.blogPosts.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucun article</p>
|
||||||
|
) : usagePreview.data.blogPosts.map((post: any) => (
|
||||||
|
<div key={post.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-indigo-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{post.title}</span>
|
||||||
|
<Link href={`/blog/edit/${post.id}`} className="p-1.5 bg-slate-800 hover:bg-indigo-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interviews */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-emerald-500/10 rounded-lg">
|
||||||
|
<UserIcon className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Interviews ({usagePreview.data.interviews.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.interviews.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucune interview</p>
|
||||||
|
) : usagePreview.data.interviews.map((item: any) => (
|
||||||
|
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-emerald-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||||
|
<Link href={`/interview/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-emerald-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<div className="p-1.5 bg-amber-500/10 rounded-lg">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Événements ({usagePreview.data.events.length})</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usagePreview.data.events.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-600 italic px-2">Aucun événement</p>
|
||||||
|
) : usagePreview.data.events.map((item: any) => (
|
||||||
|
<div key={item.id} className="p-3 bg-slate-950 rounded-xl border border-slate-800 flex justify-between items-center group hover:border-amber-500/30 transition-all">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate pr-2">{item.title}</span>
|
||||||
|
<Link href={`/event/edit/${item.id}`} className="p-1.5 bg-slate-800 hover:bg-amber-600 text-white rounded-lg transition-colors shrink-0 shadow-lg">
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
admin/src/app/taxonomies/page.tsx
Normal file
5
admin/src/app/taxonomies/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import TaxonomiesClient from "./TaxonomiesClient";
|
||||||
|
|
||||||
|
export default function TaxonomiesPage() {
|
||||||
|
return <TaxonomiesClient />;
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useState } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
|
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
|
||||||
import { Loader2, ArrowLeft, Save } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Calendar, CheckCircle2, FileText, Archive } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import RichTextEditor from './RichTextEditor';
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialData?: {
|
initialData?: {
|
||||||
@@ -20,6 +24,8 @@ interface Props {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
metaTitle?: string | null;
|
metaTitle?: string | null;
|
||||||
metaDescription?: string | null;
|
metaDescription?: string | null;
|
||||||
|
publishedAt?: Date | string | null;
|
||||||
|
status?: ContentStatus;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,29 +33,52 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [content, setContent] = useState(initialData?.content || '');
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
|
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
title: formData.get('title') as string,
|
title: formData.get('title') as string,
|
||||||
slug: formData.get('slug') as string,
|
slug: formData.get('slug') as string,
|
||||||
excerpt: formData.get('excerpt') as string,
|
excerpt: formData.get('excerpt') as string,
|
||||||
content: content,
|
content: content,
|
||||||
author: formData.get('author') as string,
|
author: formData.get('author') as string,
|
||||||
imageUrl: formData.get('imageUrl') as string,
|
imageUrl: imageUrl, // Use state value
|
||||||
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
|
tags: tags, // Use state value
|
||||||
metaTitle: formData.get('metaTitle') as string,
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data.imageUrl) {
|
||||||
|
toast.error("Une image est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = initialData
|
const result = initialData
|
||||||
? await updateBlogPost(initialData.id, data)
|
? await updateBlogPost(initialData.id, data)
|
||||||
: await createBlogPost(data);
|
: await createBlogPost(data);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(initialData ? "Article mis à jour" : "Article publié avec succès");
|
toast.success(initialData ? "Article mis à jour" : "Article créé avec succès");
|
||||||
router.push('/blog');
|
router.push('/blog');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
@@ -73,7 +102,21 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -98,15 +141,30 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
|
<div className="space-y-2">
|
||||||
<input
|
<ImageUploader
|
||||||
name="imageUrl"
|
label="Image de couverture"
|
||||||
defaultValue={initialData?.imageUrl}
|
value={imageUrl}
|
||||||
required
|
onChange={setImageUrl}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
name="imageUrl"
|
||||||
placeholder="https://images.unsplash.com/..."
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -147,12 +205,12 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
|
<TagInput
|
||||||
<input
|
value={tags}
|
||||||
name="tags"
|
onChange={setTags}
|
||||||
defaultValue={initialData?.tags?.join(', ') || ''}
|
suggestions={allExistingTags}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
label="Mots-clés (tags)"
|
||||||
placeholder="tech, afrique, innovation"
|
placeholder="tech, innovation, afrique..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +248,7 @@ export default function BlogForm({ initialData }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Save className="w-6 h-6" />
|
<Save className="w-6 h-6" />
|
||||||
)}
|
)}
|
||||||
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'}
|
{initialData ? 'Enregistrer les modifications' : 'Créer l\'article'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useState } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createEvent, updateEvent } from '@/app/actions/event';
|
import { createEvent, updateEvent } from '@/app/actions/event';
|
||||||
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon, Send } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import RichTextEditor from './RichTextEditor';
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { ContentStatus } from '@prisma/client';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
@@ -16,23 +20,46 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [description, setDescription] = useState(initialData?.description || '');
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
const data: any = {
|
const data: any = {
|
||||||
title: formData.get('title') as string,
|
title: formData.get('title') as string,
|
||||||
slug: formData.get('slug') as string,
|
slug: formData.get('slug') as string,
|
||||||
location: formData.get('location') as string,
|
location: formData.get('location') as string,
|
||||||
date: new Date(formData.get('date') as string),
|
date: new Date(formData.get('date') as string),
|
||||||
thumbnailUrl: formData.get('thumbnailUrl') as string,
|
thumbnailUrl: thumbnailUrl, // Use state value
|
||||||
link: formData.get('link') as string || null,
|
link: formData.get('link') as string || null,
|
||||||
description: description || '',
|
description: description || '',
|
||||||
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
|
tags: tags, // Use state
|
||||||
metaTitle: formData.get('metaTitle') as string,
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data.thumbnailUrl) {
|
||||||
|
toast.error("Une image est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = initialData
|
const result = initialData
|
||||||
? await updateEvent(initialData.id, data)
|
? await updateEvent(initialData.id, data)
|
||||||
@@ -63,7 +90,21 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -77,7 +118,7 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Date de l'événement</label>
|
<label className="text-sm font-medium text-slate-400">Date de l'événement (Début)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
@@ -105,6 +146,32 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">
|
||||||
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Send className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ImageUploader
|
||||||
|
label="Image (Affiche/Miniature)"
|
||||||
|
value={thumbnailUrl}
|
||||||
|
onChange={setThumbnailUrl}
|
||||||
|
name="thumbnailUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
|
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -119,17 +186,6 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">URL de l'image (Affiche/Miniature)</label>
|
|
||||||
<input
|
|
||||||
name="thumbnailUrl"
|
|
||||||
defaultValue={initialData?.thumbnailUrl}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
|
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
@@ -156,12 +212,12 @@ export default function EventForm({ initialData }: Props) {
|
|||||||
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
|
<TagInput
|
||||||
<input
|
value={tags}
|
||||||
name="tags"
|
onChange={setTags}
|
||||||
defaultValue={initialData?.tags?.join(', ') || ''}
|
suggestions={allExistingTags}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
label="Mots-clés (tags)"
|
||||||
placeholder="evenement, networking, afrique"
|
placeholder="evenement, networking, afrique..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
174
admin/src/components/ImageUploader.tsx
Normal file
174
admin/src/components/ImageUploader.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Upload, Link as LinkIcon, X, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { uploadImage } from '@/app/actions/upload';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageUploader({ value, onChange, label, placeholder, name }: Props) {
|
||||||
|
const [mode, setMode] = useState<'url' | 'upload'>(value?.startsWith('/uploads/') ? 'upload' : 'url');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error("Le fichier doit être une image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error("L'image ne doit pas dépasser 5 Mo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(formData);
|
||||||
|
if (result.success && result.url) {
|
||||||
|
onChange(result.url);
|
||||||
|
toast.success("Image uploadée avec succès");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erreur de connexion lors de l'upload");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
onChange('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-slate-400">{label || "Image"}</label>
|
||||||
|
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('url')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
mode === 'url' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
Lien URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('upload')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||||
|
mode === 'upload' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative group">
|
||||||
|
{mode === 'url' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder || "https://images.unsplash.com/..."}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!value ? (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="cursor-pointer border-2 border-dashed border-slate-700 hover:border-indigo-500 rounded-xl p-8 flex flex-col items-center justify-center gap-3 bg-slate-900/50 hover:bg-slate-900 transition-all group"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="w-10 h-10 text-indigo-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center group-hover:bg-indigo-500/20 group-hover:text-indigo-400 transition-colors">
|
||||||
|
<Upload className="w-6 h-6 text-slate-400 group-hover:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{isUploading ? "Upload en cours..." : "Cliquez pour uploader"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">PNG, JPG ou WEBP jusqu'à 5 Mo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative rounded-xl overflow-hidden aspect-video bg-slate-900 border border-slate-700">
|
||||||
|
<img
|
||||||
|
src={value}
|
||||||
|
alt="Prévisualisation"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="p-2 bg-white/10 backdrop-blur-md rounded-full text-white hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="p-2 bg-red-500/80 backdrop-blur-md rounded-full text-white hover:bg-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Hidden input for value to be sent via form if needed */}
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value && mode === 'url' && (
|
||||||
|
<div className="mt-3 relative rounded-lg overflow-hidden aspect-video border border-slate-800">
|
||||||
|
<img src={value} alt="Preview" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="absolute top-2 right-2 p-1.5 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-black transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useState } from 'react';
|
import { useTransition, useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createInterview, updateInterview } from '@/app/actions/interview';
|
import { createInterview, updateInterview } from '@/app/actions/interview';
|
||||||
import { Loader2, ArrowLeft, Save, Video, FileText } from 'lucide-react';
|
import { Loader2, ArrowLeft, Save, Video, FileText, Calendar } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { InterviewType } from '@prisma/client';
|
import { InterviewType, ContentStatus } from '@prisma/client';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import RichTextEditor from './RichTextEditor';
|
import RichTextEditor from './RichTextEditor';
|
||||||
|
import ImageUploader from './ImageUploader';
|
||||||
|
import TagInput from './TagInput';
|
||||||
|
import { getAllTags } from '@/app/actions/taxonomies';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialData?: any;
|
initialData?: any;
|
||||||
@@ -17,10 +20,26 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [content, setContent] = useState(initialData?.content || '');
|
const [content, setContent] = useState(initialData?.content || '');
|
||||||
|
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
||||||
|
const [tags, setTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [allExistingTags, setAllExistingTags] = useState<string[]>([]);
|
||||||
|
const [publishedAtValue, setPublishedAtValue] = useState(
|
||||||
|
initialData?.publishedAt
|
||||||
|
? new Date(initialData.publishedAt).toISOString().slice(0, 16)
|
||||||
|
: new Date().toISOString().slice(0, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPublished = new Date(publishedAtValue) <= new Date();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAllTags().then(setAllExistingTags);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const publishedAtStr = formData.get('publishedAt') as string;
|
||||||
|
|
||||||
const data: any = {
|
const data: any = {
|
||||||
title: formData.get('title') as string,
|
title: formData.get('title') as string,
|
||||||
slug: formData.get('slug') as string,
|
slug: formData.get('slug') as string,
|
||||||
@@ -28,16 +47,23 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
companyName: formData.get('companyName') as string,
|
companyName: formData.get('companyName') as string,
|
||||||
role: formData.get('role') as string,
|
role: formData.get('role') as string,
|
||||||
type: formData.get('type') as InterviewType,
|
type: formData.get('type') as InterviewType,
|
||||||
thumbnailUrl: formData.get('thumbnailUrl') as string,
|
thumbnailUrl: thumbnailUrl, // Use state value
|
||||||
videoUrl: formData.get('videoUrl') as string || null,
|
videoUrl: formData.get('videoUrl') as string || null,
|
||||||
excerpt: formData.get('excerpt') as string,
|
excerpt: formData.get('excerpt') as string,
|
||||||
content: content || null,
|
content: content || null,
|
||||||
duration: formData.get('duration') as string || null,
|
duration: formData.get('duration') as string || null,
|
||||||
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
|
tags: tags, // Use state
|
||||||
metaTitle: formData.get('metaTitle') as string,
|
metaTitle: formData.get('metaTitle') as string,
|
||||||
metaDescription: formData.get('metaDescription') as string,
|
metaDescription: formData.get('metaDescription') as string,
|
||||||
|
publishedAt: publishedAtStr ? new Date(publishedAtStr) : undefined,
|
||||||
|
status: formData.get('status') as ContentStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data.thumbnailUrl) {
|
||||||
|
toast.error("Une miniature est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = initialData
|
const result = initialData
|
||||||
? await updateInterview(initialData.id, data)
|
? await updateInterview(initialData.id, data)
|
||||||
@@ -68,7 +94,21 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="card space-y-6">
|
<div className="card space-y-6">
|
||||||
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
|
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white">Informations Générales</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Statut :</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={initialData?.status || 'PUBLISHED'}
|
||||||
|
className="bg-slate-900 border border-slate-700 text-white text-xs rounded-full px-4 py-1.5 focus:outline-none focus:border-brand-500 appearance-none cursor-pointer hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="DRAFT">Brouillon</option>
|
||||||
|
<option value="PUBLISHED">Publié</option>
|
||||||
|
<option value="ARCHIVED">Archivé</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -126,24 +166,29 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
<label className="text-sm font-medium text-slate-400">
|
||||||
<input
|
Date de publication {isPublished ? '(Publiée)' : '(Programmation)'}
|
||||||
name="duration"
|
</label>
|
||||||
defaultValue={initialData?.duration}
|
<div className="relative">
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
<Calendar className="absolute left-3 top-3 w-5 h-5 text-slate-500" />
|
||||||
placeholder="Ex: 12 min"
|
<input
|
||||||
/>
|
name="publishedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishedAtValue}
|
||||||
|
onChange={(e) => setPublishedAtValue(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">URL de la miniature (Thumbnail)</label>
|
<ImageUploader
|
||||||
<input
|
label="Miniature (Thumbnail)"
|
||||||
|
value={thumbnailUrl}
|
||||||
|
onChange={setThumbnailUrl}
|
||||||
name="thumbnailUrl"
|
name="thumbnailUrl"
|
||||||
defaultValue={initialData?.thumbnailUrl}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -154,6 +199,15 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="https://youtube.com/..."
|
placeholder="https://youtube.com/..."
|
||||||
/>
|
/>
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
name="duration"
|
||||||
|
defaultValue={initialData?.duration}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
||||||
|
placeholder="Ex: 12 min"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -194,12 +248,12 @@ export default function InterviewForm({ initialData }: Props) {
|
|||||||
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
|
<TagInput
|
||||||
<input
|
value={tags}
|
||||||
name="tags"
|
onChange={setTags}
|
||||||
defaultValue={initialData?.tags?.join(', ') || ''}
|
suggestions={allExistingTags}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
|
label="Mots-clés (tags)"
|
||||||
placeholder="interview, succes, tech"
|
placeholder="tech, succes, interview..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Globe,
|
Globe,
|
||||||
FileText,
|
FileText,
|
||||||
Briefcase
|
Briefcase,
|
||||||
|
Tags
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
@@ -26,7 +27,7 @@ const menuItems = [
|
|||||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
||||||
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
|
||||||
{ name: 'Secteurs & Catégories', icon: Briefcase, href: '/categories' },
|
{ name: 'Taxonomies', icon: Tags, href: '/taxonomies' },
|
||||||
{ name: 'Pays', icon: Globe, href: '/countries' },
|
{ name: 'Pays', icon: Globe, href: '/countries' },
|
||||||
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
|
||||||
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
{ name: 'Configuration', icon: Settings, href: '/settings' },
|
||||||
|
|||||||
180
admin/src/components/TagInput.tsx
Normal file
180
admin/src/components/TagInput.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
|
||||||
|
import { X, Hash, Search, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag =>
|
||||||
|
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||||
|
!value.includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else if (showSuggestions && suggestions.length > 0) {
|
||||||
|
// Show popular/all suggestions when empty but focused
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag => !value.includes(tag.toLowerCase()))
|
||||||
|
.slice(0, 8);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else {
|
||||||
|
setFilteredSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}, [inputValue, suggestions, value, showSuggestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addTag = (tagToAdd?: string) => {
|
||||||
|
const tag = (tagToAdd || inputValue).trim().toLowerCase();
|
||||||
|
if (tag && !value.includes(tag)) {
|
||||||
|
onChange([...value, tag]);
|
||||||
|
}
|
||||||
|
setInputValue('');
|
||||||
|
// Keep focus if we added via suggestion
|
||||||
|
if (tagToAdd) setShowSuggestions(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
onChange(value.filter(tag => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
|
||||||
|
addTag(filteredSuggestions[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
} else if (e.key === ',' || e.key === ';') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!showSuggestions && suggestions.length > 0) {
|
||||||
|
setShowSuggestions(true);
|
||||||
|
} else {
|
||||||
|
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||||
|
removeTag(value[value.length - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 relative" ref={containerRef}>
|
||||||
|
{label && <label className="text-sm font-medium text-slate-400">{label}</label>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-[48px] p-1.5 bg-slate-900 border border-slate-700 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-indigo-500 transition-colors cursor-text"
|
||||||
|
onClick={() => {
|
||||||
|
const input = containerRef.current?.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<Hash className="w-3 h-3 opacity-50" />
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTag(tag);
|
||||||
|
}}
|
||||||
|
className="hover:text-red-400 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
|
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
|
||||||
|
className="flex-1 bg-transparent border-none outline-none text-white text-sm min-w-[120px] p-1 placeholder:text-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions Popover */}
|
||||||
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<div className="p-2 border-b border-slate-700 bg-slate-900/50 flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
|
||||||
|
{inputValue ? 'Suggestions de recherche' : 'Mots-clés suggérés'}
|
||||||
|
</p>
|
||||||
|
{!inputValue && <span className="text-[9px] text-slate-600 font-medium">Top {filteredSuggestions.length}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||||
|
{filteredSuggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addTag(suggestion);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-slate-500'}`} />
|
||||||
|
{suggestion}
|
||||||
|
</span>
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-slate-500 italic">
|
||||||
|
Appuyez sur Entrée ou virgule pour ajouter un tag.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,16 +14,21 @@ interface Props {
|
|||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
const interview = await prisma.interview.findFirst({
|
const interview = await prisma.interview.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const event = !interview ? await prisma.event.findFirst({
|
const event = !interview ? await prisma.event.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
@@ -47,18 +52,23 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
|
|
||||||
export default async function AfroLifeDetailPage({ params }: Props) {
|
export default async function AfroLifeDetailPage({ params }: Props) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
// 1. Try to find an interview
|
// 1. Try to find an interview
|
||||||
const interview = await prisma.interview.findFirst({
|
const interview = await prisma.interview.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. If not found, try to find an event
|
// 2. If not found, try to find an event
|
||||||
const event = !interview ? await prisma.event.findFirst({
|
const event = !interview ? await prisma.event.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { slug: id }]
|
OR: [{ id }, { slug: id }],
|
||||||
|
publishedAt: { lte: now },
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
@@ -157,7 +167,7 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description / Article Content / Event Content */}
|
{/* Description / Article Content / Event Content */}
|
||||||
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600 break-words overflow-hidden">
|
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600">
|
||||||
{isEvent ? (
|
{isEvent ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
|
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
|
||||||
) : (
|
) : (
|
||||||
@@ -195,4 +205,3 @@ export default async function AfroLifeDetailPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,17 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL';
|
const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL';
|
||||||
|
|
||||||
let items: any[] = [];
|
let items: any[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (filter === 'EVENT') {
|
if (filter === 'EVENT') {
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: now
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' }
|
orderBy: { date: 'desc' }
|
||||||
});
|
});
|
||||||
items = events.map(e => ({
|
items = events.map(e => ({
|
||||||
@@ -34,7 +41,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
duration: new Date(e.date).toLocaleDateString('fr-FR')
|
duration: new Date(e.date).toLocaleDateString('fr-FR')
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const where: any = {};
|
const where: any = {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: now
|
||||||
|
}
|
||||||
|
};
|
||||||
if (filter !== 'ALL') {
|
if (filter !== 'ALL') {
|
||||||
where.type = filter as InterviewType;
|
where.type = filter as InterviewType;
|
||||||
}
|
}
|
||||||
@@ -47,6 +59,12 @@ export default async function AfroLifePage({ searchParams }: Props) {
|
|||||||
// If 'ALL', we might want to also fetch events and mix them
|
// If 'ALL', we might want to also fetch events and mix them
|
||||||
if (filter === 'ALL') {
|
if (filter === 'ALL') {
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: now
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
take: 10
|
take: 10
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { generateSlug } from '@/lib/utils'
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const posts = await prisma.blogPost.findMany({
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
})
|
})
|
||||||
return NextResponse.json(posts)
|
return NextResponse.json(posts)
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ import { USER_SAFE_SELECT, getAuthUser } from '@/lib/auth-utils'
|
|||||||
// GET /api/businesses — List all businesses (Mock + DB)
|
// GET /api/businesses — List all businesses (Mock + DB)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getAuthUser(request)
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: 'Authentification requise' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const category = searchParams.get('category')
|
const category = searchParams.get('category')
|
||||||
const q = searchParams.get('q')?.toLowerCase()
|
const q = searchParams.get('q')?.toLowerCase()
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import prisma from '@/lib/prisma'
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const interviews = await prisma.interview.findMany({
|
const interviews = await prisma.interview.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
})
|
})
|
||||||
return NextResponse.json(interviews)
|
return NextResponse.json(interviews)
|
||||||
|
|||||||
20
app/api/tags/route.ts
Normal file
20
app/api/tags/route.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const blogPosts = await prisma.blogPost.findMany({ select: { tags: true } });
|
||||||
|
const interviews = await prisma.interview.findMany({ select: { tags: true } });
|
||||||
|
const events = await prisma.event.findMany({ select: { tags: true } });
|
||||||
|
const businesses = await prisma.business.findMany({ select: { tags: true } });
|
||||||
|
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
[...blogPosts, ...interviews, ...events, ...businesses].forEach(item => {
|
||||||
|
item.tags.forEach(tag => allTags.add(tag.toLowerCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(Array.from(allTags).sort());
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch tags' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
OR: [
|
OR: [
|
||||||
{ id: id },
|
{ id: id },
|
||||||
{ slug: id }
|
{ slug: id }
|
||||||
]
|
],
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
},
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +50,11 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
OR: [
|
OR: [
|
||||||
{ id: id },
|
{ id: id },
|
||||||
{ slug: id }
|
{ slug: id }
|
||||||
]
|
],
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
},
|
||||||
|
status: 'PUBLISHED'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,7 +110,7 @@ export default async function BlogPostPage({ params }: Props) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="prose prose-lg prose-orange max-w-none text-gray-600 break-words overflow-hidden">
|
<div className="prose prose-lg prose-orange max-w-none text-gray-600">
|
||||||
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
<p className="lead text-xl text-gray-500 font-serif italic mb-6 border-b border-gray-100 pb-6">
|
||||||
{post.excerpt}
|
{post.excerpt}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export default async function BlogPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
posts = await prisma.blogPost.findMany({
|
posts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: {
|
||||||
|
lte: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,3 +24,14 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
}
|
||||||
50
app/not-found.tsx
Normal file
50
app/not-found.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Home, ArrowLeft, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex items-center justify-center px-4 sm:px-6 lg:px-8 py-24">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
{/* Animated 404 number */}
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<h1 className="text-9xl font-serif font-black text-gray-100 select-none">404</h1>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-4xl md:text-5xl font-serif font-bold text-brand-600">Oups !</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 mb-4 font-serif">
|
||||||
|
Page introuvable
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-500 mb-10 text-lg">
|
||||||
|
Il semblerait que le chemin que vous avez emprunté n'existe pas ou a été déplacé.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-full text-white bg-brand-600 hover:bg-brand-700 shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<Home className="w-5 h-5 mr-2" />
|
||||||
|
Retour à l'accueil
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/annuaire"
|
||||||
|
className="inline-flex items-center justify-center px-6 py-3 border border-gray-200 text-base font-medium rounded-full text-gray-700 bg-white hover:bg-gray-50 shadow-sm transition-all"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5 mr-2" />
|
||||||
|
Explorer l'annuaire
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-12 border-t border-gray-100">
|
||||||
|
<p className="text-sm text-gray-400 italic">
|
||||||
|
"L'échec est une étape vers la réussite, mais une erreur 404 est juste une impasse."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,6 +62,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
|
|
||||||
// 3. Fetch all blog posts
|
// 3. Fetch all blog posts
|
||||||
const blogPosts = await prisma.blogPost.findMany({
|
const blogPosts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lte: new Date() }
|
||||||
|
},
|
||||||
select: { id: true, slug: true, updatedAt: true },
|
select: { id: true, slug: true, updatedAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,6 +78,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
|
|
||||||
// 4. Fetch all Afro Life (Interviews)
|
// 4. Fetch all Afro Life (Interviews)
|
||||||
const interviews = await prisma.interview.findMany({
|
const interviews = await prisma.interview.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lte: new Date() }
|
||||||
|
},
|
||||||
select: { id: true, slug: true, updatedAt: true },
|
select: { id: true, slug: true, updatedAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +92,23 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...staticRoutes, ...businessRoutes, ...blogRoutes, ...afroLifeRoutes];
|
// 5. Fetch all Events
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
publishedAt: { lte: new Date() }
|
||||||
|
},
|
||||||
|
select: { id: true, slug: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventRoutes = events.map((event) => ({
|
||||||
|
url: `${baseUrl}/afrolife/${event.slug || event.id}`,
|
||||||
|
lastModified: event.updatedAt,
|
||||||
|
changeFrequency: 'monthly' as const,
|
||||||
|
priority: 0.7,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...staticRoutes, ...businessRoutes, ...blogRoutes, ...afroLifeRoutes, ...eventRoutes];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating sitemap:", error);
|
console.error("Error generating sitemap:", error);
|
||||||
// Fallback to only static routes if DB connection fails
|
// Fallback to only static routes if DB connection fails
|
||||||
|
|||||||
172
components/TagInput.tsx
Normal file
172
components/TagInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
|
||||||
|
import { X, Hash, Search, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag =>
|
||||||
|
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||||
|
!value.includes(tag.toLowerCase())
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else if (showSuggestions && suggestions.length > 0) {
|
||||||
|
const filtered = suggestions
|
||||||
|
.filter(tag => !value.includes(tag.toLowerCase()))
|
||||||
|
.slice(0, 8);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
} else {
|
||||||
|
setFilteredSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}, [inputValue, suggestions, value, showSuggestions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addTag = (tagToAdd?: string) => {
|
||||||
|
const tag = (tagToAdd || inputValue).trim().toLowerCase();
|
||||||
|
if (tag && !value.includes(tag)) {
|
||||||
|
onChange([...value, tag]);
|
||||||
|
}
|
||||||
|
setInputValue('');
|
||||||
|
if (tagToAdd) setShowSuggestions(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
onChange(value.filter(tag => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
|
||||||
|
addTag(filteredSuggestions[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
} else if (e.key === ',' || e.key === ';') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!showSuggestions && suggestions.length > 0) {
|
||||||
|
setShowSuggestions(true);
|
||||||
|
} else {
|
||||||
|
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||||
|
removeTag(value[value.length - 1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 relative" ref={containerRef}>
|
||||||
|
{label && <label className="text-sm font-medium text-gray-700">{label}</label>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-h-[48px] p-2 bg-white border border-gray-300 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 transition-all cursor-text shadow-sm"
|
||||||
|
onClick={() => {
|
||||||
|
const input = containerRef.current?.querySelector('input');
|
||||||
|
input?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<Hash className="w-3 h-3 opacity-50" />
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTag(tag);
|
||||||
|
}}
|
||||||
|
className="hover:text-red-500 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
|
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
|
||||||
|
className="flex-1 bg-transparent border-none outline-none text-gray-900 text-sm min-w-[120px] p-1 placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
|
<div className="p-2 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
|
||||||
|
{inputValue ? 'Suggestions' : 'Tags populaires'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
{filteredSuggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addTag(suggestion);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-brand-600 text-white' : 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-gray-400'}`} />
|
||||||
|
{suggestion}
|
||||||
|
</span>
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
import React, { useState } from 'react';
|
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle, Hash } from 'lucide-react';
|
||||||
import { Image as ImageIcon, Sparkles, Youtube, X, Globe, Facebook, Linkedin, Instagram, CheckCircle, AlertCircle } from 'lucide-react';
|
|
||||||
import { Business, Country } from '../../types';
|
import { Business, Country } from '../../types';
|
||||||
|
import TagInput from '../TagInput';
|
||||||
import { generateBusinessDescription } from '../../lib/geminiService';
|
import { generateBusinessDescription } from '../../lib/geminiService';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useUser } from '../UserProvider';
|
import { useUser } from '../UserProvider';
|
||||||
@@ -53,19 +53,22 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
const [videoPreviewId, setVideoPreviewId] = useState<string | null>(getYouTubeId(business.videoUrl || ''));
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [allTags, setAllTags] = useState<string[]>([]);
|
||||||
// Fetch countries and categories
|
// Fetch countries, categories and tags
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [cRes, catRes] = await Promise.all([
|
const [cRes, catRes, tagsRes] = await Promise.all([
|
||||||
fetch('/api/countries'),
|
fetch('/api/countries'),
|
||||||
fetch('/api/categories')
|
fetch('/api/categories'),
|
||||||
|
fetch('/api/tags')
|
||||||
]);
|
]);
|
||||||
const cData = await cRes.json();
|
const cData = await cRes.json();
|
||||||
const catData = await catRes.json();
|
const catData = await catRes.json();
|
||||||
|
const tagsData = await tagsRes.json();
|
||||||
if (!cData.error) setCountries(cData);
|
if (!cData.error) setCountries(cData);
|
||||||
if (!catData.error) setCategories(catData);
|
if (!catData.error) setCategories(catData);
|
||||||
|
if (!tagsData.error) setAllTags(tagsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching metadata:', error);
|
console.error('Error fetching metadata:', error);
|
||||||
}
|
}
|
||||||
@@ -381,6 +384,16 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
|||||||
</div>
|
</div>
|
||||||
<textarea name="description" rows={3} value={formData.description} onChange={handleInputChange} className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" />
|
<textarea name="description" rows={3} value={formData.description} onChange={handleInputChange} className="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sm:col-span-6">
|
||||||
|
<TagInput
|
||||||
|
value={formData.tags || []}
|
||||||
|
onChange={(tags) => setFormData({ ...formData, tags })}
|
||||||
|
suggestions={allTags}
|
||||||
|
label="Mots-clés & Tags"
|
||||||
|
placeholder="innovation, tech, artisanat..."
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-gray-500 italic">Ces tags aideront les clients à trouver votre boutique via la recherche.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BlogPost" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Interview" ADD COLUMN "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BlogPost" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Interview" ADD COLUMN "status" "ContentStatus" NOT NULL DEFAULT 'PUBLISHED';
|
||||||
@@ -142,19 +142,21 @@ model Offer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model BlogPost {
|
model BlogPost {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
excerpt String
|
excerpt String
|
||||||
content String
|
content String
|
||||||
author String
|
author String
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
imageUrl String
|
imageUrl String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
metaDescription String?
|
metaDescription String?
|
||||||
metaTitle String?
|
metaTitle String?
|
||||||
slug String? @unique
|
slug String? @unique
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Interview {
|
model Interview {
|
||||||
@@ -176,6 +178,8 @@ model Interview {
|
|||||||
metaTitle String?
|
metaTitle String?
|
||||||
slug String? @unique
|
slug String? @unique
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -304,19 +308,21 @@ model LegalDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Event {
|
model Event {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
description String
|
description String
|
||||||
date DateTime
|
date DateTime
|
||||||
location String
|
location String
|
||||||
thumbnailUrl String
|
thumbnailUrl String
|
||||||
link String?
|
link String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
metaDescription String?
|
metaDescription String?
|
||||||
metaTitle String?
|
metaTitle String?
|
||||||
slug String? @unique
|
slug String? @unique
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
status ContentStatus @default(PUBLISHED)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Favorite {
|
model Favorite {
|
||||||
@@ -330,6 +336,12 @@ model Favorite {
|
|||||||
@@unique([userId, businessId])
|
@@unique([userId, businessId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ContentStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
enum RatingStatus {
|
enum RatingStatus {
|
||||||
PENDING
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
|
|||||||
BIN
public/uploads/1777833997337-98dkw92.gif
Normal file
BIN
public/uploads/1777833997337-98dkw92.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/uploads/1777834233872-lz35qcr.jpg
Normal file
BIN
public/uploads/1777834233872-lz35qcr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Reference in New Issue
Block a user