SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
10.0 KB · 270 lines tsx
Raw Blame History
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> »&nbsp;? 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