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 CraftingPanel from './components/CraftingPanel';
|
||||
import AlertsPanel from './components/AlertsPanel';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { useInventoryStore } from './store/inventoryStore';
|
||||
import './App.css';
|
||||
|
||||
@@ -50,7 +51,9 @@ function App() {
|
||||
|
||||
<div className="app-content">
|
||||
<div className="sidebar">
|
||||
<StorageOverview />
|
||||
<ErrorBoundary>
|
||||
<StorageOverview />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className="main-panel">
|
||||
<div className="panel-tabs">
|
||||
@@ -80,7 +83,9 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-content-wrapper">
|
||||
{renderPanelContent()}
|
||||
<ErrorBoundary>
|
||||
{renderPanelContent()}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import ItemIcon from './ItemIcon';
|
||||
import './AlertsPanel.css';
|
||||
|
||||
function AlertsPanel() {
|
||||
const alerts = useInventoryStore((state) => state.alerts);
|
||||
const alerts = useInventoryStore((state) => state.alerts) || [];
|
||||
|
||||
return (
|
||||
<div className="alerts-panel">
|
||||
@@ -18,30 +18,36 @@ function AlertsPanel() {
|
||||
|
||||
{alerts.length > 0 ? (
|
||||
<div className="alert-list">
|
||||
{alerts.map((alert, idx) => (
|
||||
<div key={idx} className={`alert-card ${alert.triggered ? 'triggered' : 'ok'}`}>
|
||||
<div className="alert-icon">
|
||||
{alert.triggered ? '🔴' : '🟢'}
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
<div className="alert-item-row">
|
||||
<ItemIcon itemName={alert.item} size={20} />
|
||||
<span className="alert-item-name">{formatItemName(alert.item)}</span>
|
||||
{alerts.map((alert, idx) => {
|
||||
const itemName = alert.item || alert.name || alert.label || '';
|
||||
const isTriggered = alert.triggered !== undefined ? alert.triggered : true;
|
||||
const current = alert.current ?? '?';
|
||||
const threshold = alert.threshold || alert.min || '?';
|
||||
return (
|
||||
<div key={idx} className={`alert-card ${isTriggered ? 'triggered' : 'ok'}`}>
|
||||
<div className="alert-icon">
|
||||
{isTriggered ? '🔴' : '🟢'}
|
||||
</div>
|
||||
<div className="alert-details">
|
||||
<span className="alert-stock">
|
||||
Stock: <strong>{alert.current ?? '?'}</strong>
|
||||
</span>
|
||||
<span className="alert-threshold">
|
||||
Min: <strong>{alert.threshold || alert.min || '?'}</strong>
|
||||
</span>
|
||||
<div className="alert-info">
|
||||
<div className="alert-item-row">
|
||||
<ItemIcon itemName={itemName} size={20} />
|
||||
<span className="alert-item-name">{formatItemName(itemName)}</span>
|
||||
</div>
|
||||
<div className="alert-details">
|
||||
<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 className={`alert-badge ${alert.triggered ? 'low' : 'ok'}`}>
|
||||
{alert.triggered ? 'LOW' : 'OK'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-alerts">
|
||||
|
||||
@@ -35,9 +35,9 @@ function CraftingPanel() {
|
||||
{(craftable || []).map((recipe, idx) => (
|
||||
<div key={idx} className="craft-card">
|
||||
<div className="craft-output">
|
||||
<ItemIcon itemName={recipe.output} size={32} />
|
||||
<ItemIcon itemName={recipe.output || ''} size={32} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,8 +45,8 @@ function CraftingPanel() {
|
||||
<div className="craft-ingredients">
|
||||
{recipe.slots && Object.entries(recipe.slots).map(([slot, item]) => (
|
||||
<div key={slot} className="craft-ingredient">
|
||||
<ItemIcon itemName={item} size={16} />
|
||||
<span>{formatItemName(item)}</span>
|
||||
<ItemIcon itemName={item || ''} size={16} />
|
||||
<span>{formatItemName(item || '')}</span>
|
||||
</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 && (
|
||||
<div className="item-detail-panel">
|
||||
<div className="detail-header">
|
||||
<ItemIcon itemName={selectedItem.name} size={48} />
|
||||
<ItemIcon itemName={selectedItem.name || ''} size={48} />
|
||||
<div className="detail-info">
|
||||
<h3>{formatItemName(selectedItem.name)}</h3>
|
||||
<span className="detail-id">{selectedItem.name}</span>
|
||||
<h3>{formatItemName(selectedItem.name || '')}</h3>
|
||||
<span className="detail-id">{selectedItem.name || ''}</span>
|
||||
</div>
|
||||
<button className="detail-close" onClick={() => setSelectedItem(null)}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ function SmeltingPanel() {
|
||||
const furnaceStatus = inventory.furnaceStatus || {};
|
||||
const furnaceEntries = Object.entries(furnaceStatus);
|
||||
|
||||
const smeltableEntries = Object.entries(smeltable);
|
||||
const smeltableEntries = Object.entries(smeltable || {});
|
||||
|
||||
return (
|
||||
<div className="smelting-panel">
|
||||
@@ -44,31 +44,31 @@ function SmeltingPanel() {
|
||||
{furnaceEntries.length > 0 ? (
|
||||
<div className="furnace-grid">
|
||||
{furnaceEntries.map(([name, status]) => (
|
||||
<div key={name} className={`furnace-card ${status.active ? 'active' : 'idle'}`}>
|
||||
<div className="furnace-name">{name.replace(/^minecraft:/, '')}</div>
|
||||
<div key={name} className={`furnace-card ${(status && status.active) ? 'active' : 'idle'}`}>
|
||||
<div className="furnace-name">{(name || '').replace(/^minecraft:/, '')}</div>
|
||||
<div className="furnace-status">
|
||||
{status.input && (
|
||||
{status && status.input && (
|
||||
<div className="furnace-slot">
|
||||
<span className="slot-label">In:</span>
|
||||
<ItemIcon itemName={status.input.name} size={16} />
|
||||
<span>{status.input.count || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{status.fuel && (
|
||||
{status && status.fuel && (
|
||||
<div className="furnace-slot">
|
||||
<span className="slot-label">Fuel:</span>
|
||||
<ItemIcon itemName={status.fuel.name} size={16} />
|
||||
<span>{status.fuel.count || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{status.output && (
|
||||
{status && status.output && (
|
||||
<div className="furnace-slot">
|
||||
<span className="slot-label">Out:</span>
|
||||
<ItemIcon itemName={status.output.name} size={16} />
|
||||
<span>{status.output.count || 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{!status.input && !status.fuel && !status.output && (
|
||||
{(!status || (!status.input && !status.fuel && !status.output)) && (
|
||||
<span className="furnace-empty">Empty</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -90,6 +90,8 @@ function SmeltingPanel() {
|
||||
<div className="recipe-list">
|
||||
{smeltableEntries.map(([input, recipe]) => {
|
||||
const isDisabled = disabledRecipes[input];
|
||||
const result = (recipe && recipe.result) || '';
|
||||
const furnaces = (recipe && recipe.furnaces) || [];
|
||||
return (
|
||||
<div
|
||||
key={input}
|
||||
@@ -105,13 +107,13 @@ function SmeltingPanel() {
|
||||
</div>
|
||||
<span className="recipe-arrow">→</span>
|
||||
<div className="recipe-output">
|
||||
<ItemIcon itemName={recipe.result} size={20} />
|
||||
<span>{formatItemName(recipe.result)}</span>
|
||||
<ItemIcon itemName={result} size={20} />
|
||||
<span>{formatItemName(result)}</span>
|
||||
</div>
|
||||
<div className="recipe-furnaces">
|
||||
{(recipe.furnaces || []).map((f) => (
|
||||
{furnaces.map((f) => (
|
||||
<span key={f} className="furnace-tag">
|
||||
{f.replace(/^minecraft:/, '')}
|
||||
{(f || '').replace(/^minecraft:/, '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user