TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Annonces : création avec aperçu Markdown, liste avec épingle, activation et suppression.3import { useState } from "react";4import { Megaphone, Pin, PinOff, Power, Trash2 } from "lucide-react";5import { PageHeader } from "@/components/app-shell";6import { Markdown } from "@/components/chat/markdown";7import { Badge, Button, Card, EmptyState, Input, Label, Modal, Skeleton, Spinner, Tabs, Textarea } from "@/components/ui";8import { ErrorBanner, SuccessFlash, Toggle, fmtDate, postJson, useFetchJson } from "./shared";910type Announcement = {11 id: number;12 title: string;13 body: string;14 course_code: string | null;15 pinned: number;16 active: number;17 created_by: number | null;18 created_at: string;19};2021const COURSE_OPTIONS = [22 { value: "", label: "Tous les cours" },23 { value: "IMM1003", label: "IMM1003" },24 { value: "IMM1033", label: "IMM1033" },25];2627export function AdminAnnouncements() {28 const { data, error, loading, reload } = useFetchJson<{ announcements: Announcement[] }>("/api/admin/announcements");2930 // Formulaire de création31 const [title, setTitle] = useState("");32 const [body, setBody] = useState("");33 const [courseCode, setCourseCode] = useState("");34 const [pinned, setPinned] = useState(false);35 const [editTab, setEditTab] = useState("write");36 const [creating, setCreating] = useState(false);37 const [formError, setFormError] = useState<string | null>(null);38 const [flash, setFlash] = useState<string | null>(null);3940 // Actions sur la liste41 const [actionError, setActionError] = useState<string | null>(null);42 const [busyId, setBusyId] = useState<number | null>(null);43 const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);44 const [deleting, setDeleting] = useState(false);4546 async function create() {47 if (!title.trim() || !body.trim()) {48 setFormError("Le titre et le corps de l'annonce sont requis.");49 return;50 }51 setCreating(true);52 setFormError(null);53 try {54 await postJson("/api/admin/announcements", {55 action: "create",56 title: title.trim(),57 body: body.trim(),58 courseCode: courseCode === "" ? null : courseCode,59 pinned,60 });61 setTitle("");62 setBody("");63 setCourseCode("");64 setPinned(false);65 setEditTab("write");66 setFlash("Annonce publiée.");67 setTimeout(() => setFlash(null), 3500);68 await reload();69 } catch (e) {70 setFormError(e instanceof Error ? e.message : "La publication a échoué.");71 } finally {72 setCreating(false);73 }74 }7576 async function update(id: number, patch: { active?: boolean; pinned?: boolean }) {77 setBusyId(id);78 setActionError(null);79 try {80 await postJson("/api/admin/announcements", { action: "update", id, ...patch });81 await reload();82 } catch (e) {83 setActionError(e instanceof Error ? e.message : "L'action a échoué.");84 } finally {85 setBusyId(null);86 }87 }8889 async function remove() {90 if (!deleteTarget) return;91 setDeleting(true);92 setActionError(null);93 try {94 await postJson("/api/admin/announcements", { action: "delete", id: deleteTarget.id });95 setDeleteTarget(null);96 await reload();97 } catch (e) {98 setActionError(e instanceof Error ? e.message : "La suppression a échoué.");99 } finally {100 setDeleting(false);101 }102 }103104 return (105 <div className="animate-fade-up">106 <PageHeader title="Annonces" subtitle="Messages du professeur affichés aux étudiant·e·s" />107108 {flash && <div className="mb-4"><SuccessFlash message={flash} /></div>}109110 {/* Création */}111 <Card className="mb-6 p-5">112 <h2 className="mb-4 text-[15px] font-semibold text-fg">Nouvelle annonce</h2>113 <div className="grid gap-4 md:grid-cols-[1fr_220px]">114 <div>115 <Label htmlFor="ann-title">Titre</Label>116 <Input117 id="ann-title"118 value={title}119 onChange={(e) => setTitle(e.target.value)}120 maxLength={200}121 placeholder="Ex. : Report de la remise du travail 2"122 />123 </div>124 <div>125 <Label htmlFor="ann-course">Cours ciblé</Label>126 <select127 id="ann-course"128 value={courseCode}129 onChange={(e) => setCourseCode(e.target.value)}130 className="h-10 w-full rounded-lg border border-app bg-card px-3 text-sm text-fg outline-none transition-shadow focus:border-brand-400 focus:ring-2 focus:ring-brand-500/25"131 >132 {COURSE_OPTIONS.map((o) => (133 <option key={o.value} value={o.value}>{o.label}</option>134 ))}135 </select>136 </div>137 </div>138139 <div className="mt-4">140 <div className="mb-1.5 flex items-center justify-between">141 <Label className="mb-0">Corps (Markdown)</Label>142 <Tabs143 tabs={[{ key: "write", label: "Écrire" }, { key: "preview", label: "Aperçu" }]}144 active={editTab}145 onChange={setEditTab}146 />147 </div>148 {editTab === "write" ? (149 <Textarea150 value={body}151 onChange={(e) => setBody(e.target.value)}152 rows={6}153 maxLength={10_000}154 placeholder={"Contenu de l'annonce — le **Markdown** est pris en charge."}155 aria-label="Corps de l'annonce"156 />157 ) : (158 <div className="min-h-[9rem] rounded-lg border border-app bg-surface-1 px-4 py-3 dark:bg-brand-950/40">159 {body.trim() ? <Markdown content={body} /> : <p className="text-sm italic text-muted">Rien à prévisualiser.</p>}160 </div>161 )}162 </div>163164 {formError && <div className="mt-3"><ErrorBanner message={formError} /></div>}165166 <div className="mt-4 flex flex-wrap items-center justify-between gap-3">167 <label className="flex items-center gap-2.5 text-[13px] text-fg">168 <Toggle checked={pinned} onChange={setPinned} label="Épingler l'annonce" />169 Épinglée (affichée en priorité)170 </label>171 <Button onClick={create} disabled={creating}>172 {creating && <Spinner />}173 Publier l'annonce174 </Button>175 </div>176 </Card>177178 {actionError && <div className="mb-4"><ErrorBanner message={actionError} /></div>}179180 {/* Liste */}181 {loading ? (182 <div className="space-y-3">183 {Array.from({ length: 3 }).map((_, i) => (184 <Skeleton key={i} className="h-28" />185 ))}186 </div>187 ) : error || !data ? (188 <ErrorBanner message={error ?? "Données indisponibles."} onRetry={reload} />189 ) : data.announcements.length === 0 ? (190 <Card>191 <EmptyState192 icon={<Megaphone />}193 title="Aucune annonce"194 description="Les annonces publiées apparaîtront ici, les plus récentes en premier."195 />196 </Card>197 ) : (198 <div className="space-y-3">199 {data.announcements.map((a) => (200 <Card key={a.id} className="p-4">201 <div className="flex flex-wrap items-start justify-between gap-3">202 <div className="min-w-0">203 <div className="flex flex-wrap items-center gap-2">204 <h3 className="font-semibold text-fg">{a.title}</h3>205 {a.pinned ? <Badge tone="gold"><Pin size={11} /> Épinglée</Badge> : null}206 <Badge tone={a.active ? "green" : "neutral"}>{a.active ? "Active" : "Inactive"}</Badge>207 <Badge tone="brand">{a.course_code ?? "Tous les cours"}</Badge>208 </div>209 <p className="mt-0.5 text-[12px] tabular-nums text-muted">{fmtDate(a.created_at)}</p>210 </div>211 <div className="flex shrink-0 items-center gap-1">212 <Button213 size="sm"214 variant="ghost"215 onClick={() => update(a.id, { pinned: !a.pinned })}216 disabled={busyId === a.id}217 title={a.pinned ? "Désépingler" : "Épingler"}218 >219 {a.pinned ? <PinOff size={14} /> : <Pin size={14} />}220 {a.pinned ? "Désépingler" : "Épingler"}221 </Button>222 <Button223 size="sm"224 variant="ghost"225 onClick={() => update(a.id, { active: !a.active })}226 disabled={busyId === a.id}227 title={a.active ? "Désactiver" : "Réactiver"}228 >229 <Power size={14} />230 {a.active ? "Désactiver" : "Réactiver"}231 </Button>232 <Button233 size="sm"234 variant="ghost"235 onClick={() => setDeleteTarget(a)}236 disabled={busyId === a.id}237 className="text-red-600 hover:bg-red-500/10 dark:text-red-400"238 title="Supprimer"239 >240 <Trash2 size={14} />241 Supprimer242 </Button>243 </div>244 </div>245 <div className="mt-2.5 border-t border-app pt-2.5">246 <Markdown content={a.body} />247 </div>248 </Card>249 ))}250 </div>251 )}252253 <Modal open={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title="Supprimer l'annonce">254 <p className="text-sm text-fg">255 Supprimer définitivement l'annonce « <b>{deleteTarget?.title}</b> » ? Cette action est irréversible.256 </p>257 <div className="mt-5 flex justify-end gap-2">258 <Button variant="secondary" onClick={() => setDeleteTarget(null)} disabled={deleting}>259 Annuler260 </Button>261 <Button variant="danger" onClick={remove} disabled={deleting}>262 {deleting && <Spinner />}263 Supprimer264 </Button>265 </div>266 </Modal>267 </div>268 );269}270