From d57e544a189abf61f9f3709dc515d33df8e5bac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:49:42 +0000 Subject: [PATCH] =?UTF-8?q?Fil=20d'informations=20dans=20l'espace=20adh?= =?UTF-8?q?=C3=A9rent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'association pouvait publier des promotions et des rencontres, mais rien à destination de ses seuls adhérents connectés. Un troisième onglet, placé en tête de l'espace, porte désormais ce que publie le bureau : annonces, convocations, rappels. Côté rédaction, `/backend/informations` (capacité `manageInformations`, administrateur et modérateur) : titre, texte balisé avec barre de mise en forme et aperçu, image de couverture, brouillon puis publication, épinglage — une seule information en tête à la fois, appliqué dans l'action plutôt que par un index conditionnel. Côté lecture, `information_reads` porte une ligne par lecture plutôt qu'une date « vu jusqu'ici » sur l'utilisateur : c'est ce qui permet à la fois la pastille « Nouveau » par information et le « lue par 12 / 40 » du back-office. Le marquage passe par une action appelée après affichage : la pastille de l'onglet et celle de la barre latérale sont calculées par deux composants serveur distincts, dont l'ordre de rendu n'est pas garanti, et se seraient contredites sur la même page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP --- drizzle/0018_lazy_big_bertha.sql | 27 + drizzle/meta/0018_snapshot.json | 1647 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/backend/BackendShell.tsx | 10 +- src/app/backend/actions.ts | 159 ++ src/app/backend/espace/EspaceHeader.tsx | 16 +- src/app/backend/espace/informations/page.tsx | 102 + src/app/backend/espace/page.tsx | 4 + src/app/backend/espace/promotions/page.tsx | 7 +- .../backend/informations/InformationForm.tsx | 195 ++ src/app/backend/informations/page.tsx | 145 ++ src/app/backend/layout.tsx | 7 + src/app/globals.css | 48 + src/components/BackendIcons.tsx | 14 + src/components/InformationCard.tsx | 92 + src/components/MarkInformationsRead.tsx | 28 + src/db/schema.ts | 63 + src/lib/informations.ts | 142 ++ src/lib/rbac.ts | 3 + 19 files changed, 2709 insertions(+), 7 deletions(-) create mode 100644 drizzle/0018_lazy_big_bertha.sql create mode 100644 drizzle/meta/0018_snapshot.json create mode 100644 src/app/backend/espace/informations/page.tsx create mode 100644 src/app/backend/informations/InformationForm.tsx create mode 100644 src/app/backend/informations/page.tsx create mode 100644 src/components/InformationCard.tsx create mode 100644 src/components/MarkInformationsRead.tsx create mode 100644 src/lib/informations.ts diff --git a/drizzle/0018_lazy_big_bertha.sql b/drizzle/0018_lazy_big_bertha.sql new file mode 100644 index 0000000..e03f68e --- /dev/null +++ b/drizzle/0018_lazy_big_bertha.sql @@ -0,0 +1,27 @@ +CREATE TYPE "public"."info_status" AS ENUM('draft', 'published');--> statement-breakpoint +CREATE TABLE "information_reads" ( + "id" serial PRIMARY KEY NOT NULL, + "information_id" integer NOT NULL, + "user_id" integer NOT NULL, + "read_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "informations" ( + "id" serial PRIMARY KEY NOT NULL, + "title" varchar(200) NOT NULL, + "body" text NOT NULL, + "image_url" text, + "status" "info_status" DEFAULT 'draft' NOT NULL, + "pinned" boolean DEFAULT false NOT NULL, + "published_at" timestamp with time zone, + "author_id" integer, + "email_sent_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "information_reads" ADD CONSTRAINT "information_reads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "informations" ADD CONSTRAINT "informations_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "information_reads_user_info_idx" ON "information_reads" USING btree ("user_id","information_id");--> statement-breakpoint +CREATE INDEX "informations_feed_idx" ON "informations" USING btree ("status","published_at"); \ No newline at end of file diff --git a/drizzle/meta/0018_snapshot.json b/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..4960473 --- /dev/null +++ b/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1647 @@ +{ + "id": "e80a2f6a-ea01-4d00-ab3d-7472b909ff82", + "prevId": "e64a4fd6-108b-4300-889a-2a4482c0db79", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dot": { + "name": "dot", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#2C6FB3'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "accent": { + "name": "accent", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#E0A63C'" + }, + "tint": { + "name": "tint", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#f6efdc'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contact_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.information_reads": { + "name": "information_reads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "information_id": { + "name": "information_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "information_reads_user_info_idx": { + "name": "information_reads_user_info_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "information_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "information_reads_information_id_informations_id_fk": { + "name": "information_reads_information_id_informations_id_fk", + "tableFrom": "information_reads", + "tableTo": "informations", + "columnsFrom": [ + "information_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "information_reads_user_id_users_id_fk": { + "name": "information_reads_user_id_users_id_fk", + "tableFrom": "information_reads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.informations": { + "name": "informations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "info_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "email_sent_at": { + "name": "email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "informations_feed_idx": { + "name": "informations_feed_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "informations_author_id_users_id_fk": { + "name": "informations_author_id_users_id_fk", + "tableFrom": "informations", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_company": { + "name": "attendee_company", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_idx": { + "name": "meeting_registrations_meeting_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_registrations_member_meeting_idx": { + "name": "meeting_registrations_member_meeting_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "participants_per_account": { + "name": "participants_per_account", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "contact_email": { + "name": "contact_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "contact_first_name": { + "name": "contact_first_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "contact_last_name": { + "name": "contact_last_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "member_since": { + "name": "member_since", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_category_id_categories_id_fk": { + "name": "members_category_id_categories_id_fk", + "tableFrom": "members", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_requests": { + "name": "membership_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "tableTo": "past_meetings", + "columnsFrom": [ + "past_meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "badge": { + "name": "badge", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "starts_on": { + "name": "starts_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "share_facebook": { + "name": "share_facebook", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_linkedin": { + "name": "share_linkedin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "promo_suspended_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "suspended_by_id": { + "name": "suspended_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promotions_member_id_members_id_fk": { + "name": "promotions_member_id_members_id_fk", + "tableFrom": "promotions", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "promotions_suspended_by_id_users_id_fk": { + "name": "promotions_suspended_by_id_users_id_fk", + "tableFrom": "promotions", + "tableTo": "users", + "columnsFrom": [ + "suspended_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_accounts": { + "name": "social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_ok": { + "name": "last_check_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_accounts_connected_by_id_users_id_fk": { + "name": "social_accounts_connected_by_id_users_id_fk", + "tableFrom": "social_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_accounts_network_unique": { + "name": "social_accounts_network_unique", + "nullsNotDistinct": false, + "columns": [ + "network" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "promotion_id": { + "name": "promotion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "social_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_by_id": { + "name": "posted_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_promotion_idx": { + "name": "social_posts_promotion_idx", + "columns": [ + { + "expression": "promotion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_posts_promotion_id_promotions_id_fk": { + "name": "social_posts_promotion_id_promotions_id_fk", + "tableFrom": "social_posts", + "tableTo": "promotions", + "columnsFrom": [ + "promotion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "social_posts_posted_by_id_users_id_fk": { + "name": "social_posts_posted_by_id_users_id_fk", + "tableFrom": "social_posts", + "tableTo": "users", + "columnsFrom": [ + "posted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "session_version": { + "name": "session_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.contact_status": { + "name": "contact_status", + "schema": "public", + "values": [ + "new", + "read", + "archived" + ] + }, + "public.info_status": { + "name": "info_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "pending" + ] + }, + "public.promo_status": { + "name": "promo_status", + "schema": "public", + "values": [ + "pending", + "live", + "expired", + "rejected", + "suspended", + "scheduled" + ] + }, + "public.promo_suspended_by": { + "name": "promo_suspended_by", + "schema": "public", + "values": [ + "member", + "staff" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + }, + "public.social_network": { + "name": "social_network", + "schema": "public", + "values": [ + "facebook", + "linkedin" + ] + }, + "public.social_post_status": { + "name": "social_post_status", + "schema": "public", + "values": [ + "posted", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index de31eed..86e7adc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1789385157231, "tag": "0017_regular_devos", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1789399747132, + "tag": "0018_lazy_big_bertha", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index c0f4fa0..324abba 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -19,6 +19,7 @@ const TITLES: Record = { "/backend/rencontres": ["Rencontres", "Gérer les prochaines dates"], "/backend/inscriptions": ["Inscriptions", "Gérer les participants aux rencontres"], "/backend/rencontres-passees": ["Rencontres passées", "Publier les archives et leurs photos"], + "/backend/informations": ["Informations", "Ce que l'association publie dans l'espace adhérent"], "/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"], "/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"], "/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"], @@ -26,11 +27,12 @@ const TITLES: Record = { "/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"], "/backend/espace": ["Mon profil", "Votre fiche publique, vos inscriptions et votre droit à l'image"], "/backend/espace/promotions": ["Mes promotions", "Proposez une offre et suivez celles en ligne"], + "/backend/espace/informations": ["Informations", "Les annonces de l'association"], "/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"], }; type Capability = Parameters[1]; -type BadgeKey = "inbox" | "promos"; +type BadgeKey = "inbox" | "promos" | "infos"; type NavItem = { href: string; @@ -72,6 +74,7 @@ const SECTIONS: NavSection[] = [ { label: "Communication", items: [ + { href: "/backend/informations", label: "Informations", icon: "news", capability: "manageInformations" }, { href: "/backend/emails", label: "E-mails", icon: "emails", capability: "manageEmails" }, { href: "/backend/reseaux", label: "Réseaux sociaux", icon: "social", capability: "manageSettings" }, ], @@ -87,6 +90,7 @@ const SECTIONS: NavSection[] = [ { label: "Côté adhérent", items: [ + { href: "/backend/espace/informations", label: "Informations", icon: "news", badge: "infos" }, { href: "/backend/espace", label: "Mon profil", icon: "space" }, { href: "/backend/espace/promotions", label: "Mes promotions", icon: "promos" }, ], @@ -124,11 +128,13 @@ export function BackendShell({ user, pendingCount, inboxCount = 0, + infoCount = 0, children, }: { user: { name: string; role: AppRole }; pendingCount: number; inboxCount?: number; + infoCount?: number; children: ReactNode; }) { const pathname = usePathname(); @@ -164,7 +170,7 @@ export function BackendShell({ return () => window.removeEventListener("keydown", onKey); }, [menuOpen]); - const badges: Record = { inbox: inboxCount, promos: pendingCount }; + const badges: Record = { inbox: inboxCount, promos: pendingCount, infos: infoCount }; const sections = SECTIONS.map((section) => ({ ...section, diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 55143c9..0b9b262 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -16,6 +16,7 @@ import { categories, contactMessages, imageConsents, + informations, meetingRegistrations, meetings, members, @@ -42,6 +43,7 @@ import { isRangeInvalid } from "@/lib/promo-validity"; import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule"; import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish"; import { logActivity } from "@/lib/activity-log"; +import { markInformationsRead as markRead } from "@/lib/informations"; import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings"; import type { AppRole } from "@/types/next-auth"; @@ -1312,3 +1314,160 @@ export async function disconnectSocial(formData: FormData) { export async function doSignOut() { await signOut({ redirectTo: "/" }); } + +// ---- Informations de l'association ---- + +function revalidateInformationPaths() { + revalidatePath("/backend/informations"); + revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/informations"); + revalidatePath("/backend/espace/promotions"); + revalidatePath("/backend"); +} + +async function requireInformations() { + const access = await requireRole(); + if (!can(access.role, "manageInformations")) throw new Error("Accès refusé"); + return access; +} + +/** + * Une seule information épinglée à la fois. Appliqué ici plutôt que par un + * index partiel : l'écriture est réservée au staff, il n'y a pas de course + * réelle, et drizzle-kit ne génère pas les index conditionnels. + */ +async function unpinOthers(keepId: number) { + await db + .update(informations) + .set({ pinned: false }) + .where(and(eq(informations.pinned, true), ne(informations.id, keepId))); +} + +export type SavedInformation = { id: number }; + +export async function saveInformation( + formData: FormData +): Promise { + const { userId } = await requireInformations(); + + const id = Number(formData.get("id") ?? 0) || null; + const title = asString(formData, "title"); + const body = asString(formData, "body"); + if (!title) return { error: "Donnez un titre à cette information." }; + if (!body) return { error: "Le message est vide." }; + + let imageUrl: string | null = null; + try { + imageUrl = asImageDataUri(formData, "imageUrl"); + } catch (error) { + // Refus de saisie attendu : on le renvoie au formulaire, qui garde le reste. + return { error: error instanceof Error ? error.message : "Image refusée." }; + } + + const pinned = formData.get("pinned") === "on"; + const now = new Date(); + + if (id) { + const [updated] = await db + .update(informations) + .set({ title, body, imageUrl, pinned, updatedAt: now }) + .where(eq(informations.id, id)) + .returning({ id: informations.id }); + if (!updated) return { error: "Cette information n'existe plus." }; + if (pinned) await unpinOthers(updated.id); + revalidateInformationPaths(); + return { id: updated.id }; + } + + const [created] = await db + .insert(informations) + .values({ title, body, imageUrl, pinned, authorId: userId, createdAt: now, updatedAt: now }) + .returning({ id: informations.id }); + if (pinned) await unpinOthers(created.id); + await logActivity(`Information rédigée : ${title}`, "#6FB0C6"); + revalidateInformationPaths(); + return { id: created.id }; +} + +export async function publishInformation(formData: FormData) { + await requireInformations(); + const id = Number(formData.get("id") ?? 0); + if (!id) return; + + const [published] = await db + .update(informations) + .set({ status: "published", publishedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(informations.id, id), eq(informations.status, "draft"))) + .returning({ id: informations.id, title: informations.title }); + if (!published) return; + + await logActivity(`Information publiée : ${published.title}`, "#1F8A5B"); + revalidateInformationPaths(); +} + +export async function unpublishInformation(formData: FormData) { + await requireInformations(); + const id = Number(formData.get("id") ?? 0); + if (!id) return; + + // Le retrait dépingle : une information invisible ne doit pas garder la + // place de tête au retour en ligne. + const [hidden] = await db + .update(informations) + .set({ status: "draft", pinned: false, updatedAt: new Date() }) + .where(eq(informations.id, id)) + .returning({ title: informations.title }); + if (!hidden) return; + + await logActivity(`Information retirée : ${hidden.title}`, "#9A6638"); + revalidateInformationPaths(); +} + +export async function toggleInformationPin(formData: FormData) { + await requireInformations(); + const id = Number(formData.get("id") ?? 0); + if (!id) return; + + const [current] = await db + .select({ pinned: informations.pinned }) + .from(informations) + .where(eq(informations.id, id)); + if (!current) return; + + const pinned = !current.pinned; + await db.update(informations).set({ pinned, updatedAt: new Date() }).where(eq(informations.id, id)); + if (pinned) await unpinOthers(id); + revalidateInformationPaths(); +} + +export async function deleteInformation(formData: FormData) { + await requireInformations(); + const id = Number(formData.get("id") ?? 0); + if (!id) return; + // Les lignes de lecture partent en cascade avec l'information. + await db.delete(informations).where(eq(informations.id, id)); + revalidateInformationPaths(); +} + +/** + * Marque des informations comme lues pour l'utilisateur courant. + * + * Appelée par un petit composant client après affichage, plutôt qu'au rendu de + * la page : l'écriture et le compteur de la barre latérale vivent dans deux + * composants serveur distincts, dont l'ordre de rendu n'est pas garanti. En + * passant par une action, les deux pastilles retombent ensemble. + */ +export async function markInformationsRead(ids: number[]) { + const session = await getSession(); + const userId = Number(session?.user.id); + if (!Number.isFinite(userId)) return; + + const clean = [...new Set(ids.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0))]; + if (clean.length === 0) return; + + await markRead(userId, clean); + revalidatePath("/backend/espace/informations"); + revalidatePath("/backend/espace"); + revalidatePath("/backend/espace/promotions"); + revalidatePath("/backend"); +} diff --git a/src/app/backend/espace/EspaceHeader.tsx b/src/app/backend/espace/EspaceHeader.tsx index 7f51e47..8a5fe98 100644 --- a/src/app/backend/espace/EspaceHeader.tsx +++ b/src/app/backend/espace/EspaceHeader.tsx @@ -5,6 +5,7 @@ function initialsOf(name: string) { } const TABS = [ + { key: "informations", href: "/backend/espace/informations", label: "Informations" }, { key: "profil", href: "/backend/espace", label: "Mon profil" }, { key: "promotions", href: "/backend/espace/promotions", label: "Mes promotions" }, ] as const; @@ -12,9 +13,12 @@ const TABS = [ export type EspaceTab = (typeof TABS)[number]["key"]; /** - * Bandeau de l'espace adhérent et onglets Profil / Promotions : deux pages - * distinctes pour que la fiche publique et la vie des promotions ne se - * mélangent pas dans une seule longue page. + * Bandeau de l'espace adhérent et ses onglets. Trois pages distinctes pour que + * les annonces de l'association, la fiche publique et la vie des promotions ne + * se mélangent pas dans une seule longue page. + * + * Les compteurs sont passés par chaque page : sans cela ils disparaîtraient + * en changeant d'onglet. */ export function EspaceHeader({ memberName, @@ -22,6 +26,7 @@ export function EspaceHeader({ active, publicPath, promoBadge, + infoBadge, }: { memberName: string; subtitle: string; @@ -29,6 +34,8 @@ export function EspaceHeader({ publicPath?: string | null; /** Nombre de promotions en ligne, affiché sur l'onglet. */ promoBadge?: number; + /** Nombre d'informations non lues. */ + infoBadge?: number; }) { return (
@@ -49,10 +56,11 @@ export function EspaceHeader({
)} - + {/* Résumé : ce qui est visible par le public aujourd'hui */}
diff --git a/src/app/backend/informations/InformationForm.tsx b/src/app/backend/informations/InformationForm.tsx new file mode 100644 index 0000000..88f9ab5 --- /dev/null +++ b/src/app/backend/informations/InformationForm.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ImageField } from "@/components/ImageField"; +import { richTextNodes } from "@/lib/rich-text"; +import { saveInformation } from "../actions"; + +/** + * Rédaction d'une information. + * + * Pas d'éditeur WYSIWYG : un `