Application Palais Prouvost

De Association Linéage de France et d'International
Aller à la navigationAller à la recherche

import React, { useEffect, useMemo, useRef, useState } from "react";

// ============================================= // Prouvost Palace Builder – Single-file React App // ============================================= // • Créez des salons/pièces (hôtel particulier, palais) // • Définissez une image de fond par pièce (plan, photo, texture, couleur) // • Importez des photos d'objets/tableaux/meubles et placez-les dans la pièce // • Déplacez / redimensionnez / superposez les objets // • Sauvegarde auto dans localStorage + Export/Import JSON // • Mode Présentation pour naviguer en plein écran entre les pièces // =============================================

function useLocalStorage(key, initialValue) {

 const [value, setValue] = useState(() => {
   try {
     const item = window.localStorage.getItem(key);
     return item ? JSON.parse(item) : initialValue;
   } catch (e) {
     return initialValue;
   }
 });
 useEffect(() => {
   try {
     window.localStorage.setItem(key, JSON.stringify(value));
   } catch {}
 }, [key, value]);
 return [value, setValue];

}

const defaultProject = {

 title: "Hôtel particulier Prouveau",
 rooms: [
   {
     id: cryptoRandomId(),
     name: "Grand Salon",
     bgType: "color", // 'image' | 'color'
     bgColor: "#f5f1ea",
     bgImage: null,
     width: 1400,
     height: 800,
     items: [],
   },
 ],
 activeRoomId: null,

};

function cryptoRandomId() {

 const arr = new Uint8Array(8);
 if (window.crypto && window.crypto.getRandomValues) {
   window.crypto.getRandomValues(arr);
 } else {
   for (let i = 0; i < arr.length; i++) arr[i] = Math.floor(Math.random() * 256);
 }
 return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");

}

