Harden components: add ErrorBoundary, null-safe rendering
This commit is contained in:
@@ -4,6 +4,7 @@ import StorageOverview from './components/StorageOverview';
|
|||||||
import SmeltingPanel from './components/SmeltingPanel';
|
import SmeltingPanel from './components/SmeltingPanel';
|
||||||
import CraftingPanel from './components/CraftingPanel';
|
import CraftingPanel from './components/CraftingPanel';
|
||||||
import AlertsPanel from './components/AlertsPanel';
|
import AlertsPanel from './components/AlertsPanel';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import { useInventoryStore } from './store/inventoryStore';
|
import { useInventoryStore } from './store/inventoryStore';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -50,7 +51,9 @@ function App() {
|
|||||||
|
|
||||||
<div className="app-content">
|
<div className="app-content">
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<StorageOverview />
|
<ErrorBoundary>
|
||||||
|
<StorageOverview />
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className="main-panel">
|
<div className="main-panel">
|
||||||
<div className="panel-tabs">
|
<div className="panel-tabs">
|
||||||
@@ -80,7 +83,9 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-content-wrapper">
|
<div className="panel-content-wrapper">
|
||||||
{renderPanelContent()}
|
<ErrorBoundary>
|
||||||
|
{renderPanelContent()}
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ItemIcon from './ItemIcon';
|
|||||||
import './AlertsPanel.css';
|
import './AlertsPanel.css';
|
||||||
|
|
||||||
function AlertsPanel() {
|
function AlertsPanel() {
|
||||||
const alerts = useInventoryStore((state) => state.alerts);
|
const alerts = useInventoryStore((state) => state.alerts) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alerts-panel">
|
<div className="alerts-panel">
|
||||||
@@ -18,30 +18,36 @@ function AlertsPanel() {
|
|||||||
|
|
||||||
{alerts.length > 0 ? (
|
{alerts.length > 0 ? (
|
||||||
<div className="alert-list">
|
<div className="alert-list">
|
||||||
{alerts.map((alert, idx) => (
|
{alerts.map((alert, idx) => {
|
||||||
<div key={idx} className={`alert-card ${alert.triggered ? 'triggered' : 'ok'}`}>
|
const itemName = alert.item || alert.name || alert.label || '';
|
||||||
<div className="alert-icon">
|
const isTriggered = alert.triggered !== undefined ? alert.triggered : true;
|
||||||
{alert.triggered ? '🔴' : '🟢'}
|
const current = alert.current ?? '?';
|
||||||
</div>
|
const threshold = alert.threshold || alert.min || '?';
|
||||||
<div className="alert-info">
|
return (
|
||||||
<div className="alert-item-row">
|
<div key={idx} className={`alert-card ${isTriggered ? 'triggered' : 'ok'}`}>
|
||||||
<ItemIcon itemName={alert.item} size={20} />
|
<div className="alert-icon">
|
||||||
<span className="alert-item-name">{formatItemName(alert.item)}</span>
|
{isTriggered ? '🔴' : '🟢'}
|
||||||
</div>
|
</div>
|
||||||
<div className="alert-details">
|
<div className="alert-info">
|
||||||
<span className="alert-stock">
|
<div className="alert-item-row">
|
||||||
Stock: <strong>{alert.current ?? '?'}</strong>
|
<ItemIcon itemName={itemName} size={20} />
|
||||||
</span>
|
<span className="alert-item-name">{formatItemName(itemName)}</span>
|
||||||
<span className="alert-threshold">
|
</div>
|
||||||
Min: <strong>{alert.threshold || alert.min || '?'}</strong>
|
<div className="alert-details">
|
||||||
</span>
|
<span className="alert-stock">
|
||||||
|
Stock: <strong>{current}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="alert-threshold">
|
||||||
|
Min: <strong>{threshold}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`alert-badge ${isTriggered ? 'low' : 'ok'}`}>
|
||||||
|
{isTriggered ? 'LOW' : 'OK'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`alert-badge ${alert.triggered ? 'low' : 'ok'}`}>
|
);
|
||||||
{alert.triggered ? 'LOW' : 'OK'}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="no-alerts">
|
<div className="no-alerts">
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ function CraftingPanel() {
|
|||||||
{(craftable || []).map((recipe, idx) => (
|
{(craftable || []).map((recipe, idx) => (
|
||||||
<div key={idx} className="craft-card">
|
<div key={idx} className="craft-card">
|
||||||
<div className="craft-output">
|
<div className="craft-output">
|
||||||
<ItemIcon itemName={recipe.output} size={32} />
|
<ItemIcon itemName={recipe.output || ''} size={32} />
|
||||||
<div className="craft-info">
|
<div className="craft-info">
|
||||||
<span className="craft-name">{formatItemName(recipe.output)}</span>
|
<span className="craft-name">{formatItemName(recipe.output || '')}</span>
|
||||||
<span className="craft-count">Produces {recipe.count || 1}</span>
|
<span className="craft-count">Produces {recipe.count || 1}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,8 +45,8 @@ function CraftingPanel() {
|
|||||||
<div className="craft-ingredients">
|
<div className="craft-ingredients">
|
||||||
{recipe.slots && Object.entries(recipe.slots).map(([slot, item]) => (
|
{recipe.slots && Object.entries(recipe.slots).map(([slot, item]) => (
|
||||||
<div key={slot} className="craft-ingredient">
|
<div key={slot} className="craft-ingredient">
|
||||||
<ItemIcon itemName={item} size={16} />
|
<ItemIcon itemName={item || ''} size={16} />
|
||||||
<span>{formatItemName(item)}</span>
|
<span>{formatItemName(item || '')}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
54
web/client/src/components/ErrorBoundary.jsx
Normal file
54
web/client/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
class ErrorBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, errorInfo) {
|
||||||
|
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '2rem',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
color: '#ff6b6b',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '1rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
<h2>⚠️ Something went wrong</h2>
|
||||||
|
<p style={{ color: '#ccc' }}>
|
||||||
|
{this.state.error?.message || 'Unknown error'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#4a7c59',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -97,10 +97,10 @@ function InventoryGrid() {
|
|||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<div className="item-detail-panel">
|
<div className="item-detail-panel">
|
||||||
<div className="detail-header">
|
<div className="detail-header">
|
||||||
<ItemIcon itemName={selectedItem.name} size={48} />
|
<ItemIcon itemName={selectedItem.name || ''} size={48} />
|
||||||
<div className="detail-info">
|
<div className="detail-info">
|
||||||
<h3>{formatItemName(selectedItem.name)}</h3>
|
<h3>{formatItemName(selectedItem.name || '')}</h3>
|
||||||
<span className="detail-id">{selectedItem.name}</span>
|
<span className="detail-id">{selectedItem.name || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="detail-close" onClick={() => setSelectedItem(null)}>✕</button>
|
<button className="detail-close" onClick={() => setSelectedItem(null)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function SmeltingPanel() {
|
|||||||
const furnaceStatus = inventory.furnaceStatus || {};
|
const furnaceStatus = inventory.furnaceStatus || {};
|
||||||
const furnaceEntries = Object.entries(furnaceStatus);
|
const furnaceEntries = Object.entries(furnaceStatus);
|
||||||
|
|
||||||
const smeltableEntries = Object.entries(smeltable);
|
const smeltableEntries = Object.entries(smeltable || {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="smelting-panel">
|
<div className="smelting-panel">
|
||||||
@@ -44,31 +44,31 @@ function SmeltingPanel() {
|
|||||||
{furnaceEntries.length > 0 ? (
|
{furnaceEntries.length > 0 ? (
|
||||||
<div className="furnace-grid">
|
<div className="furnace-grid">
|
||||||
{furnaceEntries.map(([name, status]) => (
|
{furnaceEntries.map(([name, status]) => (
|
||||||
<div key={name} className={`furnace-card ${status.active ? 'active' : 'idle'}`}>
|
<div key={name} className={`furnace-card ${(status && status.active) ? 'active' : 'idle'}`}>
|
||||||
<div className="furnace-name">{name.replace(/^minecraft:/, '')}</div>
|
<div className="furnace-name">{(name || '').replace(/^minecraft:/, '')}</div>
|
||||||
<div className="furnace-status">
|
<div className="furnace-status">
|
||||||
{status.input && (
|
{status && status.input && (
|
||||||
<div className="furnace-slot">
|
<div className="furnace-slot">
|
||||||
<span className="slot-label">In:</span>
|
<span className="slot-label">In:</span>
|
||||||
<ItemIcon itemName={status.input.name} size={16} />
|
<ItemIcon itemName={status.input.name} size={16} />
|
||||||
<span>{status.input.count || 0}</span>
|
<span>{status.input.count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status.fuel && (
|
{status && status.fuel && (
|
||||||
<div className="furnace-slot">
|
<div className="furnace-slot">
|
||||||
<span className="slot-label">Fuel:</span>
|
<span className="slot-label">Fuel:</span>
|
||||||
<ItemIcon itemName={status.fuel.name} size={16} />
|
<ItemIcon itemName={status.fuel.name} size={16} />
|
||||||
<span>{status.fuel.count || 0}</span>
|
<span>{status.fuel.count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status.output && (
|
{status && status.output && (
|
||||||
<div className="furnace-slot">
|
<div className="furnace-slot">
|
||||||
<span className="slot-label">Out:</span>
|
<span className="slot-label">Out:</span>
|
||||||
<ItemIcon itemName={status.output.name} size={16} />
|
<ItemIcon itemName={status.output.name} size={16} />
|
||||||
<span>{status.output.count || 0}</span>
|
<span>{status.output.count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!status.input && !status.fuel && !status.output && (
|
{(!status || (!status.input && !status.fuel && !status.output)) && (
|
||||||
<span className="furnace-empty">Empty</span>
|
<span className="furnace-empty">Empty</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -90,6 +90,8 @@ function SmeltingPanel() {
|
|||||||
<div className="recipe-list">
|
<div className="recipe-list">
|
||||||
{smeltableEntries.map(([input, recipe]) => {
|
{smeltableEntries.map(([input, recipe]) => {
|
||||||
const isDisabled = disabledRecipes[input];
|
const isDisabled = disabledRecipes[input];
|
||||||
|
const result = (recipe && recipe.result) || '';
|
||||||
|
const furnaces = (recipe && recipe.furnaces) || [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={input}
|
key={input}
|
||||||
@@ -105,13 +107,13 @@ function SmeltingPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="recipe-arrow">→</span>
|
<span className="recipe-arrow">→</span>
|
||||||
<div className="recipe-output">
|
<div className="recipe-output">
|
||||||
<ItemIcon itemName={recipe.result} size={20} />
|
<ItemIcon itemName={result} size={20} />
|
||||||
<span>{formatItemName(recipe.result)}</span>
|
<span>{formatItemName(result)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="recipe-furnaces">
|
<div className="recipe-furnaces">
|
||||||
{(recipe.furnaces || []).map((f) => (
|
{furnaces.map((f) => (
|
||||||
<span key={f} className="furnace-tag">
|
<span key={f} className="furnace-tag">
|
||||||
{f.replace(/^minecraft:/, '')}
|
{(f || '').replace(/^minecraft:/, '')}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user