diff --git a/.gitignore b/.gitignore index 4e1ef74..fe24896 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ yarn-error.log # Platform specific .DS_Store -memory-bank/ \ No newline at end of file +memory-bank/ +.claude/ \ No newline at end of file diff --git a/app/controllers/api/upload_controller.ts b/app/controllers/api/upload_controller.ts new file mode 100644 index 0000000..c053f7d --- /dev/null +++ b/app/controllers/api/upload_controller.ts @@ -0,0 +1,19 @@ +import { HttpContext } from '@adonisjs/core/http' +import MinioService from '#services/minio_service' + +export default class UploadController { + async presign({ request }: HttpContext) { + const { fileName, mimeType } = request.only(['fileName', 'mimeType']) + const { url, key } = await MinioService.getPresignedUrl(fileName, mimeType) + return { url, key } + } + + async presignView({ request }: HttpContext) { + const key = request.input('key') || request.qs().key + if (!key) { + return { error: 'Missing key' } + } + const url = await MinioService.getPresignedViewUrl(key) + return { url } + } +} diff --git a/app/controllers/articles_controller.ts b/app/controllers/articles_controller.ts index 7a5d845..572add7 100644 --- a/app/controllers/articles_controller.ts +++ b/app/controllers/articles_controller.ts @@ -1,6 +1,9 @@ -import { HttpContext } from '@adonisjs/core/http' -import { articleValidator } from '#validators/article_validator' +import { ArticleStatus } from '#enums/article_status' +import { Role } from '#enums/role' import Article from '#models/article' +import ArticleStatsService from '#services/article_stats_service' +import { articleValidator } from '#validators/article_validator' +import { HttpContext } from '@adonisjs/core/http' import { DateTime } from 'luxon' export default class ArticlesController { @@ -8,7 +11,7 @@ export default class ArticlesController { const page = request.input('page', 1) const articles = await Article.query() .preload('author') - .where('is_published', true) + .where('status', ArticleStatus.PUBLISHED) .orderBy('published_at', 'desc') .paginate(page, 10) @@ -23,19 +26,20 @@ export default class ArticlesController { async store({ request, auth, response }: HttpContext) { const data = await articleValidator.validate(request.all()) - const article = new Article() - - article.title = data.title - article.content = data.content - article.excerpt = data.excerpt - article.isPublished = data.isPublished || false - article.authorId = auth.user!.id - if (data.isPublished) { - article.publishedAt = DateTime.now() + + const payload: Partial
= { ...data, authorId: auth.user!.id } + + if (data.status === ArticleStatus.PUBLISHED) { + payload.publishedAt = DateTime.now() } - await article.generateSlug() - await article.save() + payload.slug = Article.generateSlug(data.title) + + console.log('payload', payload) + + const article = await Article.create(payload) + + console.log('article', article) return response.redirect().toRoute('articles.show', { slug: article.slug }) } @@ -45,4 +49,204 @@ export default class ArticlesController { return inertia.render('articles/[slug]', { article }) } + + async dashboard({ inertia, auth }: HttpContext) { + const isAdmin = auth.user!.role === Role.ADMIN + const stats = await ArticleStatsService.getStats(auth.user!.id, isAdmin) + return inertia.render('dashboard/index', { + publishedArticles: stats.published, + draftArticles: stats.drafts, + bannedArticles: stats.banned, + discussions: 0, + questions: 0, + }) + } + + async articles({ inertia, request, auth }: HttpContext) { + const page = request.input('page', 1) + const status = request.input('status', null) + const isAdmin = auth.user!.role === Role.ADMIN + + let query = Article.query().preload('author').orderBy('created_at', 'desc') + + // Logique différente selon le rôle + if (isAdmin) { + // Admin : voir tous les articles publiés/banned + ses propres brouillons + if (status && Object.values(ArticleStatus).includes(status as ArticleStatus)) { + if (status === ArticleStatus.PUBLISHED) { + // Voir tous les articles publiés (de tous les auteurs) + query.where('status', ArticleStatus.PUBLISHED) + } else if (status === ArticleStatus.BANNED) { + // Voir tous les articles banned (de tous les auteurs) + query.where('status', ArticleStatus.BANNED) + } else { + // Voir uniquement ses propres brouillons + query.where('status', status as ArticleStatus).where('author_id', auth.user!.id) + } + } else { + // Par défaut : tous les publiés + ses brouillons + query.where((builder) => { + builder + .where('status', ArticleStatus.PUBLISHED) + .orWhere((subBuilder) => { + subBuilder.where('author_id', auth.user!.id).where('status', ArticleStatus.DRAFT) + }) + }) + } + } else { + // Membre : voir uniquement ses propres articles + query.where('author_id', auth.user!.id) + + if (status && Object.values(ArticleStatus).includes(status as ArticleStatus)) { + query.where('status', status as ArticleStatus) + } + } + + const articles = await query.paginate(page, 10) + + return inertia.render('dashboard/articles', { + articles: articles.toJSON(), + currentStatus: status, + isAdmin, + }) + } + + async edit({ inertia, params, auth, response }: HttpContext) { + const article = await Article.query() + .where('slug', params.slug) + .where('author_id', auth.user!.id) + .firstOrFail() + + return inertia.render('dashboard/articles/edit', { article }) + } + + async update({ request, params, auth, response }: HttpContext) { + const article = await Article.query() + .where('slug', params.slug) + .where('author_id', auth.user!.id) + .firstOrFail() + + const data = await articleValidator.validate(request.all()) + + const payload: Partial
= { ...data } + + // Si on passe de draft/waiting à published, mettre à jour publishedAt + if ( + data.status === ArticleStatus.PUBLISHED && + article.status !== ArticleStatus.PUBLISHED + ) { + payload.publishedAt = DateTime.now() + } + + // Si le titre change, générer un nouveau slug + if (data.title !== article.title) { + payload.slug = Article.generateSlug(data.title) + } + + await article.merge(payload).save() + + return response.redirect().toRoute('articles.show', { slug: article.slug }) + } + + // ADMIN METHODS + + /** + * Liste des articles pour l'admin + * - Tous les articles publiés (de tous les auteurs) + * - Ses propres brouillons seulement + */ + async adminArticles({ inertia, request, auth }: HttpContext) { + const page = request.input('page', 1) + const status = request.input('status', null) + + let query = Article.query().preload('author').orderBy('created_at', 'desc') + + if (status && Object.values(ArticleStatus).includes(status as ArticleStatus)) { + if (status === ArticleStatus.PUBLISHED) { + // Admin voit tous les articles publiés + query.where('status', ArticleStatus.PUBLISHED) + } else { + // Admin ne voit que ses propres brouillons/en attente + query.where('status', status as ArticleStatus).where('author_id', auth.user!.id) + } + } else { + // Par défaut : tous les publiés + ses brouillons + query.where((builder) => { + builder + .where('status', ArticleStatus.PUBLISHED) + .orWhere((subBuilder) => { + subBuilder.where('author_id', auth.user!.id).whereIn('status', [ + ArticleStatus.DRAFT, + ArticleStatus.WAITING_APPROVAL, + ]) + }) + }) + } + + const articles = await query.paginate(page, 10) + + return inertia.render('admin/articles', { + articles: articles.toJSON(), + currentStatus: status, + }) + } + + /** + * Dépublier un article (admin seulement) + * Passe le statut de PUBLISHED à DRAFT + */ + async unpublish({ params, response, session, auth }: HttpContext) { + const article = await Article.query().where('slug', params.slug).firstOrFail() + + // Vérifier que l'article est publié + if (article.status !== ArticleStatus.PUBLISHED) { + session.flash('error', 'Cet article n\'est pas publié') + return response.redirect().back() + } + + // Dépublier l'article + article.status = ArticleStatus.DRAFT + article.publishedAt = null + await article.save() + + session.flash('success', 'Article dépublié avec succès') + return response.redirect().back() + } + + /** + * Bannir un article (admin seulement) + */ + async ban({ params, request, response, session }: HttpContext) { + const article = await Article.query().where('slug', params.slug).firstOrFail() + const banReason = request.input('ban_reason') + + article.status = ArticleStatus.BANNED + article.banReason = banReason + article.publishedAt = null + await article.save() + + session.flash('success', 'Article banni avec succès') + return response.redirect().back() + } + + /** + * Débannir un article (admin seulement) + */ + async unban({ params, response, session }: HttpContext) { + const article = await Article.query().where('slug', params.slug).firstOrFail() + + // Vérifier que l'article est banni + if (article.status !== ArticleStatus.BANNED) { + session.flash('error', 'Cet article n\'est pas banni') + return response.redirect().back() + } + + // Débannir l'article (le repasser en brouillon) + article.status = ArticleStatus.DRAFT + article.banReason = null + await article.save() + + session.flash('success', 'Article débanni avec succès') + return response.redirect().back() + } } diff --git a/app/controllers/auth/register_controller.ts b/app/controllers/auth/register_controller.ts index f6d4857..4d2e8a6 100644 --- a/app/controllers/auth/register_controller.ts +++ b/app/controllers/auth/register_controller.ts @@ -1,6 +1,7 @@ import { HttpContext } from '@adonisjs/core/http' import User from '#models/user' import { registerValidator } from '#validators/register_validator' +import { Role } from '#enums/role' export default class RegisterController { async show({ inertia }: HttpContext) { @@ -10,7 +11,10 @@ export default class RegisterController { async store({ request, auth, response }: HttpContext) { const data = await registerValidator.validate(request.all()) - const user = await User.create(data) + const user = await User.create({ + ...data, + role: (data.role as Role) || Role.MEMBER, + }) await auth.use('web').login(user) return response.redirect().toRoute('dashboard') diff --git a/app/controllers/discussions_controller.ts b/app/controllers/discussions_controller.ts new file mode 100644 index 0000000..2ef4988 --- /dev/null +++ b/app/controllers/discussions_controller.ts @@ -0,0 +1,105 @@ +import BannedUser from '#models/banned_user' +import Discussion from '#models/discussion' +import Message from '#models/message' +import type { HttpContext } from '@adonisjs/core/http' + +export default class DiscussionsController { + async index({ inertia, auth }: HttpContext) { + await auth.check() + // Liste des discussions publiques + const discussions = await Discussion.query() + .preload('author') + .where('is_public', true) + .orderBy('created_at', 'desc') + return inertia.render('discussions/index', { discussions }) + } + + async show({ inertia, params, auth }: HttpContext) { + await auth.check() + // Accès à une discussion (vérifier ban) + const discussion = await Discussion.query() + .where('id', params.id) + .preload('author') + .firstOrFail() + // Vérifier si banni + if (auth.user) { + const ban = await BannedUser.query() + .where('user_id', auth.user.id) + .where('discussion_id', discussion.id) + .first() + if (ban) { + return inertia.render('errors/banned', { reason: ban.reason }) + } + } + // Charger les messages (pagination à ajouter plus tard) + const messages = await Message.query() + .where('discussion_id', discussion.id) + .orderBy('created_at', 'asc') + .preload('sender') + return inertia.render('discussions/show', { discussion, messages }) + } + + async create({ inertia, auth }: HttpContext) { + await auth.check() + return inertia.render('discussions/create') + } + + async store({ request, auth, response }: HttpContext) { + await auth.check() + const { title, banner } = request.only(['title', 'banner']) + const discussion = await Discussion.create({ + title, + banner, + authorId: auth.user!.id, + isPublic: true, + }) + return response.redirect().toRoute('discussions.show', { id: discussion.id }) + } + + async destroy({ params, response, session }: HttpContext) { + // Suppression par admin uniquement (vérifiée par middleware) + const discussion = await Discussion.query() + .where('id', params.id) + .preload('messages') + .preload('bans') + .firstOrFail() + + // Supprimer tous les messages associés + await Message.query().where('discussion_id', discussion.id).delete() + + // Supprimer tous les bans associés + await BannedUser.query().where('discussion_id', discussion.id).delete() + + // Supprimer la discussion + await discussion.delete() + + session.flash('success', 'Discussion supprimée avec succès') + return response.redirect().toRoute('discussions.index') + } + + async ban({ params, request, auth, response }: HttpContext) { + // Bannissement par admin uniquement (vérifiée par middleware) + const { userId, reason } = request.only(['userId', 'reason']) + const discussionId = params.id + + // Vérifier si déjà banni + const existing = await BannedUser.query() + .where('user_id', userId) + .where('discussion_id', discussionId) + .first() + + if (existing) { + return response.badRequest({ error: 'Utilisateur déjà banni de cette discussion' }) + } + + // Créer le ban + await BannedUser.create({ + userId, + discussionId, + adminId: auth.user!.id, + reason: reason || null, + }) + + return response.ok({ success: true, message: 'Utilisateur banni avec succès' }) + } +} diff --git a/app/controllers/home_controller.ts b/app/controllers/home_controller.ts index b170eeb..262f52d 100644 --- a/app/controllers/home_controller.ts +++ b/app/controllers/home_controller.ts @@ -1,9 +1,12 @@ -import { HttpContext } from '@adonisjs/core/http' +import { ArticleStatus } from '#enums/article_status' import Article from '#models/article' +import { HttpContext } from '@adonisjs/core/http' export default class HomeController { async index({ inertia }: HttpContext) { - const articles = await Article.query().where('is_published', true).preload('author') + const articles = await Article.query() + .where('status', ArticleStatus.PUBLISHED) + .preload('author') return inertia.render('home', { stats: { diff --git a/app/controllers/messages_controller.ts b/app/controllers/messages_controller.ts new file mode 100644 index 0000000..8f63b28 --- /dev/null +++ b/app/controllers/messages_controller.ts @@ -0,0 +1,81 @@ +import Message from '#models/message' +import { getIO } from '#start/socket' +import type { HttpContext } from '@adonisjs/core/http' + +export default class MessagesController { + async store({ request, auth, response }: HttpContext) { + const { discussionId, type, content, fileUrl, tempId } = request.only([ + 'discussionId', + 'type', + 'content', + 'fileUrl', + 'tempId', + ]) + // fileUrl est fourni par le frontend après upload via presigned URL + const message = await Message.create({ + discussionId, + senderId: auth.user!.id, + type, + content, + fileUrl: fileUrl || null, + status: 'normal', + history: [], + }) + await message.load('sender') + const sender = message.sender?.toJSON() + const messageWithTempId = { ...message.toJSON(), tempId, sender } + getIO()?.to(`discussion:${discussionId}`).emit('message:new', messageWithTempId) + return response.ok(messageWithTempId) + } + + async update({ params, request, auth, response }: HttpContext) { + const message = await Message.findOrFail(params.id) + if (message.senderId !== auth.user!.id) { + return response.unauthorized('Non autorisé') + } + // Historique + const oldVersion = { + content: message.content, + fileUrl: message.fileUrl, + updatedAt: message.updatedAt, + } + const history = message.history || [] + history.push(oldVersion) + message.merge({ + content: request.input('content'), + status: 'edited', + history, + }) + await message.save() + // Émettre l'événement socket.io + getIO()?.to(`discussion:${message.discussionId}`).emit('message:edit', message) + return response.ok(message) + } + + async destroy({ params, auth, response }: HttpContext) { + const message = await Message.findOrFail(params.id) + // Suppression par sender ou admin (à vérifier dans la logique réelle) + if (message.senderId === auth.user!.id) { + message.status = 'deleted' + message.deletedBy = 'sender' + } else if (auth.user!.role === 'ADMIN') { + message.status = 'deleted' + message.deletedBy = 'admin' + } else { + return response.unauthorized('Non autorisé') + } + await message.save() + // Émettre l'événement socket.io + getIO()?.to(`discussion:${message.discussionId}`).emit('message:delete', message) + return response.ok(message) + } + + // Endpoint pour l'historique d'un message (admin) + async history({ params, auth, response }: HttpContext) { + if (auth.user?.role !== 'ADMIN') { + return response.unauthorized('Réservé admin') + } + const message = await Message.findOrFail(params.id) + return response.ok({ history: message.history || [] }) + } +} diff --git a/app/controllers/profile_controller.ts b/app/controllers/profile_controller.ts index 451b7d4..50199e1 100644 --- a/app/controllers/profile_controller.ts +++ b/app/controllers/profile_controller.ts @@ -1,13 +1,14 @@ -import { HttpContext } from '@adonisjs/core/http' -import User from '#models/user' +import { ArticleStatus } from '#enums/article_status' import Article from '#models/article' +import User from '#models/user' +import { HttpContext } from '@adonisjs/core/http' export default class ProfileController { async show({ params, inertia }: HttpContext) { const user = await User.findByOrFail('username', params.username.replace('@', '')) const articles = await Article.query() .where('author_id', user.id) - .where('is_published', true) + .where('status', ArticleStatus.PUBLISHED) .orderBy('created_at', 'desc') .preload('author') diff --git a/app/middleware/admin_middleware.ts b/app/middleware/admin_middleware.ts new file mode 100644 index 0000000..b5deb35 --- /dev/null +++ b/app/middleware/admin_middleware.ts @@ -0,0 +1,26 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import { Role } from '#enums/role' + +/** + * Admin middleware is used to restrict access to admin-only routes + * The user must be authenticated and have an ADMIN role + */ +export default class AdminMiddleware { + /** + * The URL to redirect to when authorization fails + */ + redirectTo = '/discussions' + + async handle(ctx: HttpContext, next: NextFn) { + // Ensure user is authenticated + await ctx.auth.check() + + // Check if user has admin role + if (!ctx.auth.user || ctx.auth.user.role !== Role.ADMIN) { + return ctx.response.abort('Accès réservé aux administrateurs', 403) + } + + return next() + } +} diff --git a/app/models/article.ts b/app/models/article.ts index 766ff19..335c9d9 100644 --- a/app/models/article.ts +++ b/app/models/article.ts @@ -1,8 +1,9 @@ -import { DateTime } from 'luxon' +import { ArticleStatus } from '#enums/article_status' +import User from '#models/user' +import string from '@adonisjs/core/helpers/string' import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' import type { BelongsTo } from '@adonisjs/lucid/types/relations' -import string from '@adonisjs/core/helpers/string' -import User from '#models/user' +import { DateTime } from 'luxon' export default class Article extends BaseModel { @column({ isPrimary: true }) @@ -21,7 +22,7 @@ export default class Article extends BaseModel { declare excerpt: string @column() - declare isPublished: boolean + declare status: ArticleStatus @column() declare authorId: number @@ -38,8 +39,23 @@ export default class Article extends BaseModel { @column.dateTime() declare publishedAt: DateTime | null + @column() + declare coverImage: string | null + + @column() + declare canonicalUrl: string | null + + @column({ + prepare: (value: string[] | string | null) => (Array.isArray(value) ? value.join(',') : value), + consume: (value: string | null) => (value ? value.split(',') : []), + }) + declare tags: string[] + + @column() + declare banReason: string | null + // Generate slug before saving - public async generateSlug() { - this.slug = string.slug(this.title) + public static generateSlug(title: string) { + return string.slug(title) } } diff --git a/app/models/banned_user.ts b/app/models/banned_user.ts new file mode 100644 index 0000000..c67faa8 --- /dev/null +++ b/app/models/banned_user.ts @@ -0,0 +1,37 @@ +import Discussion from '#models/discussion' +import User from '#models/user' +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' + +export default class BannedUser extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare userId: number + + @column() + declare discussionId: number + + @column() + declare adminId: number + + @column() + declare reason: string | null + + @belongsTo(() => User, { foreignKey: 'userId' }) + declare user: BelongsTo + + @belongsTo(() => Discussion) + declare discussion: BelongsTo + + @belongsTo(() => User, { foreignKey: 'adminId' }) + declare admin: BelongsTo + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/app/models/discussion.ts b/app/models/discussion.ts new file mode 100644 index 0000000..88f7d71 --- /dev/null +++ b/app/models/discussion.ts @@ -0,0 +1,38 @@ +import BannedUser from '#models/banned_user' +import Message from '#models/message' +import User from '#models/user' +import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm' +import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' + +export default class Discussion extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare title: string + + @column() + declare banner: string | null + + @column() + declare authorId: number + + @column() + declare isPublic: boolean + + @belongsTo(() => User, { foreignKey: 'authorId' }) + declare author: BelongsTo + + @hasMany(() => Message) + declare messages: HasMany + + @hasMany(() => BannedUser) + declare bans: HasMany + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/app/models/message.ts b/app/models/message.ts new file mode 100644 index 0000000..e2677f5 --- /dev/null +++ b/app/models/message.ts @@ -0,0 +1,56 @@ +import Discussion from '#models/discussion' +import User from '#models/user' +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' + +export default class Message extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare discussionId: number + + @column() + declare senderId: number + + @column() + declare type: 'text' | 'image' | 'video' | 'audio' | 'code' + + @column() + declare content: string | null + + @column() + declare fileUrl: string | null + + @column({ + prepare: (value: any[]) => JSON.stringify(value ?? []), + consume: (value: string | null) => { + if (!value) return [] + try { + return typeof value === 'string' ? JSON.parse(value) : value + } catch { + return [] + } + }, + }) + declare history: any[] + + @column() + declare status: 'normal' | 'edited' | 'deleted' + + @column() + declare deletedBy: string | null + + @belongsTo(() => Discussion) + declare discussion: BelongsTo + + @belongsTo(() => User, { foreignKey: 'senderId' }) + declare sender: BelongsTo + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/app/models/user.ts b/app/models/user.ts index b39d318..9440aa5 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -6,6 +6,7 @@ import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' import type { HasMany } from '@adonisjs/lucid/types/relations' import Article from '#models/article' import { DbRememberMeTokensProvider } from '@adonisjs/auth/session' +import { Role } from '#enums/role' const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { uids: ['email'], @@ -38,10 +39,7 @@ export default class User extends compose(BaseModel, AuthFinder) { declare twitterId: string | null @column() - declare isAdmin: boolean - - @column() - declare isSponsor: boolean + declare role: Role @column.dateTime() declare emailVerifiedAt: DateTime | null @@ -70,8 +68,7 @@ export default class User extends compose(BaseModel, AuthFinder) { name: this.name, email: this.email, avatar: this.avatar, - isAdmin: this.isAdmin, - isSponsor: this.isSponsor, + role: this.role, } } } diff --git a/app/services/article_stats_service.ts b/app/services/article_stats_service.ts new file mode 100644 index 0000000..a229baf --- /dev/null +++ b/app/services/article_stats_service.ts @@ -0,0 +1,32 @@ +import { ArticleStatus } from '#enums/article_status' +import Article from '#models/article' + +export default class ArticleStatsService { + static async getStats(userId?: number, isAdmin: boolean = false) { + const publishedQuery = Article.query().where('status', ArticleStatus.PUBLISHED) + const draftQuery = Article.query().where('status', ArticleStatus.DRAFT) + const bannedQuery = Article.query().where('status', ArticleStatus.BANNED) + + // Pour les admins, published = tous les articles publiés (pas de filtre userId) + // Pour les membres, filtrer tous les comptes par userId + if (userId && !isAdmin) { + publishedQuery.where('author_id', userId) + draftQuery.where('author_id', userId) + bannedQuery.where('author_id', userId) + } else if (userId && isAdmin) { + // Admin: published = tous, mais draft/banned = uniquement les siens + draftQuery.where('author_id', userId) + bannedQuery.where('author_id', userId) + } + + const publishedResult = await publishedQuery.count('* as total') + const draftResult = await draftQuery.count('* as total') + const bannedResult = await bannedQuery.count('* as total') + + return { + published: Number(publishedResult[0].$extras.total), + drafts: Number(draftResult[0].$extras.total), + banned: Number(bannedResult[0].$extras.total), + } + } +} diff --git a/app/services/minio_service.ts b/app/services/minio_service.ts new file mode 100644 index 0000000..2b432f7 --- /dev/null +++ b/app/services/minio_service.ts @@ -0,0 +1,49 @@ +import { Client } from 'minio' +import env from '#start/env' +import { v4 as uuidv4 } from 'uuid' + +const minioClient = new Client({ + endPoint: env.get('MINIO_ENDPOINT') || 'localhost', + port: Number(env.get('MINIO_PORT') || '9000'), + useSSL: false, + accessKey: env.get('MINIO_ROOT_USER') || 'minio', + secretKey: env.get('MINIO_ROOT_PASSWORD') || 'minio123', +}) + +const BUCKET = env.get('MINIO_DEFAULT_BUCKETS') || 'public' + +export default class MinioService { + static async upload(file: Buffer, fileName: string, mimeType: string): Promise { + // S'assurer que le bucket existe + const exists = await minioClient.bucketExists(BUCKET) + if (!exists) { + await minioClient.makeBucket(BUCKET) + } + await minioClient.putObject(BUCKET, fileName, file, undefined, { 'Content-Type': mimeType }) + // URL publique + return `${env.get('MINIO_PUBLIC_URL') || 'http://localhost:9000'}/${BUCKET}/${fileName}` + } + + static async getPresignedUrl(fileName: string, mimeType: string) { + // S'assurer que le bucket existe + const exists = await minioClient.bucketExists(BUCKET) + if (!exists) { + await minioClient.makeBucket(BUCKET) + } + + const ext = fileName.split('.').pop() + const key = `${uuidv4()}.${ext}` + const url = await minioClient.presignedPutObject(BUCKET, key, 60 * 5) + return { url, key, mimeType } + } + + static async getPresignedViewUrl(key: string, expirySeconds = 300): Promise { + // S'assurer que le bucket existe + const exists = await minioClient.bucketExists(BUCKET) + if (!exists) { + await minioClient.makeBucket(BUCKET) + } + + return minioClient.presignedGetObject(BUCKET, key, expirySeconds) + } +} diff --git a/app/validators/article_validator.ts b/app/validators/article_validator.ts index b263745..828db0c 100644 --- a/app/validators/article_validator.ts +++ b/app/validators/article_validator.ts @@ -1,3 +1,4 @@ +import { ARTICLE_STATUS_LIST } from '#enums/article_status' import vine from '@vinejs/vine' export const articleValidator = vine.compile( @@ -5,7 +6,9 @@ export const articleValidator = vine.compile( title: vine.string().minLength(10).maxLength(255), content: vine.string().minLength(100), excerpt: vine.string().minLength(50).maxLength(160), - tags: vine.array(vine.string()), - isPublished: vine.boolean().optional(), + coverImage: vine.string().optional().nullable(), + canonicalUrl: vine.string().optional().nullable(), + tags: vine.array(vine.string()).optional(), + status: vine.enum(ARTICLE_STATUS_LIST), }) ) diff --git a/app/validators/register_validator.ts b/app/validators/register_validator.ts index 023bfd0..d04db14 100644 --- a/app/validators/register_validator.ts +++ b/app/validators/register_validator.ts @@ -1,4 +1,5 @@ import vine from '@vinejs/vine' +import { ROLES_LIST } from '#enums/role' /** * Validates the register action @@ -9,5 +10,6 @@ export const registerValidator = vine.compile( name: vine.string(), email: vine.string().email(), password: vine.string().minLength(6), + role: vine.enum(ROLES_LIST).optional(), }) ) diff --git a/bin/server.ts b/bin/server.ts index fe0fefb..d26a44f 100644 --- a/bin/server.ts +++ b/bin/server.ts @@ -9,8 +9,8 @@ | */ -import 'reflect-metadata' import { Ignitor, prettyPrintError } from '@adonisjs/core' +import 'reflect-metadata' /** * URL to the application root. AdonisJS need it to resolve diff --git a/compose.yaml b/compose.yaml index 7c3ee4f..26957b4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -67,5 +67,25 @@ services: - mysql command: sh -c "node ace migration:run" + minio: + image: minio/minio:latest + container_name: jscm-minio + ports: + - "9000:9000" + - "9001:9001" + env_file: + - .env + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL} + MINIO_DEFAULT_BUCKETS: ${MINIO_DEFAULT_BUCKETS} + MINIO_ENDPOINT: ${MINIO_ENDPOINT} + MINIO_PORT: ${MINIO_PORT} + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + volumes: - mysql_data: \ No newline at end of file + mysql_data: + minio_data: diff --git a/config/cors.ts b/config/cors.ts index dd79007..4160453 100644 --- a/config/cors.ts +++ b/config/cors.ts @@ -8,7 +8,7 @@ import { defineConfig } from '@adonisjs/cors' */ const corsConfig = defineConfig({ enabled: true, - origin: [], + origin: ['*'], methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'], headers: true, exposeHeaders: [], diff --git a/config/database.ts b/config/database.ts index bdf0a21..31ae5f3 100644 --- a/config/database.ts +++ b/config/database.ts @@ -9,10 +9,18 @@ const dbConfig = defineConfig({ connection: { host: env.get('DB_HOST'), port: env.get('DB_PORT'), - user: env.get('DB_USER'), + user: env.get('DB_USERNAME'), password: env.get('DB_PASSWORD'), database: env.get('DB_DATABASE'), }, + pool: { + min: 2, // Minimum de connexions actives + max: 10, // Maximum de connexions simultanées + acquireTimeoutMillis: 30000, // 30 secondes pour acquérir une connexion + createTimeoutMillis: 30000, // 30 secondes pour créer une connexion + idleTimeoutMillis: 30000, // 30 secondes avant de fermer une connexion inactive + createRetryIntervalMillis: 200, // 200ms entre les tentatives de création + }, migrations: { naturalSort: true, paths: ['database/migrations'], diff --git a/database/migrations/1736839410875_create_users_table.ts b/database/migrations/1736839410875_create_users_table.ts index d318ef8..58e7b96 100644 --- a/database/migrations/1736839410875_create_users_table.ts +++ b/database/migrations/1736839410875_create_users_table.ts @@ -1,3 +1,4 @@ +import { Role, ROLES_LIST } from '#enums/role' import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { @@ -9,12 +10,11 @@ export default class extends BaseSchema { table.string('username').unique().notNullable() table.string('name').nullable() table.string('email').unique().notNullable() - table.string('password').notNullable() + table.string('password').nullable() table.string('avatar').nullable() table.string('github_id').unique().nullable() table.string('twitter_id').unique().nullable() - table.boolean('is_admin').defaultTo(false) - table.boolean('is_sponsor').defaultTo(false) + table.enu('role', ROLES_LIST).notNullable().defaultTo(Role.MEMBER) table.timestamp('email_verified_at').nullable() table.timestamps(true, true) }) diff --git a/database/migrations/1736839410989_create_articles_table.ts b/database/migrations/1736839410989_create_articles_table.ts index c900276..fa78ac0 100644 --- a/database/migrations/1736839410989_create_articles_table.ts +++ b/database/migrations/1736839410989_create_articles_table.ts @@ -1,5 +1,5 @@ +import { ARTICLE_STATUS_LIST, ArticleStatus } from '#enums/article_status' import { BaseSchema } from '@adonisjs/lucid/schema' - export default class extends BaseSchema { protected tableName = 'articles' @@ -12,8 +12,11 @@ export default class extends BaseSchema { table.text('content').notNullable() table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') table.integer('author_id').unsigned().references('id').inTable('users').onDelete('CASCADE') - table.boolean('is_published').defaultTo(false) + table.enu('status', ARTICLE_STATUS_LIST).defaultTo(ArticleStatus.DRAFT) table.timestamp('published_at').nullable() + table.string('cover_image').nullable() + table.string('canonical_url').nullable() + table.string('tags', 255).nullable() table.timestamps(true, true) }) } diff --git a/database/migrations/1740729000000_create_discussions_table.ts b/database/migrations/1740729000000_create_discussions_table.ts new file mode 100644 index 0000000..b239c5b --- /dev/null +++ b/database/migrations/1740729000000_create_discussions_table.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'discussions' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('title').notNullable() + table.string('banner').nullable() + table.integer('author_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + table.boolean('is_public').defaultTo(true) + table.timestamps(true, true) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1740729000100_create_messages_table.ts b/database/migrations/1740729000100_create_messages_table.ts new file mode 100644 index 0000000..985ca1a --- /dev/null +++ b/database/migrations/1740729000100_create_messages_table.ts @@ -0,0 +1,29 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'messages' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table + .integer('discussion_id') + .unsigned() + .references('id') + .inTable('discussions') + .onDelete('CASCADE') + table.integer('sender_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + table.enu('type', ['text', 'image', 'video', 'audio', 'code']).notNullable().defaultTo('text') + table.text('content').nullable() + table.string('file_url').nullable() + table.json('history').nullable() // historique des versions + table.enu('status', ['normal', 'edited', 'deleted']).defaultTo('normal') + table.string('deleted_by').nullable() // 'admin' ou 'sender' + table.timestamps(true, true) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1740729000200_create_banned_users_table.ts b/database/migrations/1740729000200_create_banned_users_table.ts new file mode 100644 index 0000000..7621c66 --- /dev/null +++ b/database/migrations/1740729000200_create_banned_users_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'banned_users' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + table + .integer('discussion_id') + .unsigned() + .references('id') + .inTable('discussions') + .onDelete('CASCADE') + table.integer('admin_id').unsigned().references('id').inTable('users').onDelete('CASCADE') + table.string('reason').nullable() + table.timestamps(true, true) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/migrations/1761837732329_create_alter_articles_cover_image_to_texts_table.ts b/database/migrations/1761837732329_create_alter_articles_cover_image_to_texts_table.ts new file mode 100644 index 0000000..1cd2ca5 --- /dev/null +++ b/database/migrations/1761837732329_create_alter_articles_cover_image_to_texts_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'articles' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.text('cover_image').nullable().alter() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.string('cover_image').nullable().alter() + }) + } +} \ No newline at end of file diff --git a/database/migrations/1761838929142_create_add_ban_reason_to_articles_table.ts b/database/migrations/1761838929142_create_add_ban_reason_to_articles_table.ts new file mode 100644 index 0000000..2e8b37e --- /dev/null +++ b/database/migrations/1761838929142_create_add_ban_reason_to_articles_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'articles' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.text('ban_reason').nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('ban_reason') + }) + } +} \ No newline at end of file diff --git a/database/seeders/user_seeder.ts b/database/seeders/user_seeder.ts index cc2ecaa..2c1dcf2 100644 --- a/database/seeders/user_seeder.ts +++ b/database/seeders/user_seeder.ts @@ -1,27 +1,11 @@ import { BaseSeeder } from '@adonisjs/lucid/seeders' import User from '#models/user' +import { demoUsers } from '#mock-data/users' export default class UserSeeder extends BaseSeeder { async run() { // Create a test user - await User.create({ - username: 'testuser', - name: 'Test User', - email: 'test@example.com', - password: 'password123', - isAdmin: true, - isSponsor: false, - }) - - // You can add more test users if needed - await User.create({ - username: 'regularuser', - name: 'Regular User', - email: 'regular@example.com', - password: 'password123', - isAdmin: false, - isSponsor: false, - }) + await User.createMany(demoUsers) console.log('✅ Users seeded successfully') } diff --git a/enums/article_status.ts b/enums/article_status.ts new file mode 100644 index 0000000..7e94e56 --- /dev/null +++ b/enums/article_status.ts @@ -0,0 +1,7 @@ +export enum ArticleStatus { + DRAFT = 'draft', + BANNED = 'banned', + PUBLISHED = 'published', +} + +export const ARTICLE_STATUS_LIST: ArticleStatus[] = Object.values(ArticleStatus) diff --git a/enums/role.ts b/enums/role.ts new file mode 100644 index 0000000..aa8345a --- /dev/null +++ b/enums/role.ts @@ -0,0 +1,7 @@ +export enum Role { + ADMIN = 'ADMIN', + SPONSOR = 'SPONSOR', + MEMBER = 'MEMBER', +} + +export const ROLES_LIST: Role[] = Object.values(Role) diff --git a/inertia/app/app.tsx b/inertia/app/app.tsx index ddadb35..c5e3fe0 100644 --- a/inertia/app/app.tsx +++ b/inertia/app/app.tsx @@ -1,10 +1,10 @@ /// /// -import '../css/app.css' -import { hydrateRoot } from 'react-dom/client' -import { createInertiaApp } from '@inertiajs/react' import { resolvePageComponent } from '@adonisjs/inertia/helpers' +import { createInertiaApp } from '@inertiajs/react' +import { hydrateRoot } from 'react-dom/client' +import '../css/app.css' const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS' diff --git a/inertia/app/ssr.tsx b/inertia/app/ssr.tsx index 56793e6..766707b 100644 --- a/inertia/app/ssr.tsx +++ b/inertia/app/ssr.tsx @@ -1,5 +1,5 @@ -import ReactDOMServer from 'react-dom/server' import { createInertiaApp } from '@inertiajs/react' +import ReactDOMServer from 'react-dom/server' export default function render(page: any) { return createInertiaApp({ diff --git a/inertia/components/articles/article-card.tsx b/inertia/components/articles/article-card.tsx new file mode 100644 index 0000000..c4cb97a --- /dev/null +++ b/inertia/components/articles/article-card.tsx @@ -0,0 +1,83 @@ +import { Link } from '@inertiajs/react' +import { useState } from 'react' +import { useImageUrl } from '../../utils/image' + +interface ArticleCardProps { + article: { + id: number + title: string + slug: string + excerpt: string + coverImage: string | null + tags: string[] + author: { name: string; avatar?: string } + publishedAt: string + } +} + +export default function ArticleCard({ article }: ArticleCardProps) { + const [imgError, setImgError] = useState(false) + const displayImageUrl = useImageUrl(article.coverImage) + const showFallback = imgError || !displayImageUrl + + return ( + +
+
+ {!showFallback ? ( + {article.title} setImgError(true)} + /> + ) : ( +
+ + + +
+ )} +
+
+
+ {article.tags.map((tag) => ( + + {tag} + + ))} +
+

