spb/chat-spboucher-ai
Public
1"use client";2import { useEffect, useMemo, useRef, useState } from "react";3import { X, Code2, Eye, Download, Save, History, ExternalLink } from "lucide-react";4import useSWR from "swr";5import { api, fetcher } from "@/lib/client/fetcher";6import type { ArtifactDTO } from "@/lib/client/types";7import { cn, CopyButton, IconButton, Tabs, toast } from "../ui";8import { Markdown } from "./markdown";910export interface ArtifactView { id?: string; title: string; type: string; language?: string | null; content: string; conversationId?: string | null; projectId?: string | null }1112const LANG_TO_TYPE: Record<string, string> = { html: "html", svg: "svg", mermaid: "mermaid", jsx: "react", tsx: "react", react: "react", python: "python", py: "python", typescript: "typescript", ts: "typescript", markdown: "markdown", md: "markdown" };13export const typeFromLang = (l: string) => LANG_TO_TYPE[l] ?? "code";1415/** Hardened preview: sandboxed iframe (no same-origin, no top-navigation), srcdoc only, no access to cookies/session. */16function HtmlPreview({ html }: { html: string }) {17 return <iframe title="aperçu" sandbox="allow-scripts" referrerPolicy="no-referrer" className="h-full w-full bg-white" srcDoc={html} />;18}1920function ReactPreview({ code }: { code: string }) {21 // Babel standalone + React UMD from a CDN inside the sandbox — the host app shares nothing with it.22 const doc = useMemo(() => `<!doctype html><html><head><meta charset="utf-8"><script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script><script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script><script src="https://unpkg.com/@babel/standalone/babel.min.js"></script><script src="https://cdn.tailwindcss.com"></script><style>body{margin:0;font-family:system-ui;padding:12px}</style></head><body><div id="root"></div><script type="text/babel" data-presets="react,typescript" data-type="module">23${code.replace(/^\s*import[^;]*;?\s*$/gm, "").replace(/export\s+default\s+/g, "window.__Comp = ").replace(/export\s+(const|function|class)\s+/g, "$1 ")}24;(function(){ const C = window.__Comp || (typeof App !== 'undefined' ? App : null); const root = ReactDOM.createRoot(document.getElementById('root')); if (!C) { root.render(React.createElement('pre', {style:{color:'#b00'}}, 'Aucun export par défaut trouvé (export default function App…)')); } else root.render(React.createElement(C)); })();25</script></body></html>`, [code]);26 return <iframe title="aperçu react" sandbox="allow-scripts" referrerPolicy="no-referrer" className="h-full w-full bg-white" srcDoc={doc} />;27}2829function MermaidPreview({ code }: { code: string }) {30 const ref = useRef<HTMLDivElement>(null); const [err, setErr] = useState<string | null>(null);31 useEffect(() => { let alive = true; (async () => { try { const m = (await import("mermaid")).default; m.initialize({ startOnLoad: false, theme: document.documentElement.dataset.theme === "light" ? "default" : "dark", securityLevel: "strict" }); const { svg } = await m.render(`mm${Date.now()}`, code); if (alive && ref.current) { ref.current.innerHTML = svg; setErr(null); } } catch (e) { if (alive) setErr((e as Error).message); } })(); return () => { alive = false; }; }, [code]);32 return <div className="h-full overflow-auto p-4">{err && <pre className="text-xs text-danger">{err}</pre>}<div ref={ref} className="[&>svg]:mx-auto [&>svg]:max-w-full" /></div>;33}3435export function ArtifactPanel({ artifact, onClose, onSaved }: { artifact: ArtifactView; onClose: () => void; onSaved?: (a: ArtifactDTO) => void }) {36 const [tab, setTab] = useState<"preview" | "code">(["html", "svg", "mermaid", "react", "markdown"].includes(artifact.type) ? "preview" : "code");37 const { data: versions } = useSWR<{ artifact: ArtifactDTO & { content: string }; versions: { id: string; version: number; created_at: string }[] }>(artifact.id ? `/api/artifacts/${artifact.id}` : null, fetcher);38 const [showVersions, setShowVersions] = useState(false);39 const [content, setContent] = useState(artifact.content);40 useEffect(() => { setContent(artifact.content); }, [artifact.content]);41 const ext = ({ html: "html", svg: "svg", mermaid: "mmd", react: artifact.language === "jsx" ? "jsx" : "tsx", python: "py", typescript: "ts", markdown: "md" } as Record<string, string>)[artifact.type] ?? "txt";42 function download() { const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${artifact.title.replace(/[^\w.-]+/g, "_")}.${ext}`; a.click(); URL.revokeObjectURL(a.href); }43 async function save() {44 const r = await api<{ id: string; rootId: string; version: number }>("/api/artifacts", { method: "POST", json: { rootId: versions?.artifact.root_id ?? artifact.id, conversationId: artifact.conversationId ?? null, projectId: artifact.projectId ?? null, type: artifact.type, language: artifact.language, title: artifact.title, content, saved: true } });45 toast(`Artefact sauvegardé (v${r.version})`, "ok"); onSaved?.({ id: r.id, root_id: r.rootId, version: r.version, type: artifact.type, language: artifact.language ?? null, title: artifact.title, saved: true, message_id: null, created_at: new Date().toISOString() });46 }47 function openNewTab() { const blob = new Blob([artifact.type === "svg" ? content : content], { type: artifact.type === "svg" ? "image/svg+xml" : "text/html" }); window.open(URL.createObjectURL(blob), "_blank", "noopener"); }48 return (49 <div className="flex h-full flex-col border-l border-border bg-bg-elev">50 <div className="flex items-center gap-2 border-b border-border px-3 py-2">51 <div className="min-w-0 flex-1"><div className="truncate text-sm font-medium">{artifact.title}</div><div className="text-[11px] text-muted">{artifact.type}{artifact.language ? ` · ${artifact.language}` : ""}{versions?.artifact ? ` · v${versions.artifact.version}` : ""}</div></div>52 <Tabs value={tab} onChange={setTab} items={[{ value: "preview", label: <span className="flex items-center gap-1"><Eye className="h-3.5 w-3.5" />Aperçu</span> }, { value: "code", label: <span className="flex items-center gap-1"><Code2 className="h-3.5 w-3.5" />Code</span> }]} />53 <IconButton label="Fermer" onClick={onClose}><X className="h-4 w-4" /></IconButton>54 </div>55 <div className="min-h-0 flex-1">56 {tab === "preview" ? (57 artifact.type === "html" ? <HtmlPreview html={content} /> : artifact.type === "svg" ? <HtmlPreview html={`<!doctype html><body style="margin:0;display:grid;place-items:center;min-height:100vh;background:#fff">${content}</body>`} /> : artifact.type === "react" ? <ReactPreview code={content} /> : artifact.type === "mermaid" ? <MermaidPreview code={content} /> : artifact.type === "markdown" ? <div className="h-full overflow-auto p-5"><Markdown text={content} /></div> : <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted">Pas d'aperçu sûr pour ce type ; utilise l'onglet Code{artifact.type === "python" ? " ou demande au modèle d'exécuter le script avec l'outil Python" : ""}.</div>58 ) : (59 <textarea value={content} onChange={e => setContent(e.target.value)} spellCheck={false} className="h-full w-full resize-none bg-[#0d1117] p-4 font-mono text-[12.5px] leading-relaxed text-[#d4d8e2] outline-none" />60 )}61 </div>62 <div className="flex items-center gap-1 border-t border-border px-2 py-1.5 text-[12px]">63 <CopyButton text={content} />64 <button onClick={download} className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-fg-2 hover:bg-surface-2 hover:text-fg"><Download className="h-3.5 w-3.5" /> Télécharger</button>65 {(artifact.type === "html" || artifact.type === "svg") && <button onClick={openNewTab} className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-fg-2 hover:bg-surface-2 hover:text-fg"><ExternalLink className="h-3.5 w-3.5" /> Ouvrir</button>}66 <button onClick={save} className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-fg-2 hover:bg-surface-2 hover:text-fg"><Save className="h-3.5 w-3.5" /> {content !== artifact.content ? "Sauver une version" : "Sauver au projet"}</button>67 {versions && versions.versions.length > 1 && <button onClick={() => setShowVersions(v => !v)} className={cn("ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-surface-2", showVersions ? "text-fg" : "text-fg-2")}><History className="h-3.5 w-3.5" /> {versions.versions.length} versions</button>}68 </div>69 {showVersions && versions && <div className="max-h-36 overflow-y-auto border-t border-border p-2 text-[12px]">{versions.versions.map(v => <button key={v.id} onClick={async () => { const r = await api<{ artifact: { content: string } }>(`/api/artifacts/${v.id}`); setContent(r.artifact.content); }} className={cn("flex w-full items-center justify-between rounded-md px-2 py-1 hover:bg-surface-2", v.id === artifact.id && "text-accent")}><span>v{v.version}</span><span className="text-muted">{new Date(v.created_at).toLocaleString("fr-CA")}</span></button>)}</div>}70 </div>71 );72}73