v1.0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</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 →
|
||||
</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);
|
||||
}
|
||||
@@ -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}>
|
||||
×
|
||||
</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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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">−</button>
|
||||
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">◊</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);
|
||||
}
|
||||
Reference in New Issue
Block a user