TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Prompts système : sélection, édition monospace, historique des versions,3// publication d'une nouvelle version avec confirmation.4import { useCallback, useEffect, useState } from "react";5import { FileCode2, Send } from "lucide-react";6import { PageHeader } from "@/components/app-shell";7import { Badge, Button, Card, EmptyState, Modal, Skeleton, Spinner, Textarea, cn } from "@/components/ui";8import { ErrorBanner, SectionTitle, SuccessFlash, fmtDate, fmtInt, postJson, useFetchJson } from "./shared";910type HistoryRow = { id: number; version: number; active: number; created_by: string | null; created_at: string };11type PromptDetail = { name: string; content: string; history: HistoryRow[] };1213const MAX_CHARS = 20_000;14const MIN_CHARS = 10;1516export function AdminPrompts() {17 const { data, error, loading, reload } = useFetchJson<{ names: string[] }>("/api/admin/prompts");18 const [selected, setSelected] = useState<string | null>(null);19 const [detail, setDetail] = useState<PromptDetail | null>(null);20 const [detailLoading, setDetailLoading] = useState(false);21 const [detailError, setDetailError] = useState<string | null>(null);22 const [content, setContent] = useState("");23 const [confirmOpen, setConfirmOpen] = useState(false);24 const [publishing, setPublishing] = useState(false);25 const [publishError, setPublishError] = useState<string | null>(null);26 const [flash, setFlash] = useState<string | null>(null);2728 const loadDetail = useCallback(async (name: string) => {29 setDetailLoading(true);30 setDetailError(null);31 try {32 const res = await fetch(`/api/admin/prompts?name=${encodeURIComponent(name)}`);33 const j = (await res.json().catch(() => null)) as (PromptDetail & { error?: string }) | null;34 if (!res.ok || !j) throw new Error(j?.error || `Erreur ${res.status}`);35 setDetail(j);36 setContent(j.content);37 } catch (e) {38 setDetailError(e instanceof Error ? e.message : "Impossible de charger le prompt.");39 } finally {40 setDetailLoading(false);41 }42 }, []);4344 useEffect(() => {45 if (!selected && data?.names.length) {46 setSelected(data.names[0]);47 }48 }, [data, selected]);4950 useEffect(() => {51 if (selected) loadDetail(selected);52 }, [selected, loadDetail]);5354 const dirty = detail !== null && content !== detail.content;55 const tooShort = content.trim().length < MIN_CHARS;56 const tooLong = content.length > MAX_CHARS;57 const currentVersion = detail?.history.find((h) => h.active)?.version ?? detail?.history[0]?.version ?? 0;5859 async function publish() {60 if (!selected) return;61 setPublishing(true);62 setPublishError(null);63 try {64 const r = await postJson<{ ok: boolean; version: number }>("/api/admin/prompts", { name: selected, content });65 setConfirmOpen(false);66 setFlash(`Version ${r.version} de « ${selected} » publiée — elle s'applique dès les prochaines conversations.`);67 setTimeout(() => setFlash(null), 5000);68 await loadDetail(selected);69 } catch (e) {70 setPublishError(e instanceof Error ? e.message : "La publication a échoué.");71 } finally {72 setPublishing(false);73 }74 }7576 return (77 <div className="animate-fade-up">78 <PageHeader79 title="Prompts système"80 subtitle="Instructions modulaires de l'assistant — versionnées, publication immédiate"81 />8283 {flash && <div className="mb-4"><SuccessFlash message={flash} /></div>}8485 {loading ? (86 <div className="grid gap-4 lg:grid-cols-[220px_1fr]">87 <Skeleton className="h-72" />88 <Skeleton className="h-[480px]" />89 </div>90 ) : error || !data ? (91 <ErrorBanner message={error ?? "Données indisponibles."} onRetry={reload} />92 ) : data.names.length === 0 ? (93 <Card>94 <EmptyState95 icon={<FileCode2 />}96 title="Aucun prompt"97 description="Aucun prompt système n'a été trouvé (dossier prompts/ vide et base de données sans versions)."98 />99 </Card>100 ) : (101 <div className="grid items-start gap-4 lg:grid-cols-[230px_1fr]">102 {/* Liste des prompts */}103 <Card className="p-2 lg:sticky lg:top-16">104 <nav aria-label="Prompts" className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">105 {data.names.map((name) => (106 <button107 key={name}108 type="button"109 onClick={() => setSelected(name)}110 className={cn(111 "whitespace-nowrap rounded-lg px-3 py-2 text-left font-mono text-[12.5px] transition-colors lg:whitespace-normal",112 selected === name113 ? "bg-brand-100 font-semibold text-brand-700 dark:bg-brand-900/60 dark:text-brand-200"114 : "text-muted hover:bg-surface-2 hover:text-fg dark:hover:bg-brand-900/30"115 )}116 >117 {name}118 </button>119 ))}120 </nav>121 </Card>122123 {/* Éditeur */}124 <div className="min-w-0 space-y-4">125 {detailLoading ? (126 <Skeleton className="h-[480px]" />127 ) : detailError ? (128 <ErrorBanner message={detailError} onRetry={() => selected && loadDetail(selected)} />129 ) : detail ? (130 <>131 <Card className="p-5">132 <div className="mb-3 flex flex-wrap items-center justify-between gap-3">133 <div className="flex items-center gap-2.5">134 <h2 className="font-mono text-[15px] font-semibold text-fg">{detail.name}</h2>135 <Badge tone="brand">v{currentVersion}</Badge>136 {dirty && <Badge tone="amber">Modifications non publiées</Badge>}137 </div>138 <Button139 size="sm"140 onClick={() => setConfirmOpen(true)}141 disabled={!dirty || tooShort || tooLong}142 >143 <Send size={14} />144 Publier une nouvelle version145 </Button>146 </div>147 <Textarea148 value={content}149 onChange={(e) => setContent(e.target.value)}150 spellCheck={false}151 className="min-h-[420px] resize-y font-mono text-[12.5px] leading-relaxed"152 aria-label={`Contenu du prompt ${detail.name}`}153 />154 <div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-[12px]">155 <span className={cn("tabular-nums", tooLong || tooShort ? "font-medium text-red-600 dark:text-red-400" : "text-muted")}>156 {fmtInt(content.length)} / {fmtInt(MAX_CHARS)} caractères157 {tooShort && " — minimum 10 caractères"}158 {tooLong && " — limite dépassée"}159 </span>160 <span className="text-muted">161 Les changements publiés s'appliquent immédiatement aux prochaines conversations — aucune remise en route requise.162 </span>163 </div>164 </Card>165166 <Card className="p-5">167 <SectionTitle>Historique des versions</SectionTitle>168 {detail.history.length === 0 ? (169 <p className="text-sm text-muted">170 Aucune version en base — le contenu affiché provient du fichier <span className="font-mono">prompts/{detail.name}.md</span>.171 La première publication créera la version 1.172 </p>173 ) : (174 <div className="overflow-x-auto">175 <table className="w-full text-[13px]">176 <thead>177 <tr className="border-b border-app text-left text-[12px] text-muted">178 <th className="py-2 pr-3 font-medium">Version</th>179 <th className="py-2 pr-3 font-medium">Date</th>180 <th className="py-2 pr-3 font-medium">Auteur</th>181 <th className="py-2 font-medium">Statut</th>182 </tr>183 </thead>184 <tbody>185 {detail.history.map((h) => (186 <tr key={h.id} className="border-b border-app last:border-0">187 <td className="py-2 pr-3 tabular-nums font-medium text-fg">v{h.version}</td>188 <td className="py-2 pr-3 tabular-nums text-muted">{fmtDate(h.created_at)}</td>189 <td className="py-2 pr-3 text-muted">{h.created_by ?? "—"}</td>190 <td className="py-2">{h.active ? <Badge tone="green">Active</Badge> : <Badge>Archivée</Badge>}</td>191 </tr>192 ))}193 </tbody>194 </table>195 </div>196 )}197 </Card>198 </>199 ) : null}200 </div>201 </div>202 )}203204 <Modal open={confirmOpen} onClose={() => (publishing ? null : setConfirmOpen(false))} title="Publier une nouvelle version">205 <p className="text-sm text-fg">206 Publier la version <b>v{currentVersion + 1}</b> du prompt <span className="font-mono">{selected}</span> ?207 </p>208 <p className="mt-2 text-[13px] text-muted">209 La nouvelle version devient active immédiatement : toutes les prochaines conversations l'utiliseront.210 Les versions précédentes restent consultables dans l'historique.211 </p>212 {publishError && <div className="mt-3"><ErrorBanner message={publishError} /></div>}213 <div className="mt-5 flex justify-end gap-2">214 <Button variant="secondary" onClick={() => setConfirmOpen(false)} disabled={publishing}>215 Annuler216 </Button>217 <Button onClick={publish} disabled={publishing}>218 {publishing && <Spinner />}219 Publier220 </Button>221 </div>222 </Modal>223 </div>224 );225}226