Application Palais Prouvost
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">
<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"
}`}
>
</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} />
) : (
)}
</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 (
<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 (
{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>
<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.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 (
>
{item.src ? (
<img src={item.src} alt={item.label} className="w-full h-full object-contain" draggable={false} />
) : (
)}
{/* 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 && (
<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)}
/>
)}
);
}