This commit is contained in:
lejay
2026-02-13 00:21:53 +01:00
parent 81d2412835
commit 6923c319a1
85 changed files with 17836 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
/**
* AIRenderModal - Generates and displays a DALL-E 3 photorealistic render
* of the configured wardrobe, giving the customer a realistic preview.
*/
import { useState, useEffect, useRef } from 'react';
import styles from './AIRenderModal.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function AIRenderModal({ isOpen, config, roomConfig, onClose }) {
const [imageUrl, setImageUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [progress, setProgress] = useState(0);
const abortRef = useRef(null);
const timerRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
setImageUrl(null);
setError(null);
setLoading(true);
setProgress(0);
// Simulate progress (DALL-E takes ~15-25s)
timerRef.current = setInterval(() => {
setProgress(p => Math.min(p + Math.random() * 8, 92));
}, 1000);
const controller = new AbortController();
abortRef.current = controller;
const body = {
config: {
width: config.totalWidth,
height: config.height,
depth: config.depth,
installationType: config.installationType,
columns: config.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width,
shelves: m.shelves,
drawers: m.drawers,
hasRod: m.hasRod
})),
materialName: config.finishLabel || null,
doorType: config.doorType
},
roomContext: roomConfig || null
};
fetch(`${API_BASE}/api/ai/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
})
.then(r => {
if (!r.ok) throw new Error(r.status === 402 ? 'quota' : 'api');
return r.json();
})
.then(data => {
if (data.imageUrl) {
setImageUrl(data.imageUrl);
setProgress(100);
} else {
throw new Error('no_image');
}
})
.catch(err => {
if (err.name === 'AbortError') return;
if (err.message === 'quota') {
setError('Quota IA dépassé. Réessayez plus tard.');
} else {
setError('Impossible de générer le rendu. Vérifiez la configuration IA.');
}
})
.finally(() => {
setLoading(false);
if (timerRef.current) clearInterval(timerRef.current);
});
return () => {
controller.abort();
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isOpen]);
const handleDownload = () => {
if (!imageUrl) return;
const a = document.createElement('a');
a.href = imageUrl;
a.download = `armoire-rendu-${Date.now()}.png`;
a.target = '_blank';
a.click();
};
if (!isOpen) return null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.header}>
<h2 className={styles.title}>
<span className={styles.aiBadge}>IA</span>
Rendu photoréaliste
</h2>
<button className={styles.closeBtn} onClick={onClose}>×</button>
</div>
<div className={styles.body}>
{loading && !imageUrl && (
<div className={styles.loadingArea}>
<div className={styles.spinner} />
<p className={styles.loadingText}>Génération en cours...</p>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${progress}%` }}
/>
</div>
<p className={styles.loadingHint}>
L'IA crée un rendu réaliste de votre armoire. Cela peut prendre 15-25 secondes.
</p>
</div>
)}
{error && (
<div className={styles.errorArea}>
<p className={styles.errorText}>{error}</p>
<button className={styles.retryBtn} onClick={() => {
setError(null);
setLoading(true);
setProgress(0);
}}>
Réessayer
</button>
</div>
)}
{imageUrl && (
<div className={styles.imageArea}>
<img src={imageUrl} alt="Rendu photoréaliste de l'armoire" className={styles.renderImage} />
</div>
)}
</div>
<div className={styles.footer}>
{imageUrl && (
<button className={styles.downloadBtn} onClick={handleDownload}>
Télécharger l'image
</button>
)}
<button className={styles.closeFooterBtn} onClick={onClose}>
Fermer
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,199 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 14px;
width: min(95vw, 900px);
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light, #e5e7eb);
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.1rem;
margin: 0;
color: var(--text, #1f2937);
}
.aiBadge {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 7px;
border-radius: 4px;
background: var(--action, #4f46e5);
color: #fff;
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted, #6b7280);
padding: 4px 8px;
border-radius: 6px;
}
.closeBtn:hover {
background: var(--bg-subtle, #f3f4f6);
}
.body {
flex: 1;
overflow: auto;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
/* Loading */
.loadingArea {
text-align: center;
padding: 40px 20px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border-light, #e5e7eb);
border-top-color: var(--action, #4f46e5);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loadingText {
font-size: 1rem;
font-weight: 600;
color: var(--text, #1f2937);
margin: 0 0 12px;
}
.progressBar {
width: 260px;
height: 6px;
background: var(--border-light, #e5e7eb);
border-radius: 3px;
margin: 0 auto 12px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--action, #4f46e5);
border-radius: 3px;
transition: width 0.5s ease;
}
.loadingHint {
font-size: 0.8rem;
color: var(--text-muted, #6b7280);
margin: 0;
}
/* Error */
.errorArea {
text-align: center;
padding: 40px 20px;
}
.errorText {
font-size: 0.9rem;
color: var(--danger, #dc2626);
margin: 0 0 12px;
}
.retryBtn {
padding: 8px 16px;
border: 1px solid var(--action, #4f46e5);
background: transparent;
color: var(--action, #4f46e5);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.retryBtn:hover {
background: var(--bg-subtle, #f9fafb);
}
/* Image */
.imageArea {
padding: 12px;
width: 100%;
}
.renderImage {
width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
/* Footer */
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--border-light, #e5e7eb);
}
.downloadBtn {
padding: 8px 16px;
background: var(--action, #4f46e5);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
}
.downloadBtn:hover {
background: var(--action-hover, #4338ca);
}
.closeFooterBtn {
padding: 8px 16px;
background: transparent;
color: var(--text-muted, #6b7280);
border: 1px solid var(--border-light, #e5e7eb);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.closeFooterBtn:hover {
background: var(--bg-subtle, #f9fafb);
}
+301
View File
@@ -0,0 +1,301 @@
/**
* ColorPickerModal - Compact swatch-grid color picker for wardrobe elements.
* Sidebar collections + dense swatch grid = everything visible at once.
*/
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import styles from './ColorPickerModal.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas',
fileur: 'Fileur',
bandeauHaut: 'Bandeau haut',
champs: 'Champs (bords)',
caisse: 'Caisse',
cotesDoublage: 'Doublage',
cotes: 'Cotes',
dessus: 'Dessus',
fond: 'Fond',
dos: 'Dos',
portes: 'Portes',
panneauxInterieurs: 'Panneaux int.',
separateurs: 'Separateurs',
etageres: 'Etageres',
tiroirs: 'Tiroirs',
penderie: 'Tringle'
};
export default function ColorPickerModal({
isOpen,
elementType,
currentFinish,
finishes = [],
onSelect,
onClose,
onApplyToAll,
config
}) {
const [selectedFinish, setSelectedFinish] = useState(currentFinish);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCollection, setSelectedCollection] = useState('all');
const [hoveredFinish, setHoveredFinish] = useState(null);
const [aiRecs, setAiRecs] = useState([]);
const [aiLoading, setAiLoading] = useState(false);
const tooltipRef = useRef(null);
useEffect(() => {
if (isOpen) {
setSelectedFinish(currentFinish);
setSearchTerm('');
setSelectedCollection('all');
setHoveredFinish(null);
setAiRecs([]);
// Fetch AI recommendations
if (config && finishes.length > 0) {
setAiLoading(true);
const controller = new AbortController();
fetch(`${API_BASE}/api/ai/recommend-materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: {
width: config.totalWidth,
height: config.height,
depth: config.depth,
columns: config.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width
})),
materialName: config.finishLabel || null,
doorType: config.doorType
},
finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code }))
}),
signal: controller.signal
})
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.recommendations) {
setAiRecs(data.recommendations);
}
})
.catch(() => {})
.finally(() => setAiLoading(false));
return () => controller.abort();
}
}
}, [isOpen, currentFinish]);
const collections = useMemo(() => {
const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))];
return cols.sort();
}, [finishes]);
const filteredFinishes = useMemo(() => {
return finishes.filter(finish => {
const matchesSearch = !searchTerm ||
finish.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
finish.label?.toLowerCase().includes(searchTerm.toLowerCase()) ||
finish.code?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCollection = selectedCollection === 'all' ||
finish.collection === selectedCollection;
return matchesSearch && matchesCollection;
});
}, [finishes, searchTerm, selectedCollection]);
const handleConfirm = () => {
if (selectedFinish && onSelect) {
onSelect(elementType, selectedFinish);
}
onClose();
};
const handleApplyToAll = () => {
if (selectedFinish && onApplyToAll) {
onApplyToAll(selectedFinish);
}
onClose();
};
if (!isOpen) return null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* Header: title + search */}
<div className={styles.header}>
<h2 className={styles.title}>
{ELEMENT_LABELS[elementType] || elementType}
</h2>
<input
type="text"
className={styles.searchInput}
placeholder="Rechercher..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
autoFocus
/>
<button className={styles.closeBtn} onClick={onClose}>×</button>
</div>
{/* Body: sidebar + swatch grid */}
<div className={styles.body}>
{/* Collection sidebar */}
<div className={styles.sidebar}>
<button
type="button"
className={`${styles.sidebarTab} ${selectedCollection === 'all' ? styles.sidebarTabActive : ''}`}
onClick={() => setSelectedCollection('all')}
>
Tous
<span className={styles.tabCount}>{finishes.length}</span>
</button>
{collections.map(col => {
const count = finishes.filter(f => f.collection === col).length;
return (
<button
key={col}
type="button"
className={`${styles.sidebarTab} ${selectedCollection === col ? styles.sidebarTabActive : ''}`}
onClick={() => setSelectedCollection(col)}
>
{col}
<span className={styles.tabCount}>{count}</span>
</button>
);
})}
</div>
{/* Swatch grid */}
<div className={styles.swatchArea}>
{/* AI Recommendations */}
{(aiRecs.length > 0 || aiLoading) && (
<div className={styles.aiSection}>
<div className={styles.aiHeader}>
<span className={styles.aiBadge}>IA</span>
<span className={styles.aiLabel}>Recommandations</span>
</div>
{aiLoading ? (
<div className={styles.aiShimmer}>
<div className={styles.aiShimmerDot} />
<div className={styles.aiShimmerDot} />
<div className={styles.aiShimmerDot} />
</div>
) : (
<div className={styles.aiSwatches}>
{aiRecs.map(rec => {
const finish = finishes.find(f => f.id === rec.finishId);
if (!finish) return null;
const isSelected = selectedFinish?.id === finish.id;
return (
<button
key={`ai-${finish.id}`}
type="button"
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
onClick={() => setSelectedFinish(finish)}
onMouseEnter={() => setHoveredFinish({ ...finish, aiReason: rec.reason })}
onMouseLeave={() => setHoveredFinish(null)}
title={rec.reason}
>
{finish.image_url ? (
<img src={finish.image_url} alt="" className={styles.swatchImg} loading="lazy" />
) : (
<div className={styles.swatchColor} style={{ backgroundColor: finish.hexColor || '#ccc' }} />
)}
{isSelected && <span className={styles.swatchCheck}>✓</span>}
<span className={styles.aiSwatchBadge}>★</span>
</button>
);
})}
</div>
)}
</div>
)}
{filteredFinishes.length > 0 ? (
<div className={styles.swatchGrid}>
{filteredFinishes.map(finish => {
const isSelected = selectedFinish?.id === finish.id;
const isHovered = hoveredFinish?.id === finish.id;
return (
<button
key={finish.id}
type="button"
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''}`}
onClick={() => setSelectedFinish(finish)}
onMouseEnter={() => setHoveredFinish(finish)}
onMouseLeave={() => setHoveredFinish(null)}
title={`${finish.name || finish.label || ''} ${finish.code ? `(${finish.code})` : ''}`}
>
{finish.image_url ? (
<img
src={finish.image_url}
alt=""
className={styles.swatchImg}
loading="lazy"
/>
) : (
<div
className={styles.swatchColor}
style={{ backgroundColor: finish.hexColor || '#ccc' }}
/>
)}
{isSelected && <span className={styles.swatchCheck}>✓</span>}
</button>
);
})}
</div>
) : (
<div className={styles.empty}>Aucune finition trouvee</div>
)}
{/* Hover detail strip -- shows info of hovered swatch */}
{hoveredFinish && (
<div className={styles.hoverDetail} ref={tooltipRef}>
{hoveredFinish.image_url && (
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
)}
<div className={styles.hoverInfo}>
<strong>{hoveredFinish.name || hoveredFinish.label}</strong>
{hoveredFinish.code && <span className={styles.hoverCode}>{hoveredFinish.code}</span>}
{hoveredFinish.collection && <span className={styles.hoverColl}>{hoveredFinish.collection}</span>}
{hoveredFinish.price_chf != null && (
<span className={styles.hoverPrice}>{hoveredFinish.price_chf.toFixed(2)} CHF</span>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer: selection preview + actions */}
<div className={styles.footer}>
<div className={styles.preview}>
{selectedFinish ? (
<>
{selectedFinish.image_url ? (
<img src={selectedFinish.image_url} alt="" className={styles.previewImg} />
) : selectedFinish.hexColor ? (
<div className={styles.previewDot} style={{ backgroundColor: selectedFinish.hexColor }} />
) : null}
<strong>{selectedFinish.name || selectedFinish.label}</strong>
</>
) : (
<span className={styles.previewLabel}>Aucune selection</span>
)}
</div>
<div className={styles.actions}>
<button className={styles.secondaryBtn} onClick={handleApplyToAll} disabled={!selectedFinish}>
Appliquer a tous
</button>
<button className={styles.cancelBtn} onClick={onClose}>Annuler</button>
<button className={styles.confirmBtn} onClick={handleConfirm} disabled={!selectedFinish}>
Appliquer
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,491 @@
/* ===================================================================
ColorPickerModal -- compact swatch-grid layout
Sidebar collections + dense swatch grid = no scroll needed
=================================================================== */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.modal {
background: #fff;
border-radius: 12px;
width: 95vw;
max-width: 1100px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* --- Header: title + search + close --- */
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
background: var(--bg-subtle);
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.searchInput {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 0.85rem;
max-width: 260px;
}
.searchInput:focus {
outline: none;
border-color: var(--action);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
padding: 2px 6px;
line-height: 1;
margin-left: auto;
}
.closeBtn:hover {
color: var(--text);
}
/* --- Body: sidebar + swatch area --- */
.body {
display: flex;
flex: 1;
min-height: 0; /* allow flex shrink */
overflow: hidden;
}
/* --- Sidebar: vertical collection tabs --- */
.sidebar {
width: 140px;
flex-shrink: 0;
border-right: 1px solid var(--border-light);
background: var(--bg-subtle);
overflow-y: auto;
padding: 6px 0;
}
.sidebarTab {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
text-align: left;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.sidebarTab:hover {
color: var(--text);
background: rgba(0, 0, 0, 0.03);
}
.sidebarTabActive {
color: var(--action);
background: #fff;
border-left-color: var(--action);
font-weight: 600;
}
.tabCount {
font-size: 0.65rem;
color: var(--text-muted);
background: var(--border-light);
padding: 1px 5px;
border-radius: 8px;
min-width: 1.4rem;
text-align: center;
}
.sidebarTabActive .tabCount {
background: rgba(79, 70, 229, 0.12);
color: var(--action);
}
/* --- Swatch area: grid + hover detail --- */
.swatchArea {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px 16px;
position: relative;
}
/* Dense swatch grid -- small squares, many per row */
.swatchGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
gap: 6px;
align-content: start;
}
.swatch {
position: relative;
width: 100%;
aspect-ratio: 1;
border: 2px solid var(--border-light);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s;
padding: 0;
background: #fff;
}
.swatch:hover {
border-color: var(--action);
transform: scale(1.08);
z-index: 2;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.swatchSelected {
border-color: var(--action);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.25);
z-index: 1;
}
.swatchImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.swatchColor {
width: 100%;
height: 100%;
}
.swatchCheck {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: var(--action);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
}
.empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 0.9rem;
}
/* --- Hover detail strip (bottom of swatch area) --- */
.hoverDetail {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
margin-top: 8px;
background: var(--bg-subtle);
border: 1px solid var(--border-light);
border-radius: 8px;
flex-shrink: 0;
}
.hoverImg {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.hoverInfo {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
font-size: 0.8rem;
color: var(--text);
}
.hoverInfo strong {
font-size: 0.85rem;
}
.hoverCode {
font-size: 0.7rem;
color: var(--text-muted);
font-family: monospace;
}
.hoverColl {
font-size: 0.7rem;
color: var(--text-muted);
}
.hoverPrice {
font-size: 0.75rem;
color: var(--success);
font-weight: 600;
}
/* --- Footer: selection preview + action buttons --- */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
flex-shrink: 0;
gap: 12px;
}
.preview {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text);
min-width: 0;
}
.preview strong {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previewImg {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border-light);
flex-shrink: 0;
}
.previewDot {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--border-light);
flex-shrink: 0;
}
.previewLabel {
color: var(--text-muted);
font-size: 0.8rem;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.cancelBtn,
.confirmBtn,
.secondaryBtn {
padding: 7px 14px;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.cancelBtn {
background: #fff;
border: 1px solid var(--border-light);
color: var(--text);
}
.cancelBtn:hover {
background: var(--bg-subtle);
}
.secondaryBtn {
background: #fff;
border: 1px solid var(--action);
color: var(--action);
}
.secondaryBtn:hover:not(:disabled) {
background: #eef2ff;
}
.secondaryBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.confirmBtn {
background: var(--action);
border: none;
color: #fff;
}
.confirmBtn:hover:not(:disabled) {
background: var(--action-hover);
}
.confirmBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* --- Mobile --- */
@media (max-width: 640px) {
.modal {
max-height: 95vh;
max-width: 100%;
}
.header {
flex-wrap: wrap;
}
.searchInput {
max-width: none;
order: 3;
flex-basis: 100%;
}
.sidebar {
width: 100px;
}
.swatchGrid {
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
}
.footer {
flex-direction: column;
gap: 8px;
}
.actions {
width: 100%;
justify-content: flex-end;
}
}
/* AI Recommendations section */
.aiSection {
padding: 8px 12px;
margin-bottom: 8px;
background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%);
border-radius: 8px;
border: 1px solid rgba(79, 70, 229, 0.15);
}
.aiHeader {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.aiBadge {
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: 3px;
background: var(--action, #4f46e5);
color: #fff;
}
.aiLabel {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
font-weight: 500;
}
.aiSwatches {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.aiSwatch {
position: relative;
border-color: rgba(79, 70, 229, 0.3) !important;
}
.aiSwatchBadge {
position: absolute;
top: -3px;
right: -3px;
font-size: 0.5rem;
background: var(--action, #4f46e5);
color: #fff;
width: 12px;
height: 12px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.aiShimmer {
display: flex;
gap: 6px;
}
.aiShimmerDot {
width: 48px;
height: 48px;
border-radius: 6px;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
animation: aiShimmer 1.5s infinite;
}
@keyframes aiShimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
+108
View File
@@ -0,0 +1,108 @@
/**
* CuttingListTable - Display cutting list in a table format
* Shows pieces grouped by finish with dimensions and quantities
*/
import { useMemo } from 'react';
import styles from './CuttingListTable.module.css';
export default function CuttingListTable({ cuttingList, onExport }) {
if (!cuttingList || !cuttingList.pieces) {
return (
<div className={styles.empty}>
Aucune donnée de débitage disponible
</div>
);
}
const { pieces, byFinish, summary } = cuttingList;
return (
<div className={styles.container}>
{/* Summary */}
<div className={styles.summary}>
<h3 className={styles.summaryTitle}>Résumé</h3>
<div className={styles.summaryGrid}>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Pièces totales</span>
<span className={styles.summaryValue}>{summary.totalPieces}</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Surface totale</span>
<span className={styles.summaryValue}>{summary.totalSurfaceM2.toFixed(2)} m²</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Chant plaqué</span>
<span className={styles.summaryValue}>{summary.edgeBandingMl} ml</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Finitions</span>
<span className={styles.summaryValue}>{summary.finishCount}</span>
</div>
</div>
</div>
{/* Tables by finish */}
{byFinish.map((group, groupIndex) => (
<div key={groupIndex} className={styles.finishGroup}>
<h4 className={styles.finishTitle}>
<span className={styles.finishBadge}>{group.finishLabel}</span>
<span className={styles.finishSummary}>
{group.pieceCount} pièces • {group.totalSurfaceM2.toFixed(2)} m²
</span>
</h4>
<table className={styles.table}>
<thead>
<tr>
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Chant</th>
</tr>
</thead>
<tbody>
{group.pieces.map((piece, pieceIndex) => (
<tr key={pieceIndex}>
<td className={styles.descCell}>{piece.description}</td>
<td className={styles.dimCell}>{piece.widthMm}</td>
<td className={styles.dimCell}>{piece.heightMm}</td>
<td className={styles.dimCell}>{piece.thicknessMm}</td>
<td className={styles.qtyCell}>{piece.quantity}</td>
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
<td className={styles.edgeCell}>
{piece.edgeBanding.type !== 'none' && (
<span className={`${styles.edgeBadge} ${styles[piece.edgeBanding.type]}`}>
{piece.edgeBanding.sides} côtés
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{/* Export buttons */}
{onExport && (
<div className={styles.exportActions}>
<button
className={styles.exportBtn}
onClick={() => onExport('text')}
>
Exporter en texte
</button>
<button
className={`${styles.exportBtn} ${styles.primary}`}
onClick={() => onExport('pdf')}
>
Exporter en PDF
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,195 @@
.container {
background: #fff;
border-radius: 8px;
overflow: hidden;
}
.empty {
padding: 40px;
text-align: center;
color: #6b7280;
}
.summary {
background: #f0f7ff;
padding: 16px;
border-bottom: 1px solid #cce0ff;
}
.summaryTitle {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
color: #1e40af;
}
.summaryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.summaryItem {
display: flex;
flex-direction: column;
gap: 2px;
}
.summaryLabel {
font-size: 0.75rem;
color: #6b7280;
}
.summaryValue {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
}
.finishGroup {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.finishGroup:last-of-type {
border-bottom: none;
}
.finishTitle {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 12px;
font-size: 0.9rem;
font-weight: 500;
}
.finishBadge {
padding: 4px 10px;
background: #4f46e5;
color: #fff;
border-radius: 4px;
font-size: 0.8rem;
}
.finishSummary {
color: #6b7280;
font-size: 0.8rem;
font-weight: 400;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.table th {
padding: 8px 10px;
background: #f9fafb;
border: 1px solid #e5e7eb;
text-align: left;
font-weight: 600;
color: #374151;
font-size: 0.75rem;
text-transform: uppercase;
}
.table td {
padding: 8px 10px;
border: 1px solid #e5e7eb;
color: #1f2937;
}
.descCell {
min-width: 180px;
}
.dimCell {
text-align: right;
font-family: monospace;
font-size: 0.85rem;
}
.qtyCell {
text-align: center;
font-weight: 500;
}
.surfaceCell {
text-align: right;
font-family: monospace;
font-size: 0.85rem;
color: #059669;
}
.edgeCell {
text-align: center;
}
.edgeBadge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 500;
}
.edgeBadge.visible {
background: #dcfce7;
color: #166534;
}
.edgeBadge.hidden {
background: #fef3c7;
color: #92400e;
}
.exportActions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.exportBtn {
padding: 10px 20px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: #fff;
border: 1px solid #d1d5db;
color: #374151;
}
.exportBtn:hover {
background: #f3f4f6;
}
.exportBtn.primary {
background: #4f46e5;
border-color: #4f46e5;
color: #fff;
}
.exportBtn.primary:hover {
background: #4338ca;
}
@media (max-width: 640px) {
.table {
font-size: 0.75rem;
}
.descCell {
min-width: 100px;
}
.summaryGrid {
grid-template-columns: repeat(2, 1fr);
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* DesignTip - AI-powered design advice card that fades in below the 3D preview.
* Shows contextual tips, compliments, and trends based on current configuration.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import styles from './DesignTip.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const CATEGORY_ICONS = {
compliment: '✨',
conseil: '💡',
tendance: '🎯'
};
export default function DesignTip({ config }) {
const [tip, setTip] = useState(null);
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const debounceRef = useRef(null);
const abortRef = useRef(null);
const fetchTip = useCallback(async (cfg) => {
// Cancel previous request
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
try {
const body = {
config: {
width: cfg.totalWidth,
height: cfg.height,
depth: cfg.depth,
installationType: cfg.installationType,
columns: cfg.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width,
shelves: m.shelves,
drawers: m.drawers,
hasRod: m.hasRod
})),
materialName: cfg.finishLabel || null,
doorType: cfg.doorType
}
};
const res = await fetch(`${API_BASE}/api/ai/design-tips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.tip) {
setTip(data);
setVisible(true);
}
} catch (err) {
if (err.name !== 'AbortError') {
// Silently fail — tip is optional
setVisible(false);
}
} finally {
setLoading(false);
}
}, []);
// Debounced fetch on config change
useEffect(() => {
if (!config || !config.modules?.length) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchTip(config);
}, 2000); // 2s debounce to avoid spamming on rapid changes
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [
config?.totalWidth,
config?.height,
config?.modules?.length,
config?.modules?.map(m => m.type).join(','),
config?.finishId,
config?.doorType,
fetchTip
]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortRef.current) abortRef.current.abort();
};
}, []);
if (!visible && !loading) return null;
return (
<div className={`${styles.container} ${visible && tip ? styles.visible : ''}`}>
{loading && !tip && (
<div className={styles.shimmer}>
<div className={styles.shimmerLine} />
<div className={styles.shimmerLineShort} />
</div>
)}
{tip && (
<div className={styles.tipContent}>
<span className={styles.icon}>{CATEGORY_ICONS[tip.category] || '✨'}</span>
<p className={styles.text}>{tip.tip}</p>
<span className={styles.badge}>IA</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,78 @@
.container {
margin-top: 8px;
padding: 10px 14px;
border-radius: 10px;
background: linear-gradient(135deg, #f8f7ff 0%, #f0f4ff 100%);
border: 1px solid var(--border-light, #e5e7eb);
opacity: 0;
transform: translateY(6px);
transition: opacity 0.4s ease, transform 0.4s ease;
min-height: 40px;
}
.visible {
opacity: 1;
transform: translateY(0);
}
.tipContent {
display: flex;
align-items: flex-start;
gap: 10px;
}
.icon {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 1px;
}
.text {
margin: 0;
font-size: 0.85rem;
color: var(--text, #374151);
line-height: 1.45;
flex: 1;
}
.badge {
flex-shrink: 0;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 4px;
background: var(--action, #4f46e5);
color: #fff;
margin-top: 2px;
}
/* Loading shimmer */
.shimmer {
display: flex;
flex-direction: column;
gap: 6px;
}
.shimmerLine,
.shimmerLineShort {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.shimmerLine {
width: 80%;
}
.shimmerLineShort {
width: 50%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
+37
View File
@@ -0,0 +1,37 @@
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import styles from './Layout.module.css';
export default function Layout({ children }) {
const { isAdmin, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
return (
<div className={styles.app}>
<header className={styles.header}>
<Link to="/" className={styles.logo}>SIGNATURE</Link>
<nav>
{isAdmin ? (
<>
<Link to="/">Accueil</Link>
<Link to="/configurateur">Configurateur</Link>
<Link to="/materiaux">Matériaux</Link>
<Link to="/admin">Admin</Link>
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
</>
) : (
<Link to="/login">Connexion</Link>
)}
</nav>
</header>
<main className={styles.main}>
{children}
</main>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.logo {
font-weight: 700;
font-size: 1.25rem;
letter-spacing: 0.02em;
text-decoration: none;
color: var(--primary);
}
.logo:hover {
text-decoration: none;
color: var(--accent);
}
.header nav {
display: flex;
gap: 1.5rem;
}
.header nav a {
color: var(--text-muted);
font-weight: 500;
}
.header nav a:hover {
color: var(--primary);
}
.header nav .logoutBtn {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--text-muted);
font-weight: 500;
cursor: pointer;
}
.header nav .logoutBtn:hover {
color: var(--primary);
}
.main {
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import { fetchMaterialDetails } from '../api/client';
import styles from './MaterialDetailModal.module.css';
/**
* Modal for displaying detailed material information in the configurator.
* Shows product image, specs, dimension variants, and color variants.
*
* @param {Object} props
* @param {Object} props.material - The material object (basic info)
* @param {number|null} props.selectedDimensionId - Currently selected dimension variant ID
* @param {Function} props.onSelectDimension - Called when a dimension is selected (dimensionId)
* @param {Function} props.onSelectVariant - Called when a color variant is selected (material)
* @param {Function} props.onConfirm - Called when user confirms selection
* @param {Function} props.onClose - Called to close the modal
*/
export default function MaterialDetailModal({
material,
selectedDimensionId,
onSelectDimension,
onSelectVariant,
onConfirm,
onClose
}) {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [localDimensionId, setLocalDimensionId] = useState(selectedDimensionId);
useEffect(() => {
if (!material?.id) return;
setLoading(true);
setError(null);
fetchMaterialDetails(material.id)
.then(data => {
setDetails(data);
// Reset dimension to default when material changes
if (data.dimensions?.length > 0) {
const defaultDim = data.dimensions.find(d => d.is_default) || data.dimensions[0];
setLocalDimensionId(defaultDim.id);
if (onSelectDimension) onSelectDimension(defaultDim.id);
}
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [material?.id]);
const handleDimensionClick = (dimId) => {
setLocalDimensionId(dimId);
if (onSelectDimension) {
onSelectDimension(dimId);
}
};
const handleVariantClick = (variant) => {
if (onSelectVariant) {
onSelectVariant(variant);
}
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm(material.id, localDimensionId);
}
onClose();
};
const selectedDim = details?.dimensions?.find(d => d.id === localDimensionId);
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<button className={styles.closeBtn} onClick={onClose} type="button" aria-label="Fermer">
&times;
</button>
{loading && <div className={styles.loading}>Chargement...</div>}
{error && <div className={styles.error}>Erreur: {error}</div>}
{!loading && !error && details && (
<>
<div className={styles.content}>
{/* Product Image */}
<div className={styles.imageSection}>
{details.image_url ? (
<img
src={details.image_url}
alt={details.name}
className={styles.productImage}
/>
) : (
<div className={styles.noImage}>Pas d'image</div>
)}
</div>
{/* Product Info */}
<div className={styles.infoSection}>
<h2 className={styles.productName}>{details.name}</h2>
<div className={styles.specs}>
<div className={styles.specRow}>
<span className={styles.specLabel}>Code:</span>
<span className={styles.specValue}>{details.formex_code}</span>
</div>
<div className={styles.specRow}>
<span className={styles.specLabel}>Collection:</span>
<span className={styles.specValue}>{details.collection}</span>
</div>
{details.category && (
<div className={styles.specRow}>
<span className={styles.specLabel}>Catégorie:</span>
<span className={styles.specValue}>{details.category}</span>
</div>
)}
{details.price_chf && (
<div className={styles.specRow}>
<span className={styles.specLabel}>Prix:</span>
<span className={styles.specValue}>{details.price_chf.toFixed(2)} CHF</span>
</div>
)}
</div>
{/* Dimension Variants */}
{details.dimensions && details.dimensions.length > 0 && (
<div className={styles.dimensionsSection}>
<h3 className={styles.sectionTitle}>Dimensions disponibles</h3>
<div className={styles.dimensionButtons}>
{details.dimensions.map(dim => (
<button
key={dim.id}
type="button"
className={`${styles.dimensionBtn} ${dim.id === localDimensionId ? styles.selected : ''}`}
onClick={() => handleDimensionClick(dim.id)}
>
{dim.width_mm} x {dim.height_mm} x {dim.thickness_mm} mm
</button>
))}
</div>
{selectedDim && (
<p className={styles.selectedDimInfo}>
Format sélectionné: <strong>{selectedDim.width_mm} x {selectedDim.height_mm} x {selectedDim.thickness_mm} mm</strong>
</p>
)}
</div>
)}
{/* Color Variants */}
{details.variants && details.variants.length > 0 && (
<div className={styles.variantsSection}>
<h3 className={styles.sectionTitle}>Autres coloris</h3>
<div className={styles.variantThumbs}>
{details.variants.map(variant => (
<button
key={variant.id}
type="button"
className={styles.variantThumb}
onClick={() => handleVariantClick(variant)}
title={variant.variant_name}
>
{variant.image_url ? (
<img src={variant.image_url} alt={variant.variant_name} />
) : (
<span className={styles.variantCode}>{variant.variant_code}</span>
)}
</button>
))}
</div>
</div>
)}
{/* Formex Link */}
{details.formex_url && (
<a
href={details.formex_url}
target="_blank"
rel="noopener noreferrer"
className={styles.formexLink}
>
Voir sur Formex.ch &rarr;
</a>
)}
</div>
</div>
{/* Actions */}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Annuler
</button>
<button type="button" className={styles.confirmBtn} onClick={handleConfirm}>
Sélectionner ce matériau
</button>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,270 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 12px;
max-width: 900px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.closeBtn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #666;
line-height: 1;
padding: 0;
z-index: 10;
}
.closeBtn:hover {
color: #000;
}
.loading,
.error {
padding: 3rem;
text-align: center;
font-size: 1.1rem;
}
.error {
color: #c00;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
padding: 2rem;
}
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
}
/* Image Section */
.imageSection {
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border-radius: 8px;
min-height: 300px;
}
.productImage {
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 8px;
}
.noImage {
color: #999;
font-style: italic;
}
/* Info Section */
.infoSection {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.productName {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
line-height: 1.3;
}
/* Specs */
.specs {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.specRow {
display: flex;
gap: 0.75rem;
}
.specLabel {
font-weight: 500;
color: #666;
min-width: 100px;
}
.specValue {
color: #1a1a1a;
}
/* Dimensions */
.dimensionsSection,
.variantsSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sectionTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #333;
}
.dimensionButtons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dimensionBtn {
padding: 0.5rem 1rem;
border: 2px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.dimensionBtn:hover {
border-color: var(--action);
}
.dimensionBtn.selected {
border-color: var(--action);
background: #eef2ff;
color: var(--action);
font-weight: 500;
}
.selectedDimInfo {
margin: 0;
font-size: 0.9rem;
color: #666;
}
/* Color Variants */
.variantThumbs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.variantThumb {
width: 60px;
height: 60px;
border: 2px solid #ddd;
border-radius: 6px;
padding: 2px;
background: #fff;
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.variantThumb:hover {
border-color: var(--action);
transform: scale(1.05);
}
.variantThumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.variantCode {
font-size: 0.7rem;
color: #666;
text-align: center;
}
/* Formex Link */
.formexLink {
display: inline-block;
color: var(--action);
text-decoration: none;
font-size: 0.9rem;
margin-top: auto;
}
.formexLink:hover {
text-decoration: underline;
}
/* Actions -- sticky at bottom */
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem 2rem;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
border-radius: 0 0 12px 12px;
position: sticky;
bottom: 0;
z-index: 5;
}
.cancelBtn,
.confirmBtn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.cancelBtn {
background: #fff;
border: 1px solid #ddd;
color: #666;
}
.cancelBtn:hover {
background: #f5f5f5;
}
.confirmBtn {
background: var(--action);
border: none;
color: #fff;
font-weight: 500;
}
.confirmBtn:hover {
background: var(--action-hover);
}
+201
View File
@@ -0,0 +1,201 @@
import { useState, useEffect } from 'react';
import { createFormexMaterial, updateFormexMaterial } from '../api/client';
import styles from './MaterialModal.module.css';
export default function MaterialModal({ material, collections, onClose, onSave }) {
const isEditing = !!material;
const [formData, setFormData] = useState({
formex_code: '',
name: '',
collection: '',
width_mm: '',
height_mm: '',
thickness_mm: '',
formex_url: ''
});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (material) {
setFormData({
formex_code: material.formex_code || '',
name: material.name || '',
collection: material.collection || '',
width_mm: material.width_mm?.toString() || '',
height_mm: material.height_mm?.toString() || '',
thickness_mm: material.thickness_mm?.toString() || '',
formex_url: material.formex_url || ''
});
}
}, [material]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// Validation
if (!formData.formex_code.trim() || !formData.name.trim() || !formData.collection.trim()) {
setError('Le code, le nom et la collection sont requis.');
return;
}
setSaving(true);
try {
const payload = {
formex_code: formData.formex_code.trim(),
name: formData.name.trim(),
collection: formData.collection.trim(),
width_mm: formData.width_mm ? parseInt(formData.width_mm, 10) : null,
height_mm: formData.height_mm ? parseInt(formData.height_mm, 10) : null,
thickness_mm: formData.thickness_mm ? parseInt(formData.thickness_mm, 10) : null,
formex_url: formData.formex_url.trim() || null
};
if (isEditing) {
await updateFormexMaterial(material.id, payload);
} else {
await createFormexMaterial(payload);
}
onSave();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className={styles.overlay} onClick={handleOverlayClick}>
<div className={styles.modal}>
<div className={styles.header}>
<h2>{isEditing ? 'Modifier le matériau' : 'Nouveau matériau'}</h2>
<button type="button" onClick={onClose} className={styles.closeBtn}>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.field}>
<label htmlFor="formex_code">Code Formex *</label>
<input
type="text"
id="formex_code"
name="formex_code"
value={formData.formex_code}
onChange={handleChange}
placeholder="ex: 10000"
required
/>
</div>
<div className={styles.field}>
<label htmlFor="name">Nom *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="ex: Panneaux mélaminés Duplex Plus 10000 Tilia"
required
/>
</div>
<div className={styles.field}>
<label htmlFor="collection">Collection *</label>
<input
type="text"
id="collection"
name="collection"
value={formData.collection}
onChange={handleChange}
list="collections-list"
placeholder="ex: Duplex Plus"
required
/>
<datalist id="collections-list">
{collections.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</div>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="width_mm">Largeur (mm)</label>
<input
type="number"
id="width_mm"
name="width_mm"
value={formData.width_mm}
onChange={handleChange}
placeholder="2800"
/>
</div>
<div className={styles.field}>
<label htmlFor="height_mm">Hauteur (mm)</label>
<input
type="number"
id="height_mm"
name="height_mm"
value={formData.height_mm}
onChange={handleChange}
placeholder="2070"
/>
</div>
<div className={styles.field}>
<label htmlFor="thickness_mm">Épaisseur (mm)</label>
<input
type="number"
id="thickness_mm"
name="thickness_mm"
value={formData.thickness_mm}
onChange={handleChange}
placeholder="19"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="formex_url">URL Formex</label>
<input
type="url"
id="formex_url"
name="formex_url"
value={formData.formex_url}
onChange={handleChange}
placeholder="https://www.formex.ch/fr/shop/..."
/>
</div>
<div className={styles.actions}>
<button type="button" onClick={onClose} className={styles.cancelBtn}>
Annuler
</button>
<button type="submit" disabled={saving} className={styles.saveBtn}>
{saving ? 'Enregistrement…' : (isEditing ? 'Enregistrer' : 'Créer')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,135 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 8px;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--primary);
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
.closeBtn:hover {
color: var(--primary);
}
.form {
padding: 1.5rem;
}
.error {
color: #b91c1c;
font-size: 0.875rem;
margin: 0 0 1rem;
padding: 0.75rem;
background: #fef2f2;
border-radius: 6px;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--primary);
}
.field input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
}
.field input:focus {
outline: none;
border-color: var(--accent);
}
.row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.cancelBtn {
padding: 0.5rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.cancelBtn:hover {
border-color: var(--accent);
}
.saveBtn {
padding: 0.5rem 1.25rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.saveBtn:hover:not(:disabled) {
background: var(--primary);
}
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Plans2DView - Display 2D plans (assembly, side, top views)
* With tabs for different views and export options
*/
import { useState, useEffect, useCallback } from 'react';
import CuttingListTable from './CuttingListTable';
import styles from './Plans2DView.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function Plans2DView({ config, modules = [], colors = {}, onClose }) {
const [activeTab, setActiveTab] = useState('assembly');
const [plans, setPlans] = useState(null);
const [cuttingList, setCuttingList] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch plans and cutting list
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
// Fetch plans
const plansRes = await fetch(`${API_BASE}/api/plans/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const plansData = await plansRes.json();
if (plansData.success) {
setPlans(plansData.plans);
}
// Fetch cutting list
const cutRes = await fetch(`${API_BASE}/api/plans/cutting-list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const cutData = await cutRes.json();
if (cutData.success) {
setCuttingList(cutData.cuttingList);
}
} catch (err) {
console.error('Error fetching plans:', err);
setError('Erreur lors de la génération des plans');
} finally {
setLoading(false);
}
};
fetchData();
}, [config, modules, colors]);
// Export handlers
const handleExport = useCallback(async (format) => {
try {
if (format === 'text') {
const res = await fetch(`${API_BASE}/api/plans/export-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'liste-debitage.txt';
a.click();
URL.revokeObjectURL(url);
} else if (format === 'pdf') {
const res = await fetch(`${API_BASE}/api/plans/export-pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const html = await res.text();
// Open in new window for printing
const printWindow = window.open('', '_blank');
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
} catch (err) {
console.error('Export error:', err);
alert('Erreur lors de l\'export');
}
}, [config, modules, colors]);
const tabs = [
{ id: 'assembly', label: 'Vue de face' },
{ id: 'side', label: 'Vue de côté' },
{ id: 'top', label: 'Vue de dessus' },
{ id: 'cutting', label: 'Liste de débitage' }
];
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>Plans de fabrication</h2>
<button className={styles.closeBtn} onClick={onClose}>×</button>
</div>
<div className={styles.tabs}>
{tabs.map(tab => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className={styles.content}>
{loading ? (
<div className={styles.loading}>Génération des plans...</div>
) : error ? (
<div className={styles.error}>{error}</div>
) : (
<>
{activeTab === 'assembly' && plans?.assembly && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.assembly }}
/>
)}
{activeTab === 'side' && plans?.side && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.side }}
/>
)}
{activeTab === 'top' && plans?.top && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.top }}
/>
)}
{activeTab === 'cutting' && (
<CuttingListTable
cuttingList={cuttingList}
onExport={handleExport}
/>
)}
</>
)}
</div>
<div className={styles.footer}>
<div className={styles.specs}>
<span>{config.widthMm} × {config.heightMm} × {config.depthMm} mm</span>
<span>•</span>
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
<span>•</span>
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
</div>
<div className={styles.actions}>
<button
className={styles.exportBtn}
onClick={() => handleExport('pdf')}
>
Imprimer / PDF
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,192 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 12px;
width: 100%;
max-width: 1000px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
padding: 4px 8px;
line-height: 1;
}
.closeBtn:hover {
color: var(--text);
}
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-light);
padding: 0 20px;
background: var(--bg-subtle);
}
.tab {
padding: 12px 20px;
background: none;
border: none;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.tab:hover {
color: var(--action);
}
.tab.active {
color: var(--action);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--action);
}
.content {
flex: 1;
overflow: auto;
padding: 20px;
min-height: 400px;
}
.loading,
.error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 0.9rem;
}
.error {
color: var(--danger);
}
.planContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--bg-subtle);
border-radius: 8px;
min-height: 300px;
}
.planContainer svg {
max-width: 100%;
height: auto;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
}
.specs {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text-muted);
}
.actions {
display: flex;
gap: 10px;
}
.exportBtn {
padding: 10px 20px;
background: var(--action);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.exportBtn:hover {
background: var(--action-hover);
}
@media (max-width: 768px) {
.modal {
max-height: 95vh;
}
.tabs {
overflow-x: auto;
padding: 0 10px;
}
.tab {
padding: 10px 14px;
white-space: nowrap;
font-size: 0.85rem;
}
.footer {
flex-direction: column;
gap: 12px;
}
.specs {
flex-wrap: wrap;
justify-content: center;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children }) {
const { isAdmin } = useAuth();
const location = useLocation();
if (!isAdmin) {
return <Navigate to={`/login?next=${encodeURIComponent(location.pathname)}`} replace />;
}
return children;
}
+370
View File
@@ -0,0 +1,370 @@
import { useMemo } from 'react';
import * as THREE from 'three';
/**
* Room3D component - Renders a 3D room based on AI analysis
* Used to display the wardrobe in context of the user's actual room
*/
// Floor material based on type
function FloorMaterial({ material, color }) {
const texture = useMemo(() => {
// In a real implementation, you'd load actual textures
// For now, we use colors with some roughness variation
return null;
}, [material]);
return (
<meshStandardMaterial
color={color || '#c4a574'}
roughness={material === 'wood' ? 0.7 : material === 'tile' ? 0.3 : 0.8}
metalness={0.05}
/>
);
}
// Get material properties from wall material type
function getWallMaterialProps(material, texture) {
const props = { roughness: 0.8, metalness: 0.02 };
switch (material) {
case 'paint':
props.roughness = texture === 'glossy' ? 0.3 : texture === 'smooth' ? 0.7 : 0.8;
break;
case 'wallpaper':
props.roughness = 0.85;
break;
case 'brick':
props.roughness = 0.95;
props.metalness = 0.0;
break;
case 'wood_panel':
props.roughness = 0.65;
props.metalness = 0.05;
break;
case 'concrete':
props.roughness = 0.9;
props.metalness = 0.01;
break;
case 'plaster':
props.roughness = 0.85;
break;
case 'stone':
props.roughness = 0.92;
props.metalness = 0.0;
break;
case 'tile':
props.roughness = 0.3;
props.metalness = 0.05;
break;
default:
break;
}
return props;
}
// Wall with optional window/door cutouts
function Wall({ position, rotation, size, color, material, texture, windows = [], doors = [] }) {
const [width, height] = size;
const matProps = getWallMaterialProps(material, texture);
// Create wall geometry with cutouts (simplified - just overlays for now)
return (
<group position={position} rotation={rotation}>
{/* Main wall */}
<mesh receiveShadow>
<planeGeometry args={[width, height]} />
<meshStandardMaterial
color={color || '#f5f5f0'}
side={THREE.DoubleSide}
roughness={matProps.roughness}
metalness={matProps.metalness}
/>
</mesh>
{/* Windows */}
{windows.map((win, i) => {
const winWidth = width * win.width;
const winHeight = height * win.height;
const winX = (win.x - 0.5) * width;
const winY = (win.y - 0.5) * height;
return (
<group key={`window-${i}`} position={[winX, winY, 0.01]}>
{/* Window glass */}
<mesh>
<planeGeometry args={[winWidth, winHeight]} />
<meshStandardMaterial
color="#87CEEB"
transparent
opacity={0.3}
side={THREE.DoubleSide}
/>
</mesh>
{/* Window frame */}
<lineSegments>
<edgesGeometry args={[new THREE.PlaneGeometry(winWidth, winHeight)]} />
<lineBasicMaterial color="#555" linewidth={2} />
</lineSegments>
</group>
);
})}
{/* Doors */}
{doors.map((door, i) => {
const doorWidth = width * door.width;
const doorHeight = height * door.height;
const doorX = (door.x - 0.5) * width;
const doorY = -(height / 2) + (doorHeight / 2);
return (
<group key={`door-${i}`} position={[doorX, doorY, 0.01]}>
{/* Door opening (darker rectangle) */}
<mesh>
<planeGeometry args={[doorWidth, doorHeight]} />
<meshStandardMaterial color="#2a2a2a" side={THREE.DoubleSide} />
</mesh>
{/* Door frame */}
<lineSegments>
<edgesGeometry args={[new THREE.PlaneGeometry(doorWidth, doorHeight)]} />
<lineBasicMaterial color="#8b7355" linewidth={2} />
</lineSegments>
</group>
);
})}
</group>
);
}
export default function Room3D({ roomConfig, wardrobeConfig }) {
if (!roomConfig) return null;
const { width, length, height, walls, windows, doors, floor, ceiling, wardrobePlacement } = roomConfig;
// Calculate wall positions
const halfWidth = width / 2;
const halfLength = length / 2;
const halfHeight = height / 2;
// Get windows/doors for each wall
const backWindows = windows.filter(w => w.wall === 'back').map(w => ({
x: w.x, y: w.y, width: w.width, height: w.height
}));
const leftWindows = windows.filter(w => w.wall === 'left').map(w => ({
x: w.x, y: w.y, width: w.width, height: w.height
}));
const rightWindows = windows.filter(w => w.wall === 'right').map(w => ({
x: w.x, y: w.y, width: w.width, height: w.height
}));
const backDoors = doors.filter(d => d.wall === 'back').map(d => ({
x: d.x, width: d.width, height: d.height
}));
const leftDoors = doors.filter(d => d.wall === 'left').map(d => ({
x: d.x, width: d.width, height: d.height
}));
const rightDoors = doors.filter(d => d.wall === 'right').map(d => ({
x: d.x, width: d.width, height: d.height
}));
// Calculate wardrobe position based on placement
const getWardrobePosition = () => {
if (!wardrobeConfig || !wardrobePlacement) return [0, 0, 0];
const wardrobeWidth = (wardrobeConfig.widthMm || 2400) / 1000;
const wardrobeDepth = (wardrobeConfig.depthMm || 600) / 1000;
switch (wardrobePlacement.wall) {
case 'back':
return [
((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 2,
0,
-halfLength + wardrobeDepth / 2 + 0.05
];
case 'left':
return [
-halfWidth + wardrobeDepth / 2 + 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2
];
case 'right':
return [
halfWidth - wardrobeDepth / 2 - 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2
];
default:
return [0, 0, -halfLength + wardrobeDepth / 2 + 0.05];
}
};
const wardrobePosition = getWardrobePosition();
const wardrobeRotation = wardrobePlacement?.wall === 'left'
? [0, Math.PI / 2, 0]
: wardrobePlacement?.wall === 'right'
? [0, -Math.PI / 2, 0]
: [0, 0, 0];
return (
<group>
{/* Floor */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, 0]}
receiveShadow
>
<planeGeometry args={[width, length]} />
<FloorMaterial material={floor?.material} color={floor?.color} />
</mesh>
{/* Back wall */}
{walls?.back?.visible !== false && (
<Wall
position={[0, halfHeight, -halfLength]}
rotation={[0, 0, 0]}
size={[width, height]}
color={walls?.back?.color}
material={walls?.back?.material}
texture={walls?.back?.texture}
windows={backWindows}
doors={backDoors}
/>
)}
{/* Left wall */}
{walls?.left?.visible !== false && (
<Wall
position={[-halfWidth, halfHeight, 0]}
rotation={[0, Math.PI / 2, 0]}
size={[length, height]}
color={walls?.left?.color}
material={walls?.left?.material}
texture={walls?.left?.texture}
windows={leftWindows}
doors={leftDoors}
/>
)}
{/* Right wall */}
{walls?.right?.visible !== false && (
<Wall
position={[halfWidth, halfHeight, 0]}
rotation={[0, -Math.PI / 2, 0]}
size={[length, height]}
color={walls?.right?.color}
material={walls?.right?.material}
texture={walls?.right?.texture}
windows={rightWindows}
doors={rightDoors}
/>
)}
{/* Ceiling (plafond) */}
{ceiling?.visible !== false && (() => {
const ceilingColor = ceiling?.color || '#ffffff';
const ceilingType = ceiling?.type || 'flat';
const slopeAngle = ceiling?.slopeAngleDeg || 0;
const ceilingMatProps = getWallMaterialProps(ceiling?.material || 'paint', 'smooth');
if (ceilingType === 'sloped' && slopeAngle > 0) {
// Sloped ceiling - render as a tilted plane
const slopeRad = (slopeAngle * Math.PI) / 180;
const slopeDir = ceiling?.slopeDirection || 'front_to_back';
const slopeRotX = slopeDir === 'front_to_back' ? Math.PI / 2 + slopeRad * 0.5 : Math.PI / 2;
const slopeRotZ = slopeDir === 'left_to_right' ? slopeRad * 0.5 : 0;
const slopeW = slopeDir === 'left_to_right' ? width / Math.cos(slopeRad * 0.5) : width;
const slopeL = slopeDir === 'front_to_back' ? length / Math.cos(slopeRad * 0.5) : length;
return (
<mesh
rotation={[slopeRotX, 0, slopeRotZ]}
position={[0, height, 0]}
receiveShadow
>
<planeGeometry args={[slopeW, slopeL]} />
<meshStandardMaterial
color={ceilingColor}
side={THREE.DoubleSide}
roughness={ceilingMatProps.roughness}
metalness={ceilingMatProps.metalness}
/>
</mesh>
);
}
// Flat ceiling
return (
<mesh
rotation={[Math.PI / 2, 0, 0]}
position={[0, height, 0]}
receiveShadow
>
<planeGeometry args={[width, length]} />
<meshStandardMaterial
color={ceilingColor}
side={THREE.DoubleSide}
roughness={ceilingMatProps.roughness}
metalness={ceilingMatProps.metalness}
/>
</mesh>
);
})()}
{/* Wardrobe position indicator (for debugging/visualization) */}
{wardrobePlacement && (
<mesh position={[wardrobePosition[0], 0.01, wardrobePosition[2]]}>
<planeGeometry args={[
(wardrobeConfig?.widthMm || 2400) / 1000,
(wardrobeConfig?.depthMm || 600) / 1000
]} />
<meshBasicMaterial color="#0066cc" transparent opacity={0.3} />
</mesh>
)}
</group>
);
}
// Export position calculator for use by parent
export function calculateWardrobeTransform(roomConfig, wardrobeConfig) {
if (!roomConfig || !roomConfig.wardrobePlacement) {
return { position: [0, 0, 0], rotation: [0, 0, 0] };
}
const { width, length, wardrobePlacement } = roomConfig;
const halfWidth = width / 2;
const halfLength = length / 2;
const wardrobeWidth = (wardrobeConfig?.widthMm || 2400) / 1000;
const wardrobeDepth = (wardrobeConfig?.depthMm || 600) / 1000;
let position, rotation;
switch (wardrobePlacement.wall) {
case 'back':
position = [
((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 4,
0,
-halfLength + wardrobeDepth / 2 + 0.05
];
rotation = [0, 0, 0];
break;
case 'left':
position = [
-halfWidth + wardrobeDepth / 2 + 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length
];
rotation = [0, Math.PI / 2, 0];
break;
case 'right':
position = [
halfWidth - wardrobeDepth / 2 - 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length
];
rotation = [0, -Math.PI / 2, 0];
break;
default:
position = [0, 0, -halfLength + wardrobeDepth / 2 + 0.05];
rotation = [0, 0, 0];
}
return { position, rotation };
}
+234
View File
@@ -0,0 +1,234 @@
import { useState, useRef, useCallback } from 'react';
import styles from './RoomUpload.module.css';
/**
* RoomUpload component - Drag & drop or click to upload room photos
* Sends to AI for analysis and returns room configuration
*/
export default function RoomUpload({ onAnalysisComplete, onClose }) {
const [dragOver, setDragOver] = useState(false);
const [preview, setPreview] = useState(null);
const [file, setFile] = useState(null);
const [analyzing, setAnalyzing] = useState(false);
const [error, setError] = useState(null);
const [analysis, setAnalysis] = useState(null);
const fileInputRef = useRef(null);
const handleDragOver = useCallback((e) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
setDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile && droppedFile.type.startsWith('image/')) {
handleFile(droppedFile);
} else {
setError('Veuillez déposer une image (JPEG, PNG ou WebP)');
}
}, []);
const handleFile = (selectedFile) => {
setFile(selectedFile);
setError(null);
setAnalysis(null);
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target.result);
};
reader.readAsDataURL(selectedFile);
};
const handleFileSelect = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile) {
handleFile(selectedFile);
}
};
const handleAnalyze = async () => {
if (!file) return;
setAnalyzing(true);
setError(null);
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/room/analyze', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Erreur lors de l\'analyse');
}
setAnalysis(data);
} catch (err) {
setError(err.message);
} finally {
setAnalyzing(false);
}
};
const handleConfirm = () => {
if (analysis && onAnalysisComplete) {
onAnalysisComplete(analysis);
}
};
const handleReset = () => {
setFile(null);
setPreview(null);
setAnalysis(null);
setError(null);
};
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<button className={styles.closeBtn} onClick={onClose} type="button">
&times;
</button>
<h2 className={styles.title}>Visualiser dans ma pièce</h2>
<p className={styles.subtitle}>
Téléchargez une photo de votre pièce et notre IA analysera l'espace pour y placer votre armoire.
</p>
{!preview ? (
<div
className={`${styles.dropzone} ${dragOver ? styles.dragOver : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className={styles.dropIcon}>📷</div>
<p className={styles.dropText}>
Glissez-déposez une photo ici<br />
<span>ou cliquez pour sélectionner</span>
</p>
<p className={styles.dropHint}>JPEG, PNG ou WebP - Max 10 MB</p>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
className={styles.fileInput}
/>
</div>
) : (
<div className={styles.previewContainer}>
<img src={preview} alt="Aperçu de la pièce" className={styles.previewImage} />
{!analysis && !analyzing && (
<button type="button" className={styles.changeBtn} onClick={handleReset}>
Changer d'image
</button>
)}
</div>
)}
{error && (
<div className={styles.error}>
{error}
</div>
)}
{analyzing && (
<div className={styles.analyzing}>
<div className={styles.spinner}></div>
<p>Analyse de votre pièce en cours...</p>
<p className={styles.analyzingHint}>L'IA détecte les dimensions, fenêtres et portes</p>
</div>
)}
{analysis && (
<div className={styles.analysisResult}>
<h3>Analyse terminée</h3>
<div className={styles.resultGrid}>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Dimensions estimées</span>
<span className={styles.resultValue}>
{analysis.analysis.roomDimensions.widthM}m × {analysis.analysis.roomDimensions.lengthM}m × {analysis.analysis.roomDimensions.heightM}m
</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Type de pièce</span>
<span className={styles.resultValue}>
{analysis.analysis.roomType === 'bedroom' ? 'Chambre' :
analysis.analysis.roomType === 'living_room' ? 'Salon' :
analysis.analysis.roomType === 'hallway' ? 'Couloir' :
analysis.analysis.roomType === 'dressing' ? 'Dressing' : 'Autre'}
</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Fenêtres détectées</span>
<span className={styles.resultValue}>{analysis.analysis.windows?.length || 0}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Portes détectées</span>
<span className={styles.resultValue}>{analysis.analysis.doors?.length || 0}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Mur suggéré pour l'armoire</span>
<span className={styles.resultValue}>
{analysis.analysis.suggestedWardrobeWall === 'back' ? 'Fond' :
analysis.analysis.suggestedWardrobeWall === 'left' ? 'Gauche' : 'Droite'}
</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Confiance</span>
<span className={styles.resultValue}>{analysis.analysis.confidence}%</span>
</div>
</div>
{analysis.analysis.suggestedWardrobePosition?.reason && (
<p className={styles.suggestion}>
<strong>Suggestion :</strong> {analysis.analysis.suggestedWardrobePosition.reason}
</p>
)}
</div>
)}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Annuler
</button>
{preview && !analysis && !analyzing && (
<button type="button" className={styles.analyzeBtn} onClick={handleAnalyze}>
Analyser la pièce
</button>
)}
{analysis && (
<button type="button" className={styles.confirmBtn} onClick={handleConfirm}>
Utiliser cette configuration
</button>
)}
</div>
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 12px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
padding: 2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.closeBtn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #666;
line-height: 1;
}
.closeBtn:hover {
color: #000;
}
.title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
margin: 0 0 1.5rem;
color: #666;
font-size: 0.95rem;
line-height: 1.5;
}
/* Dropzone */
.dropzone {
border: 2px dashed #ccc;
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #fafafa;
}
.dropzone:hover,
.dropzone.dragOver {
border-color: #0066cc;
background: #f0f7ff;
}
.dropIcon {
font-size: 3rem;
margin-bottom: 1rem;
}
.dropText {
margin: 0 0 0.5rem;
font-size: 1rem;
color: #333;
}
.dropText span {
color: #0066cc;
text-decoration: underline;
}
.dropHint {
margin: 0;
font-size: 0.85rem;
color: #999;
}
.fileInput {
display: none;
}
/* Preview */
.previewContainer {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.previewImage {
width: 100%;
max-height: 300px;
object-fit: contain;
background: #f0f0f0;
border-radius: 8px;
}
.changeBtn {
position: absolute;
bottom: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.changeBtn:hover {
background: rgba(0, 0, 0, 0.9);
}
/* Error */
.error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #fee2e2;
border: 1px solid #fca5a5;
border-radius: 8px;
color: #b91c1c;
font-size: 0.9rem;
}
/* Analyzing */
.analyzing {
margin-top: 1.5rem;
text-align: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e0e0e0;
border-top-color: #0066cc;
border-radius: 50%;
margin: 0 auto 1rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.analyzing p {
margin: 0;
color: #333;
font-weight: 500;
}
.analyzingHint {
margin-top: 0.5rem !important;
font-size: 0.85rem;
color: #666 !important;
font-weight: 400 !important;
}
/* Analysis Result */
.analysisResult {
margin-top: 1.5rem;
padding: 1.25rem;
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 8px;
}
.analysisResult h3 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #166534;
}
.resultGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.resultItem {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.resultLabel {
font-size: 0.8rem;
color: #666;
}
.resultValue {
font-size: 0.95rem;
font-weight: 500;
color: #1a1a1a;
}
.suggestion {
margin: 1rem 0 0;
padding-top: 1rem;
border-top: 1px solid #86efac;
font-size: 0.9rem;
color: #333;
line-height: 1.5;
}
/* Actions */
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
}
.cancelBtn,
.analyzeBtn,
.confirmBtn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.cancelBtn {
background: #fff;
border: 1px solid #ddd;
color: #666;
}
.cancelBtn:hover {
background: #f5f5f5;
}
.analyzeBtn {
background: #0066cc;
border: none;
color: #fff;
font-weight: 500;
}
.analyzeBtn:hover {
background: #0052a3;
}
.confirmBtn {
background: #16a34a;
border: none;
color: #fff;
font-weight: 500;
}
.confirmBtn:hover {
background: #15803d;
}
+447
View File
@@ -0,0 +1,447 @@
/**
* WardrobeElement - Clickable 3D element for the wardrobe preview
* Each element can have its own color/finish and responds to click events
* Uses dominant color extracted from material swatch images for realistic preview
*/
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useThree } from '@react-three/fiber';
import * as THREE from 'three';
// Default colors for different element types
const DEFAULT_COLORS = {
plintheBas: '#8b7355',
fileur: '#8b7355',
bandeauHaut: '#8b7355',
champs: '#6b5344',
caisse: '#c4a574',
cotesDoublage: '#c4a574',
cotes: '#b09060',
dessus: '#c4a574',
fond: '#d4c4a4',
dos: '#d4c4a4',
portes: '#a08060',
panneauxInterieurs: '#d4c4a4',
separateurs: '#6b5344',
etageres: '#d4c4a4',
tiroirs: '#c4a574',
penderie: '#8b7355'
};
// Element labels for display
const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas',
fileur: 'Fileur',
bandeauHaut: 'Bandeau haut',
champs: 'Champs',
caisse: 'Caisse',
cotesDoublage: 'Côtés de doublage',
cotes: 'Côtés',
dessus: 'Dessus',
fond: 'Fond',
dos: 'Dos',
portes: 'Portes',
panneauxInterieurs: 'Panneaux intérieurs',
separateurs: 'Séparateurs',
etageres: 'Étagères',
tiroirs: 'Tiroirs',
penderie: 'Tringle penderie'
};
/**
* Get color from config or use default
*/
function getElementColor(elementType, colors) {
if (colors && colors[elementType]) {
if (colors[elementType].hexColor) {
return colors[elementType].hexColor;
}
}
return DEFAULT_COLORS[elementType] || '#cccccc';
}
/**
* Get image URL for element texture (if available)
*/
function getElementImageUrl(elementType, colors) {
if (colors && colors[elementType] && colors[elementType].imageUrl) {
return colors[elementType].imageUrl;
}
return null;
}
/**
* Global cache: image URL → extracted dominant color (hex string)
* Avoids re-extracting colors for the same image URL
*/
const colorCache = new Map();
/**
* Extract the dominant color from the center of a swatch image.
* The Formex swatch images have black borders and watermarks,
* so we sample only the center 40% to get the actual material color.
* Returns a promise that resolves to a hex color string.
*/
function extractDominantColor(imageUrl) {
if (colorCache.has(imageUrl)) {
return colorCache.get(imageUrl);
}
const promise = new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Sample center 40% of the image (avoids border + watermark)
const cropFraction = 0.4;
const sx = Math.floor(img.width * (1 - cropFraction) / 2);
const sy = Math.floor(img.height * (1 - cropFraction) / 2);
const sw = Math.floor(img.width * cropFraction);
const sh = Math.floor(img.height * cropFraction);
// Draw cropped region to small canvas for fast sampling
canvas.width = 16;
canvas.height = 16;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, 16, 16);
const data = ctx.getImageData(0, 0, 16, 16).data;
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
// Skip very dark pixels (might be border remnants)
if (data[i] < 15 && data[i+1] < 15 && data[i+2] < 15) continue;
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
if (count === 0) count = 1;
r = Math.round(r / count);
g = Math.round(g / count);
b = Math.round(b / count);
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
resolve(hex);
} catch {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = imageUrl;
});
colorCache.set(imageUrl, promise);
return promise;
}
/**
* Hook to extract the dominant color from a material swatch image URL.
* Returns a hex color string or null while loading.
*/
function useExtractedColor(imageUrl) {
const [extractedColor, setExtractedColor] = useState(null);
useEffect(() => {
if (!imageUrl) {
setExtractedColor(null);
return;
}
let cancelled = false;
extractDominantColor(imageUrl).then((hex) => {
if (!cancelled && hex) {
setExtractedColor(hex);
}
});
return () => { cancelled = true; };
}, [imageUrl]);
return extractedColor;
}
/**
* Global texture cache — stores cropped CanvasTextures (borders removed)
*/
const textureCache = new Map();
/**
* Load a material swatch image, crop out the borders/watermark,
* and create a Three.js CanvasTexture from the center region.
* Returns a cache entry { texture, error, loading }.
*/
function loadCroppedTexture(url) {
if (textureCache.has(url)) {
return textureCache.get(url);
}
const entry = { texture: null, error: false, loading: true };
textureCache.set(url, entry);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Crop center 60% to remove black border + FORMEX watermark
const cropFraction = 0.6;
const sx = Math.floor(img.width * (1 - cropFraction) / 2);
const sy = Math.floor(img.height * (1 - cropFraction) / 2);
const sw = Math.floor(img.width * cropFraction);
const sh = Math.floor(img.height * cropFraction);
canvas.width = sw;
canvas.height = sh;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.needsUpdate = true;
entry.texture = tex;
entry.loading = false;
} catch {
entry.error = true;
entry.loading = false;
}
};
img.onerror = () => {
entry.error = true;
entry.loading = false;
};
img.src = url;
return entry;
}
/**
* Hook to load a cropped texture from a swatch image URL.
* Returns a cloned texture with proper repeat settings, or null.
*/
function useCroppedTexture(imageUrl, geometryArgs, geometryType) {
const [texture, setTexture] = useState(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
useEffect(() => {
if (!imageUrl) {
setTexture((prev) => { if (prev) prev.dispose(); return null; });
return;
}
const entry = loadCroppedTexture(imageUrl);
if (entry.texture) {
const tex = entry.texture.clone();
configureTextureRepeat(tex, geometryArgs, geometryType);
setTexture((prev) => { if (prev) prev.dispose(); return tex; });
return;
}
if (entry.error) {
setTexture((prev) => { if (prev) prev.dispose(); return null; });
return;
}
// Poll until loaded
const interval = setInterval(() => {
if (!mountedRef.current) { clearInterval(interval); return; }
if (entry.texture) {
const tex = entry.texture.clone();
configureTextureRepeat(tex, geometryArgs, geometryType);
setTexture((prev) => { if (prev) prev.dispose(); return tex; });
clearInterval(interval);
} else if (entry.error) {
setTexture((prev) => { if (prev) prev.dispose(); return null; });
clearInterval(interval);
}
}, 50);
return () => clearInterval(interval);
}, [imageUrl, geometryType, geometryArgs]);
// Update repeat when geometry changes (but same texture)
useEffect(() => {
if (texture) {
configureTextureRepeat(texture, geometryArgs, geometryType);
}
}, [geometryArgs, geometryType]);
return texture;
}
/**
* Configure texture repeat based on geometry dimensions.
* The cropped swatch represents approximately 0.3m x 0.3m of real material.
*/
function configureTextureRepeat(tex, geometryArgs, geometryType) {
if (!tex || !geometryArgs) return;
const TEXTURE_PATCH_SIZE = 0.3; // meters
let widthM, heightM;
switch (geometryType) {
case 'box':
widthM = geometryArgs[0] || 1;
heightM = geometryArgs[1] || 1;
break;
case 'plane':
widthM = geometryArgs[0] || 1;
heightM = geometryArgs[1] || 1;
break;
case 'cylinder':
widthM = 0.3;
heightM = 0.3;
break;
default:
widthM = 1;
heightM = 1;
}
tex.repeat.set(
Math.max(1, widthM / TEXTURE_PATCH_SIZE),
Math.max(1, heightM / TEXTURE_PATCH_SIZE)
);
tex.needsUpdate = true;
}
/**
* WardrobeElement - A single clickable 3D mesh with optional texture
*/
export default function WardrobeElement({
elementType,
geometryType = 'box',
geometryArgs = [1, 1, 1],
position = [0, 0, 0],
rotation = [0, 0, 0],
colors = {},
onElementClick,
castShadow = true,
receiveShadow = true,
transparent = false,
opacity = 1,
materialProps = {}
}) {
const [hovered, setHovered] = useState(false);
const { gl } = useThree();
const pointerDownPos = useRef(null);
const materialRef = useRef();
const color = useMemo(() =>
getElementColor(elementType, colors),
[elementType, colors]
);
const imageUrl = useMemo(() =>
getElementImageUrl(elementType, colors),
[elementType, colors]
);
// Extract dominant color from swatch image (immediate visual feedback)
const extractedColor = useExtractedColor(imageUrl);
// Load cropped texture (for materials with visible grain/pattern)
const texture = useCroppedTexture(imageUrl, geometryArgs, geometryType);
// Determine the display color: extracted > hex > default
const baseColor = extractedColor || color;
// Hover effect - slightly brighter color
const hoverColor = useMemo(() => {
if (!hovered) return baseColor;
const hex = baseColor.replace('#', '');
const r = Math.min(255, parseInt(hex.slice(0, 2), 16) + 30);
const g = Math.min(255, parseInt(hex.slice(2, 4), 16) + 30);
const b = Math.min(255, parseInt(hex.slice(4, 6), 16) + 30);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}, [baseColor, hovered]);
const handlePointerOver = useCallback((e) => {
e.stopPropagation();
setHovered(true);
gl.domElement.style.cursor = 'pointer';
}, [gl]);
const handlePointerOut = useCallback((e) => {
e.stopPropagation();
setHovered(false);
gl.domElement.style.cursor = 'auto';
}, [gl]);
// Track pointer position on down to distinguish clicks from drags
const handlePointerDown = useCallback((e) => {
e.stopPropagation();
pointerDownPos.current = { x: e.clientX, y: e.clientY };
}, []);
const handleClick = useCallback((e) => {
e.stopPropagation();
if (!pointerDownPos.current) return;
const dx = e.clientX - pointerDownPos.current.x;
const dy = e.clientY - pointerDownPos.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
pointerDownPos.current = null;
if (distance > 5) return; // Was a drag, not a click
if (onElementClick) {
onElementClick(elementType, ELEMENT_LABELS[elementType] || elementType);
}
}, [elementType, onElementClick]);
const geometry = useMemo(() => {
switch (geometryType) {
case 'plane':
return <planeGeometry args={geometryArgs} />;
case 'cylinder':
return <cylinderGeometry args={geometryArgs} />;
case 'box':
default:
return <boxGeometry args={geometryArgs} />;
}
}, [geometryType, geometryArgs]);
// Imperatively force material update when the texture map reference changes
// (R3F sometimes misses null↔Texture transitions without a shader recompile)
useEffect(() => {
const mat = materialRef.current;
if (!mat) return;
mat.map = texture || null;
mat.needsUpdate = true;
}, [texture]);
// Determine current color for the material
const displayColor = texture ? (hovered ? '#e8e8e8' : '#ffffff') : hoverColor;
return (
<mesh
position={position}
rotation={rotation}
onPointerDown={handlePointerDown}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onClick={handleClick}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
{geometry}
<meshStandardMaterial
ref={materialRef}
key={imageUrl || 'flat'}
transparent={transparent}
opacity={opacity}
roughness={materialProps.roughness ?? 0.6}
metalness={materialProps.metalness ?? 0.05}
color={displayColor}
map={texture || null}
/>
</mesh>
);
}
// Export helper functions and constants
export { DEFAULT_COLORS, ELEMENT_LABELS, getElementColor };
+801
View File
@@ -0,0 +1,801 @@
import { useRef, Suspense, useMemo, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import { Canvas, useThree } from '@react-three/fiber';
import { OrbitControls, Html } from '@react-three/drei';
import * as THREE from 'three';
import Room3D, { calculateWardrobeTransform } from './Room3D';
import WardrobeElement from './WardrobeElement';
import styles from './WardrobePreview.module.css';
const SCALE = 1 / 1000;
const PANEL_THICKNESS = 0.019; // 19mm in meters
/* ===================================================================
ResizableSeparator - a draggable handle between two modules
=================================================================== */
function ResizableSeparator({
position,
height,
depth,
separatorIndex,
colors,
onElementClick,
isResizing,
onResizePointerDown,
}) {
const [hovered, setHovered] = useState(false);
const { gl } = useThree();
const meshRef = useRef();
const handlePointerOver = useCallback((e) => {
e.stopPropagation();
setHovered(true);
gl.domElement.style.cursor = 'col-resize';
}, [gl]);
const handlePointerOut = useCallback((e) => {
e.stopPropagation();
if (!isResizing) {
setHovered(false);
gl.domElement.style.cursor = 'auto';
}
}, [gl, isResizing]);
const handlePointerDown = useCallback((e) => {
e.stopPropagation();
if (onResizePointerDown) {
onResizePointerDown(separatorIndex, e);
}
}, [separatorIndex, onResizePointerDown]);
const baseColor = hovered || isResizing ? '#4f46e5' : '#d4c4a4';
return (
<group position={position}>
{/* Visual panel (thin) */}
<WardrobeElement
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, height, depth]}
position={[0, 0, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Invisible wider hit area for grabbing */}
<mesh
ref={meshRef}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onPointerDown={handlePointerDown}
>
<boxGeometry args={[PANEL_THICKNESS * 6, height, depth]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
{/* Highlight overlay when hovered/resizing */}
{(hovered || isResizing) && (
<mesh>
<boxGeometry args={[PANEL_THICKNESS * 1.5, height * 0.95, depth * 0.95]} />
<meshBasicMaterial color={baseColor} transparent opacity={0.35} />
</mesh>
)}
</group>
);
}
/* ===================================================================
WidthLabel - floating HTML label showing module width in mm
=================================================================== */
function WidthLabel({ position, widthMm }) {
return (
<Html position={position} center style={{ pointerEvents: 'none' }}>
<div style={{
background: 'rgba(79, 70, 229, 0.9)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
whiteSpace: 'nowrap',
userSelect: 'none',
}}>
{Math.round(widthMm)}mm
</div>
</Html>
);
}
/* ===================================================================
DrawerBox - renders drawer front faces stacked vertically
=================================================================== */
function DrawerBox({ moduleCenter, moduleWidth, bodyH, plinthH, drawersCount, colors, onElementClick }) {
const drawerHeight = 0.15 * SCALE * 1000; // ~150mm in scene units
const gap = 0.008; // gap between drawers
const totalDrawerZone = Math.min(bodyH * 0.6, drawersCount * (drawerHeight + gap));
const actualDrawerH = (totalDrawerZone - gap * (drawersCount - 1)) / drawersCount;
const startY = -bodyH / 2 + PANEL_THICKNESS;
return (
<>
{Array.from({ length: drawersCount }).map((_, di) => {
const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap);
return (
<WardrobeElement
key={`drawer-${di}`}
elementType="tiroirs"
geometryType="box"
geometryArgs={[moduleWidth * 0.92, actualDrawerH, PANEL_THICKNESS]}
position={[moduleCenter, plinthH / 2 + y, moduleWidth > 0 ? 0.28 : 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.5, metalness: 0.05 }}
/>
);
})}
</>
);
}
/* ===================================================================
WardrobeMesh - Full wardrobe model with drag/resize/drawers
=================================================================== */
function WardrobeMesh({
config,
colors = {},
onElementClick,
onModuleClick,
selectedModuleIndex,
modules = [],
showDoors = true,
onResizeModules,
}) {
const { gl, camera, raycaster } = useThree();
const useModules = modules && modules.length > 0;
// --- Dimensions ---
const totalWidth = useModules
? modules.reduce((s, m) => s + (m.widthMm || 0), 0)
: config.widthMm || 2400;
const width = totalWidth * SCALE;
const height = (config.heightMm || 2400) * SCALE;
const depth = (config.depthMm || 600) * SCALE;
const plinthH = (config.plinthHeightMm || 100) * SCALE;
const bodyH = height - plinthH;
const isNiche = config.installationType === 'niche';
const isSliding = config.doorType === 'sliding';
// --- Module positions ---
const modulePositions = useMemo(() => {
const items = useModules ? modules : [];
const result = [];
let offset = 0;
items.forEach((item) => {
const w = (item.widthMm || 0) * SCALE;
result.push({ x0: offset, width: w, center: -width / 2 + offset + w / 2 });
offset += w;
});
return result;
}, [useModules, modules, width]);
// --- Separator positions (between modules) ---
const separatorPositions = useMemo(() => {
const items = useModules ? modules : [];
return items.slice(0, -1).map((item, i) => {
const x = modulePositions[i].x0 + modulePositions[i].width;
return { x: -width / 2 + x, index: i };
});
}, [modulePositions, width, useModules, modules]);
// --- Hover state for width labels ---
const [hoveredModule, setHoveredModule] = useState(null);
const [resizeState, setResizeState] = useState(null); // { separatorIndex, startX }
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
const intersectPoint = useRef(new THREE.Vector3());
// --- Helpers to project pointer to world X ---
const getWorldX = useCallback((e) => {
const rect = gl.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
planeRef.current.set(new THREE.Vector3(0, 1, 0), -height / 2);
if (raycaster.ray.intersectPlane(planeRef.current, intersectPoint.current)) {
return intersectPoint.current.x;
}
return null;
}, [gl, camera, raycaster, height]);
// --- Resize pointer down ---
const handleResizePointerDown = useCallback((separatorIndex, e) => {
e.stopPropagation();
const worldX = getWorldX(e);
if (worldX === null) return;
setResizeState({ separatorIndex, startX: worldX });
}, [getWorldX]);
// Attach window-level pointer events for resize
const onPointerMoveRef = useRef(null);
const onPointerUpRef = useRef(null);
useMemo(() => {
const handleMove = (e) => {
if (resizeState) {
const worldX = getWorldX(e);
if (worldX !== null) {
const deltaX = worldX - resizeState.startX;
const deltaXmm = deltaX / SCALE;
if (onResizeModules && Math.abs(deltaXmm) > 1) {
onResizeModules(resizeState.separatorIndex, deltaXmm);
setResizeState(prev => prev ? { ...prev, startX: worldX } : null);
}
}
}
};
const handleUp = () => {
if (resizeState) {
setResizeState(null);
gl.domElement.style.cursor = 'auto';
}
};
onPointerMoveRef.current = handleMove;
onPointerUpRef.current = handleUp;
}, [resizeState, getWorldX, onResizeModules, gl]);
// Attach/detach event listeners
useMemo(() => {
const el = gl.domElement;
const move = (e) => onPointerMoveRef.current?.(e);
const up = (e) => onPointerUpRef.current?.(e);
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
return () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
};
}, [gl]);
// Disable OrbitControls during resize
const interactionActive = resizeState !== null;
return (
<group position={[0, height / 2, 0]}>
{/* Plinthe bas */}
<WardrobeElement
elementType="plintheBas"
geometryType="box"
geometryArgs={[width, plinthH, depth]}
position={[0, -height / 2 + plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.7, metalness: 0.1 }}
/>
{/* Bandeau haut */}
<WardrobeElement
elementType="bandeauHaut"
geometryType="box"
geometryArgs={[width, PANEL_THICKNESS, depth]}
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Dessus (top panel) */}
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS * 1.5, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Fond (bottom panel) */}
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
position={[0, -height / 2 + plinthH + PANEL_THICKNESS / 2, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Côté gauche */}
<WardrobeElement
elementType="cotes"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
position={[-width / 2 + PANEL_THICKNESS / 2, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Côté droit */}
<WardrobeElement
elementType="cotes"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
position={[width / 2 - PANEL_THICKNESS / 2, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Dos (back panel) */}
<WardrobeElement
elementType="dos"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, PANEL_THICKNESS / 2]}
position={[0, plinthH / 2, -depth / 2 + PANEL_THICKNESS / 4]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.8 }}
/>
{/* Fileurs (si encastré) */}
{isNiche && (
<>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
position={[-width / 2 - PANEL_THICKNESS / 4, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
position={[width / 2 + PANEL_THICKNESS / 4, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
</>
)}
{/* Separators between modules (resizable) */}
{useModules && onResizeModules
? separatorPositions.map((pos, i) => (
<ResizableSeparator
key={`sep-resize-${i}`}
position={[pos.x, plinthH / 2, 0]}
height={bodyH - PANEL_THICKNESS * 2}
depth={depth - PANEL_THICKNESS}
separatorIndex={pos.index}
colors={colors}
onElementClick={onElementClick}
isResizing={resizeState?.separatorIndex === pos.index}
onResizePointerDown={handleResizePointerDown}
/>
))
: separatorPositions.map((pos, i) => (
<WardrobeElement
key={`sep-${i}`}
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH - PANEL_THICKNESS * 2, depth - PANEL_THICKNESS]}
position={[pos.x, plinthH / 2, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
))
}
{/* Width labels -- show during resize or hover */}
{modules.length > 0 && (resizeState !== null || hoveredModule !== null) && (
<>
{resizeState !== null && (
<>
{modules[resizeState.separatorIndex] && (
<WidthLabel
position={[modulePositions[resizeState.separatorIndex]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
widthMm={modules[resizeState.separatorIndex].widthMm}
/>
)}
{modules[resizeState.separatorIndex + 1] && (
<WidthLabel
position={[modulePositions[resizeState.separatorIndex + 1]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
widthMm={modules[resizeState.separatorIndex + 1].widthMm}
/>
)}
</>
)}
{resizeState === null && hoveredModule !== null && modules[hoveredModule] && (
<WidthLabel
position={[modulePositions[hoveredModule]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
widthMm={modules[hoveredModule].widthMm}
/>
)}
</>
)}
{/* Module contents: doors, shelves, rods, drawers + click-to-select */}
{useModules && modules.map((module, moduleIndex) => {
const pos = modulePositions[moduleIndex];
if (!pos) return null;
const moduleCenter = pos.center;
const moduleWidth = pos.width;
const shelfCount = module.shelves || 0;
const drawersCount = module.drawers || 0;
const isSelected = selectedModuleIndex === moduleIndex;
return (
<group key={`mod-${module.id}`}>
{/* Invisible click/hover area for module selection */}
<mesh
position={[moduleCenter, plinthH / 2, 0]}
onPointerOver={() => setHoveredModule(moduleIndex)}
onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)}
onClick={(e) => {
e.stopPropagation();
if (onModuleClick) onModuleClick(moduleIndex);
}}
>
<boxGeometry args={[moduleWidth - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, depth * 0.5]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
{/* Selection highlight */}
{isSelected && (
<mesh position={[moduleCenter, plinthH / 2, depth / 2 + 0.005]}>
<planeGeometry args={[moduleWidth * 0.98, bodyH * 0.98]} />
<meshBasicMaterial color="#4f46e5" transparent opacity={0.08} />
</mesh>
)}
{/* Portes battantes per module */}
{showDoors && !isSliding && (
<WardrobeElement
elementType="portes"
geometryType="plane"
geometryArgs={[moduleWidth * 0.96, bodyH * 0.96]}
position={[moduleCenter, plinthH / 2, depth / 2 + 0.002]}
colors={colors}
onElementClick={onElementClick}
transparent={true}
opacity={0.9}
materialProps={{ roughness: 0.5 }}
/>
)}
{/* Shelves */}
{Array.from({ length: shelfCount }).map((_, shelfIndex) => {
const shelfY = -height / 2 + plinthH + PANEL_THICKNESS +
((bodyH - PANEL_THICKNESS * 2) / (shelfCount + 1)) * (shelfIndex + 1);
return (
<WardrobeElement
key={`shelf-${moduleIndex}-${shelfIndex}`}
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[moduleWidth - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
position={[moduleCenter, shelfY, 0]}
colors={colors}
onElementClick={onElementClick}
/>
);
})}
{/* Drawers */}
{drawersCount > 0 && (
<DrawerBox
moduleCenter={moduleCenter}
moduleWidth={moduleWidth}
bodyH={bodyH}
plinthH={plinthH}
drawersCount={drawersCount}
colors={colors}
onElementClick={onElementClick}
/>
)}
{/* Rod (penderie) */}
{module.hasRod && (
<WardrobeElement
elementType="penderie"
geometryType="cylinder"
geometryArgs={[0.012, 0.012, moduleWidth - 0.06, 16]}
position={[moduleCenter, height / 2 - plinthH / 2 - 0.15, 0]}
rotation={[0, 0, Math.PI / 2]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.3, metalness: 0.8 }}
/>
)}
</group>
);
})}
{/* Portes coulissantes (sliding) */}
{showDoors && isSliding && (
<WardrobeElement
elementType="portes"
geometryType="plane"
geometryArgs={[width * 0.98, bodyH * 0.98]}
position={[0, plinthH / 2, depth / 2 + 0.002]}
colors={colors}
onElementClick={onElementClick}
transparent={true}
opacity={0.85}
materialProps={{ roughness: 0.5 }}
/>
)}
{/* Module content is rendered above in the module loop */}
{/* Non-module door rendering is also above */}
{/* Pass interaction state to parent for OrbitControls toggling */}
<InteractionBlocker active={interactionActive} />
</group>
);
}
/* ===================================================================
InteractionBlocker - disables OrbitControls during drag/resize
Uses useThree to access the controls
=================================================================== */
function InteractionBlocker({ active }) {
const { controls } = useThree();
useMemo(() => {
if (controls) {
controls.enabled = !active;
}
}, [active, controls]);
return null;
}
/* ===================================================================
CameraController - exposes zoom/reset via ref
=================================================================== */
const CameraController = forwardRef(({ roomConfig, config }, ref) => {
const { camera, gl } = useThree();
const controlsRef = useRef();
const minDistance = roomConfig ? 1 : 0.5;
const maxDistance = roomConfig ? 15 : 8;
const cameraTarget = useMemo(() => {
if (roomConfig) {
return [0, roomConfig.height / 2, 0];
}
return [0, (config.heightMm || 2400) * SCALE * 0.5, 0];
}, [roomConfig, config]);
useImperativeHandle(ref, () => ({
zoomIn: () => {
if (controlsRef.current) {
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
const newDistance = Math.max(minDistance, currentDistance * 0.8);
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
controlsRef.current.update();
}
},
zoomOut: () => {
if (controlsRef.current) {
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
const newDistance = Math.min(maxDistance, currentDistance * 1.25);
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
controlsRef.current.update();
}
},
resetView: () => {
if (controlsRef.current) {
const defaultDist = roomConfig ? Math.max(roomConfig.width, roomConfig.length) * 1.2 : 3;
camera.position.set(defaultDist * 0.7, defaultDist * 0.5, defaultDist * 0.7);
controlsRef.current.update();
}
}
}), [camera, minDistance, maxDistance, roomConfig]);
return (
<OrbitControls
ref={controlsRef}
enablePan={true}
enableZoom={true}
minDistance={minDistance}
maxDistance={maxDistance}
target={cameraTarget}
makeDefault
/>
);
});
/* ===================================================================
Scene - assembles everything
=================================================================== */
function Scene({ config, roomConfig, controllerRef, colors, modules, onElementClick, onModuleClick, selectedModuleIndex, showDoors, onResizeModules }) {
const wardrobeTransform = useMemo(() => {
if (roomConfig) {
return calculateWardrobeTransform(roomConfig, config);
}
return { position: [0, 0, 0], rotation: [0, 0, 0] };
}, [roomConfig, config]);
return (
<>
<ambientLight intensity={roomConfig ? 0.5 : 0.6} />
<directionalLight
position={[4, 6, 5]}
intensity={roomConfig ? 1.0 : 1.2}
castShadow
shadow-mapSize={[1024, 1024]}
shadow-camera-far={20}
shadow-camera-left={-5}
shadow-camera-right={5}
shadow-camera-top={5}
shadow-camera-bottom={-5}
/>
<directionalLight position={[-2, 4, 3]} intensity={0.4} />
{roomConfig && (
<Room3D roomConfig={roomConfig} wardrobeConfig={config} />
)}
<group
position={wardrobeTransform.position}
rotation={wardrobeTransform.rotation}
>
<WardrobeMesh
config={config}
colors={colors}
modules={modules}
onElementClick={onElementClick}
onModuleClick={onModuleClick}
selectedModuleIndex={selectedModuleIndex}
showDoors={showDoors}
onResizeModules={onResizeModules}
/>
</group>
<CameraController ref={controllerRef} roomConfig={roomConfig} config={config} />
</>
);
}
/* ===================================================================
WardrobePreview - main exported component
=================================================================== */
export default function WardrobePreview({
config,
roomConfig,
colors = {},
modules = [],
onElementClick,
onModuleClick,
onResizeModules,
selectedModuleIndex,
}) {
const containerRef = useRef(null);
const controllerRef = useRef(null);
const [selectedElement, setSelectedElement] = useState(null);
const [showDoors, setShowDoors] = useState(true);
const cameraPosition = useMemo(() => {
if (roomConfig) {
const roomSize = Math.max(roomConfig.width, roomConfig.length);
return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8];
}
return [2.5, 1.5, 2.5];
}, [roomConfig]);
const handleZoomIn = useCallback(() => {
controllerRef.current?.zoomIn();
}, []);
const handleZoomOut = useCallback(() => {
controllerRef.current?.zoomOut();
}, []);
const handleResetView = useCallback(() => {
controllerRef.current?.resetView();
}, []);
const handleToggleDoors = useCallback(() => {
setShowDoors(prev => !prev);
}, []);
const handleElementClick = useCallback((elementType, label) => {
setSelectedElement({ type: elementType, label });
if (onElementClick) {
onElementClick(elementType, label);
}
}, [onElementClick]);
// All element types that can be color-picked (including back panel)
const PICKABLE_ELEMENTS = useMemo(() => [
{ type: 'portes', label: 'Portes' },
{ type: 'cotes', label: 'Côtés' },
{ type: 'dessus', label: 'Dessus' },
{ type: 'fond', label: 'Fond (bas)' },
{ type: 'dos', label: 'Dos (arrière)' },
{ type: 'plintheBas', label: 'Plinthe' },
{ type: 'bandeauHaut', label: 'Bandeau haut' },
{ type: 'panneauxInterieurs', label: 'Panneaux int.' },
{ type: 'separateurs', label: 'Séparateurs' },
{ type: 'etageres', label: 'Étagères' },
{ type: 'tiroirs', label: 'Tiroirs' },
{ type: 'penderie', label: 'Tringle' },
], []);
const handleElementSelect = useCallback((e) => {
const val = e.target.value;
if (!val) return;
const item = PICKABLE_ELEMENTS.find(p => p.type === val);
if (item) {
handleElementClick(item.type, item.label);
}
// Reset select to placeholder
e.target.value = '';
}, [PICKABLE_ELEMENTS, handleElementClick]);
return (
<div ref={containerRef} className={styles.container} style={{ background: roomConfig ? '#87CEEB' : '#e8e4df' }}>
{selectedElement && (
<div className={styles.selectedElement}>
Élément sélectionné: <strong>{selectedElement.label}</strong>
</div>
)}
{/* Zoom Controls */}
<div className={styles.controls}>
<button type="button" className={styles.controlBtn} onClick={handleZoomIn} title="Zoom avant">+</button>
<button type="button" className={styles.controlBtn} onClick={handleZoomOut} title="Zoom arrière">&minus;</button>
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">&loz;</button>
<div className={styles.controlDivider} />
<button
type="button"
className={`${styles.controlBtn} ${!showDoors ? styles.controlBtnActive : ''}`}
onClick={handleToggleDoors}
title={showDoors ? 'Masquer les portes' : 'Afficher les portes'}
>
{showDoors ? '\u{1F6AA}' : '\u{1F441}\uFE0F'}
</button>
<div className={styles.controlDivider} />
<select
className={styles.elementSelect}
onChange={handleElementSelect}
defaultValue=""
title="Choisir un élément pour changer sa couleur"
>
<option value="" disabled>🎨 Couleur…</option>
{PICKABLE_ELEMENTS.map(el => (
<option key={el.type} value={el.type}>{el.label}</option>
))}
</select>
</div>
<Suspense fallback={<div className={styles.loading}>Chargement 3D…</div>}>
<Canvas
shadows
camera={{ position: cameraPosition, fov: roomConfig ? 60 : 45 }}
gl={{ antialias: true, alpha: false }}
style={{ width: '100%', height: '100%', display: 'block' }}
>
<Scene
config={config}
roomConfig={roomConfig}
controllerRef={controllerRef}
colors={colors}
modules={modules}
onElementClick={handleElementClick}
onModuleClick={onModuleClick}
selectedModuleIndex={selectedModuleIndex}
showDoors={showDoors}
onResizeModules={onResizeModules}
/>
</Canvas>
</Suspense>
{/* Hint */}
<div className={styles.hint}>
Cliquer un module = configurer {'\u2022'} Cliquer un {'\u00e9'}l{'\u00e9'}ment = couleur {'\u2022'} Tirer s{'\u00e9'}parateurs = redimensionner
</div>
</div>
);
}
@@ -0,0 +1,123 @@
.container {
position: relative;
width: 100%;
height: 100%;
min-height: 280px;
border-radius: 8px;
overflow: hidden;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 10;
}
.controlBtn {
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 1.25rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.2s;
}
.controlBtn:hover {
background: #fff;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.controlBtn:active {
transform: scale(0.95);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 0.875rem;
}
.hint {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 0.75rem;
border-radius: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0.7;
}
.selectedElement {
position: absolute;
top: 10px;
left: 10px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.95);
color: #333;
font-size: 0.85rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.selectedElement strong {
color: var(--action);
}
.controlDivider {
width: 24px;
height: 1px;
background: rgba(0, 0, 0, 0.15);
margin: 4px 6px;
}
.controlBtnActive {
background: var(--action);
color: #fff;
}
.controlBtnActive:hover {
background: var(--action-hover);
}
.elementSelect {
font-size: 0.75rem;
padding: 4px 6px;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
color: #333;
cursor: pointer;
max-width: 110px;
outline: none;
}
.elementSelect:hover {
border-color: var(--action);
}
.elementSelect:focus {
border-color: var(--action);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}