export default function ProuveauPalaceBuilder() {

 const [project, setProject] = useLocalStorage("prv_palace_project_v1", defaultProject);
 const activeRoom = useMemo(() => {
   const id = project.activeRoomId ?? project.rooms[0]?.id;
   return project.rooms.find((r) => r.id === id) || project.rooms[0];
 }, [project]);
 // Ensure activeRoomId initialized
 useEffect(() => {
   if (!project.activeRoomId && project.rooms[0]) {
     setProject((p) => ({ ...p, activeRoomId: p.rooms[0].id }));
   }
 }, []);
 const setActiveRoom = (id) => setProject((p) => ({ ...p, activeRoomId: id }));
 const addRoom = () => {
   const r = {
     id: cryptoRandomId(),
     name: `Pièce ${project.rooms.length + 1}`,
     bgType: "color",
     bgColor: "#ffffff",
     bgImage: null,
     width: 1200,
     height: 700,
     items: [],
   };
   setProject((p) => ({ ...p, rooms: [...p.rooms, r], activeRoomId: r.id }));
 };
 const deleteRoom = (id) => {
   setProject((p) => {
     const nextRooms = p.rooms.filter((r) => r.id !== id);
     const nextActive = nextRooms[0]?.id || null;
     return { ...p, rooms: nextRooms, activeRoomId: nextActive };
   });
 };
 const updateRoom = (patch) => {
   setProject((p) => ({
     ...p,
     rooms: p.rooms.map((r) => (r.id === activeRoom.id ? { ...r, ...patch } : r)),
   }));
 };
 const exportJSON = () => {
   const blob = new Blob([JSON.stringify(project, null, 2)], { type: "application/json" });
   const url = URL.createObjectURL(blob);
   const a = document.createElement("a");
   a.href = url;
   a.download = `${(project.title || "projet").replace(/\s+/g, "_")}.json`;
   a.click();
   URL.revokeObjectURL(url);
 };
 const importJSON = (file) => {
   const reader = new FileReader();
   reader.onload = () => {
     try {
       const data = JSON.parse(reader.result);
       if (data && data.rooms) setProject(data);
     } catch (e) {
       alert("Fichier invalide");
     }
   };
   reader.readAsText(file);
 };
 const [present, setPresent] = useState(false);
 return (
     {/* Header */}
     <header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
PP
         <input
           className="text-xl md:text-2xl font-semibold bg-transparent outline-none flex-1"
           value={project.title}
           onChange={(e) => setProject((p) => ({ ...p, title: e.target.value }))}
         />
           <button className="px-3 py-1.5 rounded-lg bg-neutral-900 text-white hover:bg-neutral-800" onClick={addRoom}>
             + Ajouter une pièce
           </button>
           <label className="px-3 py-1.5 rounded-lg bg-neutral-200 hover:bg-neutral-300 cursor-pointer">
             Importer
             <input type="file" className="hidden" accept="application/json" onChange={(e) => e.target.files?.[0] && importJSON(e.target.files[0])} />
           </label>
           <button className="px-3 py-1.5 rounded-lg bg-neutral-200 hover:bg-neutral-300" onClick={exportJSON}>
             Exporter
           </button>
           <button className="px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => setPresent((s) => !s)}>
             {present ? "Quitter Présentation" : "Mode Présentation"}
           </button>
     </header>
     <main className="mx-auto max-w-7xl grid grid-cols-1 md:grid-cols-12 gap-4 px-4 py-4">
       {/* Sidebar rooms */}
       <aside className="md:col-span-3 lg:col-span-3 bg-white border border-neutral-200 rounded-2xl p-3 shadow-sm">

Pièces

           {project.rooms.map((r) => (
             <button
               key={r.id}
               onClick={() => setActiveRoom(r.id)}
               className={`w-full text-left px-3 py-2 rounded-xl border ${
                 r.id === activeRoom?.id ? "border-neutral-900 bg-neutral-900 text-white" : "border-neutral-200 hover:border-neutral-300"
               }`}
             >
{r.name}
{r.width} × {r.height}px
             </button>
           ))}
         {activeRoom && (
               <input
                 className="border rounded-lg px-2 py-1 flex-1"
                 value={activeRoom.name}
                 onChange={(e) => updateRoom({ name: e.target.value })}
               />
               <button className="px-3 py-1.5 rounded-lg border border-red-200 text-red-700 hover:bg-red-50" onClick={() => deleteRoom(activeRoom.id)}>
                 Supprimer
               </button>
               <label className="col-span-2 text-sm font-semibold">Fond de la pièce</label>
               <button
                 className={`px-2 py-1 rounded-lg border ${activeRoom.bgType === "color" ? "border-neutral-900" : "border-neutral-200"}`}
                 onClick={() => updateRoom({ bgType: "color" })}
               >
                 Couleur
               </button>
               <button
                 className={`px-2 py-1 rounded-lg border ${activeRoom.bgType === "image" ? "border-neutral-900" : "border-neutral-200"}`}
                 onClick={() => updateRoom({ bgType: "image" })}
               >
                 Image
               </button>
               {activeRoom.bgType === "color" ? (
                 <input
                   type="color"
                   value={activeRoom.bgColor}
                   onChange={(e) => updateRoom({ bgColor: e.target.value })}
                   className="col-span-2 w-full h-10 rounded-lg border"
                 />
               ) : (
                 <label className="col-span-2 w-full px-3 py-2 rounded-lg border cursor-pointer hover:bg-neutral-50">
                   Importer une image de fond
                   <input
                     type="file"
                     accept="image/*"
                     className="hidden"
                     onChange={async (e) => {
                       const f = e.target.files?.[0];
                       if (!f) return;
                       const dataUrl = await fileToDataURL(f);
                       updateRoom({ bgImage: dataUrl });
                     }}
                   />
                 </label>
               )}
               <label className="col-span-2 text-sm font-semibold">Taille de la scène</label>
               <input
                 type="number"
                 className="border rounded-lg px-2 py-1"
                 value={activeRoom.width}
                 onChange={(e) => updateRoom({ width: clampInt(e.target.value, 400, 4000) })}
               />
               <input
                 type="number"
                 className="border rounded-lg px-2 py-1"
                 value={activeRoom.height}
                 onChange={(e) => updateRoom({ height: clampInt(e.target.value, 300, 3000) })}
               />
             <AddItemPanel onAdd={async (item) => {
               setProject((p) => ({
                 ...p,
                 rooms: p.rooms.map((r) => r.id === activeRoom.id ? { ...r, items: [...r.items, item] } : r),
               }));
             }} />
             <LayersPanel room={activeRoom} onChange={(items) => updateRoom({ items })} />
         )}
       </aside>
       {/* Canvas */}
       <section className="md:col-span-9 lg:col-span-9">
         {activeRoom ? (
           <RoomCanvas key={activeRoom.id} room={activeRoom} onChange={(patch) => updateRoom(patch)} present={present} />
         ) : (
Aucune pièce
         )}
       </section>
     </main>
 );

}

