diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..59d3ddf
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+public/fonts/
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..341239c
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Excalidraw – schémas
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..bd02304
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "excalidraw-go-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "prebuild": "node scripts/copy-fonts.mjs",
+ "predev": "node scripts/copy-fonts.mjs",
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@excalidraw/excalidraw": "0.18.1",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.0",
+ "@types/react-dom": "^19.1.0",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "^5.8.0",
+ "vite": "^7.0.0"
+ }
+}
diff --git a/frontend/scripts/copy-fonts.mjs b/frontend/scripts/copy-fonts.mjs
new file mode 100644
index 0000000..e0a0349
--- /dev/null
+++ b/frontend/scripts/copy-fonts.mjs
@@ -0,0 +1,10 @@
+// Copie les polices d'Excalidraw dans public/fonts pour un usage hors ligne.
+import { cpSync, existsSync } from "node:fs";
+
+const src = "node_modules/@excalidraw/excalidraw/dist/prod/fonts";
+if (!existsSync(src)) {
+ console.error(`polices introuvables : ${src}`);
+ process.exit(1);
+}
+cpSync(src, "public/fonts", { recursive: true });
+console.log("polices Excalidraw copiées dans public/fonts");
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
new file mode 100644
index 0000000..6f94e79
--- /dev/null
+++ b/frontend/src/api.ts
@@ -0,0 +1,91 @@
+// Client de l'API Go (voir internal/api/api.go).
+
+export interface Entry {
+ name: string;
+ path: string;
+ isDir: boolean;
+ size: number;
+ modTime: string;
+}
+
+export class ApiError extends Error {
+ constructor(public status: number, message: string) {
+ super(message);
+ }
+}
+
+async function request(url: string, init?: RequestInit): Promise {
+ const res = await fetch(url, init);
+ if (!res.ok) {
+ let msg = res.statusText;
+ try {
+ msg = (await res.json()).error ?? msg;
+ } catch {
+ /* corps non JSON */
+ }
+ throw new ApiError(res.status, msg);
+ }
+ return res;
+}
+
+const q = (p: string) => encodeURIComponent(p);
+const json = (body: unknown): RequestInit => ({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+});
+
+export const api = {
+ list: async (dir: string): Promise =>
+ (await request(`/api/list?path=${q(dir)}`)).json(),
+
+ search: async (text: string): Promise =>
+ (await request(`/api/search?q=${q(text)}`)).json(),
+
+ read: async (path: string): Promise<{ text: string; etag: string }> => {
+ const res = await request(`/api/file?path=${q(path)}`);
+ return { text: await res.text(), etag: res.headers.get("ETag") ?? "" };
+ },
+
+ /** etag : "" = écrasement libre, "*" = création seule, sinon contrôle optimiste. */
+ write: async (path: string, body: string, etag: string): Promise => {
+ const headers: Record = { "Content-Type": "application/json" };
+ if (etag === "*") headers["If-None-Match"] = "*";
+ else if (etag) headers["If-Match"] = etag;
+ const res = await request(`/api/file?path=${q(path)}`, { method: "PUT", headers, body });
+ return res.headers.get("ETag") ?? "";
+ },
+
+ mkdir: async (path: string): Promise => (await request("/api/folder", json({ path }))).json(),
+
+ move: async (from: string, to: string): Promise =>
+ (await request("/api/move", json({ from, to }))).json(),
+
+ copy: async (from: string, to: string): Promise =>
+ (await request("/api/copy", json({ from, to }))).json(),
+
+ remove: async (path: string, recursive: boolean): Promise => {
+ await request(`/api/entry?path=${q(path)}&recursive=${recursive}`, { method: "DELETE" });
+ },
+
+ readLibrary: async (): Promise => (await request("/api/library")).json(),
+
+ writeLibrary: async (body: string): Promise => {
+ await request("/api/library", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body,
+ });
+ },
+
+ downloadUrl: (path: string) => `/api/file?path=${q(path)}`,
+};
+
+// Helpers de chemins (format "slash", relatifs à la racine).
+export const dirname = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
+export const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1);
+export const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
+export const EXT = ".excalidraw";
+export const withExt = (name: string) => (name.toLowerCase().endsWith(EXT) ? name : name + EXT);
+export const stripExt = (name: string) =>
+ name.toLowerCase().endsWith(EXT) ? name.slice(0, -EXT.length) : name;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..8f24a9b
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "@excalidraw/excalidraw/index.css";
+import "./styles.css";
+import App from "./App";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..fbfbd1f
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,5 @@
+///
+
+interface Window {
+ EXCALIDRAW_ASSET_PATH?: string | string[];
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..b5318a6
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "allowImportingTsExtensions": false,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..dd18e44
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ // Le build est embarqué dans le binaire Go (web/dist).
+ build: { outDir: "../web/dist", emptyOutDir: true, chunkSizeWarningLimit: 4096 },
+ // `npm run dev` : l'API Go tourne sur :8080.
+ server: { proxy: { "/api": "http://localhost:8080" } },
+ define: { "process.env.IS_PREACT": JSON.stringify("false") },
+});