components/bugs/bug-editor.tsx
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.
Concepts détectés — comprends la théorie
Hooks React
10 occurrencesCe fichier utilise des hooks React. Les hooks sont la façon moderne de gérer l'état et les effets dans React. Voir l'architecture mobile pour le pattern complet.
Voir l'article général
Appel API (fetch)
4 occurrencesCe fichier fait des appels HTTP. Voir la convention API wari.pro pour les patterns de gestion d'erreur et de Bearer token.
Voir l'article général
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 ``;
} 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");"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 ``;
} 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");