+ {article.title} +

+

{article.excerpt}

+
+ {article.author.avatar ? ( + {article.author.name} + ) : ( +
+ )} + {article.author.name} + + {article.publishedAt} +
+
+
+ + ) +} diff --git a/inertia/components/articles/create-form.tsx b/inertia/components/articles/create-form.tsx index 69cb270..e6920f8 100644 --- a/inertia/components/articles/create-form.tsx +++ b/inertia/components/articles/create-form.tsx @@ -1,7 +1,9 @@ -import { FormEvent, useState, Suspense, lazy } from 'react' -import { useForm } from '@inertiajs/react' +import { FormEvent, Suspense, lazy } from 'react' import { Switch } from '@headlessui/react' import SlideOver from '../slide-over' +import TagInput from './tag-input' +import { useCreateArticleForm } from '../../hooks/useCreateArticleForm' +import { ArticleStatus } from '#enums/article_status' const MDEditor = lazy(() => import('@uiw/react-md-editor')) @@ -11,23 +13,18 @@ interface CreateArticleFormProps { } export default function CreateArticleForm({ isOpen, onClose }: CreateArticleFormProps) { - const [isDraft, setIsDraft] = useState(true) - const { data, setData, post, processing, errors } = useForm({ - title: '', - canonicalUrl: '', - content: '', - excerpt: '', - tags: [], - language: 'fr', - coverImage: null as File | null, - }) - - function handleSubmit(e: FormEvent) { - e.preventDefault() - post('/articles', { - onSuccess: () => onClose(), - }) - } + const { + data, + setData, + processing, + errors, + isDraft, + fileInputRef, + coverPreview, + handleCoverChange, + handleSwitchChange, + handleSubmit, + } = useCreateArticleForm(onClose) return ( @@ -55,8 +52,41 @@ export default function CreateArticleForm({ isOpen, onClose }: CreateArticleForm
-
-
+
fileInputRef.current?.click()} + > + {coverPreview ? ( + cover + ) : ( + <> + + + + Ajouter une photo de couverture + + )} + +
+ +
+
@@ -71,7 +101,7 @@ export default function CreateArticleForm({ isOpen, onClose }: CreateArticleForm {errors.title &&

{errors.title}

}
-
+
@@ -79,7 +109,7 @@ export default function CreateArticleForm({ isOpen, onClose }: CreateArticleForm
+
+
+ + setData('canonicalUrl', e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" + /> +
+
+ + setData('tags', tags)} /> +
+
+
diff --git a/inertia/components/articles/tag-input.tsx b/inertia/components/articles/tag-input.tsx new file mode 100644 index 0000000..89ab65e --- /dev/null +++ b/inertia/components/articles/tag-input.tsx @@ -0,0 +1,60 @@ +import { useState, KeyboardEvent } from 'react' + +interface TagInputProps { + value: string[] + onChange: (tags: string[]) => void +} + +export default function TagInput({ value, onChange }: TagInputProps) { + const [input, setInput] = useState('') + + function handleKeyDown(e: KeyboardEvent) { + if ((e.key === 'Enter' || e.key === ',' || e.key === ' ') && input.trim()) { + e.preventDefault() + if (value.length >= 3) return + if (!value.includes(input.trim())) { + onChange([...value, input.trim()]) + } + setInput('') + } else if (e.key === 'Backspace' && !input && value.length) { + onChange(value.slice(0, -1)) + } + } + + function removeTag(tag: string) { + onChange(value.filter(t => t !== tag)) + } + + return ( +
+ {value.map((tag) => ( + + {tag} + + + ))} + = 3 ? 'opacity-50 cursor-not-allowed' : ''}`} + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={value.length ? '' : 'Ajouter un tag...'} + disabled={value.length >= 3} + + /> +
+ {value.length}/3 +
+
+ ) +} \ No newline at end of file diff --git a/inertia/components/live-waveform.tsx b/inertia/components/live-waveform.tsx new file mode 100644 index 0000000..1eb63c0 --- /dev/null +++ b/inertia/components/live-waveform.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react' + +interface LiveWaveformProps { + stream: MediaStream | null + width?: number + height?: number +} + +export default function LiveWaveform({ stream, width = 220, height = 36 }: LiveWaveformProps) { + const canvasRef = useRef(null) + const animationRef = useRef() + const analyserRef = useRef(null) + const dataArrayRef = useRef(null) + const audioCtxRef = useRef(null) + const sourceRef = useRef(null) + + useEffect(() => { + if (!stream || !canvasRef.current) return + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)() + audioCtxRef.current = audioCtx + const analyser = audioCtx.createAnalyser() + analyser.fftSize = 128 + analyserRef.current = analyser + const bufferLength = analyser.frequencyBinCount + dataArrayRef.current = new Uint8Array(bufferLength) + const source = audioCtx.createMediaStreamSource(stream) + sourceRef.current = source + source.connect(analyser) + + function draw() { + if (!canvasRef.current || !analyserRef.current || !dataArrayRef.current) return + const ctx = canvasRef.current.getContext('2d')! + analyserRef.current.getByteTimeDomainData(dataArrayRef.current) + ctx.clearRect(0, 0, width, height) + ctx.beginPath() + const sliceWidth = width / dataArrayRef.current.length + let x = 0 + for (let i = 0; i < dataArrayRef.current.length; i++) { + const v = dataArrayRef.current[i] / 128.0 + const y = (v * height) / 2 + if (i === 0) { + ctx.moveTo(x, y) + } else { + ctx.lineTo(x, y) + } + x += sliceWidth + } + ctx.strokeStyle = '#4f8cff' + ctx.lineWidth = 2 + ctx.stroke() + animationRef.current = requestAnimationFrame(draw) + } + draw() + return () => { + if (animationRef.current) cancelAnimationFrame(animationRef.current) + analyser.disconnect() + source.disconnect() + audioCtx.close() + } + }, [stream, width, height]) + + return +} \ No newline at end of file diff --git a/inertia/hooks/useCreateArticleForm.ts b/inertia/hooks/useCreateArticleForm.ts new file mode 100644 index 0000000..89b9e60 --- /dev/null +++ b/inertia/hooks/useCreateArticleForm.ts @@ -0,0 +1,71 @@ +import { useState, useRef, FormEvent, Suspense, lazy } from 'react' +import { useForm } from '@inertiajs/react' +import { ArticleStatus } from '#enums/article_status' +import axios from 'axios' + +export function useCreateArticleForm(onClose: () => void) { + const [isDraft, setIsDraft] = useState(true) + const { data, setData, post, processing, errors } = useForm({ + title: '', + content: '', + excerpt: '', + language: 'fr', + status: ArticleStatus.DRAFT, + coverImage: '', + canonicalUrl: '', + tags: [] as string[], + }) + + const fileInputRef = useRef(null) + const [coverPreview, setCoverPreview] = useState(null) + + async function handleCoverChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + // 1. Demander une URL presign au backend + const presignRes = await axios.post('/api/upload/presign', { + fileName: file.name, + mimeType: file.type, + }) + const { url, key } = presignRes.data + // 2. Uploader le fichier sur l'URL presignée + await fetch(url, { + method: 'PUT', + body: file, + credentials: 'same-origin', + headers: { 'Content-Type': file.type }, + }) + // 3. Stocker la clé et récupérer une presigned URL de lecture + setData('coverImage', key) + const viewRes = await axios.get(`/api/upload/presign-view?key=${encodeURIComponent(key)}`) + setCoverPreview(viewRes.data.url) + } + + function handleSwitchChange(checked: boolean) { + setIsDraft(!checked) + setData('status', checked ? ArticleStatus.PUBLISHED : ArticleStatus.DRAFT) + } + + function handleSubmit(e: FormEvent) { + e.preventDefault() + + console.log('data', data) + post('/articles', { onSuccess: () => onClose() }) + } + + return { + data, + setData, + post, + processing, + errors, + isDraft, + setIsDraft, + fileInputRef, + coverPreview, + setCoverPreview, + handleCoverChange, + handleSwitchChange, + handleSubmit, + } +} \ No newline at end of file diff --git a/inertia/hooks/useSocket.ts b/inertia/hooks/useSocket.ts new file mode 100644 index 0000000..c8d0835 --- /dev/null +++ b/inertia/hooks/useSocket.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef } from 'react' +import { io, Socket } from 'socket.io-client' + +export function useSocket(userId: number | string) { + const socketRef = useRef(null) + + useEffect(() => { + if (!userId) return + // Connexion socket.io avec auth userId + const socket = io('/', { + auth: { userId }, + transports: ['websocket'], + }) + socketRef.current = socket + + socket.on('connect', () => { + console.log('Socket.io connecté !', socket.id) + }) + socket.on('connect_error', (err) => { + console.error('Erreur de connexion Socket.io', err) + }) + + return () => { + socket.disconnect() + } + }, [userId]) + + function joinDiscussion(discussionId: number) { + socketRef.current?.emit('join', discussionId) + } + function leaveDiscussion(discussionId: number) { + socketRef.current?.emit('leave', discussionId) + } + function sendMessage(event: string, payload: any) { + socketRef.current?.emit(event, payload) + } + function on(event: string, cb: (...args: any[]) => void) { + socketRef.current?.on(event, cb) + } + function off(event: string, cb?: (...args: any[]) => void) { + socketRef.current?.off(event, cb) + } + + return { + socket: socketRef.current, + joinDiscussion, + leaveDiscussion, + sendMessage, + on, + off, + } +} diff --git a/inertia/layouts/app.tsx b/inertia/layouts/app.tsx index 1a479a3..19fd548 100644 --- a/inertia/layouts/app.tsx +++ b/inertia/layouts/app.tsx @@ -1,5 +1,5 @@ -import { PropsWithChildren } from 'react' import { usePage } from '@inertiajs/react' +import { PropsWithChildren } from 'react' export default function AppLayout({ children }: PropsWithChildren) { const { user } = usePage().props diff --git a/inertia/layouts/provider.tsx b/inertia/layouts/provider.tsx new file mode 100644 index 0000000..6234789 --- /dev/null +++ b/inertia/layouts/provider.tsx @@ -0,0 +1,15 @@ +"use client" +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + + + +const Provider = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient() + return ( + + {children} + + ) +} + +export default Provider \ No newline at end of file diff --git a/inertia/pages/admin/articles.tsx b/inertia/pages/admin/articles.tsx new file mode 100644 index 0000000..f23c6a9 --- /dev/null +++ b/inertia/pages/admin/articles.tsx @@ -0,0 +1,502 @@ +import { Head, Link, router, useForm } from '@inertiajs/react' +import DashboardLayout from '../../layouts/dashboard' +import { useState } from 'react' + +interface Article { + id: number + title: string + slug: string + status: string + excerpt: string + coverImage: string | null + publishedAt: string | null + createdAt: string + updatedAt: string + author: { + id: number + name: string + username: string + } +} + +interface ArticlesProps { + articles: { + data: Article[] + meta: { + total: number + per_page: number + current_page: number + last_page: number + } + } + currentStatus: string | null +} + +const statusConfig = { + published: { + label: 'Publié', + color: 'bg-green-100 text-green-800 border-green-200', + }, + draft: { + label: 'Brouillon', + color: 'bg-yellow-100 text-yellow-800 border-yellow-200', + }, + waiting_approval: { + label: 'En attente', + color: 'bg-blue-100 text-blue-800 border-blue-200', + }, +} + +export default function AdminArticles({ articles, currentStatus }: ArticlesProps) { + const [searchQuery, setSearchQuery] = useState('') + const { post, processing } = useForm() + + const handleFilterChange = (status: string | null) => { + if (status) { + router.get('/admin/articles', { status }, { preserveState: true }) + } else { + router.get('/admin/articles', {}, { preserveState: true }) + } + } + + const handleUnpublish = (slug: string) => { + if (confirm('Voulez-vous vraiment dépublier cet article ?')) { + post(`/admin/articles/${slug}/unpublish`, { + preserveScroll: true, + }) + } + } + + const filteredArticles = articles.data.filter((article) => + article.title.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const formatDate = (date: string | null) => { + if (!date) return '-' + return new Date(date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + } + + const getStatusBadge = (status: string) => { + const config = statusConfig[status as keyof typeof statusConfig] + if (!config) return null + + return ( + + {config.label} + + ) + } + + return ( + + + +
+ {/* Header */} +
+
+

Gestion des Articles

+

+ Gérez tous les articles publiés et vos brouillons +

+
+
+ + Dashboard + + + + + + Nouvel Article + +
+
+ + {/* Info Badge */} +
+
+
+ + + +
+
+

+ Admin: Vous voyez tous les articles publiés + (tous auteurs) et uniquement vos propres brouillons. +

+
+
+
+ + {/* Filters */} +
+
+ {/* Status Filter */} +
+ +
+ + + + +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Rechercher un article..." + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-4 py-2 border" + /> +
+
+
+ + {/* Articles List */} +
+ {filteredArticles.length === 0 ? ( +
+ + + +

Aucun article

+

+ {currentStatus + ? `Aucun article avec le statut sélectionné.` + : 'Aucun article trouvé.'} +

+
+ ) : ( +
+ + + + + + + + + + + + {filteredArticles.map((article) => ( + + + + + + + + ))} + +
+ Article + + Auteur + + Statut + + Date + + Actions +
+
+ {article.coverImage && ( +
+ {article.title} +
+ )} +
+ + {article.title} + + {article.excerpt && ( +

+ {article.excerpt} +

+ )} +
+
+
+
+
+ {article.author.name || article.author.username} +
+
@{article.author.username}
+
+
+ {getStatusBadge(article.status)} + +
+
+ {article.status === 'published' + ? formatDate(article.publishedAt) + : formatDate(article.createdAt)} +
+
+ {article.status === 'published' ? 'Publié' : 'Créé'} +
+
+
+
+ + + + + + Voir + + {article.status === 'published' && ( + + )} +
+
+
+ )} +
+ + {/* Pagination */} + {articles.meta.last_page > 1 && ( +
+
+ + +
+
+
+

+ Affichage de{' '} + + {(articles.meta.current_page - 1) * articles.meta.per_page + 1} + {' '} + à{' '} + + {Math.min( + articles.meta.current_page * articles.meta.per_page, + articles.meta.total + )} + {' '} + sur {articles.meta.total} résultats +

+
+
+ +
+
+
+ )} +
+
+ ) +} diff --git a/inertia/pages/articles/[slug].tsx b/inertia/pages/articles/[slug].tsx index d82a4ed..a440eef 100644 --- a/inertia/pages/articles/[slug].tsx +++ b/inertia/pages/articles/[slug].tsx @@ -1,15 +1,22 @@ -import { Head } from '@inertiajs/react' +import { Head, Link } from '@inertiajs/react' import Navbar from '../../components/navbar' import Footer from '../../components/footer' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import { useImageUrl } from '../../utils/image' + interface Article { title: string content: string + excerpt: string + coverImage: string | null + tags: string[] publishedAt: string + createdAt: string author: { name: string username: string + avatar: string | null } } @@ -18,48 +25,184 @@ interface ArticleShowProps { } export default function ArticleShow({ article }: ArticleShowProps) { + const displayImageUrl = useImageUrl(article.coverImage) + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('fr-FR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + } + return ( <> -
-
-
-

- Article -

-

- {article.title} -

+
+ {/* Hero Section with Cover Image */} + {displayImageUrl && ( +
+ {article.title} { + e.currentTarget.style.display = 'none' + }} + /> +
-
-
-
- {article.content} + )} + + {/* Main Content Container */} +
+ {/* Article Header */} +
+ {/* Tags */} + {article.tags && article.tags.length > 0 && ( +
+ {article.tags.map((tag) => ( + + {tag} + + ))}
-
+ )} + + {/* Title */} +

+ {article.title} +

+ + {/* Excerpt */} + {article.excerpt && ( +

+ {article.excerpt} +

+ )} -
+ {/* Author Info */} +
-
+ + {article.author.avatar ? ( + {article.author.name} + ) : ( +
+ {article.author.name.charAt(0).toUpperCase()} +
+ )} + +
+ + {article.author.name} + +

@{article.author.username}

+
+
+
+

Publié le

+

+ {formatDate(article.publishedAt || article.createdAt)} +

+
+
+
+ + {/* Article Content */} + + + {/* Author Card */} +
+
+ + {article.author.avatar ? ( -
-
-

{article.author.name}

-

- Published on{' '} - {new Date(article.publishedAt).toLocaleDateString('fr-FR', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} -

-
+ ) : ( +
+ {article.author.name.charAt(0).toUpperCase()} +
+ )} + +
+ + {article.author.name} + +

@{article.author.username}

+ + Voir le profil +
diff --git a/inertia/pages/articles/create.tsx b/inertia/pages/articles/create.tsx index c619e94..ea35edc 100644 --- a/inertia/pages/articles/create.tsx +++ b/inertia/pages/articles/create.tsx @@ -2,6 +2,7 @@ import { Head, useForm } from '@inertiajs/react' import { FormEvent } from 'react' import Navbar from '../../components/navbar' import Footer from '../../components/footer' +import { ARTICLE_STATUS_LIST, ArticleStatus } from '../../../enums/article_status' export default function ArticleCreate() { const { data, setData, post, processing, errors } = useForm({ @@ -9,7 +10,7 @@ export default function ArticleCreate() { content: '', excerpt: '', tags: [], - published: false, + status: ArticleStatus.DRAFT, }) function handleSubmit(e: FormEvent) { @@ -67,17 +68,21 @@ export default function ArticleCreate() { {errors.content &&

{errors.content}

}
-
- setData('published', e.target.checked)} - className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" - /> -
+ {/* Demo Buttons */} +
+ + +
+ {/* Flash Messages */} {(flash?.error || pageErrors?.form) && (
@@ -83,7 +110,7 @@ export default function Login() { {/* Social Login */}
diff --git a/inertia/pages/auth/register.tsx b/inertia/pages/auth/register.tsx index 5ee664d..669bd6a 100644 --- a/inertia/pages/auth/register.tsx +++ b/inertia/pages/auth/register.tsx @@ -1,7 +1,8 @@ -import { Link, useForm } from '@inertiajs/react' +import { useForm } from '@inertiajs/react' import { FormEvent } from 'react' -import Navbar from '../../components/navbar' +import { Role, ROLES_LIST } from '../../../enums/role' import Footer from '../../components/footer' +import Navbar from '../../components/navbar' export default function Register() { const { data, setData, post, processing, errors } = useForm({ @@ -9,6 +10,7 @@ export default function Register() { name: '', email: '', password: '', + role: Role.MEMBER, }) function handleSubmit(e: FormEvent) { @@ -209,6 +211,25 @@ export default function Register() { )}
+
+ + + {errors.role &&

{errors.role}

} +
+
diff --git a/inertia/pages/dashboard/articles.tsx b/inertia/pages/dashboard/articles.tsx new file mode 100644 index 0000000..acdebc2 --- /dev/null +++ b/inertia/pages/dashboard/articles.tsx @@ -0,0 +1,606 @@ +import { Head, Link, router } from '@inertiajs/react' +import DashboardLayout from '../../layouts/dashboard' +import { useState } from 'react' +import { useImageUrl } from '../../utils/image' + +interface Article { + id: number + title: string + slug: string + status: string + excerpt: string + coverImage: string | null + publishedAt: string | null + createdAt: string + updatedAt: string + author: { + id: number + name: string + username: string + } +} + +interface ArticlesProps { + articles: { + data: Article[] + meta: { + total: number + per_page: number + current_page: number + last_page: number + } + } + currentStatus: string | null + isAdmin: boolean +} + +const statusConfig = { + published: { + label: 'Publié', + color: 'bg-green-100 text-green-800 border-green-200', + }, + draft: { + label: 'Brouillon', + color: 'bg-yellow-100 text-yellow-800 border-yellow-200', + }, + banned: { + label: 'Banni', + color: 'bg-red-100 text-red-800 border-red-200', + }, +} + +// Composant pour afficher une ligne d'article avec l'image +function ArticleRow({ article, getStatusBadge, formatDate, isAdmin, onUnpublish, onBan, onUnban }: { + article: Article + getStatusBadge: (status: string) => JSX.Element | null + formatDate: (date: string | null) => string + isAdmin: boolean + onUnpublish: (slug: string) => void + onBan: (slug: string) => void + onUnban: (slug: string) => void +}) { + const displayImageUrl = useImageUrl(article.coverImage) + + return ( + + +
+ {displayImageUrl && ( +
+ {article.title} { + e.currentTarget.style.display = 'none' + }} + /> +
+ )} +
+ + {article.title} + + {article.excerpt && ( +

+ {article.excerpt} +

+ )} +
+
+ + {isAdmin && ( + +
+
{article.author.name}
+
@{article.author.username}
+
+ + )} + + {getStatusBadge(article.status)} + + +
+
+ {article.status === 'published' + ? formatDate(article.publishedAt) + : formatDate(article.createdAt)} +
+
+ {article.status === 'published' ? 'Publié' : 'Créé'} +
+
+ + +
+ + + + + Éditer + + + + + + + Voir + + {isAdmin && article.status === 'published' && ( + <> + + + + )} + {isAdmin && article.status === 'banned' && ( + + )} +
+ + + ) +} + +export default function DashboardArticles({ articles, currentStatus, isAdmin }: ArticlesProps) { + const [searchQuery, setSearchQuery] = useState('') + + const handleFilterChange = (status: string | null) => { + if (status) { + router.get('/dashboard/articles', { status }, { preserveState: true }) + } else { + router.get('/dashboard/articles', {}, { preserveState: true }) + } + } + + const handleUnpublish = (slug: string) => { + if (confirm('Voulez-vous vraiment dépublier cet article ?')) { + router.post(`/admin/articles/${slug}/unpublish`, {}, { + preserveState: true, + preserveScroll: true, + }) + } + } + + const handleBan = (slug: string) => { + const banReason = prompt('Raison du bannissement (optionnel):') + if (banReason !== null) { + router.post(`/admin/articles/${slug}/ban`, { ban_reason: banReason }, { + preserveState: true, + preserveScroll: true, + }) + } + } + + const handleUnban = (slug: string) => { + if (confirm('Voulez-vous vraiment débannir cet article ?')) { + router.post(`/admin/articles/${slug}/unban`, {}, { + preserveState: true, + preserveScroll: true, + }) + } + } + + const filteredArticles = articles.data.filter((article) => + article.title.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const formatDate = (date: string | null) => { + if (!date) return '-' + return new Date(date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + } + + const getStatusBadge = (status: string) => { + const config = statusConfig[status as keyof typeof statusConfig] + if (!config) return null + + return ( + + {config.label} + + ) + } + + return ( + + + +
+ {/* Header */} +
+
+

+ {isAdmin ? 'Gestion des Articles' : 'Mes Articles'} +

+

+ {isAdmin + ? 'Tous les articles publiés + vos brouillons personnels' + : 'Gérez tous vos articles depuis un seul endroit'} +

+
+ + + + + Nouvel Article + +
+ + {/* Filters */} +
+
+ {/* Status Filter */} +
+ +
+ + + + +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Rechercher un article..." + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-4 py-2 border" + /> +
+
+
+ + {/* Articles List */} +
+ {filteredArticles.length === 0 ? ( +
+ + + +

Aucun article

+

+ {currentStatus + ? `Vous n'avez aucun article avec le statut sélectionné.` + : 'Commencez par créer votre premier article.'} +

+
+ + + + + Nouvel Article + +
+
+ ) : ( +
+ + + + + {isAdmin && ( + + )} + + + + + + + {filteredArticles.map((article) => ( + + ))} + +
+ Article + + Auteur + + Statut + + Date + + Actions +
+
+ )} +
+ + {/* Pagination */} + {articles.meta.last_page > 1 && ( +
+
+ + +
+
+
+

+ Affichage de{' '} + + {(articles.meta.current_page - 1) * articles.meta.per_page + 1} + {' '} + à{' '} + + {Math.min( + articles.meta.current_page * articles.meta.per_page, + articles.meta.total + )} + {' '} + sur {articles.meta.total} résultats +

+
+
+ +
+
+
+ )} +
+
+ ) +} diff --git a/inertia/pages/dashboard/articles/edit.tsx b/inertia/pages/dashboard/articles/edit.tsx new file mode 100644 index 0000000..5afae17 --- /dev/null +++ b/inertia/pages/dashboard/articles/edit.tsx @@ -0,0 +1,673 @@ +import { Head, Link, router, useForm } from '@inertiajs/react' +import DashboardLayout from '../../../layouts/dashboard' +import { FormEvent, useState, useRef } from 'react' +import { ARTICLE_STATUS_LIST, ArticleStatus } from '../../../../enums/article_status' +import axios from 'axios' +import { useImageUrl } from '../../../utils/image' + +interface Article { + id: number + title: string + slug: string + content: string + excerpt: string + status: ArticleStatus + coverImage: string | null + canonicalUrl: string | null + tags: string[] + publishedAt: string | null + createdAt: string + updatedAt: string + banReason: string | null +} + +interface EditArticleProps { + article: Article +} + +const statusOptions = [ + { + value: ArticleStatus.DRAFT, + label: 'Brouillon', + description: 'L\'article est en cours de rédaction', + color: 'text-yellow-600 bg-yellow-50 border-yellow-200', + }, + { + value: ArticleStatus.PUBLISHED, + label: 'Publié', + description: 'L\'article est visible par tous', + color: 'text-green-600 bg-green-50 border-green-200', + }, +] + +export default function EditArticle({ article }: EditArticleProps) { + const [isSaving, setIsSaving] = useState(false) + const { data, setData, put, processing, errors } = useForm({ + title: article.title, + content: article.content, + excerpt: article.excerpt, + tags: article.tags || [], + status: article.status, + coverImage: article.coverImage || '', + canonicalUrl: article.canonicalUrl || '', + }) + + const [tagInput, setTagInput] = useState('') + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = useRef(null) + + // Convertir la clé MinIO en URL affichable + const displayImageUrl = useImageUrl(data.coverImage) + + const handleImageUpload = async (file: File) => { + try { + setIsUploading(true) + + // 1. Demander une URL presignée + const presignRes = await axios.post('/api/upload/presign', { + fileName: file.name, + mimeType: file.type, + }) + + const { url, key } = presignRes.data + + // 2. Uploader le fichier sur l'URL presignée + await axios.put(url, file, { + headers: { + 'Content-Type': file.type, + }, + }) + + // 3. Stocker uniquement la clé (pas l'URL complète) + // L'URL sera générée à la demande côté serveur + setData('coverImage', key) + } catch (error) { + console.error('Erreur lors de l\'upload:', error) + alert('Erreur lors de l\'upload de l\'image') + } finally { + setIsUploading(false) + } + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + // Vérifier que c'est une image + if (!file.type.startsWith('image/')) { + alert('Veuillez sélectionner une image') + return + } + + // Vérifier la taille (max 5MB) + if (file.size > 5 * 1024 * 1024) { + alert('L\'image ne doit pas dépasser 5MB') + return + } + + handleImageUpload(file) + } + } + + const triggerFileInput = () => { + fileInputRef.current?.click() + } + + function handleSubmit(e: FormEvent) { + e.preventDefault() + setIsSaving(true) + put(`/dashboard/articles/${article.slug}`, { + onSuccess: () => { + setIsSaving(false) + }, + onError: () => { + setIsSaving(false) + }, + }) + } + + const addTag = () => { + if (tagInput.trim() && !data.tags.includes(tagInput.trim())) { + setData('tags', [...data.tags, tagInput.trim()]) + setTagInput('') + } + } + + const removeTag = (tagToRemove: string) => { + setData( + 'tags', + data.tags.filter((tag) => tag !== tagToRemove) + ) + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + addTag() + } + } + + const getStatusOption = (status: ArticleStatus) => { + return statusOptions.find((opt) => opt.value === status) + } + + const currentStatus = getStatusOption(data.status) + + return ( + + + +
+ {/* Header */} +
+
+ + + + + Retour à mes articles + +
+

Éditer l'article

+
+ + + + + Prévisualiser + +
+ + {/* Ban Warning */} + {article.status === ArticleStatus.BANNED && article.banReason && ( +
+
+
+ + + +
+
+

Article banni

+
+

{article.banReason}

+
+
+
+
+ )} + + + {/* Hidden file input */} + + + {/* Cover Image Section */} +
+

Image de couverture

+
+ {data.coverImage ? ( +
+
+ Cover { + e.currentTarget.src = + 'https://via.placeholder.com/1200x630?text=Image+non+disponible' + }} + /> +
+
+ + +
+
+ ) : ( + + )} + +
+ +
+ setData('coverImage', e.target.value)} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-4 py-3 border transition-colors" + placeholder="https://exemple.com/image.jpg ou collez une clé MinIO" + /> + {data.coverImage && ( + + )} +
+

+ Recommandé: 1200x630px (ratio 1.91:1) - Format JPG, PNG ou WebP +

+ {errors.coverImage && ( +

{errors.coverImage}

+ )} +
+
+
+ + {/* Status Card */} +
+

Statut de l'article

+
+ {statusOptions.map((option) => ( + + ))} +
+ {errors.status &&

{errors.status}

} +
+ + {/* Main Content */} +
+

Contenu principal

+ + {/* Title */} +
+ + setData('title', e.target.value)} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-4 py-3 border transition-colors" + placeholder="Donnez un titre accrocheur à votre article..." + /> + {errors.title &&

{errors.title}

} +
+ + {/* Excerpt */} +
+ +