mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
Fil d'informations dans l'espace adhérent
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
19 files changed
+2709
-7
No files matched your search
@@ -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");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const TITLES: Record<string, [string, string]> = {
|
||||
"/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<string, [string, string]> = {
|
||||
"/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<typeof can>[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<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount };
|
||||
const badges: Record<BadgeKey, number> = { inbox: inboxCount, promos: pendingCount, infos: infoCount };
|
||||
|
||||
const sections = SECTIONS.map((section) => ({
|
||||
...section,
|
||||
|
||||
@@ -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<SavedInformation | ActionError> {
|
||||
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 : <strong>${title}</strong>`, "#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 : <strong>${published.title}</strong>`, "#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 : <strong>${hidden.title}</strong>`, "#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");
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
@@ -49,10 +56,11 @@ export function EspaceHeader({
|
||||
<nav className="espace-tabs" aria-label="Sections de l'espace adhérent">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.key === active;
|
||||
const badge = tab.key === "promotions" ? promoBadge : tab.key === "informations" ? infoBadge : 0;
|
||||
return (
|
||||
<Link key={tab.key} href={tab.href} className={`espace-tab${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined}>
|
||||
{tab.label}
|
||||
{tab.key === "promotions" && promoBadge ? <span className="espace-tab__badge">{promoBadge}</span> : null}
|
||||
{badge ? <span className="espace-tab__badge">{badge}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { db } from "@/db";
|
||||
import { categories, members, promotions } from "@/db/schema";
|
||||
import { memberPath } from "@/lib/seo";
|
||||
import { getMemberFeed } from "@/lib/informations";
|
||||
import { InformationCard } from "@/components/InformationCard";
|
||||
import { MarkInformationsRead } from "@/components/MarkInformationsRead";
|
||||
import { EspaceHeader } from "../EspaceHeader";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Espace adhérent, onglet Informations : ce que publie l'association.
|
||||
*
|
||||
* Les non-lues sont calculées **avant** le marquage, qui a lieu après le rendu
|
||||
* (`MarkInformationsRead`) : les pastilles « Nouveau » sont donc visibles sur
|
||||
* la visite où elles disparaissent.
|
||||
*/
|
||||
export default async function EspaceInformationsPage() {
|
||||
const session = await getSession();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const memberId = session.user.memberId ?? null;
|
||||
|
||||
let memberName = session.user.name ?? "Adhérent";
|
||||
let subtitle = "Espace adhérent";
|
||||
let publicPath: string | null = null;
|
||||
let livePromoCount = 0;
|
||||
|
||||
if (memberId) {
|
||||
const [profile] = await db.select().from(members).where(eq(members.id, memberId));
|
||||
if (profile) {
|
||||
memberName = profile.name;
|
||||
const [category] = profile.categoryId
|
||||
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, profile.categoryId))
|
||||
: [];
|
||||
subtitle = ["Espace adhérent", category?.label, profile.city].filter(Boolean).join(" · ");
|
||||
if (profile.status === "active") publicPath = memberPath(profile);
|
||||
}
|
||||
const [live] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(promotions)
|
||||
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")));
|
||||
livePromoCount = live?.total ?? 0;
|
||||
}
|
||||
|
||||
const feed = await getMemberFeed(userId);
|
||||
const unreadIds = feed.filter((item) => item.unread).map((item) => item.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EspaceHeader
|
||||
memberName={memberName}
|
||||
subtitle={subtitle}
|
||||
active="informations"
|
||||
publicPath={publicPath}
|
||||
promoBadge={livePromoCount}
|
||||
infoBadge={unreadIds.length}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "end", gap: 12, flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 19, color: "#26201a" }}>
|
||||
Informations de l'association
|
||||
</h2>
|
||||
<div style={{ color: "#8c8068", fontSize: 13, marginTop: 4 }}>
|
||||
Annonces, convocations et rappels publiés par le bureau.
|
||||
</div>
|
||||
</div>
|
||||
{unreadIds.length > 0 && (
|
||||
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "6px 13px", fontSize: 12.5, fontWeight: 800 }}>
|
||||
{unreadIds.length} non lue{unreadIds.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{feed.length === 0 ? (
|
||||
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: 28, color: "#a99c82", fontSize: 13.5 }}>
|
||||
L'association n'a encore rien publié. Les annonces du bureau apparaîtront ici.
|
||||
</div>
|
||||
) : (
|
||||
feed.map((item) => (
|
||||
<InformationCard
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
body={item.body}
|
||||
imageUrl={item.imageUrl}
|
||||
publishedAt={item.publishedAt}
|
||||
authorName={item.authorName}
|
||||
pinned={item.pinned}
|
||||
unread={item.unread}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<MarkInformationsRead ids={unreadIds} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { HoursEditor } from "@/components/HoursEditor";
|
||||
import { TagsField } from "@/components/TagsField";
|
||||
import { MemberContactFields } from "@/components/MemberContactFields";
|
||||
import { communeOptions } from "@/lib/communes";
|
||||
import { unreadInformationCount } from "@/lib/informations";
|
||||
import { saveImageConsent, updateOwnProfile } from "../actions";
|
||||
import { EspaceHeader } from "./EspaceHeader";
|
||||
|
||||
@@ -21,6 +22,8 @@ export default async function EspacePage() {
|
||||
const session = await getSession();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
const fallbackName = session?.user.name ?? "Adhérent";
|
||||
const userId = Number(session?.user.id);
|
||||
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
|
||||
|
||||
let memberName = fallbackName;
|
||||
let subtitle = "Espace adhérent";
|
||||
@@ -88,6 +91,7 @@ export default async function EspacePage() {
|
||||
active="profil"
|
||||
publicPath={profile && profile.status === "active" ? memberPath(profile) : null}
|
||||
promoBadge={livePromoCount}
|
||||
infoBadge={infoBadge}
|
||||
/>
|
||||
|
||||
{memberId && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { memberPath } from "@/lib/seo";
|
||||
import { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
|
||||
import { scheduleLabel } from "@/lib/promo-schedule";
|
||||
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
|
||||
import { unreadInformationCount } from "@/lib/informations";
|
||||
import { EspaceHeader } from "../EspaceHeader";
|
||||
import { MemberSpaceForm } from "../MemberSpaceForm";
|
||||
|
||||
@@ -51,6 +52,10 @@ function fmtDate(d: Date) {
|
||||
export default async function EspacePromotionsPage() {
|
||||
const session = await getSession();
|
||||
const memberId = session?.user.memberId ?? null;
|
||||
// Le compteur suit l'adhérent d'onglet en onglet : sans cela il
|
||||
// disparaîtrait en quittant le fil.
|
||||
const userId = Number(session?.user.id);
|
||||
const infoBadge = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
|
||||
let memberName = session?.user.name ?? "Adhérent";
|
||||
let memberLogoUrl: string | null = null;
|
||||
let subtitle = "Espace adhérent · Promotions";
|
||||
@@ -139,7 +144,7 @@ export default async function EspacePromotionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} />
|
||||
<EspaceHeader memberName={memberName} subtitle={subtitle} active="promotions" publicPath={publicPath} promoBadge={live.length} infoBadge={infoBadge} />
|
||||
|
||||
{/* Résumé : ce qui est visible par le public aujourd'hui */}
|
||||
<div className="grid grid-3" style={{ gap: 12, marginBottom: 24 }}>
|
||||
|
||||
@@ -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 `<textarea>` et des boutons qui encadrent la
|
||||
* sélection, plus un aperçu rendu par **le même** analyseur que le site
|
||||
* (`richTextNodes`). L'aperçu est donc fidèle par construction, et rien de ce
|
||||
* qui est saisi ne peut devenir du HTML.
|
||||
*/
|
||||
|
||||
type Draft = {
|
||||
id: number | null;
|
||||
title: string;
|
||||
body: string;
|
||||
imageUrl: string | null;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const TOOLS = [
|
||||
{ label: "B", title: "Gras", before: "**", after: "**", sample: "texte en gras", strong: true },
|
||||
{ label: "I", title: "Italique", before: "*", after: "*", sample: "texte en italique", italic: true },
|
||||
{ label: "Titre", title: "Sous-titre", before: "## ", after: "", sample: "Sous-titre", line: true },
|
||||
{ label: "• liste", title: "Liste à puces", before: "- ", after: "", sample: "premier point", line: true },
|
||||
{ label: "1. liste", title: "Liste numérotée", before: "1. ", after: "", sample: "première étape", line: true },
|
||||
{ label: "Lien", title: "Lien", before: "[", after: "](https://)", sample: "libellé du lien" },
|
||||
] as const;
|
||||
|
||||
export function InformationForm({ draft }: { draft: Draft }) {
|
||||
const router = useRouter();
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const [body, setBody] = useState(draft.body);
|
||||
const [pinned, setPinned] = useState(draft.pinned);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Remonte le formulaire après une création : `ImageField` n'est pas contrôlé,
|
||||
// seul un changement de clé le remet à vide.
|
||||
const [round, setRound] = useState(0);
|
||||
|
||||
function applyTool(tool: (typeof TOOLS)[number]) {
|
||||
const field = bodyRef.current;
|
||||
if (!field) return;
|
||||
const { selectionStart, selectionEnd } = field;
|
||||
const selected = field.value.slice(selectionStart, selectionEnd) || tool.sample;
|
||||
// `setRangeText` conserve la pile d'annulation du navigateur, ce qu'une
|
||||
// réécriture complète de la valeur ferait perdre.
|
||||
const insert = "line" in tool && tool.line && selectionStart > 0 && field.value[selectionStart - 1] !== "\n";
|
||||
field.setRangeText(`${insert ? "\n" : ""}${tool.before}${selected}${tool.after}`, selectionStart, selectionEnd, "select");
|
||||
field.focus();
|
||||
setBody(field.value);
|
||||
}
|
||||
|
||||
async function submit(formData: FormData) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await saveInformation(formData);
|
||||
setSaving(false);
|
||||
if (result && "error" in result) {
|
||||
// Refus de saisie : le formulaire reste ouvert avec ce qui a été écrit.
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (draft.id) {
|
||||
router.push("/backend/informations");
|
||||
} else {
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setPinned(false);
|
||||
setRound((value) => value + 1);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form key={round} action={submit} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "20px 22px" }}>
|
||||
{draft.id && <input type="hidden" name="id" value={draft.id} />}
|
||||
|
||||
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 14 }}>
|
||||
{draft.id ? "Modifier l'information" : "Nouvelle information"}
|
||||
</div>
|
||||
|
||||
<label className="field-label" htmlFor="information-title">Titre</label>
|
||||
<input
|
||||
id="information-title"
|
||||
className="field"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="Assemblée générale — mardi 14 octobre, 19 h"
|
||||
style={{ marginBottom: 15, fontWeight: 700 }}
|
||||
/>
|
||||
|
||||
<label className="field-label" htmlFor="information-body">Message</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap",
|
||||
border: "1px solid #ddd2bb", borderBottom: "none", borderRadius: "10px 10px 0 0",
|
||||
background: "#fff", padding: "6px 7px",
|
||||
}}
|
||||
>
|
||||
{TOOLS.map((tool) => (
|
||||
<button
|
||||
key={tool.label}
|
||||
type="button"
|
||||
title={tool.title}
|
||||
onClick={() => applyTool(tool)}
|
||||
style={{
|
||||
minWidth: 30, height: 28, padding: "0 9px", borderRadius: 7, border: "none", background: "transparent",
|
||||
color: "#6c6150", fontSize: 12.5, fontWeight: 700, cursor: "pointer",
|
||||
fontStyle: "italic" in tool && tool.italic ? "italic" : undefined,
|
||||
}}
|
||||
>
|
||||
{tool.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
id="information-body"
|
||||
ref={bodyRef}
|
||||
className="field"
|
||||
name="body"
|
||||
rows={10}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={"Rédigez ici. Laissez une ligne vide entre deux paragraphes.\n\n- une puce\n**du gras**, [un lien](https://pleinr.fr)"}
|
||||
style={{ borderRadius: "0 0 10px 10px", resize: "vertical", lineHeight: 1.7 }}
|
||||
/>
|
||||
<p className="field-hint" style={{ marginTop: 6 }}>
|
||||
Mise en forme simple : <strong>**gras**</strong>, <em>*italique*</em>, <code>- puce</code>,{" "}
|
||||
<code>[libellé](https://…)</code>. Les liens s'ouvrent dans un nouvel onglet.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<ImageField name="imageUrl" label="Image (facultative)" defaultValue={draft.imageUrl ?? ""} height={150} />
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: 16, cursor: "pointer" }}>
|
||||
<input type="checkbox" name="pinned" checked={pinned} onChange={(event) => setPinned(event.target.checked)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
<strong style={{ fontSize: 13.5, color: "#26201a" }}>Épingler en tête de l'espace adhérent</strong>
|
||||
<span className="field-hint" style={{ display: "block" }}>
|
||||
Une seule information à la fois : celle déjà épinglée redescend dans le fil.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{body.trim() && (
|
||||
<div style={{ marginTop: 18, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
|
||||
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 10 }}>
|
||||
Aperçu
|
||||
</div>
|
||||
<div style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 12, padding: "14px 16px" }}>
|
||||
<h3 className="font-display" style={{ margin: "0 0 8px", fontWeight: 700, fontSize: 17, color: "#26201a" }}>
|
||||
{title || "Sans titre"}
|
||||
</h3>
|
||||
<div className="rich-text">{richTextNodes(body)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 9, marginTop: 18, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 800, fontSize: 13.5, padding: "12px 20px", borderRadius: 10, cursor: saving ? "progress" : "pointer", opacity: saving ? 0.7 : 1 }}
|
||||
>
|
||||
{saving ? "Enregistrement…" : draft.id ? "Enregistrer les modifications" : "Enregistrer le brouillon"}
|
||||
</button>
|
||||
{draft.id && (
|
||||
<Link
|
||||
href="/backend/informations"
|
||||
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 13.5, padding: "12px 18px", borderRadius: 10, textDecoration: "none" }}
|
||||
>
|
||||
Annuler
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { CSSProperties } from "react";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { can } from "@/lib/rbac";
|
||||
import { countMemberAccounts, getAdminInformations, getInformation } from "@/lib/informations";
|
||||
import { richTextExcerpt } from "@/lib/rich-text";
|
||||
import { InformationForm } from "./InformationForm";
|
||||
import {
|
||||
deleteInformation,
|
||||
publishInformation,
|
||||
toggleInformationPin,
|
||||
unpublishInformation,
|
||||
} from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "17px 18px" };
|
||||
const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 };
|
||||
const rowButton: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12, padding: "6px 11px", borderRadius: 8, cursor: "pointer" };
|
||||
const pill: CSSProperties = { borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" };
|
||||
|
||||
function formatDay(value: Date) {
|
||||
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rédaction et suivi des informations diffusées dans l'espace adhérent.
|
||||
*
|
||||
* L'information en cours d'édition est désignée par `?modifier=<id>` plutôt
|
||||
* que par un état client : la page reste un composant serveur, et un lien
|
||||
* suffit à passer d'une information à l'autre.
|
||||
*/
|
||||
export default async function InformationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ modifier?: string }>;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
if (!can(session?.user.role, "manageInformations")) redirect("/backend");
|
||||
|
||||
const { modifier } = await searchParams;
|
||||
const editId = Number(modifier ?? 0) || null;
|
||||
const editing = editId ? await getInformation(editId) : null;
|
||||
|
||||
const [items, memberCount] = await Promise.all([getAdminInformations(), countMemberAccounts()]);
|
||||
const published = items.filter((item) => item.status === "published");
|
||||
const drafts = items.filter((item) => item.status === "draft");
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(0, 1fr)", gap: 18, alignItems: "start" }} className="informations-split">
|
||||
<InformationForm
|
||||
key={editing?.id ?? "nouvelle"}
|
||||
draft={{
|
||||
id: editing?.id ?? null,
|
||||
title: editing?.title ?? "",
|
||||
body: editing?.body ?? "",
|
||||
imageUrl: editing?.imageUrl ?? null,
|
||||
pinned: editing?.pinned ?? false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<section style={panel}>
|
||||
<div style={eyebrow}>Publiées · {published.length}</div>
|
||||
{published.length === 0 && (
|
||||
<div style={{ color: "#a99c82", fontSize: 13 }}>
|
||||
Rien n'est encore visible dans l'espace adhérent.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
{published.map((item, index) => (
|
||||
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
|
||||
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
|
||||
{item.pinned && <span style={{ ...pill, background: "#fbeede", color: "#9a6638" }}>Épinglée</span>}
|
||||
</div>
|
||||
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
|
||||
{item.publishedAt ? formatDay(item.publishedAt) : "—"}
|
||||
{" · "}
|
||||
lue par {item.readCount} / {memberCount}
|
||||
{item.authorName ? ` · ${item.authorName}` : ""}
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
|
||||
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
|
||||
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
|
||||
Modifier
|
||||
</Link>
|
||||
<form action={toggleInformationPin}>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<button type="submit" style={rowButton}>{item.pinned ? "Décrocher" : "Épingler"}</button>
|
||||
</form>
|
||||
<form action={unpublishInformation}>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<button type="submit" style={rowButton}>Retirer</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={panel}>
|
||||
<div style={eyebrow}>Brouillons · {drafts.length}</div>
|
||||
{drafts.length === 0 && <div style={{ color: "#a99c82", fontSize: 13 }}>Aucun brouillon en attente.</div>}
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
{drafts.map((item, index) => (
|
||||
<div key={item.id} style={index > 0 ? { borderTop: "1px solid #f0e8d6", paddingTop: 12 } : undefined}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }}>
|
||||
<span className="font-display" style={{ fontWeight: 700, fontSize: 15, color: "#26201a" }}>{item.title}</span>
|
||||
<span style={{ ...pill, background: "#f1efe7", color: "#a99c82" }}>Brouillon</span>
|
||||
</div>
|
||||
<div style={{ color: "#9a8d72", fontSize: 12.5, marginTop: 3 }}>
|
||||
Modifié le {formatDay(item.updatedAt)}
|
||||
{item.authorName ? ` · ${item.authorName}` : ""}
|
||||
</div>
|
||||
<div style={{ color: "#8c8068", fontSize: 12.5, marginTop: 5 }}>{richTextExcerpt(item.body, 110)}</div>
|
||||
<div style={{ display: "flex", gap: 7, marginTop: 9, flexWrap: "wrap" }}>
|
||||
<form action={publishInformation}>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<button
|
||||
type="submit"
|
||||
style={{ ...rowButton, border: "none", background: "#13324F", color: "#fff", fontWeight: 800 }}
|
||||
>
|
||||
Publier
|
||||
</button>
|
||||
</form>
|
||||
<Link href={`/backend/informations?modifier=${item.id}`} style={{ ...rowButton, textDecoration: "none" }}>
|
||||
Modifier
|
||||
</Link>
|
||||
<form action={deleteInformation}>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<button type="submit" style={{ ...rowButton, color: "#d8472b", borderColor: "#e0c3bb" }}>
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { db } from "@/db";
|
||||
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
|
||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||
import { isStaff } from "@/lib/rbac";
|
||||
import { unreadInformationCount } from "@/lib/informations";
|
||||
import { saveImageConsent } from "./actions";
|
||||
import { BackendShell } from "./BackendShell";
|
||||
import { NOINDEX } from "@/lib/seo";
|
||||
@@ -70,6 +71,11 @@ export default async function BackendLayout({
|
||||
}
|
||||
}
|
||||
|
||||
// Le compteur d'informations non lues suit le compte, pas le rôle : un
|
||||
// membre du bureau a lui aussi un espace adhérent.
|
||||
const userId = Number(session.user.id);
|
||||
const infoCount = Number.isFinite(userId) ? await unreadInformationCount(userId) : 0;
|
||||
|
||||
let pendingCount = 0;
|
||||
let inboxCount = 0;
|
||||
if (isStaff(session.user.role)) {
|
||||
@@ -87,6 +93,7 @@ export default async function BackendLayout({
|
||||
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
|
||||
pendingCount={pendingCount}
|
||||
inboxCount={inboxCount}
|
||||
infoCount={infoCount}
|
||||
>
|
||||
{children}
|
||||
</BackendShell>
|
||||
|
||||
@@ -998,6 +998,54 @@ img {
|
||||
background: #33291D;
|
||||
color: #fff;
|
||||
}
|
||||
/* Corps d'une information : rendu par `richTextNodes`, donc uniquement des
|
||||
p / ul / ol / li / strong / em / a / br. Le rythme vertical vit ici plutôt
|
||||
qu'en styles en ligne, parce qu'il porte sur des balises engendrées. */
|
||||
.rich-text {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.rich-text > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.rich-text > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.rich-text p {
|
||||
margin: 0 0 11px;
|
||||
}
|
||||
.rich-text h4 {
|
||||
font-family: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
|
||||
font-size: 15.5px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
.rich-text ul,
|
||||
.rich-text ol {
|
||||
margin: 0 0 11px;
|
||||
padding-left: 21px;
|
||||
}
|
||||
.rich-text li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.rich-text strong {
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.rich-text a {
|
||||
color: var(--blue);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Rédaction d'une information : éditeur et listes côte à côte, empilés dès
|
||||
que la colonne de droite n'a plus la place d'être lisible. */
|
||||
@media (max-width: 1100px) {
|
||||
.informations-split {
|
||||
grid-template-columns: minmax(0, 1fr) !important;
|
||||
}
|
||||
}
|
||||
/* Suggestions de tags : pastilles cliquables sous le champ. */
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
|
||||
@@ -13,7 +13,9 @@ export type BackendIconName =
|
||||
| "meetings"
|
||||
| "registrations"
|
||||
| "gallery"
|
||||
| "news"
|
||||
| "emails"
|
||||
| "mailbox"
|
||||
| "social"
|
||||
| "categories"
|
||||
| "settings"
|
||||
@@ -70,6 +72,18 @@ const PATHS: Record<BackendIconName, ReactNode> = {
|
||||
<path d="m4 17 4.8-4.4a1.8 1.8 0 0 1 2.4 0L16 17M14 14.4l1.6-1.5a1.8 1.8 0 0 1 2.4 0L21 15.6" />
|
||||
</>
|
||||
),
|
||||
news: (
|
||||
<>
|
||||
<path d="M17.5 4.5H4.2A1.2 1.2 0 0 0 3 5.7v12a1.8 1.8 0 0 0 1.8 1.8h13.4a2.8 2.8 0 0 0 2.8-2.8V9.2h-3.5V4.5Z" />
|
||||
<path d="M6.2 8.3h8.1M6.2 11.6h8.1M6.2 14.9h5.2" />
|
||||
</>
|
||||
),
|
||||
mailbox: (
|
||||
<>
|
||||
<rect x="2.6" y="5" width="18.8" height="14" rx="2.4" />
|
||||
<path d="m3.4 7.2 7.4 5.3a2 2 0 0 0 2.4 0l7.4-5.3" />
|
||||
</>
|
||||
),
|
||||
emails: (
|
||||
<>
|
||||
<path d="M20.5 3.5 10.8 13.2M20.5 3.5 14.3 21l-3.5-7.8L3 9.7l17.5-6.2Z" />
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { richTextNodes } from "@/lib/rich-text";
|
||||
|
||||
/**
|
||||
* Une information de l'association. Rendu unique, partagé par le fil de
|
||||
* l'espace adhérent et l'aperçu du back-office : une seule carte à maintenir.
|
||||
*
|
||||
* Le corps passe par `richTextNodes()` : des éléments React, jamais du HTML
|
||||
* injecté.
|
||||
*/
|
||||
|
||||
export type InformationCardProps = {
|
||||
title: string;
|
||||
body: string;
|
||||
imageUrl?: string | null;
|
||||
publishedAt?: Date | null;
|
||||
authorName?: string | null;
|
||||
pinned?: boolean;
|
||||
unread?: boolean;
|
||||
/** Mention d'état supplémentaire (« Brouillon », « Lue par 12 / 40 »…). */
|
||||
note?: string | null;
|
||||
};
|
||||
|
||||
function formatDay(value: Date) {
|
||||
return new Date(value).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export function InformationCard({
|
||||
title,
|
||||
body,
|
||||
imageUrl,
|
||||
publishedAt,
|
||||
authorName,
|
||||
pinned,
|
||||
unread,
|
||||
note,
|
||||
}: InformationCardProps) {
|
||||
// Une seule accentuation à la fois : l'épinglage prime sur la nouveauté,
|
||||
// sinon les deux liserés se disputeraient le même bord.
|
||||
const accent = pinned ? "#E0A63C" : unread ? "#1F8A5B" : null;
|
||||
|
||||
return (
|
||||
<article
|
||||
style={{
|
||||
background: unread || pinned ? "#fff" : "#fdfcf8",
|
||||
border: `1px solid ${pinned ? "#e5cf9a" : "#e6dcc6"}`,
|
||||
borderRadius: 14,
|
||||
padding: "18px 20px",
|
||||
marginBottom: 14,
|
||||
boxShadow: accent ? `inset 4px 0 0 ${accent}` : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap", marginBottom: 9 }}>
|
||||
{pinned && (
|
||||
<span style={{ background: "#fbeede", color: "#9a6638", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
|
||||
Épinglée
|
||||
</span>
|
||||
)}
|
||||
{unread && (
|
||||
<span style={{ background: "#e6f4ec", color: "#1f8a5b", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
|
||||
Nouveau
|
||||
</span>
|
||||
)}
|
||||
<span style={{ color: "#9a8d72", fontSize: 12.5 }}>
|
||||
{publishedAt ? `Publié le ${formatDay(publishedAt)}` : "Non publiée"}
|
||||
{authorName ? ` par ${authorName}` : ""}
|
||||
</span>
|
||||
{note && (
|
||||
<span style={{ background: "#f1efe7", color: "#a99c82", borderRadius: 999, padding: "4px 10px", fontSize: 11, fontWeight: 800 }}>
|
||||
{note}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-display" style={{ margin: 0, fontWeight: 700, fontSize: 18, color: "#26201a" }}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{ display: "block", width: "100%", maxHeight: 280, objectFit: "cover", borderRadius: 10, border: "1px solid #e6dcc6", margin: "13px 0 4px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rich-text" style={{ marginTop: 9 }}>
|
||||
{richTextNodes(body)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markInformationsRead } from "@/app/backend/actions";
|
||||
|
||||
/**
|
||||
* Marque les informations affichées comme lues, une fois la page rendue.
|
||||
*
|
||||
* Le marquage ne peut pas se faire pendant le rendu de la page : le compteur
|
||||
* de la barre latérale est calculé par le gabarit, un composant serveur
|
||||
* distinct dont l'ordre de rendu n'est pas garanti — la pastille de l'onglet
|
||||
* et celle du menu auraient pu se contredire sur la même page. Ici les deux
|
||||
* retombent ensemble au rafraîchissement.
|
||||
*/
|
||||
export function MarkInformationsRead({ ids }: { ids: number[] }) {
|
||||
const router = useRouter();
|
||||
const handled = useRef("");
|
||||
const key = ids.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || handled.current === key) return;
|
||||
handled.current = key;
|
||||
void markInformationsRead(key.split(",").map(Number)).then(() => router.refresh());
|
||||
}, [key, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export const contactStatusEnum = pgEnum("contact_status", [
|
||||
"read",
|
||||
"archived",
|
||||
]);
|
||||
export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]);
|
||||
|
||||
// ---- Categories (métiers) ----
|
||||
export const categories = pgTable("categories", {
|
||||
@@ -292,6 +293,51 @@ export const imageConsents = pgTable("image_consents", {
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ---- Informations de l'association (fil de l'espace adhérent) ----
|
||||
// Le corps est du texte balisé (`src/lib/rich-text.ts`), jamais du HTML :
|
||||
// il est analysé puis rendu en éléments React.
|
||||
export const informations = pgTable(
|
||||
"informations",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
body: text("body").notNull(),
|
||||
imageUrl: text("image_url"),
|
||||
status: infoStatusEnum("status").notNull().default("draft"),
|
||||
pinned: boolean("pinned").notNull().default(false),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
authorId: integer("author_id").references(() => users.id, { onDelete: "set null" }),
|
||||
// Garde anti-double-diffusion, sur le modèle de `publishPromoShares` :
|
||||
// une information déjà envoyée ne repart pas à la republication.
|
||||
emailSentAt: timestamp("email_sent_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
feedIdx: index("informations_feed_idx").on(table.status, table.publishedAt),
|
||||
})
|
||||
);
|
||||
|
||||
// Une ligne par lecture. Une simple date « vu jusqu'ici » sur l'utilisateur ne
|
||||
// permettrait ni la pastille « nouveau » par information, ni le « lue par
|
||||
// 12 / 40 » du back-office.
|
||||
export const informationReads = pgTable(
|
||||
"information_reads",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
informationId: integer("information_id")
|
||||
.notNull()
|
||||
.references(() => informations.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
readAt: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
uniq: uniqueIndex("information_reads_user_info_idx").on(table.userId, table.informationId),
|
||||
})
|
||||
);
|
||||
|
||||
// ---- Relations ----
|
||||
export const membersRelations = relations(members, ({ one, many }) => ({
|
||||
category: one(categories, {
|
||||
@@ -362,6 +408,21 @@ export const imageConsentsRelations = relations(imageConsents, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const informationsRelations = relations(informations, ({ one, many }) => ({
|
||||
author: one(users, {
|
||||
fields: [informations.authorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
reads: many(informationReads),
|
||||
}));
|
||||
|
||||
export const informationReadsRelations = relations(informationReads, ({ one }) => ({
|
||||
information: one(informations, {
|
||||
fields: [informationReads.informationId],
|
||||
references: [informations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Category = typeof categories.$inferSelect;
|
||||
export type Member = typeof members.$inferSelect;
|
||||
export type User = typeof users.$inferSelect;
|
||||
@@ -378,3 +439,5 @@ export type MeetingRegistration = typeof meetingRegistrations.$inferSelect;
|
||||
export type PastMeeting = typeof pastMeetings.$inferSelect;
|
||||
export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect;
|
||||
export type ImageConsent = typeof imageConsents.$inferSelect;
|
||||
export type Information = typeof informations.$inferSelect;
|
||||
export type InformationRead = typeof informationReads.$inferSelect;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { informationReads, informations, users } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Lectures du fil d'informations.
|
||||
*
|
||||
* Ce module vit hors de `backend/actions.ts` parce qu'il a plusieurs appelants
|
||||
* qui ne sont pas des actions : le gabarit du back-office (pour le compteur de
|
||||
* non-lues de la barre latérale) et les trois pages de l'espace adhérent.
|
||||
*/
|
||||
|
||||
export type FeedItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
imageUrl: string | null;
|
||||
pinned: boolean;
|
||||
publishedAt: Date | null;
|
||||
authorName: string | null;
|
||||
unread: boolean;
|
||||
};
|
||||
|
||||
export type AdminItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
imageUrl: string | null;
|
||||
status: "draft" | "published";
|
||||
pinned: boolean;
|
||||
publishedAt: Date | null;
|
||||
emailSentAt: Date | null;
|
||||
updatedAt: Date;
|
||||
authorName: string | null;
|
||||
readCount: number;
|
||||
};
|
||||
|
||||
/** Épinglée d'abord, puis la plus récente : l'ordre est le même partout. */
|
||||
const FEED_ORDER = [desc(informations.pinned), desc(informations.publishedAt), desc(informations.id)];
|
||||
|
||||
/**
|
||||
* Le fil tel que le voit un adhérent, avec l'état de lecture **avant** la
|
||||
* visite : c'est ce qui permet d'afficher les pastilles « Nouveau » sur le
|
||||
* rendu même où elles sont marquées comme lues.
|
||||
*/
|
||||
export async function getMemberFeed(userId: number, limit?: number): Promise<FeedItem[]> {
|
||||
const query = db
|
||||
.select({
|
||||
id: informations.id,
|
||||
title: informations.title,
|
||||
body: informations.body,
|
||||
imageUrl: informations.imageUrl,
|
||||
pinned: informations.pinned,
|
||||
publishedAt: informations.publishedAt,
|
||||
authorName: users.name,
|
||||
readId: informationReads.id,
|
||||
})
|
||||
.from(informations)
|
||||
.leftJoin(users, eq(users.id, informations.authorId))
|
||||
.leftJoin(
|
||||
informationReads,
|
||||
and(eq(informationReads.informationId, informations.id), eq(informationReads.userId, userId)),
|
||||
)
|
||||
.where(eq(informations.status, "published"))
|
||||
.orderBy(...FEED_ORDER);
|
||||
|
||||
const rows = await (limit ? query.limit(limit) : query);
|
||||
return rows.map(({ readId, ...row }) => ({ ...row, unread: readId === null }));
|
||||
}
|
||||
|
||||
export async function countPublishedInformations(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(informations)
|
||||
.where(eq(informations.status, "published"));
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
/** Compteur du badge : publiées moins déjà lues par cet utilisateur. */
|
||||
export async function unreadInformationCount(userId: number): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(informations)
|
||||
.leftJoin(
|
||||
informationReads,
|
||||
and(eq(informationReads.informationId, informations.id), eq(informationReads.userId, userId)),
|
||||
)
|
||||
.where(and(eq(informations.status, "published"), isNull(informationReads.id)));
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque comme lues les informations passées. Idempotent : la contrainte
|
||||
* d'unicité `(user_id, information_id)` absorbe les visites répétées, un
|
||||
* double clic ou deux onglets ouverts.
|
||||
*/
|
||||
export async function markInformationsRead(userId: number, ids: number[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
await db
|
||||
.insert(informationReads)
|
||||
.values(ids.map((informationId) => ({ informationId, userId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** Liste du back-office : brouillons compris, avec le nombre de lectures. */
|
||||
export async function getAdminInformations(): Promise<AdminItem[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: informations.id,
|
||||
title: informations.title,
|
||||
body: informations.body,
|
||||
imageUrl: informations.imageUrl,
|
||||
status: informations.status,
|
||||
pinned: informations.pinned,
|
||||
publishedAt: informations.publishedAt,
|
||||
emailSentAt: informations.emailSentAt,
|
||||
updatedAt: informations.updatedAt,
|
||||
authorName: users.name,
|
||||
readCount: sql<number>`(
|
||||
select count(*)::int from ${informationReads}
|
||||
where ${informationReads.informationId} = ${informations.id}
|
||||
)`,
|
||||
})
|
||||
.from(informations)
|
||||
.leftJoin(users, eq(users.id, informations.authorId))
|
||||
.orderBy(desc(informations.status), ...FEED_ORDER, desc(informations.updatedAt));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getInformation(id: number) {
|
||||
const [row] = await db.select().from(informations).where(eq(informations.id, id));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Nombre de comptes adhérents destinataires : le dénominateur du « lue par ». */
|
||||
export async function countMemberAccounts(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(eq(users.role, "member"));
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type Capability =
|
||||
| "manageAdmins"
|
||||
| "manageCategories"
|
||||
| "manageMeetings"
|
||||
| "manageInformations"
|
||||
| "manageEmails"
|
||||
| "manageSettings"
|
||||
| "memberSpace";
|
||||
@@ -35,6 +36,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||
"manageAdmins",
|
||||
"manageCategories",
|
||||
"manageMeetings",
|
||||
"manageInformations",
|
||||
"manageEmails",
|
||||
"manageSettings",
|
||||
"memberSpace",
|
||||
@@ -45,6 +47,7 @@ const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||
"moderatePromos",
|
||||
"publishSocial",
|
||||
"manageMeetings",
|
||||
"manageInformations",
|
||||
],
|
||||
editor: ["viewDashboard", "manageMembers"],
|
||||
member: ["memberSpace"],
|
||||
|
||||
Reference in new issue
Block a user