feat: Add MiningAreasPanel component for managing mining areas with create, update, and delete functionalities
This commit is contained in:
@@ -6,13 +6,14 @@ import StatsPanel from './components/StatsPanel';
|
|||||||
import GroupsPanel from './components/GroupsPanel';
|
import GroupsPanel from './components/GroupsPanel';
|
||||||
import TaskPanel from './components/TaskPanel';
|
import TaskPanel from './components/TaskPanel';
|
||||||
import PathRecorder from './components/PathRecorder';
|
import PathRecorder from './components/PathRecorder';
|
||||||
|
import MiningAreasPanel from './components/MiningAreasPanel';
|
||||||
import { useTurtleStore } from './store/turtleStore';
|
import { useTurtleStore } from './store/turtleStore';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const connect = useTurtleStore((state) => state.connect);
|
const connect = useTurtleStore((state) => state.connect);
|
||||||
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
|
||||||
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths'
|
const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths', 'areas'
|
||||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||||
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ function App() {
|
|||||||
return <TaskPanel turtles={turtles} apiUrl={apiUrl} />;
|
return <TaskPanel turtles={turtles} apiUrl={apiUrl} />;
|
||||||
case 'paths':
|
case 'paths':
|
||||||
return <PathRecorder turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
return <PathRecorder turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||||
|
case 'areas':
|
||||||
|
return <MiningAreasPanel turtles={turtles} selectedTurtle={selectedTurtle} apiUrl={apiUrl} />;
|
||||||
default:
|
default:
|
||||||
return <ControlPanel />;
|
return <ControlPanel />;
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,13 @@ function App() {
|
|||||||
>
|
>
|
||||||
🛤️ Paths
|
🛤️ Paths
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={panelTab === 'areas' ? 'active' : ''}
|
||||||
|
onClick={() => setPanelTab('areas')}
|
||||||
|
title="Mining Areas"
|
||||||
|
>
|
||||||
|
⛏️ Areas
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-content-wrapper">
|
<div className="panel-content-wrapper">
|
||||||
{renderPanelContent()}
|
{renderPanelContent()}
|
||||||
|
|||||||
@@ -414,6 +414,98 @@ function HomeMarker({ position }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mining area wireframe component
|
||||||
|
function MiningArea({ area, turtle, isSelected, onClick }) {
|
||||||
|
const meshRef = useRef();
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
// Calculate dimensions
|
||||||
|
const width = Math.abs(area.endX - area.startX) + 1;
|
||||||
|
const height = Math.abs(area.endY - area.startY) + 1;
|
||||||
|
const depth = Math.abs(area.endZ - area.startZ) + 1;
|
||||||
|
|
||||||
|
// Calculate center position
|
||||||
|
const centerX = (area.startX + area.endX) / 2;
|
||||||
|
const centerY = (area.startY + area.endY) / 2;
|
||||||
|
const centerZ = (area.startZ + area.endZ) / 2;
|
||||||
|
|
||||||
|
// Color based on turtle or status
|
||||||
|
const getColor = () => {
|
||||||
|
if (area.status === 'completed') return '#10b981'; // green
|
||||||
|
if (area.status === 'mining') return '#f59e0b'; // orange
|
||||||
|
if (turtle) return turtle.color || '#3b82f6'; // turtle color or blue
|
||||||
|
return '#6366f1'; // default purple
|
||||||
|
};
|
||||||
|
|
||||||
|
const color = getColor();
|
||||||
|
const opacity = isSelected ? 0.3 : hovered ? 0.2 : 0.1;
|
||||||
|
const lineWidth = isSelected ? 2.5 : hovered ? 2 : 1.5;
|
||||||
|
|
||||||
|
// Animate rotation slightly
|
||||||
|
useFrame((state) => {
|
||||||
|
if (meshRef.current && isSelected) {
|
||||||
|
meshRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
position={[centerX, centerY, centerZ]}
|
||||||
|
onClick={onClick}
|
||||||
|
onPointerOver={() => setHovered(true)}
|
||||||
|
onPointerOut={() => setHovered(false)}
|
||||||
|
ref={meshRef}
|
||||||
|
>
|
||||||
|
{/* Wireframe box */}
|
||||||
|
<lineSegments>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(width, height, depth)]} />
|
||||||
|
<lineBasicMaterial color={color} linewidth={lineWidth} />
|
||||||
|
</lineSegments>
|
||||||
|
|
||||||
|
{/* Semi-transparent fill */}
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[width, height, depth]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={opacity}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<Text
|
||||||
|
position={[0, height / 2 + 1, 0]}
|
||||||
|
fontSize={0.5}
|
||||||
|
color={color}
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
>
|
||||||
|
{area.areaName || `Area ${area.areaID}`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Dimensions label */}
|
||||||
|
<Text
|
||||||
|
position={[0, height / 2 + 0.3, 0]}
|
||||||
|
fontSize={0.3}
|
||||||
|
color="white"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
>
|
||||||
|
{width}×{height}×{depth}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
{area.status === 'mining' && (
|
||||||
|
<mesh position={[0, height / 2 + 1.8, 0]}>
|
||||||
|
<sphereGeometry args={[0.2, 16, 16]} />
|
||||||
|
<meshStandardMaterial color="#f59e0b" emissive="#f59e0b" emissiveIntensity={0.8} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Main scene component
|
// Main scene component
|
||||||
function Scene() {
|
function Scene() {
|
||||||
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
const turtles = useTurtleStore((state) => state.getTurtleArray());
|
||||||
@@ -421,6 +513,29 @@ function Scene() {
|
|||||||
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
|
const selectTurtle = useTurtleStore((state) => state.selectTurtle);
|
||||||
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
|
const worldBlocks = useTurtleStore((state) => state.worldBlocks || []);
|
||||||
|
|
||||||
|
// Mining areas state
|
||||||
|
const [miningAreas, setMiningAreas] = useState([]);
|
||||||
|
const [selectedAreaId, setSelectedAreaId] = useState(null);
|
||||||
|
|
||||||
|
// Load mining areas
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMiningAreas = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3001/api/mining-areas');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setMiningAreas(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch mining areas:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMiningAreas();
|
||||||
|
const interval = setInterval(fetchMiningAreas, 5000); // Refresh every 5 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Calculate center point for camera focus
|
// Calculate center point for camera focus
|
||||||
const centerPoint = useMemo(() => {
|
const centerPoint = useMemo(() => {
|
||||||
if (turtles.length === 0) return [0, 0, 0];
|
if (turtles.length === 0) return [0, 0, 0];
|
||||||
@@ -469,6 +584,20 @@ function Scene() {
|
|||||||
{/* Render discovered blocks */}
|
{/* Render discovered blocks */}
|
||||||
<WorldBlocks blocks={worldBlocks} />
|
<WorldBlocks blocks={worldBlocks} />
|
||||||
|
|
||||||
|
{/* Mining areas */}
|
||||||
|
{miningAreas.map((area) => {
|
||||||
|
const turtle = turtles.find(t => t.turtleID === area.turtleID);
|
||||||
|
return (
|
||||||
|
<MiningArea
|
||||||
|
key={area.areaID}
|
||||||
|
area={area}
|
||||||
|
turtle={turtle}
|
||||||
|
isSelected={selectedAreaId === area.areaID}
|
||||||
|
onClick={() => setSelectedAreaId(selectedAreaId === area.areaID ? null : area.areaID)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Home marker */}
|
{/* Home marker */}
|
||||||
{homePosition && <HomeMarker position={homePosition} />}
|
{homePosition && <HomeMarker position={homePosition} />}
|
||||||
|
|
||||||
|
|||||||
434
client/src/components/MiningAreasPanel.css
Normal file
434
client/src/components/MiningAreasPanel.css
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
.mining-areas-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-bottom: 2px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter buttons */
|
||||||
|
.filter-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create form */
|
||||||
|
.create-form {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinates-header label {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-use-position {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-use-position:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-use-position:disabled {
|
||||||
|
background: #334155;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-inputs input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Areas list */
|
||||||
|
.areas-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Area card */
|
||||||
|
.area-card {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-card:hover {
|
||||||
|
border-color: #475569;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-card.has-conflict {
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #2d1818 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Area info */
|
||||||
|
.area-info {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conflict warning */
|
||||||
|
.conflict-warning {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-warning ul {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conflict-warning li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Area actions */
|
||||||
|
.area-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start:hover {
|
||||||
|
background: #059669;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-complete {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-complete:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.areas-list::-webkit-scrollbar,
|
||||||
|
.create-form::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-list::-webkit-scrollbar-track,
|
||||||
|
.create-form::-webkit-scrollbar-track {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-list::-webkit-scrollbar-thumb,
|
||||||
|
.create-form::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-list::-webkit-scrollbar-thumb:hover,
|
||||||
|
.create-form::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.panel-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-buttons {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-list {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.coordinate-inputs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
443
client/src/components/MiningAreasPanel.jsx
Normal file
443
client/src/components/MiningAreasPanel.jsx
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import './MiningAreasPanel.css';
|
||||||
|
|
||||||
|
export default function MiningAreasPanel({ turtles, selectedTurtle, apiUrl }) {
|
||||||
|
const [areas, setAreas] = useState([]);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [newArea, setNewArea] = useState({
|
||||||
|
areaName: '',
|
||||||
|
startX: '',
|
||||||
|
startY: '',
|
||||||
|
startZ: '',
|
||||||
|
endX: '',
|
||||||
|
endY: '',
|
||||||
|
endZ: '',
|
||||||
|
turtleID: ''
|
||||||
|
});
|
||||||
|
const [filterStatus, setFilterStatus] = useState('all'); // all, planned, mining, completed
|
||||||
|
|
||||||
|
// Load mining areas
|
||||||
|
useEffect(() => {
|
||||||
|
loadAreas();
|
||||||
|
const interval = setInterval(loadAreas, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
const loadAreas = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/mining-areas`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setAreas(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load mining areas:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new mining area
|
||||||
|
const handleCreateArea = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!newArea.areaName || !newArea.turtleID) {
|
||||||
|
alert('Please provide area name and assign a turtle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate coordinates
|
||||||
|
const coords = ['startX', 'startY', 'startZ', 'endX', 'endY', 'endZ'];
|
||||||
|
for (const coord of coords) {
|
||||||
|
if (newArea[coord] === '' || isNaN(Number(newArea[coord]))) {
|
||||||
|
alert(`Please provide valid ${coord} coordinate`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/mining-areas`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...newArea,
|
||||||
|
startX: Number(newArea.startX),
|
||||||
|
startY: Number(newArea.startY),
|
||||||
|
startZ: Number(newArea.startZ),
|
||||||
|
endX: Number(newArea.endX),
|
||||||
|
endY: Number(newArea.endY),
|
||||||
|
endZ: Number(newArea.endZ),
|
||||||
|
status: 'planned'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setNewArea({
|
||||||
|
areaName: '',
|
||||||
|
startX: '',
|
||||||
|
startY: '',
|
||||||
|
startZ: '',
|
||||||
|
endX: '',
|
||||||
|
endY: '',
|
||||||
|
endZ: '',
|
||||||
|
turtleID: ''
|
||||||
|
});
|
||||||
|
loadAreas();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Failed to create area: ${error.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create mining area:', error);
|
||||||
|
alert('Failed to create mining area');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete mining area
|
||||||
|
const handleDeleteArea = async (areaID) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this mining area?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/mining-areas/${areaID}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadAreas();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete area');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete area:', error);
|
||||||
|
alert('Failed to delete area');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update area status
|
||||||
|
const handleUpdateStatus = async (areaID, newStatus) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/mining-areas/${areaID}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadAreas();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update status:', error);
|
||||||
|
alert('Failed to update status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-fill from selected turtle position
|
||||||
|
const handleUseCurrentPosition = () => {
|
||||||
|
if (!selectedTurtle || !selectedTurtle.position) {
|
||||||
|
alert('No turtle selected or turtle has no position');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = selectedTurtle.position;
|
||||||
|
setNewArea(prev => ({
|
||||||
|
...prev,
|
||||||
|
startX: pos.x.toString(),
|
||||||
|
startY: pos.y.toString(),
|
||||||
|
startZ: pos.z.toString(),
|
||||||
|
turtleID: selectedTurtle.turtleID.toString()
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate volume
|
||||||
|
const calculateVolume = (area) => {
|
||||||
|
const width = Math.abs(area.endX - area.startX) + 1;
|
||||||
|
const height = Math.abs(area.endY - area.startY) + 1;
|
||||||
|
const depth = Math.abs(area.endZ - area.startZ) + 1;
|
||||||
|
return width * height * depth;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for overlapping areas
|
||||||
|
const checkOverlap = (area1, area2) => {
|
||||||
|
const overlapX = Math.max(
|
||||||
|
Math.min(area1.endX, area2.endX) - Math.max(area1.startX, area2.startX) + 1,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const overlapY = Math.max(
|
||||||
|
Math.min(area1.endY, area2.endY) - Math.max(area1.startY, area2.startY) + 1,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const overlapZ = Math.max(
|
||||||
|
Math.min(area1.endZ, area2.endZ) - Math.max(area1.startZ, area2.startZ) + 1,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return overlapX > 0 && overlapY > 0 && overlapZ > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find conflicts
|
||||||
|
const findConflicts = (area) => {
|
||||||
|
return areas.filter(other =>
|
||||||
|
other.areaID !== area.areaID &&
|
||||||
|
checkOverlap(area, other)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter areas
|
||||||
|
const filteredAreas = areas.filter(area => {
|
||||||
|
if (filterStatus === 'all') return true;
|
||||||
|
return area.status === filterStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status badge component
|
||||||
|
const StatusBadge = ({ status }) => {
|
||||||
|
const colors = {
|
||||||
|
planned: '#6366f1',
|
||||||
|
mining: '#f59e0b',
|
||||||
|
completed: '#10b981'
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className="status-badge" style={{ backgroundColor: colors[status] || '#6b7280' }}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mining-areas-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>⛏️ Mining Areas</h2>
|
||||||
|
<button
|
||||||
|
className="btn-create"
|
||||||
|
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||||
|
>
|
||||||
|
{showCreateForm ? '✕ Cancel' : '+ New Area'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter buttons */}
|
||||||
|
<div className="filter-buttons">
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filterStatus === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilterStatus('all')}
|
||||||
|
>
|
||||||
|
All ({areas.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filterStatus === 'planned' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilterStatus('planned')}
|
||||||
|
>
|
||||||
|
📋 Planned ({areas.filter(a => a.status === 'planned').length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filterStatus === 'mining' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilterStatus('mining')}
|
||||||
|
>
|
||||||
|
⛏️ Mining ({areas.filter(a => a.status === 'mining').length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-btn ${filterStatus === 'completed' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilterStatus('completed')}
|
||||||
|
>
|
||||||
|
✓ Done ({areas.filter(a => a.status === 'completed').length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="create-form">
|
||||||
|
<h3>Create Mining Area</h3>
|
||||||
|
<form onSubmit={handleCreateArea}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Area Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newArea.areaName}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, areaName: e.target.value })}
|
||||||
|
placeholder="e.g., Diamond Mine #1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Assign Turtle:</label>
|
||||||
|
<select
|
||||||
|
value={newArea.turtleID}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, turtleID: e.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select turtle...</option>
|
||||||
|
{turtles.map(turtle => (
|
||||||
|
<option key={turtle.turtleID} value={turtle.turtleID}>
|
||||||
|
🐢 {turtle.turtleID} {turtle.label ? `- ${turtle.label}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="coordinates-section">
|
||||||
|
<div className="coordinates-header">
|
||||||
|
<label>Start Position:</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-use-position"
|
||||||
|
onClick={handleUseCurrentPosition}
|
||||||
|
disabled={!selectedTurtle}
|
||||||
|
>
|
||||||
|
Use Current Position
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="coordinate-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="X"
|
||||||
|
value={newArea.startX}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, startX: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Y"
|
||||||
|
value={newArea.startY}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, startY: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Z"
|
||||||
|
value={newArea.startZ}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, startZ: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="coordinates-section">
|
||||||
|
<label>End Position:</label>
|
||||||
|
<div className="coordinate-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="X"
|
||||||
|
value={newArea.endX}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, endX: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Y"
|
||||||
|
value={newArea.endY}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, endY: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Z"
|
||||||
|
value={newArea.endZ}
|
||||||
|
onChange={(e) => setNewArea({ ...newArea, endZ: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn-submit">
|
||||||
|
Create Area
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Areas list */}
|
||||||
|
<div className="areas-list">
|
||||||
|
{filteredAreas.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No mining areas {filterStatus !== 'all' ? `with status "${filterStatus}"` : ''}</p>
|
||||||
|
{filterStatus === 'all' && (
|
||||||
|
<p>Create a new area to get started</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAreas.map(area => {
|
||||||
|
const turtle = turtles.find(t => t.turtleID === area.turtleID);
|
||||||
|
const conflicts = findConflicts(area);
|
||||||
|
const volume = calculateVolume(area);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={area.areaID} className={`area-card ${conflicts.length > 0 ? 'has-conflict' : ''}`}>
|
||||||
|
<div className="area-header">
|
||||||
|
<h3>{area.areaName}</h3>
|
||||||
|
<StatusBadge status={area.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="area-info">
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Turtle:</span>
|
||||||
|
<span className="info-value">
|
||||||
|
🐢 {turtle ? `${turtle.turtleID} ${turtle.label || ''}` : area.turtleID}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Coordinates:</span>
|
||||||
|
<span className="info-value coordinate-value">
|
||||||
|
({area.startX}, {area.startY}, {area.startZ}) →
|
||||||
|
({area.endX}, {area.endY}, {area.endZ})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Volume:</span>
|
||||||
|
<span className="info-value">{volume.toLocaleString()} blocks</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{area.createdAt && (
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">Created:</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{new Date(area.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conflicts.length > 0 && (
|
||||||
|
<div className="conflict-warning">
|
||||||
|
⚠️ Overlaps with {conflicts.length} other area(s):
|
||||||
|
<ul>
|
||||||
|
{conflicts.map(c => (
|
||||||
|
<li key={c.areaID}>{c.areaName}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="area-actions">
|
||||||
|
{area.status === 'planned' && (
|
||||||
|
<button
|
||||||
|
className="btn-action btn-start"
|
||||||
|
onClick={() => handleUpdateStatus(area.areaID, 'mining')}
|
||||||
|
>
|
||||||
|
▶️ Start Mining
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{area.status === 'mining' && (
|
||||||
|
<button
|
||||||
|
className="btn-action btn-complete"
|
||||||
|
onClick={() => handleUpdateStatus(area.areaID, 'completed')}
|
||||||
|
>
|
||||||
|
✓ Mark Complete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-action btn-delete"
|
||||||
|
onClick={() => handleDeleteArea(area.areaID)}
|
||||||
|
>
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user