components/bugs/bug-editor.tsx

component·app·17.2 KB · 431 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

1 export

BugEditor

Code source· tsx· tronqué à 200 lignes sur 431

"use client";

import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent, type DragEvent } from "react";
import { useRouter } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Card, CardContent } from "@/components/ui/card";
import { Icon } from "@/components/icons";
import {
  AREAS,
  AREA_LABEL,
  PROJECT_LABEL,
  projectSupportsArea,
  type Area,
  type BugFull,
  type Project,
  type Status,
} from "@/lib/bugs-types";

interface Props {
  initialBug: BugFull;
}

const STATUS_BADGE: Record<Status, string> = {
  OPEN:
    "inline-flex items-center gap-1 rounded-full bg-amber-100 dark:bg-amber-950 px-2.5 py-1 text-xs font-medium text-amber-800 dark:text-amber-200",
  RESOLVED:
    "inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-950 px-2.5 py-1 text-xs font-medium text-emerald-800 dark:text-emerald-200",
};

const PROJECT_BADGE: Record<Project, string> = {
  "dev-tour":
    "inline-flex items-center gap-1 rounded-md bg-sky-100 dark:bg-sky-950 px-2 py-0.5 text-xs font-mono text-sky-800 dark:text-sky-200",
  "wari-web":
    "inline-flex items-center gap-1 rounded-md bg-violet-100 dark:bg-violet-950 px-2 py-0.5 text-xs font-mono text-violet-800 dark:text-violet-200",
  "wari-mobile":
    "inline-flex items-center gap-1 rounded-md bg-fuchsia-100 dark:bg-fuchsia-950 px-2 py-0.5 text-xs font-mono text-fuchsia-800 dark:text-fuchsia-200",
  wariot:
    "inline-flex items-center gap-1 rounded-md bg-orange-100 dark:bg-orange-950 px-2 py-0.5 text-xs font-mono text-orange-800 dark:text-orange-200",
};

const AREA_BADGE =
  "inline-flex items-center rounded-md bg-zinc-100 dark:bg-zinc-900 px-2 py-0.5 text-xs text-zinc-700 dark:text-zinc-300";

export function BugEditor({ initialBug }: Props) {
  const router = useRouter();
  const [bug, setBug] = useState<BugFull>(initialBug);
  const [editing, setEditing] = useState(false);
  const [draftTitle, setDraftTitle] = useState(bug.title);
  const [draftBody, setDraftBody] = useState(bug.body);
  const [draftArea, setDraftArea] = useState<Area | "">(bug.area ?? "");
  const [saving, setSaving] = useState(false);
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    setBug(initialBug);
    setDraftTitle(initialBug.title);
    setDraftBody(initialBug.body);
    setDraftArea(initialBug.area ?? "");
  }, [initialBug]);

  async function uploadImageFile(file: File): Promise<string | null> {
    setUploading(true);
    setError(null);
    try {
      const form = new FormData();
      form.append("file", file);
      const res = await fetch(`/api/bugs/${bug.project}/${bug.id}/images`, {
        method: "POST",
        body: form,
      });
      if (!res.ok) {
        const data = (await res.json().catch(() => ({}))) as { error?: string };
        throw new Error(data.error ?? `HTTP ${res.status}`);
      }
      const data = (await res.json()) as { filename: string; url: string };
      return `![${data.filename}](${data.url})`;
    } catch (err) {
      setError(err instanceof Error ? err.message : "Upload échoué");
      return null;
    } finally {
      setUploading(false);
    }
  }

  function insertAtCursor(text: string) {
    const ta = textareaRef.current;
    if (!ta) {
      setDraftBody((prev) => prev + (prev.endsWith("\n") || prev === "" ? "" : "\n") + text + "\n");
      return;
    }
    const start = ta.selectionStart ?? draftBody.length;
    const end = ta.selectionEnd ?? draftBody.length;
    const before = draftBody.slice(0, start);
    const after = draftBody.slice(end);
    const needsLeadingNl = before.length > 0 && !before.endsWith("\n");
    const insert = (needsLeadingNl ? "\n" : "") + text + "\n";
    const next = before + insert + after;
    setDraftBody(next);
    requestAnimationFrame(() => {
      const pos = before.length + insert.length;
      ta.focus();
      ta.setSelectionRange(pos, pos);
    });
  }

  async function handlePaste(e: ClipboardEvent<HTMLTextAreaElement>) {
    const items = e.clipboardData?.items;
    if (!items) return;
    for (const item of items) {
      if (item.kind === "file" && item.type.startsWith("image/")) {
        e.preventDefault();
        const file = item.getAsFile();
        if (!file) continue;
        const md = await uploadImageFile(file);
        if (md) insertAtCursor(md);
        return;
      }
    }
  }

  async function handleDrop(e: DragEvent<HTMLTextAreaElement>) {
    const files = Array.from(e.dataTransfer?.files ?? []).filter((f) => f.type.startsWith("image/"));
    if (files.length === 0) return;
    e.preventDefault();
    for (const file of files) {
      const md = await uploadImageFile(file);
      if (md) insertAtCursor(md);
    }
  }

  async function handleFileInput(e: ChangeEvent<HTMLInputElement>) {
    if (!e.target.files) return;
    const files = Array.from(e.target.files).filter((f) => f.type.startsWith("image/"));
    e.target.value = "";
    for (const file of files) {
      const md = await uploadImageFile(file);
      if (md) insertAtCursor(md);
    }
  }

  async function save() {
    setSaving(true);
    setError(null);
    try {
      // area: "" → on envoie null pour explicitement effacer
      const areaPatch =
        bug.area === (draftArea || undefined) ? undefined : draftArea === "" ? null : draftArea;
      const patch: Record<string, unknown> = { title: draftTitle, body: draftBody };
      if (areaPatch !== undefined) patch.area = areaPatch;

      const res = await fetch(`/api/bugs/${bug.project}/${bug.id}`, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(patch),
      });
      if (!res.ok) {
        const data = (await res.json().catch(() => ({}))) as { error?: string };
        throw new Error(data.error ?? `HTTP ${res.status}`);
      }
      const updated = (await res.json()) as BugFull;
      setBug(updated);
      setEditing(false);
      router.refresh();
    } catch (err) {
      setError(err instanceof Error ? err.message : "Sauvegarde échouée");
    } finally {
      setSaving(false);
    }
  }

  async function toggleStatus() {
    const next: Status = bug.status === "OPEN" ? "RESOLVED" : "OPEN";
    setError(null);
    const res = await fetch(`/api/bugs/${bug.project}/${bug.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ status: next }),
    });
    if (!res.ok) {
      const data = (await res.json().catch(() => ({}))) as { error?: string };
      setError(data.error ?? `HTTP ${res.status}`);
      return;
    }
    const updated = (await res.json()) as BugFull;
    setBug(updated);
    router.refresh();
  }

  async function remove() {
    if (!confirm(`Supprimer ${bug.id} ? Le dossier et toutes les images seront perdus.`)) return;
    const res = await fetch(`/api/bugs/${bug.project}/${bug.id}`, { method: "DELETE" });
    if (!res.ok) {
      setError("Suppression échouée");
      return;
    }
    router.push("/bugs");