"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import useSWR, { mutate } from "swr";
import { Plus, MessageSquare, FileText, Trash2, Download, Upload, Save, Eye, Pencil, ExternalLink, Brain, LayoutPanelLeft, ShieldCheck } from "lucide-react";
import { api, fetcher, relTime } from "@/lib/client/fetcher";
import type { ArtifactDTO, ConversationDTO, MemoryDTO, ProjectDTO } from "@/lib/client/types";
import { useModels } from "@/lib/client/use-models";
import { Button, Empty, Spinner, Tabs, Textarea, Toggle, toast, cn, Badge } from "../ui";
import { Markdown } from "../chat/markdown";
import { MemoryCard } from "../memory/memory-card";
import { AddMemoryDialog } from "../memory/add-memory-dialog";
import { fmtBytes } from "./project-shared";
export interface ProjectFile { id: string; filename: string; mime: string; size_bytes: number; page_count: number | null; created_at: string }
interface ToolInfo { name: string; description: string; permission: string; category: string }
export function ConversationsTab({ project }: { project: ProjectDTO }) {
const router = useRouter();
const { data } = useSWR<{ conversations: ConversationDTO[] }>(`/api/conversations?project=${project.id}`, fetcher);
return (
router.push(`/?project=${project.id}`)}> Nouvelle conversation dans ce projet
{!data &&
}
{data && !data.conversations.length &&
} title="Aucune conversation" hint="Les conversations démarrées depuis ce projet héritent de ses instructions, de son modèle et de sa mémoire." />}
{data?.conversations.map(c => (
{c.title} {c.message_count} messages · {Number(c.total_cost_usd).toFixed(3)} $ · {c.model_id ?? "auto"}
{relTime(c.last_message_at ?? c.created_at)}
))}
);
}
export function InstructionsTab({ project, onSaved }: { project: ProjectDTO; onSaved: () => void }) {
const { chat } = useModels();
const { data: settings } = useSWR<{ tools: ToolInfo[] }>("/api/settings", fetcher);
const [instructions, setInstructions] = useState(project.instructions ?? ""); const [model, setModel] = useState(project.default_model ?? "");
const [perms, setPerms] = useState>(() => Object.fromEntries(Object.entries(project.tool_permissions ?? {}).map(([k, v]) => [k, !!v]))); const [busy, setBusy] = useState(false);
useEffect(() => { setInstructions(project.instructions ?? ""); setModel(project.default_model ?? ""); }, [project.id, project.instructions, project.default_model]);
const tools = settings?.tools ?? [];
const allowed = (name: string) => perms[name] ?? true;
async function save() {
setBusy(true);
try { await api(`/api/projects/${project.id}`, { method: "PATCH", json: { instructions: instructions.trim() || null, defaultModel: model || null, toolPermissions: Object.fromEntries(tools.map(t => [t.name, allowed(t.name)])) } }); toast("Instructions enregistrées", "ok"); onSaved(); }
catch (e) { toast((e as Error).message, "error"); } finally { setBusy(false); }
}
return (
Modèle par défaut
setModel(e.target.value)} className="input">— Suivre le choix du composeur — {chat.map(m => {m.displayName} ({m.provider}) )}
Outils autorisés dans ce projet
{!tools.length &&
}
{tools.map(t => (
setPerms(p => ({ ...p, [t.name]: e.target.checked }))} className="mt-1 accent-[var(--accent)]" />
{t.name}{t.permission === "auto" ? "auto" : "approbation"} {t.description}
))}
Enregistrer
);
}
export function FilesTab({ project, files, onChanged }: { project: ProjectDTO; files: ProjectFile[]; onChanged: () => void }) {
const [uploading, setUploading] = useState(false); const [drag, setDrag] = useState(false); const input = useRef(null);
async function upload(list: FileList | File[]) {
const arr = Array.from(list); if (!arr.length) return; setUploading(true);
try { const fd = new FormData(); for (const f of arr) fd.append("file", f); fd.append("projectId", project.id); await api("/api/attachments", { method: "POST", body: fd }); toast(`${arr.length} fichier(s) ajouté(s)`, "ok"); onChanged(); }
catch (e) { toast((e as Error).message, "error"); } finally { setUploading(false); }
}
async function del(f: ProjectFile) { if (!confirm(`Supprimer « ${f.filename} » ?`)) return; await api(`/api/attachments/${f.id}`, { method: "DELETE" }); onChanged(); }
return (
{ e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={e => { e.preventDefault(); setDrag(false); void upload(e.dataTransfer.files); }} onClick={() => input.current?.click()} className={cn("mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-4 py-8 text-center transition-colors", drag ? "border-accent bg-accent/5" : "border-border hover:border-border-strong hover:bg-surface")}>
{ if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />
{uploading ?
:
}
{uploading ? "Téléversement…" : "Dépose des fichiers ici ou clique pour choisir"}
PDF, DOCX, XLSX, CSV, TXT, Markdown, images · texte extrait et indexé pour l'outil search_documents
{!files.length &&
} title="Aucun fichier" hint="Les documents du projet sont consultables par le modèle dans toutes les conversations du projet." />}
{files.map(f => (
{f.filename} {fmtBytes(f.size_bytes)}{f.page_count ? ` · ${f.page_count} p.` : ""} · {relTime(f.created_at)}
del(f)} className="rounded-lg p-1.5 text-fg-2 hover:bg-surface-2 hover:text-danger" title="Supprimer">
))}
);
}
export function MemoryTab({ project, memories, onChanged }: { project: ProjectDTO; memories: MemoryDTO[]; onChanged: () => void }) {
const [add, setAdd] = useState(false);
return (
Souvenirs propres à ce projet : injectés seulement dans ses conversations, en plus de la mémoire globale.
setAdd(true)}> Ajouter
{!memories.length &&
} title="Aucun souvenir de projet" />}
{memories.map(m => )}
setAdd(false)} onAdded={onChanged} projectId={project.id} />
);
}
export function NotesTab({ project }: { project: ProjectDTO }) {
const [notes, setNotes] = useState(project.notes ?? ""); const [preview, setPreview] = useState(false); const [state, setState] = useState<"idle" | "saving" | "saved">("idle"); const first = useRef(true);
useEffect(() => { setNotes(project.notes ?? ""); first.current = true; }, [project.id]);
useEffect(() => {
if (first.current) { first.current = false; return; }
setState("saving");
const t = setTimeout(async () => { try { await api(`/api/projects/${project.id}`, { method: "PATCH", json: { notes } }); setState("saved"); void mutate("/api/projects"); } catch { setState("idle"); toast("Échec de la sauvegarde des notes", "error"); } }, 900);
return () => clearTimeout(t);
}, [notes, project.id]);
return (
setPreview(v === "preview")} items={[{ value: "edit", label: Éditer }, { value: "preview", label: Aperçu }]} />{state === "saving" ? "Enregistrement…" : state === "saved" ? "Enregistré" : "Sauvegarde automatique"}
{preview ?
{notes.trim() ? : Rien à afficher. }
:
);
}
export function ArtifactsTab({ artifacts }: { artifacts: ArtifactDTO[] }) {
if (!artifacts.length) return } title="Aucun artefact sauvegardé" hint="Depuis une conversation, ouvre un artefact (HTML, React, SVG, Mermaid…) puis « Sauver au projet »." />;
return ;
}
export function ToggleRow({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return {label} {hint && {hint} } ;
}