function clampInt(val, min, max) {

 const n = parseInt(val, 10);
 if (isNaN(n)) return min;
 return Math.max(min, Math.min(max, n));

}

async function fileToDataURL(file) {

 return new Promise((resolve, reject) => {
   const reader = new FileReader();
   reader.onload = () => resolve(reader.result);
   reader.onerror = reject;
   reader.readAsDataURL(file);
 });

}

function AddItemPanel({ onAdd }) {

 const [label, setLabel] = useState("");
 const [scale, setScale] = useState(1);
 const [file, setFile] = useState(null);
 const handleAdd = async () => {
   let src = null;
   if (file) src = await fileToDataURL(file);
   const item = {
     id: cryptoRandomId(),
     label: label || "Objet",
     x: 50 + Math.random() * 200,
     y: 50 + Math.random() * 120,
     w: 180,
     h: 180,
     rotation: 0,
     src,
     scale: scale || 1,
     z: Date.now(),
     note: "",
   };
   onAdd(item);
   setLabel("");
   setFile(null);
   setScale(1);
 };
 return (
Ajouter un objet / tableau / meuble
       <input className="border rounded-lg px-2 py-1" placeholder="Nom (ex. Portrait de 1860)" value={label} onChange={(e) => setLabel(e.target.value)} />
       <label className="px-3 py-2 rounded-lg border cursor-pointer hover:bg-neutral-50">
         Importer une image (PNG/JPG)
         <input type="file" accept="image/*" className="hidden" onChange={(e) => setFile(e.target.files?.[0] || null)} />
       </label>
         Échelle
         <input type="range" min={0.2} max={3} step={0.1} value={scale} onChange={(e) => setScale(parseFloat(e.target.value))} className="flex-1" />
         {scale.toFixed(1)}×
       <button className="px-3 py-2 rounded-lg bg-neutral-900 text-white hover:bg-neutral-800" onClick={handleAdd}>+ Ajouter à la pièce</button>

Astuce : vous pouvez ajouter des objets sans image (cartels textuels) pour matérialiser des idées à imaginer plus tard.

 );

}

function LayersPanel({ room, onChange }) {

 const [selectedId, setSelectedId] = useState(null);
 const items = room.items || [];
 const remove = (id) => onChange(items.filter((i) => i.id !== id));
 const bringToFront = (id) => onChange(items.map((i) => (i.id === id ? { ...i, z: Date.now() } : i)));
 return (
Calques / objets ({items.length})
{items.length === 0 &&
Aucun objet encore
}
       {items
         .slice()
         .sort((a, b) => a.z - b.z)
         .map((i) => (
             <button
               className="px-2 py-1 rounded border"
               onClick={() => bringToFront(i.id)}
               title="Amener au premier plan"
             >⬆️</button>
{i.label}
x:{i.x.toFixed(0)} y:{i.y.toFixed(0)} • rot:{i.rotation}°
             <button className="px-2 py-1 rounded border border-red-200 text-red-700" onClick={() => remove(i.id)}>Suppr</button>
         ))}
 );

}

