From 6dcae6956c671eb5880d60df3b6c6eee74291cba Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Sat, 9 May 2026 17:25:34 +0200 Subject: [PATCH] feat(blog): admin CRUD + image upload + sidebar link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'éditeur du blog (jusqu'ici limité au seeder) a maintenant une vraie interface au-dessus de l'API. Backend (apps/api) : * Migration users.is_admin (boolean default false). * Middleware admin (404 si user.is_admin=false après auth). * Commande ace promote:admin --email=… [--revoke]. * AdminPostsController CRUD complet : list/show/store/update/publish/ unpublish/destroy + suggest-slug. Au save, contentHtml + wordCount + readingTime sont re-calculés via blog_renderer. Au publish, durcit la validation SEO (titre ≤60, excerpt 120-160, hero+alt requis, ≥600 mots), flippe status='published' + publishedAt, ping Google+Bing pour le sitemap. * BlogUploadsController : - POST /api/v1/admin/uploads (multipart, JPEG/PNG/WebP, max 4MB) → MinIO clé uploads/blog/{uuid}.{ext} → renvoie URL relative /api/v1/uploads/blog/{filename} - GET /api/v1/uploads/blog/:filename (public, cache immutable 1 an) → stream depuis MinIO, regex anti-traversal sur le nom. * UserTransformer expose isAdmin (cf. shared/types/user). * k3s/app/landing.yml : NodePort 30111 explicite (pour Traefik repo proxmox). Frontend (apps/web) : * Lib typée admin-blog (calls API, queryKeys, helpers URL). * Route /admin/blog : liste filtrable avec status badge, ouverture publique, dépublier, supprimer, "+ Nouveau brouillon". * Route /admin/blog/:id : éditeur 2-colonnes - Gauche : @uiw/react-md-editor (lazy import) avec preview live. - Droite : hero image (drag&drop + alt), excerpt avec compteur 120-160, tags, aperçu Google snippet, validations bloquantes. - Autosave debounce 2s + bouton Publier qui sauve d'abord. - Hero image upload via MinIO (HeroImageUpload component). * Sidebar : lien "Blog (admin)" si user.isAdmin. * Gate côté client (beforeLoad redirect si non admin) + côté serveur (middleware admin) — defense in depth. Note : les requirements de publish miroir backend ↔ frontend (cf. PUBLISH_REQUIREMENTS dans validators/post.ts et VALIDATION_RULES dans admin.blog_.\$id.tsx). À synchroniser si un seuil bouge. Co-Authored-By: Claude Opus 4.7 --- .../app/controllers/admin_posts_controller.ts | 221 ++++++++ .../controllers/blog_uploads_controller.ts | 64 +++ apps/api/app/middleware/admin_middleware.ts | 19 + apps/api/app/services/blog_uploads.ts | 107 ++++ apps/api/app/transformers/user_transformer.ts | 3 + apps/api/app/validators/post.ts | 72 +++ apps/api/commands/promote_admin.ts | 47 ++ ...78340000000_add_is_admin_to_users_table.ts | 25 + apps/api/start/kernel.ts | 1 + apps/api/start/routes.ts | 52 ++ .../src/components/sections/HowItWorks.tsx | 2 +- apps/web/package.json | 1 + .../src/components/admin/HeroImageUpload.tsx | 159 ++++++ apps/web/src/components/layout/AppSidebar.tsx | 13 +- apps/web/src/lib/admin-blog.ts | 86 +++ apps/web/src/mocks/db.ts | 2 + apps/web/src/routes/_app/admin.blog.tsx | 232 ++++++++ apps/web/src/routes/_app/admin.blog_.$id.tsx | 522 ++++++++++++++++++ k3s/app/landing.yml | 7 +- packages/shared/src/types/user.ts | 2 + pnpm-lock.yaml | 398 +++++++++++++ 21 files changed, 2031 insertions(+), 4 deletions(-) create mode 100644 apps/api/app/controllers/admin_posts_controller.ts create mode 100644 apps/api/app/controllers/blog_uploads_controller.ts create mode 100644 apps/api/app/middleware/admin_middleware.ts create mode 100644 apps/api/app/services/blog_uploads.ts create mode 100644 apps/api/app/validators/post.ts create mode 100644 apps/api/commands/promote_admin.ts create mode 100644 apps/api/database/migrations/1778340000000_add_is_admin_to_users_table.ts create mode 100644 apps/web/src/components/admin/HeroImageUpload.tsx create mode 100644 apps/web/src/lib/admin-blog.ts create mode 100644 apps/web/src/routes/_app/admin.blog.tsx create mode 100644 apps/web/src/routes/_app/admin.blog_.$id.tsx diff --git a/apps/api/app/controllers/admin_posts_controller.ts b/apps/api/app/controllers/admin_posts_controller.ts new file mode 100644 index 0000000..6b635c8 --- /dev/null +++ b/apps/api/app/controllers/admin_posts_controller.ts @@ -0,0 +1,221 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { DateTime } from 'luxon' + +import Post from '#models/post' +import { renderPost, slugify } from '#services/blog_renderer' +import { createPostValidator, updatePostValidator, PUBLISH_REQUIREMENTS } from '#validators/post' +import PostTransformer from '#transformers/post_transformer' + +const SITE_URL = 'https://rubis.pro' + +/** + * AdminPostsController — CRUD complet pour l'admin du blog. + * + * Routes (sous /api/v1/admin/posts/*, auth + admin) : + * GET / → liste tous (drafts + published) + * POST / → crée un brouillon + * GET /:id → détail (par UUID, pas par slug) + * PATCH /:id → modifie (re-render contentHtml + recompte mots) + * POST /:id/publish → durcit la validation puis flip published + * POST /:id/unpublish → revient en draft + * DELETE /:id → hard delete (V1, on n'a pas beaucoup d'articles) + * + * Convention : on retourne TOUJOURS le post complet sérialisé après mutation + * pour que l'admin SPA puisse refresh sans re-fetch. + */ +export default class AdminPostsController { + /** GET /api/v1/admin/posts */ + async index({ response }: HttpContext) { + const posts = await Post.query().orderBy('updatedAt', 'desc').orderBy('createdAt', 'desc') + return response.json({ + data: posts.map((p) => new PostTransformer(p).toObject()), + }) + } + + /** GET /api/v1/admin/posts/:id */ + async show({ params, response }: HttpContext) { + const post = await Post.find(params.id) + if (!post) return response.status(404).json({ error: 'post_not_found' }) + return response.json({ data: new PostTransformer(post).toObject() }) + } + + /** POST /api/v1/admin/posts */ + async store({ request, response }: HttpContext) { + const payload = await request.validateUsing(createPostValidator) + + // Slug unicité + const existing = await Post.findBy('slug', payload.slug) + if (existing) { + return response.status(422).json({ + errors: [{ field: 'slug', message: 'Slug déjà pris.' }], + }) + } + + const { contentHtml, wordCount, readingTimeMinutes } = renderPost(payload.contentMd) + const post = await Post.create({ + slug: payload.slug, + title: payload.title, + excerpt: payload.excerpt, + contentMd: payload.contentMd, + contentHtml, + wordCount, + readingTimeMinutes, + tags: payload.tags ?? [], + authorName: payload.authorName ?? 'Arthur Barré', + heroImageUrl: payload.heroImageUrl ?? null, + heroImageAlt: payload.heroImageAlt ?? null, + ogImageUrl: payload.ogImageUrl ?? null, + canonicalUrl: payload.canonicalUrl ?? null, + noindex: payload.noindex ?? false, + status: 'draft', + aiGenerated: false, + }) + + return response.status(201).json({ data: new PostTransformer(post).toObject() }) + } + + /** PATCH /api/v1/admin/posts/:id */ + async update({ params, request, response }: HttpContext) { + const post = await Post.find(params.id) + if (!post) return response.status(404).json({ error: 'post_not_found' }) + + const payload = await request.validateUsing(updatePostValidator) + + // Slug unicité si changement + if (payload.slug && payload.slug !== post.slug) { + const conflicting = await Post.findBy('slug', payload.slug) + if (conflicting && conflicting.id !== post.id) { + return response.status(422).json({ + errors: [{ field: 'slug', message: 'Slug déjà pris.' }], + }) + } + post.slug = payload.slug + } + + if (payload.title !== undefined) post.title = payload.title + if (payload.excerpt !== undefined) post.excerpt = payload.excerpt + if (payload.tags !== undefined) post.tags = payload.tags + if (payload.authorName !== undefined) post.authorName = payload.authorName + if (payload.heroImageUrl !== undefined) post.heroImageUrl = payload.heroImageUrl + if (payload.heroImageAlt !== undefined) post.heroImageAlt = payload.heroImageAlt + if (payload.ogImageUrl !== undefined) post.ogImageUrl = payload.ogImageUrl + if (payload.canonicalUrl !== undefined) post.canonicalUrl = payload.canonicalUrl + if (payload.noindex !== undefined) post.noindex = payload.noindex + + if (payload.contentMd !== undefined) { + post.contentMd = payload.contentMd + const { contentHtml, wordCount, readingTimeMinutes } = renderPost(payload.contentMd) + post.contentHtml = contentHtml + post.wordCount = wordCount + post.readingTimeMinutes = readingTimeMinutes + } + + await post.save() + return response.json({ data: new PostTransformer(post).toObject() }) + } + + /** + * POST /api/v1/admin/posts/:id/publish + * Durcit la validation SEO ici (on raisonne sur l'objet déjà persisté + * avec wordCount calculé). + */ + async publish({ params, response }: HttpContext) { + const post = await Post.find(params.id) + if (!post) return response.status(404).json({ error: 'post_not_found' }) + + const errors: Array<{ field: string; message: string }> = [] + + if (post.title.length > PUBLISH_REQUIREMENTS.titleMaxChars) { + errors.push({ + field: 'title', + message: `Titre trop long pour Google (${post.title.length} > ${PUBLISH_REQUIREMENTS.titleMaxChars} chars).`, + }) + } + if ( + post.excerpt.length < PUBLISH_REQUIREMENTS.excerptMinChars + || post.excerpt.length > PUBLISH_REQUIREMENTS.excerptMaxChars + ) { + errors.push({ + field: 'excerpt', + message: `L'excerpt doit faire entre ${PUBLISH_REQUIREMENTS.excerptMinChars} et ${PUBLISH_REQUIREMENTS.excerptMaxChars} chars (actuel : ${post.excerpt.length}).`, + }) + } + if (!post.heroImageUrl) { + errors.push({ field: 'heroImageUrl', message: 'Hero image requise pour publication.' }) + } + if (!post.heroImageAlt) { + errors.push({ field: 'heroImageAlt', message: "Texte alt de l'image hero requis (SEO + accessibilité)." }) + } + if (post.wordCount < PUBLISH_REQUIREMENTS.minWordCount) { + errors.push({ + field: 'contentMd', + message: `L'article doit faire au moins ${PUBLISH_REQUIREMENTS.minWordCount} mots (actuel : ${post.wordCount}).`, + }) + } + + if (errors.length > 0) { + return response.status(422).json({ errors }) + } + + post.status = 'published' + post.publishedAt = DateTime.now().toUTC().startOf('minute') + await post.save() + + // Ping fire-and-forget — on n'attend pas (pas critique). + pingSitemap(SITE_URL).catch(() => { + /* swallow — l'article est publié, le ping est best-effort */ + }) + + return response.json({ data: new PostTransformer(post).toObject() }) + } + + /** POST /api/v1/admin/posts/:id/unpublish */ + async unpublish({ params, response }: HttpContext) { + const post = await Post.find(params.id) + if (!post) return response.status(404).json({ error: 'post_not_found' }) + + post.status = 'draft' + // On garde publishedAt en historique (même si plus visible publiquement). + await post.save() + return response.json({ data: new PostTransformer(post).toObject() }) + } + + /** DELETE /api/v1/admin/posts/:id */ + async destroy({ params, response }: HttpContext) { + const post = await Post.find(params.id) + if (!post) return response.status(404).json({ error: 'post_not_found' }) + await post.delete() + return response.status(204) + } + + /** + * GET /api/v1/admin/posts/suggest-slug?title=... + * Helper pour l'UI : génère un slug propre depuis un titre. + */ + async suggestSlug({ request, response }: HttpContext) { + const title = String(request.input('title', '')).trim() + if (!title) return response.json({ data: { slug: '' } }) + + const base = slugify(title) + // Si dispo, on retourne tel quel ; sinon on suffixe. + let candidate = base + let n = 2 + while (await Post.findBy('slug', candidate)) { + candidate = `${base}-${n}` + n += 1 + } + return response.json({ data: { slug: candidate } }) + } +} + +/** + * Ping Google + Bing pour qu'ils re-crawlent le sitemap. + * Ces endpoints sont stables et n'exigent pas d'auth pour les sitemaps publics. + */ +async function pingSitemap(siteUrl: string): Promise { + const sitemapUrl = encodeURIComponent(`${siteUrl}/sitemap.xml`) + await Promise.allSettled([ + fetch(`https://www.google.com/ping?sitemap=${sitemapUrl}`), + fetch(`https://www.bing.com/ping?sitemap=${sitemapUrl}`), + ]) +} diff --git a/apps/api/app/controllers/blog_uploads_controller.ts b/apps/api/app/controllers/blog_uploads_controller.ts new file mode 100644 index 0000000..7f41841 --- /dev/null +++ b/apps/api/app/controllers/blog_uploads_controller.ts @@ -0,0 +1,64 @@ +import type { HttpContext } from '@adonisjs/core/http' + +import { uploadBlogImage, readBlogImage } from '#services/blog_uploads' + +/** + * BlogUploadsController — gère les images du blog. + * + * - POST /api/v1/admin/uploads (auth + admin) + * multipart 'file' → upload MinIO → renvoie { url, contentType, sizeBytes } + * - GET /api/v1/uploads/blog/:filename (public) + * stream depuis MinIO avec Cache-Control immutable. + */ +export default class BlogUploadsController { + /** + * POST /api/v1/admin/uploads — admin only. + */ + async store({ request, response }: HttpContext) { + const file = request.file('file', { + size: '4mb', + extnames: ['jpg', 'jpeg', 'png', 'webp'], + }) + + if (!file) { + return response.status(422).json({ + errors: [{ field: 'file', message: 'Fichier manquant (champ multipart `file`).' }], + }) + } + if (!file.isValid) { + return response.status(422).json({ + errors: file.errors.map((e) => ({ field: 'file', message: e.message })), + }) + } + + try { + const result = await uploadBlogImage(file) + return response.status(201).json({ + data: { + url: result.publicPath, + contentType: result.contentType, + sizeBytes: result.sizeBytes, + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'upload_failed' + return response.status(422).json({ errors: [{ field: 'file', message: msg }] }) + } + } + + /** + * GET /api/v1/uploads/blog/:filename — public, cache long. + */ + async show({ params, response }: HttpContext) { + const filename = String(params.filename ?? '') + const result = await readBlogImage(filename) + if (!result) { + return response.status(404).send('not_found') + } + + response.header('Content-Type', result.contentType) + // Filename = uuid → contenu immuable. Cache infini, pas de revalidation. + response.header('Cache-Control', 'public, max-age=31536000, immutable') + return response.send(result.buffer) + } +} diff --git a/apps/api/app/middleware/admin_middleware.ts b/apps/api/app/middleware/admin_middleware.ts new file mode 100644 index 0000000..882aa2c --- /dev/null +++ b/apps/api/app/middleware/admin_middleware.ts @@ -0,0 +1,19 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * AdminMiddleware — bloque l'accès aux endpoints réservés aux admins. + * + * Doit être stacké APRÈS auth() (l'auth est responsable de poser ctx.auth.user). + * Un user authentifié mais sans `is_admin` reçoit 403 ; un user non authentifié + * a déjà reçu 401 par le middleware auth. + */ +export default class AdminMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + const user = ctx.auth.getUserOrFail() + if (!user.isAdmin) { + return ctx.response.status(403).json({ error: 'admin_only' }) + } + return next() + } +} diff --git a/apps/api/app/services/blog_uploads.ts b/apps/api/app/services/blog_uploads.ts new file mode 100644 index 0000000..fab92f7 --- /dev/null +++ b/apps/api/app/services/blog_uploads.ts @@ -0,0 +1,107 @@ +/** + * blog_uploads — upload + serve d'images du blog. + * + * Architecture (cf. /docs/tech/architecture.md §3) : + * - Upload : POST /api/v1/admin/uploads → multipart → MinIO (drive S3) + * sous la clé `uploads/blog/{uuid}.{ext}`, visibilité privée. + * - Lecture : GET /api/v1/uploads/blog/:filename → stream depuis MinIO + * avec Cache-Control: public, max-age=31536000, immutable. Le fichier + * est immuable (uuid dans le nom = chaque upload = nouvelle URL), donc + * cache infini sans risque d'invalidation. + * - L'URL publique est ensuite stockée dans posts.hero_image_url (et + * optionnellement og_image_url) — pas de FK, simple reference texte. + * + * Orphelins : quand un post change de hero, l'ancienne image reste sur + * MinIO (~quelques KB par fichier). Acceptable pour V1 ; périodique + * cleanup à ajouter quand on dépassera ~100 articles. + */ + +import { randomUUID } from 'node:crypto' +import path from 'node:path' + +import drive from '@adonisjs/drive/services/main' +import type { MultipartFile } from '@adonisjs/core/bodyparser' + +const ALLOWED_EXTS = ['jpg', 'jpeg', 'png', 'webp'] as const +const MAX_BYTES = 4 * 1024 * 1024 // 4 MB + +export type UploadResult = { + /** URL publique relative (à préfixer du host API côté client). */ + publicPath: string + /** Clé S3 réelle dans le bucket (debug / cleanup). */ + storageKey: string + /** Type MIME inféré de l'extension. */ + contentType: string + /** Taille réelle en bytes. */ + sizeBytes: number +} + +/** + * Reçoit un MultipartFile (validé Adonis) et le pousse sur MinIO. + * Throw si le fichier ne respecte pas les contraintes (taille, MIME). + */ +export async function uploadBlogImage(file: MultipartFile): Promise { + const ext = (file.extname ?? '').toLowerCase() + if (!ALLOWED_EXTS.includes(ext as (typeof ALLOWED_EXTS)[number])) { + throw new Error(`unsupported_extension: ${ext} (autorisés : ${ALLOWED_EXTS.join(', ')})`) + } + if (file.size > MAX_BYTES) { + throw new Error(`file_too_large: ${file.size}B (max ${MAX_BYTES}B)`) + } + + const uuid = randomUUID() + const filename = `${uuid}.${ext}` + const storageKey = `uploads/blog/${filename}` + + // Adonis Drive accepte un path local (le multipart écrit le tmp file). + if (!file.tmpPath) { + throw new Error('multipart_no_tmpPath') + } + await drive.use().moveFromFs(file.tmpPath, storageKey) + + return { + publicPath: `/api/v1/uploads/blog/${filename}`, + storageKey, + contentType: extToContentType(ext), + sizeBytes: file.size, + } +} + +/** + * Stream une image depuis MinIO. Renvoie un Buffer + le contentType + * pour que le contrôleur réponde avec les bons headers. + */ +export async function readBlogImage(filename: string): Promise<{ + buffer: Buffer + contentType: string +} | null> { + // Sécurité : pas de path traversal. Le slug doit matcher `uuid.ext`. + if (!/^[a-f0-9-]{36}\.(jpg|jpeg|png|webp)$/i.test(filename)) { + return null + } + const storageKey = `uploads/blog/${filename}` + + try { + const buffer = Buffer.from(await drive.use().getArrayBuffer(storageKey)) + return { + buffer, + contentType: extToContentType(path.extname(filename).slice(1).toLowerCase()), + } + } catch { + return null + } +} + +function extToContentType(ext: string): string { + switch (ext) { + case 'jpg': + case 'jpeg': + return 'image/jpeg' + case 'png': + return 'image/png' + case 'webp': + return 'image/webp' + default: + return 'application/octet-stream' + } +} diff --git a/apps/api/app/transformers/user_transformer.ts b/apps/api/app/transformers/user_transformer.ts index d161e18..1842bc2 100644 --- a/apps/api/app/transformers/user_transformer.ts +++ b/apps/api/app/transformers/user_transformer.ts @@ -12,6 +12,9 @@ export default class UserTransformer extends BaseTransformer { organizationId: u.organizationId, signature: u.signature, initials: u.initials, + // Permet au SPA d'afficher (ou cacher) le lien "Admin" dans la sidebar + // et d'auto-rediriger /admin/* si non-admin. + isAdmin: u.isAdmin, createdAt: u.createdAt.toISO()!, updatedAt: u.updatedAt?.toISO() ?? u.createdAt.toISO()!, } diff --git a/apps/api/app/validators/post.ts b/apps/api/app/validators/post.ts new file mode 100644 index 0000000..80b51b4 --- /dev/null +++ b/apps/api/app/validators/post.ts @@ -0,0 +1,72 @@ +import vine from '@vinejs/vine' + +/** + * Règles SEO + édito : + * - title ≤ 60 chars (sweet spot Google snippet) + * - excerpt 120-160 chars (sweet spot meta description) + * - slug kebab-case ASCII unique + * - tags max 5 + */ +const slug = () => + vine + .string() + .minLength(3) + .maxLength(200) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + +const title = () => vine.string().minLength(5).maxLength(80) +const excerpt = () => vine.string().minLength(80).maxLength(280) +const tags = () => vine.array(vine.string().maxLength(50)).maxLength(5).distinct() + +/** + * Création d'un brouillon. On accepte `status` mais c'est toujours `draft` + * à la création — la publication passe par /publish. + */ +export const createPostValidator = vine.compile( + vine.object({ + slug: slug(), + title: title(), + excerpt: excerpt(), + contentMd: vine.string().minLength(50), + tags: tags().optional(), + authorName: vine.string().minLength(2).maxLength(100).optional(), + heroImageUrl: vine.string().url().nullable().optional(), + heroImageAlt: vine.string().maxLength(250).nullable().optional(), + ogImageUrl: vine.string().url().nullable().optional(), + canonicalUrl: vine.string().url().nullable().optional(), + noindex: vine.boolean().optional(), + }), +) + +/** + * Update partiel d'un post existant. Tous les champs optionnels. + * `status` n'est pas mutable ici (passer par publish/unpublish). + */ +export const updatePostValidator = vine.compile( + vine.object({ + slug: slug().optional(), + title: title().optional(), + excerpt: excerpt().optional(), + contentMd: vine.string().minLength(50).optional(), + tags: tags().optional(), + authorName: vine.string().minLength(2).maxLength(100).optional(), + heroImageUrl: vine.string().url().nullable().optional(), + heroImageAlt: vine.string().maxLength(250).nullable().optional(), + ogImageUrl: vine.string().url().nullable().optional(), + canonicalUrl: vine.string().url().nullable().optional(), + noindex: vine.boolean().optional(), + }), +) + +/** + * Au publish on durcit les règles SEO inline dans AdminPostsController.publish + * (cf. PUBLISH_REQUIREMENTS) — pas un validator vine séparé car on raisonne + * sur l'objet Post déjà persisté (avec wordCount calculé), pas sur un payload + * de requête. + */ +export const PUBLISH_REQUIREMENTS = { + titleMaxChars: 60, // sweet spot snippet Google + excerptMinChars: 120, + excerptMaxChars: 160, + minWordCount: 600, // ~3 min de lecture, considéré "substantiel" +} as const diff --git a/apps/api/commands/promote_admin.ts b/apps/api/commands/promote_admin.ts new file mode 100644 index 0000000..9067168 --- /dev/null +++ b/apps/api/commands/promote_admin.ts @@ -0,0 +1,47 @@ +import { BaseCommand, flags } from '@adonisjs/core/ace' +import type { CommandOptions } from '@adonisjs/core/types/ace' + +import User from '#models/user' + +/** + * Bascule un user en admin (accès à /admin/blog + endpoints /api/v1/admin/*). + * + * node ace promote:admin --email=arthurbarre.js@gmail.com + * node ace promote:admin --email=... --revoke # retire le rôle + */ +export default class PromoteAdmin extends BaseCommand { + static commandName = 'promote:admin' + static description = 'Promeut (ou révoque) le rôle admin pour un user existant' + + static options: CommandOptions = { + startApp: true, + } + + @flags.string({ + description: "Email du user à promouvoir", + required: true, + }) + declare email: string + + @flags.boolean({ + description: 'Retire le rôle admin au lieu de l\'octroyer', + default: false, + }) + declare revoke: boolean + + async run() { + const user = await User.findBy('email', String(this.email).toLowerCase()) + if (!user) { + this.logger.error(`User introuvable : ${this.email}`) + this.exitCode = 1 + return + } + + user.isAdmin = !this.revoke + await user.save() + + this.logger.success( + `${user.email} → is_admin = ${user.isAdmin}`, + ) + } +} diff --git a/apps/api/database/migrations/1778340000000_add_is_admin_to_users_table.ts b/apps/api/database/migrations/1778340000000_add_is_admin_to_users_table.ts new file mode 100644 index 0000000..717f8af --- /dev/null +++ b/apps/api/database/migrations/1778340000000_add_is_admin_to_users_table.ts @@ -0,0 +1,25 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Flag `is_admin` sur les users — ouvre l'accès à l'admin du blog + * (apps/web /admin/blog) et aux endpoints /api/v1/admin/*. + * + * Bool simple plutôt qu'une table de rôles : on a un seul rôle privilégié + * pour V1 (l'éditeur du blog). Si la palette de rôles grandit (modération, + * support, etc.), on basculera sur RBAC propre. + */ +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.boolean('is_admin').notNullable().defaultTo(false) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('is_admin') + }) + } +} diff --git a/apps/api/start/kernel.ts b/apps/api/start/kernel.ts index a158c1b..0c8c1ea 100644 --- a/apps/api/start/kernel.ts +++ b/apps/api/start/kernel.ts @@ -48,4 +48,5 @@ router.use([ */ export const middleware = router.named({ auth: () => import('#middleware/auth_middleware'), + admin: () => import('#middleware/admin_middleware'), }) diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 387507b..67c8b53 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -18,6 +18,8 @@ import { controllers } from '#generated/controllers' * Pas auth, pas de paginiation V1 (volume cible <100 articles). */ const BlogController = () => import('#controllers/blog_controller') +const AdminPostsController = () => import('#controllers/admin_posts_controller') +const BlogUploadsController = () => import('#controllers/blog_uploads_controller') router @@ -55,6 +57,14 @@ router }) .prefix('posts') .as('posts') + + /** + * Image hero servie depuis MinIO. Public, cache infini (le filename est + * un UUID immuable, chaque upload = nouvelle URL). + */ + router + .get('uploads/blog/:filename', [BlogUploadsController, 'show']) + .as('uploads.blog.show') }) .prefix('/api/v1') @@ -317,5 +327,47 @@ router .prefix('invoices') .as('invoices') .use(middleware.auth()) + + /** + * Admin — édition du blog. Toutes auth + admin (cf. is_admin sur users). + * Cf. apps/web/src/routes/_app/admin.* pour le SPA consommateur. + */ + router + .group(() => { + router + .group(() => { + router.get('', [AdminPostsController, 'index']).as('index') + router.post('', [AdminPostsController, 'store']).as('store') + router.get('suggest-slug', [AdminPostsController, 'suggestSlug']).as('suggest-slug') + router + .get(':id', [AdminPostsController, 'show']) + .as('show') + .where('id', router.matchers.uuid()) + router + .patch(':id', [AdminPostsController, 'update']) + .as('update') + .where('id', router.matchers.uuid()) + router + .post(':id/publish', [AdminPostsController, 'publish']) + .as('publish') + .where('id', router.matchers.uuid()) + router + .post(':id/unpublish', [AdminPostsController, 'unpublish']) + .as('unpublish') + .where('id', router.matchers.uuid()) + router + .delete(':id', [AdminPostsController, 'destroy']) + .as('destroy') + .where('id', router.matchers.uuid()) + }) + .prefix('posts') + .as('posts') + + router.post('uploads', [BlogUploadsController, 'store']).as('uploads.store') + }) + .prefix('admin') + .as('admin') + .use(middleware.auth()) + .use(middleware.admin()) }) .prefix('/api/v1') diff --git a/apps/landing/src/components/sections/HowItWorks.tsx b/apps/landing/src/components/sections/HowItWorks.tsx index 50b5a6d..0b2ea9a 100644 --- a/apps/landing/src/components/sections/HowItWorks.tsx +++ b/apps/landing/src/components/sections/HowItWorks.tsx @@ -94,7 +94,7 @@ function Step({ num, title, body, flip = false, children }: StepProps) {

{title}

-
+
{body}
diff --git a/apps/web/package.json b/apps/web/package.json index 6cc68d6..dbdf456 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "@uiw/react-md-editor": "^4.0.5", "lucide-react": "^0.475.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/apps/web/src/components/admin/HeroImageUpload.tsx b/apps/web/src/components/admin/HeroImageUpload.tsx new file mode 100644 index 0000000..e38a493 --- /dev/null +++ b/apps/web/src/components/admin/HeroImageUpload.tsx @@ -0,0 +1,159 @@ +import { useState, useRef, type DragEvent } from "react"; +import { Upload, X, Image as ImageIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { adminBlogApi, absolutizeApiUrl } from "@/lib/admin-blog"; +import { Button, cn } from "@rubis/ui"; + +const ACCEPTED = "image/jpeg,image/png,image/webp"; + +type Props = { + value: string | null; + alt: string | null; + onChange: (next: { url: string | null; alt: string | null }) => void; +}; + +/** + * HeroImageUpload — drag & drop image (JPEG/PNG/WebP, max 4 MB). + * Upload va dans MinIO via /api/v1/admin/uploads, l'URL renvoyée est stockée + * dans posts.hero_image_url. L'alt est requis pour publier (SEO + a11y). + */ +export function HeroImageUpload({ value, alt, onChange }: Props) { + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(null); + + const handleFile = async (file: File) => { + setUploading(true); + try { + const result = await adminBlogApi.uploadImage(file); + onChange({ url: result.url, alt: alt ?? deriveAltFromFilename(file.name) }); + toast.success("Image uploadée."); + } catch (err) { + const msg = err instanceof Error ? err.message : "Échec upload"; + toast.error(`Échec upload : ${msg}`); + } finally { + setUploading(false); + } + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) { + void handleFile(file); + } + }; + + return ( +
+ {/* Single hidden input — réutilisé par le drop zone et le bouton "Remplacer". */} + { + const file = e.target.files?.[0]; + if (file) void handleFile(file); + e.target.value = ""; + }} + /> + {value ? ( +
+ {alt + +
+ ) : ( +
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + className={cn( + "relative cursor-pointer w-full aspect-[16/9] rounded-card border-2 border-dashed", + "flex flex-col items-center justify-center gap-3 transition-colors", + dragOver + ? "border-rubis bg-rubis-glow/40" + : "border-line bg-cream-2 hover:border-ink-3 hover:bg-cream-2/60", + )} + > + {uploading ? ( +
+
+ Upload en cours… +
+ ) : ( + <> +
+ +
+
+
+ Drag & drop ou clique pour uploader +
+
+ JPEG, PNG, WebP — max 4 MB · ratio 16:9 idéal +
+
+ + )} +
+ )} + + {/* Alt text — bloquant pour publier */} +
+ + onChange({ url: value, alt: e.target.value })} + placeholder="ex. Une facture en papier traversée par un rubis facetté" + maxLength={250} + className="w-full px-3 py-2 rounded-default border border-line bg-white text-[14px] text-ink focus:border-rubis focus:outline-none focus:ring-4 focus:ring-rubis-glow/40" + /> +
+ Décris l'image pour les lecteurs d'écran et le SEO. {alt?.length ?? 0}/250. +
+
+ + {value && !uploading && ( + + )} +
+ ); +} + +function deriveAltFromFilename(filename: string): string { + return filename + .replace(/\.[^.]+$/, "") + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx index 9734947..eded520 100644 --- a/apps/web/src/components/layout/AppSidebar.tsx +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -10,6 +10,7 @@ import { Users, Settings, TrendingUp, + PenSquare, } from "lucide-react"; import { Brand } from "@rubis/ui"; @@ -35,7 +36,7 @@ const STORAGE_KEY = "rubis.sidebar.collapsed"; * en haut reste, donc l'identité de marque ne disparaît jamais. */ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) { - const { user: _user } = useAuth(); + const { user } = useAuth(); const [collapsed, setCollapsed] = useState(false); // Lecture localStorage à l'init (côté client uniquement). @@ -110,6 +111,16 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) label="Paramètres" collapsed={collapsed} /> + + {/* Admin — visible uniquement pour les users avec is_admin=true. */} + {user?.isAdmin && ( + } + label="Blog (admin)" + collapsed={collapsed} + /> + )}
diff --git a/apps/web/src/lib/admin-blog.ts b/apps/web/src/lib/admin-blog.ts new file mode 100644 index 0000000..7e2ca15 --- /dev/null +++ b/apps/web/src/lib/admin-blog.ts @@ -0,0 +1,86 @@ +import { api } from "./api"; +import { env } from "./env"; + +/** + * Client API typé pour l'admin du blog. Toutes les routes nécessitent + * `auth + is_admin` côté serveur (cf. apps/api/start/routes.ts). + */ + +export type PostStatus = "draft" | "published"; + +export type AdminPost = { + id: string; + slug: string; + title: string; + excerpt: string; + contentMd: string; + contentHtml: string; + authorName: string; + tags: string[]; + status: PostStatus; + publishedAt: string | null; + heroImageUrl: string | null; + heroImageAlt: string | null; + ogImageUrl: string | null; + canonicalUrl: string | null; + noindex: boolean; + wordCount: number; + readingTimeMinutes: number; + createdAt: string; + updatedAt: string | null; +}; + +export type CreatePostInput = { + slug: string; + title: string; + excerpt: string; + contentMd: string; + tags?: string[]; + authorName?: string; + heroImageUrl?: string | null; + heroImageAlt?: string | null; + ogImageUrl?: string | null; + canonicalUrl?: string | null; + noindex?: boolean; +}; + +export type UpdatePostInput = Partial; + +export type UploadResult = { + /** URL publique relative (ex: /api/v1/uploads/blog/{uuid}.jpg) — préfixée par VITE_API_URL pour usage navigateur. */ + url: string; + contentType: string; + sizeBytes: number; +}; + +/** Renvoie l'URL absolue exploitable côté navigateur depuis une URL relative API. */ +export function absolutizeApiUrl(relativeOrAbsolute: string): string { + if (!relativeOrAbsolute) return relativeOrAbsolute; + if (relativeOrAbsolute.startsWith("http")) return relativeOrAbsolute; + return `${env.VITE_API_URL}${relativeOrAbsolute}`; +} + +export const adminBlogApi = { + list: () => api.get("/api/v1/admin/posts"), + get: (id: string) => api.get(`/api/v1/admin/posts/${id}`), + create: (input: CreatePostInput) => api.post("/api/v1/admin/posts", input), + update: (id: string, input: UpdatePostInput) => + api.patch(`/api/v1/admin/posts/${id}`, input), + publish: (id: string) => api.post(`/api/v1/admin/posts/${id}/publish`, {}), + unpublish: (id: string) => api.post(`/api/v1/admin/posts/${id}/unpublish`, {}), + delete: (id: string) => api.delete(`/api/v1/admin/posts/${id}`), + suggestSlug: (title: string) => + api.get<{ slug: string }>(`/api/v1/admin/posts/suggest-slug?title=${encodeURIComponent(title)}`), + + uploadImage: async (file: File): Promise => { + const form = new FormData(); + form.append("file", file); + return api.post("/api/v1/admin/uploads", form); + }, +}; + +export const adminBlogQueryKeys = { + all: () => ["admin", "blog"] as const, + list: () => ["admin", "blog", "list"] as const, + detail: (id: string) => ["admin", "blog", "detail", id] as const, +}; diff --git a/apps/web/src/mocks/db.ts b/apps/web/src/mocks/db.ts index c546340..acaab0e 100644 --- a/apps/web/src/mocks/db.ts +++ b/apps/web/src/mocks/db.ts @@ -72,6 +72,7 @@ const seedDb = (): Db => ({ fullName: "Arthur Démo", organizationId: "org_demo", signature: "Cordialement,\nArthur — Rubis Démo", + isAdmin: true, createdAt: new Date("2026-01-01").toISOString(), updatedAt: new Date().toISOString(), passwordHash: "demo1234", @@ -188,6 +189,7 @@ export const mockDb = { fullName: input.fullName, organizationId: orgId, signature: null, + isAdmin: false, createdAt: now, updatedAt: now, passwordHash: input.password, diff --git a/apps/web/src/routes/_app/admin.blog.tsx b/apps/web/src/routes/_app/admin.blog.tsx new file mode 100644 index 0000000..51c0800 --- /dev/null +++ b/apps/web/src/routes/_app/admin.blog.tsx @@ -0,0 +1,232 @@ +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Plus, FileText, ExternalLink, Trash2, Eye, EyeOff } from "lucide-react"; +import { toast } from "sonner"; + +import { authStore } from "@/lib/auth"; +import { + adminBlogApi, + adminBlogQueryKeys, + type AdminPost, +} from "@/lib/admin-blog"; +import { Button, EmptyState } from "@rubis/ui"; + +export const Route = createFileRoute("/_app/admin/blog")({ + beforeLoad: () => { + // Gate côté client : si pas admin, on redirige vers / (404 visuel). + // Le backend re-vérifie de toute façon (middleware admin sur API). + if (!authStore.user?.isAdmin) { + throw redirect({ to: "/" }); + } + }, + loader: ({ context }) => { + void context.queryClient.prefetchQuery({ + queryKey: adminBlogQueryKeys.list(), + queryFn: () => adminBlogApi.list(), + }); + }, + component: AdminBlogList, +}); + +function AdminBlogList() { + const qc = useQueryClient(); + const { data: posts = [], isPending } = useQuery({ + queryKey: adminBlogQueryKeys.list(), + queryFn: () => adminBlogApi.list(), + staleTime: 5_000, + }); + + const [creating, setCreating] = useState(false); + const createMut = useMutation({ + mutationFn: () => + adminBlogApi.create({ + slug: `nouveau-brouillon-${Date.now()}`, + title: "Nouveau brouillon", + excerpt: + "Brouillon vide à compléter avant publication — fais une description suffisamment longue pour le SEO (minimum 120 caractères).", + contentMd: + "# Brouillon\n\nÉcris ton contenu ici. **Markdown** supporté.", + }), + onSuccess: (post) => { + qc.invalidateQueries({ queryKey: adminBlogQueryKeys.all() }); + toast.success("Brouillon créé."); + setCreating(false); + window.location.href = `/admin/blog/${post.id}`; + }, + onError: (err: Error) => { + toast.error(`Échec création : ${err.message}`); + setCreating(false); + }, + }); + + return ( +
+
+
+

+ Articles du blog +

+

+ Crée, édite et publie sur{" "} + + rubis.pro/blog + + . +

+
+ +
+ + {isPending ? ( +
Chargement…
+ ) : posts.length === 0 ? ( + } + title="Aucun article" + description="Tu n'as pas encore d'article. Clique sur Nouveau brouillon pour commencer." + /> + ) : ( +
+ + + + + + + + + + + + {posts.map((post) => ( + + ))} + +
StatutTitreSlugMotsMise à jour +
+
+ )} +
+ ); +} + +function PostRow({ post }: { post: AdminPost }) { + const qc = useQueryClient(); + + const deleteMut = useMutation({ + mutationFn: () => adminBlogApi.delete(post.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: adminBlogQueryKeys.all() }); + toast.success("Article supprimé."); + }, + onError: (err: Error) => toast.error(`Échec suppression : ${err.message}`), + }); + + const unpublishMut = useMutation({ + mutationFn: () => adminBlogApi.unpublish(post.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: adminBlogQueryKeys.all() }); + toast.success("Article repassé en brouillon."); + }, + onError: (err: Error) => toast.error(`Échec : ${err.message}`), + }); + + return ( + + + + + + + {post.title} + + + {post.slug} + + {post.wordCount} · {post.readingTimeMinutes} min + + + {new Intl.DateTimeFormat("fr-FR", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(post.updatedAt ?? post.createdAt))} + + + {post.status === "published" && ( + + + + )} + {post.status === "published" && ( + + )} + {post.status === "draft" && ( + + + + )} + + + + ); +} + +function StatusBadge({ status }: { status: AdminPost["status"] }) { + if (status === "published") { + return ( + + + Publié + + ); + } + return ( + + + Brouillon + + ); +} diff --git a/apps/web/src/routes/_app/admin.blog_.$id.tsx b/apps/web/src/routes/_app/admin.blog_.$id.tsx new file mode 100644 index 0000000..633c08b --- /dev/null +++ b/apps/web/src/routes/_app/admin.blog_.$id.tsx @@ -0,0 +1,522 @@ +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, Save, Eye, Trash2, ChevronLeft } from "lucide-react"; +import { toast } from "sonner"; + +import { authStore } from "@/lib/auth"; +import { + adminBlogApi, + adminBlogQueryKeys, + type AdminPost, +} from "@/lib/admin-blog"; +import { Button, Card, cn } from "@rubis/ui"; +import { HeroImageUpload } from "@/components/admin/HeroImageUpload"; + +// Lazy : @uiw/react-md-editor charge ~250 KB d'éditeur + preview, on ne veut +// pas le bundler dans le critical path du SaaS. Code-splitté par route. +const MdEditor = lazy(() => import("@uiw/react-md-editor")); + +export const Route = createFileRoute("/_app/admin/blog_/$id")({ + beforeLoad: () => { + if (!authStore.user?.isAdmin) { + throw redirect({ to: "/" }); + } + }, + loader: ({ params, context }) => { + void context.queryClient.prefetchQuery({ + queryKey: adminBlogQueryKeys.detail(params.id), + queryFn: () => adminBlogApi.get(params.id), + }); + }, + component: AdminBlogEditor, +}); + +/* ============================================================================ + * Validations SEO miroir des règles backend (cf. apps/api/app/validators/post) + * — on les exécute ici pour bloquer le bouton "Publier" avec feedback live. + * ========================================================================= */ + +const VALIDATION_RULES = { + titleMaxChars: 60, + excerptMinChars: 120, + excerptMaxChars: 160, + minWordCount: 600, +} as const; + +type ValidationIssue = { field: string; message: string }; + +function validateForPublish( + draft: { title: string; excerpt: string; heroImageUrl: string | null; heroImageAlt: string | null; wordCount: number; slug: string }, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + if (!draft.slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(draft.slug)) { + issues.push({ field: "slug", message: "Slug invalide (kebab-case ASCII)." }); + } + if (draft.title.length > VALIDATION_RULES.titleMaxChars) { + issues.push({ + field: "title", + message: `Titre trop long pour Google (${draft.title.length} > ${VALIDATION_RULES.titleMaxChars}).`, + }); + } + if (draft.title.length < 5) { + issues.push({ field: "title", message: "Titre trop court (min 5 chars)." }); + } + if (draft.excerpt.length < VALIDATION_RULES.excerptMinChars) { + issues.push({ + field: "excerpt", + message: `Excerpt trop court (${draft.excerpt.length} < ${VALIDATION_RULES.excerptMinChars}).`, + }); + } + if (draft.excerpt.length > VALIDATION_RULES.excerptMaxChars) { + issues.push({ + field: "excerpt", + message: `Excerpt trop long (${draft.excerpt.length} > ${VALIDATION_RULES.excerptMaxChars}).`, + }); + } + if (!draft.heroImageUrl) { + issues.push({ field: "heroImageUrl", message: "Hero image requise." }); + } + if (!draft.heroImageAlt || draft.heroImageAlt.length < 3) { + issues.push({ field: "heroImageAlt", message: "Texte alt requis." }); + } + if (draft.wordCount < VALIDATION_RULES.minWordCount) { + issues.push({ + field: "contentMd", + message: `Article trop court (${draft.wordCount} < ${VALIDATION_RULES.minWordCount} mots).`, + }); + } + return issues; +} + +/* ============================================================================ + * Component + * ========================================================================= */ + +function AdminBlogEditor() { + const { id } = Route.useParams(); + const navigate = useNavigate(); + + const { data: post, isPending } = useQuery({ + queryKey: adminBlogQueryKeys.detail(id), + queryFn: () => adminBlogApi.get(id), + }); + + if (isPending || !post) { + return ( +
+ Chargement… +
+ ); + } + + return navigate({ to: "/admin/blog" })} />; +} + +function Editor({ post, onBack }: { post: AdminPost; onBack: () => void }) { + const qc = useQueryClient(); + + // État local — on travaille sur une copie du post pour autosave debouncée. + const [draft, setDraft] = useState({ + slug: post.slug, + title: post.title, + excerpt: post.excerpt, + contentMd: post.contentMd, + heroImageUrl: post.heroImageUrl, + heroImageAlt: post.heroImageAlt, + tags: post.tags.join(", "), + }); + + // Re-sync si le post est rafraîchi par un autre canal (rare mais sûr). + useEffect(() => { + setDraft({ + slug: post.slug, + title: post.title, + excerpt: post.excerpt, + contentMd: post.contentMd, + heroImageUrl: post.heroImageUrl, + heroImageAlt: post.heroImageAlt, + tags: post.tags.join(", "), + }); + }, [post.id]); // eslint-disable-line react-hooks/exhaustive-deps + + const wordCount = useMemo(() => countWords(draft.contentMd), [draft.contentMd]); + const issues = useMemo( + () => + validateForPublish({ + title: draft.title, + excerpt: draft.excerpt, + slug: draft.slug, + heroImageUrl: draft.heroImageUrl, + heroImageAlt: draft.heroImageAlt, + wordCount, + }), + [draft, wordCount], + ); + + /* === Save mutation (manuel + autosave debounce) === */ + const saveMut = useMutation({ + mutationFn: () => + adminBlogApi.update(post.id, { + slug: draft.slug, + title: draft.title, + excerpt: draft.excerpt, + contentMd: draft.contentMd, + heroImageUrl: draft.heroImageUrl, + heroImageAlt: draft.heroImageAlt, + tags: draft.tags + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0), + }), + onSuccess: (saved) => { + qc.setQueryData(adminBlogQueryKeys.detail(post.id), saved); + qc.invalidateQueries({ queryKey: adminBlogQueryKeys.list() }); + }, + }); + + // Autosave debounce 2s sur changement de draft. + const debounceRef = useRef | null>(null); + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + // Skip si rien n'a changé vs persisted + const persisted = { + slug: post.slug, + title: post.title, + excerpt: post.excerpt, + contentMd: post.contentMd, + heroImageUrl: post.heroImageUrl, + heroImageAlt: post.heroImageAlt, + tags: post.tags.join(", "), + }; + const changed = JSON.stringify(persisted) !== JSON.stringify(draft); + if (changed && !saveMut.isPending) { + saveMut.mutate(); + } + }, 2000); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [draft]); // eslint-disable-line react-hooks/exhaustive-deps + + /* === Publish === */ + const publishMut = useMutation({ + mutationFn: async () => { + // S'assurer qu'on a sauvé avant de publier (sinon l'API juge sur l'état persisté). + await saveMut.mutateAsync(); + return adminBlogApi.publish(post.id); + }, + onSuccess: (saved) => { + qc.setQueryData(adminBlogQueryKeys.detail(post.id), saved); + qc.invalidateQueries({ queryKey: adminBlogQueryKeys.list() }); + toast.success("Article publié — visible sur rubis.pro/blog (cache 5 min)."); + }, + onError: (err: Error) => { + toast.error(`Échec publication : ${err.message}`); + }, + }); + + const unpublishMut = useMutation({ + mutationFn: () => adminBlogApi.unpublish(post.id), + onSuccess: (saved) => { + qc.setQueryData(adminBlogQueryKeys.detail(post.id), saved); + qc.invalidateQueries({ queryKey: adminBlogQueryKeys.list() }); + toast.success("Article repassé en brouillon."); + }, + }); + + const deleteMut = useMutation({ + mutationFn: () => adminBlogApi.delete(post.id), + onSuccess: () => { + toast.success("Article supprimé."); + onBack(); + }, + }); + + const canPublish = issues.length === 0; + const isPublished = post.status === "published"; + const publicUrl = `https://rubis.pro/blog/${post.slug}`; + + return ( +
+ {/* Topbar */} +
+ + +
+ + {isPublished && ( + + + Voir sur rubis.pro + + )} + + {isPublished ? ( + + ) : ( + + )} + +
+
+ + {/* Title */} + setDraft((d) => ({ ...d, title: e.target.value }))} + placeholder="Titre de l'article…" + className="w-full bg-transparent border-none font-display text-[32px] sm:text-[40px] font-bold tracking-[-0.025em] text-ink placeholder:text-ink-3 focus:outline-none mb-2" + /> + + + {/* Slug */} +
+ rubis.pro/blog/ + setDraft((d) => ({ ...d, slug: e.target.value }))} + placeholder="slug-de-l-article" + className="flex-1 max-w-[400px] bg-transparent border-b border-line font-mono text-ink focus:outline-none focus:border-rubis" + /> + +
+ + {/* Layout 2 colonnes */} +
+ {/* Colonne gauche : éditeur markdown */} +
+ Chargement de l'éditeur…
}> + setDraft((d) => ({ ...d, contentMd: val ?? "" }))} + height={650} + preview="live" + previewOptions={{ + /* On utilise le preview natif @uiw, qui rend correctement le GFM + markdown pour donner un aperçu. Le rendu final sur rubis.pro + passe par notre blog_renderer (marked v15), légèrement + différent — l'aperçu sert à valider la STRUCTURE, pas le + pixel-perfect. Pour ça : "Voir sur rubis.pro" après publish. */ + }} + /> + +
+ {wordCount} mots · ~{Math.max(1, Math.round(wordCount / 220))} min + {wordCount < VALIDATION_RULES.minWordCount && ( + + · Min {VALIDATION_RULES.minWordCount} mots requis pour publier + + )} +
+
+ + {/* Colonne droite : meta SEO + hero */} +
+ {/* Hero image */} + +
+ Image de couverture +
+ + setDraft((d) => ({ ...d, heroImageUrl: url, heroImageAlt: alt })) + } + /> +
+ + {/* Meta description */} + +
+ Description (SEO) +
+