feat(blog): admin CRUD + image upload + sidebar link
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 <noreply@anthropic.com>
This commit is contained in:
parent
77fdb6af48
commit
6dcae6956c
221
apps/api/app/controllers/admin_posts_controller.ts
Normal file
221
apps/api/app/controllers/admin_posts_controller.ts
Normal file
@ -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<void> {
|
||||
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}`),
|
||||
])
|
||||
}
|
||||
64
apps/api/app/controllers/blog_uploads_controller.ts
Normal file
64
apps/api/app/controllers/blog_uploads_controller.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
19
apps/api/app/middleware/admin_middleware.ts
Normal file
19
apps/api/app/middleware/admin_middleware.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
107
apps/api/app/services/blog_uploads.ts
Normal file
107
apps/api/app/services/blog_uploads.ts
Normal file
@ -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<UploadResult> {
|
||||
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'
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,9 @@ export default class UserTransformer extends BaseTransformer<User> {
|
||||
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()!,
|
||||
}
|
||||
|
||||
72
apps/api/app/validators/post.ts
Normal file
72
apps/api/app/validators/post.ts
Normal file
@ -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
|
||||
47
apps/api/commands/promote_admin.ts
Normal file
47
apps/api/commands/promote_admin.ts
Normal file
@ -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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -48,4 +48,5 @@ router.use([
|
||||
*/
|
||||
export const middleware = router.named({
|
||||
auth: () => import('#middleware/auth_middleware'),
|
||||
admin: () => import('#middleware/admin_middleware'),
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -94,7 +94,7 @@ function Step({ num, title, body, flip = false, children }: StepProps) {
|
||||
<h3 className="font-display font-bold text-ink text-[26px] sm:text-[30px] lg:text-[32px] tracking-[-0.022em] leading-[1.15]">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-4 md:mt-5 text-[16.5px] md:text-[17px] leading-relaxed text-ink-2 md:text-justify hyphens-auto">
|
||||
<div className="mt-4 md:mt-5 text-[16.5px] md:text-[17px] leading-relaxed text-ink-2">
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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",
|
||||
|
||||
159
apps/web/src/components/admin/HeroImageUpload.tsx
Normal file
159
apps/web/src/components/admin/HeroImageUpload.tsx
Normal file
@ -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<HTMLInputElement>(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 (
|
||||
<div className="space-y-3">
|
||||
{/* Single hidden input — réutilisé par le drop zone et le bouton "Remplacer". */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{value ? (
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={absolutizeApiUrl(value)}
|
||||
alt={alt ?? ""}
|
||||
className="w-full aspect-[16/9] object-cover rounded-card border border-line"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ url: null, alt: null })}
|
||||
className="absolute top-3 right-3 size-8 rounded-full bg-white/90 backdrop-blur border border-line flex items-center justify-center text-ink-2 hover:text-rubis-deep hover:bg-white transition-colors shadow-soft"
|
||||
title="Retirer l'image"
|
||||
aria-label="Retirer l'image"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
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 ? (
|
||||
<div className="flex flex-col items-center gap-2 text-ink-2">
|
||||
<div className="size-8 border-2 border-current border-r-transparent rounded-full animate-spin" />
|
||||
<span className="text-[13px]">Upload en cours…</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="size-12 rounded-full bg-white border border-line flex items-center justify-center text-ink-3">
|
||||
<Upload size={20} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-display font-semibold text-[15px] text-ink">
|
||||
Drag & drop ou clique pour uploader
|
||||
</div>
|
||||
<div className="text-[12.5px] text-ink-3 mt-0.5">
|
||||
JPEG, PNG, WebP — max 4 MB · ratio 16:9 idéal
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alt text — bloquant pour publier */}
|
||||
<div>
|
||||
<label className="block text-[12px] uppercase tracking-[0.08em] font-semibold text-ink-3 mb-1.5">
|
||||
Texte alternatif (alt) — requis pour publier
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={alt ?? ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="text-[11.5px] text-ink-3 mt-1">
|
||||
Décris l'image pour les lecteurs d'écran et le SEO. {alt?.length ?? 0}/250.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value && !uploading && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<ImageIcon size={14} />
|
||||
Remplacer l'image
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveAltFromFilename(filename: string): string {
|
||||
return filename
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
@ -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 && (
|
||||
<NavLink
|
||||
to="/admin/blog"
|
||||
icon={<PenSquare size={17} />}
|
||||
label="Blog (admin)"
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
|
||||
86
apps/web/src/lib/admin-blog.ts
Normal file
86
apps/web/src/lib/admin-blog.ts
Normal file
@ -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<CreatePostInput>;
|
||||
|
||||
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<AdminPost[]>("/api/v1/admin/posts"),
|
||||
get: (id: string) => api.get<AdminPost>(`/api/v1/admin/posts/${id}`),
|
||||
create: (input: CreatePostInput) => api.post<AdminPost>("/api/v1/admin/posts", input),
|
||||
update: (id: string, input: UpdatePostInput) =>
|
||||
api.patch<AdminPost>(`/api/v1/admin/posts/${id}`, input),
|
||||
publish: (id: string) => api.post<AdminPost>(`/api/v1/admin/posts/${id}/publish`, {}),
|
||||
unpublish: (id: string) => api.post<AdminPost>(`/api/v1/admin/posts/${id}/unpublish`, {}),
|
||||
delete: (id: string) => api.delete<void>(`/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<UploadResult> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return api.post<UploadResult>("/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,
|
||||
};
|
||||
@ -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,
|
||||
|
||||
232
apps/web/src/routes/_app/admin.blog.tsx
Normal file
232
apps/web/src/routes/_app/admin.blog.tsx
Normal file
@ -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 (
|
||||
<div className="container mx-auto max-w-[1100px] px-5 py-8">
|
||||
<div className="flex items-baseline justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.02em] text-ink">
|
||||
Articles du blog
|
||||
</h1>
|
||||
<p className="text-[14px] text-ink-3 mt-1">
|
||||
Crée, édite et publie sur{" "}
|
||||
<a href="https://rubis.pro/blog" target="_blank" rel="noopener" className="underline">
|
||||
rubis.pro/blog
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCreating(true);
|
||||
createMut.mutate();
|
||||
}}
|
||||
loading={creating || createMut.isPending}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Nouveau brouillon
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<div className="text-ink-3 text-sm">Chargement…</div>
|
||||
) : posts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText size={32} />}
|
||||
title="Aucun article"
|
||||
description="Tu n'as pas encore d'article. Clique sur Nouveau brouillon pour commencer."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-card border border-line shadow-soft overflow-hidden">
|
||||
<table className="w-full text-[14px]">
|
||||
<thead className="bg-cream-2 text-[11.5px] uppercase tracking-[0.08em] font-semibold text-ink-3">
|
||||
<tr>
|
||||
<th className="text-left px-5 py-3">Statut</th>
|
||||
<th className="text-left px-5 py-3">Titre</th>
|
||||
<th className="text-left px-5 py-3">Slug</th>
|
||||
<th className="text-left px-5 py-3">Mots</th>
|
||||
<th className="text-left px-5 py-3">Mise à jour</th>
|
||||
<th className="text-right px-5 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{posts.map((post) => (
|
||||
<PostRow key={post.id} post={post} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<tr className="hover:bg-cream-2/40 transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={post.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
to="/admin/blog/$id"
|
||||
params={{ id: post.id }}
|
||||
className="font-medium text-ink hover:text-rubis"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-ink-3 font-mono text-[12.5px]">{post.slug}</td>
|
||||
<td className="px-5 py-4 text-ink-2 tabular-nums">
|
||||
{post.wordCount} <span className="text-ink-3">·</span> {post.readingTimeMinutes} min
|
||||
</td>
|
||||
<td className="px-5 py-4 text-ink-3 text-[13px]">
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(post.updatedAt ?? post.createdAt))}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right whitespace-nowrap">
|
||||
{post.status === "published" && (
|
||||
<a
|
||||
href={`https://rubis.pro/blog/${post.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis mr-3"
|
||||
title="Voir sur rubis.pro"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
)}
|
||||
{post.status === "published" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unpublishMut.mutate()}
|
||||
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis-deep mr-3"
|
||||
title="Dépublier"
|
||||
>
|
||||
<EyeOff size={13} />
|
||||
</button>
|
||||
)}
|
||||
{post.status === "draft" && (
|
||||
<Link
|
||||
to="/admin/blog/$id"
|
||||
params={{ id: post.id }}
|
||||
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis mr-3"
|
||||
title="Éditer"
|
||||
>
|
||||
<Eye size={13} />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Supprimer "${post.title}" ?`)) {
|
||||
deleteMut.mutate();
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[12.5px] text-ink-3 hover:text-rubis-deep"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: AdminPost["status"] }) {
|
||||
if (status === "published") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-rubis">
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-rubis" />
|
||||
Publié
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-ink-3">
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-ink-3" />
|
||||
Brouillon
|
||||
</span>
|
||||
);
|
||||
}
|
||||
522
apps/web/src/routes/_app/admin.blog_.$id.tsx
Normal file
522
apps/web/src/routes/_app/admin.blog_.$id.tsx
Normal file
@ -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 (
|
||||
<div className="container mx-auto max-w-[1100px] px-5 py-8 text-ink-3">
|
||||
Chargement…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Editor post={post} onBack={() => 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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="container mx-auto max-w-[1280px] px-5 py-6">
|
||||
{/* Topbar */}
|
||||
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1 text-[14px] text-ink-2 hover:text-rubis"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
Retour aux articles
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SaveStatus
|
||||
isSaving={saveMut.isPending}
|
||||
lastSavedAt={saveMut.data?.updatedAt ?? post.updatedAt}
|
||||
/>
|
||||
{isPublished && (
|
||||
<a
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-ink-2 hover:text-rubis px-3 py-2"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Voir sur rubis.pro
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => saveMut.mutate()}
|
||||
loading={saveMut.isPending}
|
||||
>
|
||||
<Save size={14} />
|
||||
Sauvegarder
|
||||
</Button>
|
||||
{isPublished ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => unpublishMut.mutate()}
|
||||
loading={unpublishMut.isPending}
|
||||
>
|
||||
Dépublier
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => publishMut.mutate()}
|
||||
disabled={!canPublish}
|
||||
loading={publishMut.isPending}
|
||||
title={canPublish ? "Publier sur rubis.pro/blog" : "Corrige les erreurs SEO d'abord"}
|
||||
>
|
||||
<Eye size={14} />
|
||||
Publier
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`Supprimer définitivement "${post.title}" ?`)) {
|
||||
deleteMut.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<CharCounter
|
||||
value={draft.title.length}
|
||||
max={VALIDATION_RULES.titleMaxChars}
|
||||
label="Titre"
|
||||
/>
|
||||
|
||||
{/* Slug */}
|
||||
<div className="mt-3 flex items-center gap-2 text-[13px] text-ink-3">
|
||||
<span>rubis.pro/blog/</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.slug}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await adminBlogApi.suggestSlug(draft.title);
|
||||
setDraft((d) => ({ ...d, slug: result.slug }));
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}}
|
||||
className="text-rubis hover:text-rubis-deep underline-offset-2 hover:underline"
|
||||
>
|
||||
générer depuis le titre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Layout 2 colonnes */}
|
||||
<div className="mt-8 grid lg:grid-cols-[2fr_1fr] gap-8">
|
||||
{/* Colonne gauche : éditeur markdown */}
|
||||
<div data-color-mode="light" className="rubis-md-editor-wrapper">
|
||||
<Suspense fallback={<div className="text-ink-3">Chargement de l'éditeur…</div>}>
|
||||
<MdEditor
|
||||
value={draft.contentMd}
|
||||
onChange={(val) => 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. */
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
<div className="mt-3 text-[12.5px] text-ink-3 flex items-center gap-3">
|
||||
<span>{wordCount} mots · ~{Math.max(1, Math.round(wordCount / 220))} min</span>
|
||||
{wordCount < VALIDATION_RULES.minWordCount && (
|
||||
<span className="text-rubis-deep">
|
||||
· Min {VALIDATION_RULES.minWordCount} mots requis pour publier
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : meta SEO + hero */}
|
||||
<div className="space-y-6">
|
||||
{/* Hero image */}
|
||||
<Card padding="md">
|
||||
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
|
||||
Image de couverture
|
||||
</div>
|
||||
<HeroImageUpload
|
||||
value={draft.heroImageUrl}
|
||||
alt={draft.heroImageAlt}
|
||||
onChange={({ url, alt }) =>
|
||||
setDraft((d) => ({ ...d, heroImageUrl: url, heroImageAlt: alt }))
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Meta description */}
|
||||
<Card padding="md">
|
||||
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
|
||||
Description (SEO)
|
||||
</div>
|
||||
<textarea
|
||||
value={draft.excerpt}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, excerpt: e.target.value }))}
|
||||
rows={4}
|
||||
placeholder="120 à 160 caractères. Cette ligne apparaît dans les résultats Google et sur LinkedIn/Twitter quand l'article est partagé."
|
||||
className="w-full px-3 py-2 rounded-default border border-line bg-white text-[14px] text-ink leading-relaxed focus:border-rubis focus:outline-none focus:ring-4 focus:ring-rubis-glow/40 resize-none"
|
||||
/>
|
||||
<div className="text-[11.5px] mt-1.5 flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
draft.excerpt.length < VALIDATION_RULES.excerptMinChars ||
|
||||
draft.excerpt.length > VALIDATION_RULES.excerptMaxChars
|
||||
? "text-rubis-deep"
|
||||
: "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{draft.excerpt.length} / {VALIDATION_RULES.excerptMinChars}-
|
||||
{VALIDATION_RULES.excerptMaxChars} chars
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tags */}
|
||||
<Card padding="md">
|
||||
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
|
||||
Tags
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.tags}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
|
||||
placeholder="trésorerie, LME, retards (séparés par virgules)"
|
||||
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"
|
||||
/>
|
||||
<div className="text-[11.5px] text-ink-3 mt-1.5">
|
||||
Sert au "Articles liés" sur rubis.pro/blog (intersection de tags). Max 5.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu Google snippet */}
|
||||
<Card padding="md">
|
||||
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-ink mb-3">
|
||||
Aperçu Google
|
||||
</div>
|
||||
<div className="bg-white border border-line rounded-default p-4">
|
||||
<div className="text-[12.5px] text-ink-3">rubis.pro › blog › {draft.slug}</div>
|
||||
<div className="mt-1 font-display text-[18px] text-rubis hover:underline cursor-pointer">
|
||||
{(draft.title || "…").slice(0, 60)}
|
||||
</div>
|
||||
<div className="text-[13.5px] text-ink-2 leading-snug line-clamp-2 mt-1">
|
||||
{draft.excerpt || "Description…"}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Validations bloquantes */}
|
||||
{issues.length > 0 && (
|
||||
<Card padding="md" variant="flat">
|
||||
<div className="font-display font-semibold text-[14px] uppercase tracking-[0.08em] text-rubis-deep mb-2">
|
||||
À corriger avant publication
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-[13px] text-ink-2">
|
||||
{issues.map((issue, i) => (
|
||||
<li key={i} className="flex items-baseline gap-2">
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-rubis flex-shrink-0 mt-1.5" />
|
||||
<span>{issue.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveStatus({ isSaving, lastSavedAt }: { isSaving: boolean; lastSavedAt: string | null }) {
|
||||
if (isSaving) {
|
||||
return <span className="text-[12.5px] text-ink-3">Sauvegarde…</span>;
|
||||
}
|
||||
if (!lastSavedAt) return null;
|
||||
const date = new Date(lastSavedAt);
|
||||
return (
|
||||
<span className="text-[12.5px] text-ink-3" title={date.toISOString()}>
|
||||
Sauvé à{" "}
|
||||
{new Intl.DateTimeFormat("fr-FR", { hour: "2-digit", minute: "2-digit" }).format(date)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CharCounter({ value, max, label }: { value: number; max: number; label: string }) {
|
||||
const isOver = value > max;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[11.5px] tabular-nums",
|
||||
isOver ? "text-rubis-deep" : "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{label} : {value}/{max} chars {isOver && "· trop long pour Google"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compte les mots côté client — heuristique simple, le serveur a la source de vérité. */
|
||||
function countWords(md: string): number {
|
||||
const stripped = md
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/`[^`]*`/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[*_~#>]/g, " ");
|
||||
const matches = stripped.match(/\S+/g);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
@ -59,19 +59,22 @@ spec:
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
---
|
||||
# ClusterIP — exposé à Traefik via IngressRoute (gérée dans le repo proxmox).
|
||||
# NodePort — Traefik (sur la VM Proxmox) tape sur 10.10.10.5:30111.
|
||||
# Le routing dynamique Traefik vit dans le repo proxmox :
|
||||
# ansible/roles/traefik/templates/rubis.yml.j2
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: rubis-landing
|
||||
namespace: rubis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
type: NodePort
|
||||
selector:
|
||||
app: rubis-landing
|
||||
ports:
|
||||
- port: 4321
|
||||
targetPort: http
|
||||
nodePort: 30111
|
||||
name: http
|
||||
---
|
||||
apiVersion: v1
|
||||
|
||||
@ -10,6 +10,8 @@ export type User = {
|
||||
organizationId: string;
|
||||
/** Signature email utilisée comme expéditeur des relances. */
|
||||
signature: string | null;
|
||||
/** Accès aux endpoints /admin/* (édition du blog notamment). */
|
||||
isAdmin: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
398
pnpm-lock.yaml
generated
398
pnpm-lock.yaml
generated
@ -289,6 +289,9 @@ importers:
|
||||
'@tuyau/client':
|
||||
specifier: ^0.2.10
|
||||
version: 0.2.10
|
||||
'@uiw/react-md-editor':
|
||||
specifier: ^4.0.5
|
||||
version: 4.1.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -3912,6 +3915,9 @@ packages:
|
||||
'@types/esrecurse@4.3.1':
|
||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@ -3969,6 +3975,9 @@ packages:
|
||||
'@types/pluralize@0.0.33':
|
||||
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
|
||||
|
||||
'@types/prismjs@1.26.6':
|
||||
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@ -3989,6 +3998,9 @@ packages:
|
||||
'@types/tedious@4.0.14':
|
||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
@ -4057,6 +4069,21 @@ packages:
|
||||
resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@uiw/copy-to-clipboard@1.0.20':
|
||||
resolution: {integrity: sha512-IFQhS62CLNon1YgYJTEzXR2N3WVXg7V1FaBRDLMlzU6JY5X6Hr3OPAcw4WNoKcz2XcFD6XCgwEjlsmj+JA0mWA==}
|
||||
|
||||
'@uiw/react-markdown-preview@5.2.0':
|
||||
resolution: {integrity: sha512-39kgf+Wk6DXpYtztUt34xFPt0BzGkuxmFZKI7rNAlCFKXdAmyhqLlRbQKyrRwOVeuRyrQy/Z96UBSw5AHFBivg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@uiw/react-md-editor@4.1.0':
|
||||
resolution: {integrity: sha512-ti8WO9Mf55bFdb5JXEJmQ77NQRG8VyUAuE6c5XRwMI3S05Q0uuiRKOs5yAyJWiISgkV39DMmf4UU8DbADPILhw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
@ -4293,6 +4320,9 @@ packages:
|
||||
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
bcp-47-match@2.0.3:
|
||||
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
|
||||
|
||||
better-sqlite3@12.9.0:
|
||||
resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
|
||||
@ -4415,6 +4445,9 @@ packages:
|
||||
character-entities@2.0.2:
|
||||
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||
|
||||
character-reference-invalid@2.0.1:
|
||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||
|
||||
check-disk-space@3.4.0:
|
||||
resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==}
|
||||
engines: {node: '>=16'}
|
||||
@ -4599,6 +4632,9 @@ packages:
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
css-selector-parser@3.3.0:
|
||||
resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==}
|
||||
|
||||
css-tree@2.2.1:
|
||||
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||
@ -4797,6 +4833,10 @@ packages:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
direction@2.0.1:
|
||||
resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==}
|
||||
hasBin: true
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
@ -5036,6 +5076,9 @@ packages:
|
||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
@ -5358,6 +5401,12 @@ packages:
|
||||
hast-util-from-parse5@8.0.3:
|
||||
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
||||
|
||||
hast-util-has-property@3.0.0:
|
||||
resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
|
||||
|
||||
hast-util-heading-rank@3.0.0:
|
||||
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
|
||||
|
||||
hast-util-is-element@3.0.0:
|
||||
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
|
||||
|
||||
@ -5367,12 +5416,21 @@ packages:
|
||||
hast-util-raw@9.1.0:
|
||||
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
|
||||
|
||||
hast-util-select@6.0.4:
|
||||
resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
hast-util-to-parse5@8.0.1:
|
||||
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
|
||||
|
||||
hast-util-to-string@3.0.1:
|
||||
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
|
||||
|
||||
hast-util-to-text@4.0.2:
|
||||
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
|
||||
|
||||
@ -5422,6 +5480,9 @@ packages:
|
||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@ -5565,6 +5626,9 @@ packages:
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
@ -5584,6 +5648,12 @@ packages:
|
||||
iron-webcrypto@1.2.1:
|
||||
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
||||
|
||||
is-alphabetical@2.0.1:
|
||||
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
|
||||
|
||||
is-alphanumerical@2.0.1:
|
||||
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
@ -5596,6 +5666,9 @@ packages:
|
||||
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-decimal@2.0.1:
|
||||
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@ -5626,6 +5699,9 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-hexadecimal@2.0.1:
|
||||
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
|
||||
|
||||
is-in-ssh@1.0.0:
|
||||
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
|
||||
engines: {node: '>=20'}
|
||||
@ -6022,6 +6098,15 @@ packages:
|
||||
mdast-util-gfm@3.1.0:
|
||||
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
|
||||
|
||||
mdast-util-mdx-expression@2.0.1:
|
||||
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
|
||||
|
||||
mdast-util-mdx-jsx@3.2.0:
|
||||
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
|
||||
|
||||
mdast-util-mdxjs-esm@2.0.1:
|
||||
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||
|
||||
@ -6406,6 +6491,9 @@ packages:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
parse-imports@3.0.0:
|
||||
resolution: {integrity: sha512-IwiqoJANa4O6M76LBWEvoS2iPIUqBOnKG1lV3/J0oVM6V2XjED+mYAXedEMX5xUglVjfGpZOfaEyuOUjBuUE4g==}
|
||||
engines: {node: '>= 22'}
|
||||
@ -6421,6 +6509,9 @@ packages:
|
||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
parse-numeric-range@1.3.0:
|
||||
resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==}
|
||||
|
||||
parse-svg-path@0.1.2:
|
||||
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
||||
|
||||
@ -6699,6 +6790,12 @@ packages:
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18'
|
||||
react: '>=18'
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
@ -6812,6 +6909,9 @@ packages:
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
refractor@5.0.0:
|
||||
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
@ -6829,12 +6929,33 @@ packages:
|
||||
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
|
||||
hasBin: true
|
||||
|
||||
rehype-attr@4.0.2:
|
||||
resolution: {integrity: sha512-v4+gw7pvUVLbG/dUpLgBE6r3TWTBYJ7z+sfAH3zapmM5CKzk5+CopFQgr4gMR6OBSKl/qpI6HR7gv1Cbig0uow==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
rehype-autolink-headings@7.1.0:
|
||||
resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==}
|
||||
|
||||
rehype-ignore@2.0.3:
|
||||
resolution: {integrity: sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
rehype-parse@9.0.1:
|
||||
resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==}
|
||||
|
||||
rehype-prism-plus@2.0.2:
|
||||
resolution: {integrity: sha512-jTHb8ZtQHd2VWAAKeCINgv/8zNEF0+LesmwJak69GemoPVN9/8fGEARTvqOpKqmN57HwaM9z8UKBVNVJe8zggw==}
|
||||
|
||||
rehype-raw@7.0.0:
|
||||
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||
|
||||
rehype-rewrite@4.0.4:
|
||||
resolution: {integrity: sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
rehype-slug@6.0.0:
|
||||
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
|
||||
|
||||
rehype-stringify@10.0.1:
|
||||
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
|
||||
|
||||
@ -6844,6 +6965,10 @@ packages:
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
remark-github-blockquote-alert@1.3.1:
|
||||
resolution: {integrity: sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
remark-parse@11.0.0:
|
||||
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
||||
|
||||
@ -7226,6 +7351,12 @@ packages:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||
|
||||
style-to-object@1.0.14:
|
||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||
|
||||
superagent@10.3.0:
|
||||
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
@ -7506,6 +7637,9 @@ packages:
|
||||
unifont@0.7.4:
|
||||
resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==}
|
||||
|
||||
unist-util-filter@5.0.1:
|
||||
resolution: {integrity: sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==}
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
|
||||
|
||||
@ -7530,6 +7664,9 @@ packages:
|
||||
unist-util-visit-parents@6.0.2:
|
||||
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
|
||||
|
||||
unist-util-visit@5.0.0:
|
||||
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
@ -11847,6 +11984,10 @@ snapshots:
|
||||
|
||||
'@types/esrecurse@4.3.1': {}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
@ -11911,6 +12052,8 @@ snapshots:
|
||||
|
||||
'@types/pluralize@0.0.33': {}
|
||||
|
||||
'@types/prismjs@1.26.6': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
@ -11936,6 +12079,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
@ -12033,6 +12178,41 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@uiw/copy-to-clipboard@1.0.20': {}
|
||||
|
||||
'@uiw/react-markdown-preview@5.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@uiw/copy-to-clipboard': 1.0.20
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
react-markdown: 10.1.0(@types/react@19.2.14)(react@19.2.5)
|
||||
rehype-attr: 4.0.2
|
||||
rehype-autolink-headings: 7.1.0
|
||||
rehype-ignore: 2.0.3
|
||||
rehype-prism-plus: 2.0.2
|
||||
rehype-raw: 7.0.0
|
||||
rehype-rewrite: 4.0.4
|
||||
rehype-slug: 6.0.0
|
||||
remark-gfm: 4.0.1
|
||||
remark-github-blockquote-alert: 1.3.1
|
||||
unist-util-visit: 5.1.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
'@uiw/react-md-editor@4.1.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@uiw/react-markdown-preview': 5.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
rehype: 13.0.2
|
||||
rehype-prism-plus: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
'@vinejs/compiler@4.1.3': {}
|
||||
@ -12371,6 +12551,8 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
bcp-47-match@2.0.3: {}
|
||||
|
||||
better-sqlite3@12.9.0:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
@ -12496,6 +12678,8 @@ snapshots:
|
||||
|
||||
character-entities@2.0.2: {}
|
||||
|
||||
character-reference-invalid@2.0.1: {}
|
||||
|
||||
check-disk-space@3.4.0: {}
|
||||
|
||||
check-error@2.1.3: {}
|
||||
@ -12665,6 +12849,8 @@ snapshots:
|
||||
domutils: 3.2.2
|
||||
nth-check: 2.1.1
|
||||
|
||||
css-selector-parser@3.3.0: {}
|
||||
|
||||
css-tree@2.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.28
|
||||
@ -12811,6 +12997,8 @@ snapshots:
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
direction@2.0.1: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dom-accessibility-api@0.5.16: {}
|
||||
@ -13118,6 +13306,8 @@ snapshots:
|
||||
|
||||
estraverse@5.3.0: {}
|
||||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
@ -13448,6 +13638,14 @@ snapshots:
|
||||
vfile-location: 5.0.3
|
||||
web-namespaces: 2.0.1
|
||||
|
||||
hast-util-has-property@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hast-util-heading-rank@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hast-util-is-element@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@ -13472,6 +13670,24 @@ snapshots:
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-select@6.0.4:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
bcp-47-match: 2.0.3
|
||||
comma-separated-tokens: 2.0.3
|
||||
css-selector-parser: 3.3.0
|
||||
devlop: 1.1.0
|
||||
direction: 2.0.1
|
||||
hast-util-has-property: 3.0.0
|
||||
hast-util-to-string: 3.0.1
|
||||
hast-util-whitespace: 3.0.0
|
||||
nth-check: 2.1.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
unist-util-visit: 5.1.0
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@ -13486,6 +13702,26 @@ snapshots:
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
comma-separated-tokens: 2.0.3
|
||||
devlop: 1.1.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
hast-util-whitespace: 3.0.0
|
||||
mdast-util-mdx-expression: 2.0.1
|
||||
mdast-util-mdx-jsx: 3.2.0
|
||||
mdast-util-mdxjs-esm: 2.0.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
style-to-js: 1.1.21
|
||||
unist-util-position: 5.0.0
|
||||
vfile-message: 4.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
hast-util-to-parse5@8.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@ -13496,6 +13732,10 @@ snapshots:
|
||||
web-namespaces: 2.0.1
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-string@3.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hast-util-to-text@4.0.2:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@ -13562,6 +13802,8 @@ snapshots:
|
||||
htmlparser2: 8.0.2
|
||||
selderee: 0.11.0
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
@ -13672,6 +13914,8 @@ snapshots:
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
interpret@2.2.0: {}
|
||||
@ -13694,6 +13938,13 @@ snapshots:
|
||||
|
||||
iron-webcrypto@1.2.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
|
||||
is-alphanumerical@2.0.1:
|
||||
dependencies:
|
||||
is-alphabetical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
@ -13706,6 +13957,8 @@ snapshots:
|
||||
dependencies:
|
||||
hasown: 2.0.3
|
||||
|
||||
is-decimal@2.0.1: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-docker@4.0.0: {}
|
||||
@ -13724,6 +13977,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-hexadecimal@2.0.1: {}
|
||||
|
||||
is-in-ssh@1.0.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
@ -14137,6 +14392,45 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdx-expression@2.0.1:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdx-jsx@3.2.0:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
parse-entities: 4.0.2
|
||||
stringify-entities: 4.0.4
|
||||
unist-util-stringify-position: 4.0.0
|
||||
vfile-message: 4.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdxjs-esm@2.0.1:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@ -14624,6 +14918,16 @@ snapshots:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
character-entities-legacy: 3.0.0
|
||||
character-reference-invalid: 2.0.1
|
||||
decode-named-character-reference: 1.3.0
|
||||
is-alphanumerical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
is-hexadecimal: 2.0.1
|
||||
|
||||
parse-imports@3.0.0:
|
||||
dependencies:
|
||||
es-module-lexer: 1.7.0
|
||||
@ -14646,6 +14950,8 @@ snapshots:
|
||||
|
||||
parse-ms@4.0.0: {}
|
||||
|
||||
parse-numeric-range@1.3.0: {}
|
||||
|
||||
parse-svg-path@0.1.2: {}
|
||||
|
||||
parse5@7.3.0:
|
||||
@ -14918,6 +15224,24 @@ snapshots:
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/react': 19.2.14
|
||||
devlop: 1.1.0
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
html-url-attributes: 3.0.1
|
||||
mdast-util-to-hast: 13.2.1
|
||||
react: 19.2.5
|
||||
remark-parse: 11.0.0
|
||||
remark-rehype: 11.1.2
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
@ -15031,6 +15355,13 @@ snapshots:
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
refractor@5.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/prismjs': 1.26.6
|
||||
hastscript: 9.0.1
|
||||
parse-entities: 4.0.2
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
@ -15047,18 +15378,61 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.1.0
|
||||
|
||||
rehype-attr@4.0.2:
|
||||
dependencies:
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.0.0
|
||||
|
||||
rehype-autolink-headings@7.1.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
hast-util-heading-rank: 3.0.0
|
||||
hast-util-is-element: 3.0.0
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.1.0
|
||||
|
||||
rehype-ignore@2.0.3:
|
||||
dependencies:
|
||||
hast-util-select: 6.0.4
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.1.0
|
||||
|
||||
rehype-parse@9.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-from-html: 2.0.3
|
||||
unified: 11.0.5
|
||||
|
||||
rehype-prism-plus@2.0.2:
|
||||
dependencies:
|
||||
hast-util-to-string: 3.0.1
|
||||
parse-numeric-range: 1.3.0
|
||||
refractor: 5.0.0
|
||||
rehype-parse: 9.0.1
|
||||
unist-util-filter: 5.0.1
|
||||
unist-util-visit: 5.1.0
|
||||
|
||||
rehype-raw@7.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-raw: 9.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
rehype-rewrite@4.0.4:
|
||||
dependencies:
|
||||
hast-util-select: 6.0.4
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.1.0
|
||||
|
||||
rehype-slug@6.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
github-slugger: 2.0.0
|
||||
hast-util-heading-rank: 3.0.0
|
||||
hast-util-to-string: 3.0.1
|
||||
unist-util-visit: 5.1.0
|
||||
|
||||
rehype-stringify@10.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@ -15083,6 +15457,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
remark-github-blockquote-alert@1.3.1:
|
||||
dependencies:
|
||||
unist-util-visit: 5.1.0
|
||||
|
||||
remark-parse@11.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@ -15533,6 +15911,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
style-to-js@1.1.21:
|
||||
dependencies:
|
||||
style-to-object: 1.0.14
|
||||
|
||||
style-to-object@1.0.14:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
superagent@10.3.0:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
@ -15797,6 +16183,12 @@ snapshots:
|
||||
ofetch: 1.5.1
|
||||
ohash: 2.0.11
|
||||
|
||||
unist-util-filter@5.0.1:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@ -15833,6 +16225,12 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
unist-util-visit@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user