function RoomCanvas({ room, onChange, present }) {

 const containerRef = useRef(null);
 const [drag, setDrag] = useState(null); // {id, dx, dy}
 const [resize, setResize] = useState(null); // {id, startW, startH}
 const [rot, setRot] = useState(null); // {id, startRot}
 const setItems = (updater) => onChange({ items: updater(room.items || []) });
 const styleBg = room.bgType === "image"
   ? { backgroundImage: `url(${room.bgImage})`, backgroundSize: "cover", backgroundPosition: "center" }
   : { backgroundColor: room.bgColor };
 const scaleFit = present ? Math.min((window.innerWidth - 48) / room.width, (window.innerHeight - 160) / room.height, 1) : 1;
 useEffect(() => {
   const onUp = () => {
     setDrag(null);
     setResize(null);
     setRot(null);
   };
   const onMove = (e) => {
     if (drag) {
       const { id, startX, startY, origX, origY } = drag;
       const dx = (e.clientX - startX) / scaleFit;
       const dy = (e.clientY - startY) / scaleFit;
       setItems((items) => items.map((it) => (it.id === id ? { ...it, x: origX + dx, y: origY + dy } : it)));
     }
     if (resize) {
       const { id, startX, startY, startW, startH } = resize;
       const dx = (e.clientX - startX) / scaleFit;
       const dy = (e.clientY - startY) / scaleFit;
       setItems((items) => items.map((it) => (it.id === id ? { ...it, w: Math.max(40, startW + dx), h: Math.max(40, startH + dy) } : it)));
     }
     if (rot) {
       const { id, centerX, centerY, startAngle } = rot;
       const ang = Math.atan2(e.clientY - centerY, e.clientX - centerX);
       const deg = Math.round((ang * 180) / Math.PI);
       setItems((items) => items.map((it) => (it.id === id ? { ...it, rotation: deg - startAngle } : it)));
     }
   };
   window.addEventListener("mousemove", onMove);
   window.addEventListener("mouseup", onUp);
   return () => {
     window.removeEventListener("mousemove", onMove);
     window.removeEventListener("mouseup", onUp);
   };
 }, [drag, resize, rot, scaleFit]);
 const startDrag = (e, it) => {
   e.stopPropagation();
   setDrag({ id: it.id, startX: e.clientX, startY: e.clientY, origX: it.x, origY: it.y });
   bringToFront(it.id);
 };
 const startResize = (e, it) => {
   e.stopPropagation();
   setResize({ id: it.id, startX: e.clientX, startY: e.clientY, startW: it.w, startH: it.h });
   bringToFront(it.id);
 };
 const startRotate = (e, it, box) => {
   e.stopPropagation();
   const rect = box.getBoundingClientRect();
   const centerX = rect.left + rect.width / 2;
   const centerY = rect.top + rect.height / 2;
   const ang = Math.atan2(e.clientY - centerY, e.clientX - centerX);
   const startAngle = Math.round((ang * 180) / Math.PI) - it.rotation;
   setRot({ id: it.id, centerX, centerY, startAngle });
   bringToFront(it.id);
 };
 const bringToFront = (id) => setItems((items) => items.map((i) => (i.id === id ? { ...i, z: Date.now() } : i)));
 return (
{room.name}
Scène {room.width} × {room.height}px
Glissez, redimensionnez, faites pivoter. Double‑clic pour éditer le libellé.
         {(room.items || [])
           .slice()
           .sort((a, b) => a.z - b.z)
           .map((it) => (
             <DraggableItem
               key={it.id}
               item={it}
               onMouseDown={startDrag}
               onResize={startResize}
               onRotate={startRotate}
               onChange={(patch) => setItems((items) => items.map((i) => (i.id === it.id ? { ...i, ...patch } : i)))}
             />
           ))}
 );

}

function DraggableItem({ item, onMouseDown, onResize, onRotate, onChange }) {

 const boxRef = useRef(null);
 const [editing, setEditing] = useState(false);
 const style = {
   position: "absolute",
   left: item.x,
   top: item.y,
   width: item.w,
   height: item.h,
   transform: `rotate(${item.rotation || 0}deg) scale(${item.scale || 1})`,
   transformOrigin: "center center",
 };
 return (
setEditing(true)}>
onMouseDown(e, item)}
     >
       {item.src ? (
         <img src={item.src} alt={item.label} className="w-full h-full object-contain" draggable={false} />
       ) : (
{item.label}
(Sans image – idée/repère)
       )}
     {/* Controls */}
       <button
         className="px-2 py-1 text-xs rounded-lg bg-neutral-900 text-white"
         onClick={() => onChange({ note: prompt("Ajouter/éditer une note :", item.note || "") || item.note })}
       >
         ✎ Note
       </button>
     <button
       className="absolute -bottom-3 -right-3 w-6 h-6 rounded-full bg-neutral-900 text-white grid place-items-center cursor-se-resize"
       onMouseDown={(e) => onResize(e, item)}
       title="Redimensionner"
     >
       ↘
     </button>
     <button
       className="absolute -top-3 -right-3 w-6 h-6 rounded-full bg-neutral-900 text-white grid place-items-center cursor-alias"
       onMouseDown={(e) => onRotate(e, item, boxRef.current)}
       title="Pivoter"
     >
       ⟳
     </button>
     {editing && (
e.stopPropagation()}>
         <input
           autoFocus
           className="w-11/12 border rounded-lg px-2 py-1"
           value={item.label}
           onChange={(e) => onChange({ label: e.target.value })}
           onBlur={() => setEditing(false)}
           onKeyDown={(e) => e.key === "Enter" && setEditing(false)}
         />
     )}
 );

}