Major UI overhaul: bigger icons, creative inventory tabs, grouped smelting, alerts popup, analytics, mod icons
- Inventory: Bigger item icons (48px) filling their slots, MC-style hover tooltips - Creative inventory category tabs (All/Blocks/Tools/Combat/Food/Redstone/Materials/Misc) - Smelting recipes grouped by output item with collapsible sections - Alerts moved from full tab to popup overlay triggered from header bell icon - New Analytics tab with SVG charts (storage over time, top items, item trend lookup) - Mod icon support for ComputerCraft (CC:Tweaked) via GitHub CDN fallback - Server: new /api/history-summary endpoint for aggregate storage history - Store: fetchHistorySummary and fetchItemHistory for analytics data
This commit is contained in:
@@ -84,6 +84,57 @@ body {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* === Alerts Bell === */
|
||||
.alerts-bell {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 0;
|
||||
transition: all 0.1s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.alerts-bell:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.alerts-bell.has-alerts {
|
||||
animation: bell-shake 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.alerts-bell-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: var(--mc-text-red);
|
||||
color: white;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
padding: 1px 3px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--mc-dark);
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
@keyframes bell-shake {
|
||||
0%, 80%, 100% { transform: rotate(0deg); }
|
||||
85% { transform: rotate(8deg); }
|
||||
90% { transform: rotate(-8deg); }
|
||||
95% { transform: rotate(4deg); }
|
||||
}
|
||||
|
||||
/* === Connection Status === */
|
||||
.connection-status {
|
||||
display: flex;
|
||||
|
||||
@@ -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 AnalyticsPanel from './components/AnalyticsPanel';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { useInventoryStore } from './store/inventoryStore';
|
||||
import './App.css';
|
||||
@@ -12,7 +13,9 @@ function App() {
|
||||
const connect = useInventoryStore((state) => state.connect);
|
||||
const connected = useInventoryStore((state) => state.connected);
|
||||
const commandResult = useInventoryStore((state) => state.commandResult);
|
||||
const alerts = useInventoryStore((state) => state.alerts) || [];
|
||||
const [panelTab, setPanelTab] = useState('inventory');
|
||||
const [showAlerts, setShowAlerts] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
@@ -26,20 +29,35 @@ function App() {
|
||||
return <SmeltingPanel />;
|
||||
case 'crafting':
|
||||
return <CraftingPanel />;
|
||||
case 'alerts':
|
||||
return <AlertsPanel />;
|
||||
case 'analytics':
|
||||
return <AnalyticsPanel />;
|
||||
default:
|
||||
return <InventoryGrid />;
|
||||
}
|
||||
};
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered !== false);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="app-header">
|
||||
<h1>⛏️ Inventory Manager</h1>
|
||||
<div className={`connection-status ${connected ? 'connected' : 'disconnected'}`}>
|
||||
<span className="status-dot"></span>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
<div className="header-right">
|
||||
{/* Alerts bell button */}
|
||||
<button
|
||||
className={`alerts-bell ${triggeredAlerts.length > 0 ? 'has-alerts' : ''}`}
|
||||
onClick={() => setShowAlerts(!showAlerts)}
|
||||
title="Low-stock alerts"
|
||||
>
|
||||
🔔
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<span className="alerts-bell-badge">{triggeredAlerts.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className={`connection-status ${connected ? 'connected' : 'disconnected'}`}>
|
||||
<span className="status-dot"></span>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +67,9 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerts popup overlay */}
|
||||
<AlertsPanel isOpen={showAlerts} onClose={() => setShowAlerts(false)} />
|
||||
|
||||
<div className="app-content">
|
||||
<div className="sidebar">
|
||||
<ErrorBoundary>
|
||||
@@ -76,10 +97,10 @@ function App() {
|
||||
🔨 Crafting
|
||||
</button>
|
||||
<button
|
||||
className={panelTab === 'alerts' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('alerts')}
|
||||
className={panelTab === 'analytics' ? 'active' : ''}
|
||||
onClick={() => setPanelTab('analytics')}
|
||||
>
|
||||
🔔 Alerts
|
||||
📊 Analytics
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-content-wrapper">
|
||||
|
||||
@@ -1,24 +1,71 @@
|
||||
.alerts-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
/* ===== Alerts Popup Overlay ===== */
|
||||
.alerts-overlay {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
animation: alerts-slide-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes alerts-slide-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.alerts-popup {
|
||||
width: 340px;
|
||||
max-height: 400px;
|
||||
background: #333;
|
||||
border: 3px solid var(--mc-dark);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.6),
|
||||
inset 0 2px 0 #555,
|
||||
inset 0 -2px 0 #2a2a2a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alerts-header {
|
||||
.alerts-popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #3b3b3b;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alerts-header h2 {
|
||||
font-size: 1rem;
|
||||
.alerts-popup-header h3 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 2px 2px 0 var(--mc-dark);
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
flex: 1;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.alerts-close {
|
||||
background: none;
|
||||
border: 2px solid #555;
|
||||
color: var(--mc-text-gray);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.alerts-close:hover {
|
||||
color: var(--mc-text-red);
|
||||
border-color: var(--mc-text-red);
|
||||
}
|
||||
|
||||
.alerts-popup-body {
|
||||
overflow-y: auto;
|
||||
max-height: 330px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-count {
|
||||
|
||||
@@ -1,60 +1,90 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './AlertsPanel.css';
|
||||
|
||||
function AlertsPanel() {
|
||||
function AlertsPanel({ isOpen, onClose }) {
|
||||
const alerts = useInventoryStore((state) => state.alerts) || [];
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClickOutside = (e) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) {
|
||||
// Check if the click was on the bell button (has alerts-bell class)
|
||||
if (e.target.closest('.alerts-bell')) return;
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="alerts-panel">
|
||||
<div className="alerts-header">
|
||||
<h2>🔔 Low-Stock Alerts</h2>
|
||||
<span className="alert-count">
|
||||
{alerts.length} alert{alerts.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="alerts-overlay" ref={panelRef}>
|
||||
<div className="alerts-popup">
|
||||
<div className="alerts-popup-header">
|
||||
<h3>🔔 Low-Stock Alerts</h3>
|
||||
<span className="alert-count">
|
||||
{alerts.length} alert{alerts.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button className="alerts-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{alerts.length > 0 ? (
|
||||
<div className="alert-list">
|
||||
{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-info">
|
||||
<div className="alert-item-row">
|
||||
<ItemIcon itemName={itemName} size={20} />
|
||||
<span className="alert-item-name">{formatItemName(itemName)}</span>
|
||||
<div className="alerts-popup-body">
|
||||
{alerts.length > 0 ? (
|
||||
<div className="alert-list">
|
||||
{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-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-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>
|
||||
) : (
|
||||
<div className="no-alerts">
|
||||
<span className="no-alerts-icon">✅</span>
|
||||
<span>All stock levels are OK</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-alerts">
|
||||
<span className="no-alerts-icon">✅</span>
|
||||
<span>No alerts configured or all stock levels are OK</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
243
web/client/src/components/AnalyticsPanel.css
Normal file
243
web/client/src/components/AnalyticsPanel.css
Normal file
@@ -0,0 +1,243 @@
|
||||
.analytics-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--mc-dark);
|
||||
}
|
||||
|
||||
.analytics-header h2 {
|
||||
font-size: 1rem;
|
||||
color: var(--mc-text-yellow);
|
||||
text-shadow: 2px 2px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
/* Quick Stats */
|
||||
.analytics-stats {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.analytics-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.analytics-stat .stat-label {
|
||||
font-size: 0.5rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.analytics-stat .stat-value {
|
||||
font-size: 0.85rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
/* Chart Grid */
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
padding: 0.75rem;
|
||||
background: #3b3b3b;
|
||||
border: 3px solid var(--mc-dark);
|
||||
box-shadow:
|
||||
inset 0 2px 0 #555,
|
||||
inset 0 -2px 0 #2a2a2a;
|
||||
}
|
||||
|
||||
.analytics-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.analytics-card h3 {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mc-text-gold);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* SVG Charts */
|
||||
.svg-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.7rem;
|
||||
gap: 0.25rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.chart-empty-sub {
|
||||
font-size: 0.55rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Bar Chart */
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.5rem;
|
||||
color: var(--mc-text-gray);
|
||||
min-width: 100px;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-label span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-white);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Item History Search */
|
||||
.item-history-search {
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.item-history-search .search-input {
|
||||
width: 100%;
|
||||
background: var(--mc-dark);
|
||||
border: 2px solid #333;
|
||||
color: var(--mc-text-white);
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.item-history-search .search-input:focus {
|
||||
border-color: var(--mc-text-aqua);
|
||||
}
|
||||
|
||||
.item-history-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #2a2a2a;
|
||||
border: 2px solid #444;
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item-history-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-white);
|
||||
transition: background 0.05s;
|
||||
}
|
||||
|
||||
.item-history-option:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
margin-left: auto;
|
||||
color: var(--mc-text-gray);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.item-history-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.item-history-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.analytics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.analytics-stats {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.analytics-stat {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
329
web/client/src/components/AnalyticsPanel.jsx
Normal file
329
web/client/src/components/AnalyticsPanel.jsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName, formatCount } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './AnalyticsPanel.css';
|
||||
|
||||
// ===== Simple SVG Line Chart =====
|
||||
function LineChart({ data, width = 400, height = 140, color = '#55ff55', label = '' }) {
|
||||
if (!data || data.length < 2) {
|
||||
return (
|
||||
<div className="chart-empty" style={{ width, height }}>
|
||||
<span>Not enough data yet</span>
|
||||
<span className="chart-empty-sub">History is recorded every 5 minutes</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = { top: 16, right: 12, bottom: 24, left: 50 };
|
||||
const w = width - pad.left - pad.right;
|
||||
const h = height - pad.top - pad.bottom;
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
const maxY = Math.max(...values);
|
||||
const minY = Math.min(...values);
|
||||
const range = maxY - minY || 1;
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: pad.left + (i / (data.length - 1 || 1)) * w,
|
||||
y: pad.top + h - ((d.value - minY) / range) * h,
|
||||
}));
|
||||
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
const areaD =
|
||||
pathD +
|
||||
` L ${points[points.length - 1].x.toFixed(1)} ${(pad.top + h).toFixed(1)} L ${points[0].x.toFixed(1)} ${(pad.top + h).toFixed(1)} Z`;
|
||||
|
||||
// Y axis labels
|
||||
const yLabels = [0, 0.5, 1].map((frac) => ({
|
||||
y: pad.top + h * (1 - frac),
|
||||
text: formatCount(Math.round(minY + range * frac)),
|
||||
}));
|
||||
|
||||
// X axis labels (first, middle, last timestamps)
|
||||
const xLabels = [0, Math.floor(data.length / 2), data.length - 1]
|
||||
.filter((idx) => data[idx]?.time)
|
||||
.map((idx) => ({
|
||||
x: pad.left + (idx / (data.length - 1 || 1)) * w,
|
||||
text: formatTime(data[idx].time),
|
||||
}));
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="svg-chart">
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||
<line
|
||||
key={frac}
|
||||
x1={pad.left}
|
||||
y1={pad.top + h * (1 - frac)}
|
||||
x2={pad.left + w}
|
||||
y2={pad.top + h * (1 - frac)}
|
||||
stroke="#333"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{/* Y axis labels */}
|
||||
{yLabels.map((l, i) => (
|
||||
<text key={i} x={pad.left - 4} y={l.y + 3} fill="#888" fontSize="8" textAnchor="end" fontFamily="'Silkscreen', monospace">
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
{/* X axis labels */}
|
||||
{xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={pad.top + h + 14} fill="#666" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
{/* Area fill */}
|
||||
<path d={areaD} fill={color} opacity="0.12" />
|
||||
{/* Line */}
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="2" />
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<text x={pad.left + 4} y={pad.top - 4} fill="#aaa" fontSize="8" fontFamily="'Silkscreen', monospace">
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Horizontal Bar Chart =====
|
||||
function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="chart-empty" style={{ width, height }}>
|
||||
<span>No data</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxVal = Math.max(...data.map((d) => d.value)) || 1;
|
||||
const barHeight = Math.min(20, (height - 8) / data.length);
|
||||
|
||||
return (
|
||||
<div className="bar-chart" style={{ width }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.name || i} className="bar-row">
|
||||
<div className="bar-label">
|
||||
<ItemIcon itemName={d.name} size={14} />
|
||||
<span>{formatItemName(d.name)}</span>
|
||||
</div>
|
||||
<div className="bar-track">
|
||||
<div
|
||||
className="bar-fill"
|
||||
style={{
|
||||
width: `${(d.value / maxVal) * 100}%`,
|
||||
background: color,
|
||||
height: barHeight,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-value">{formatCount(d.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const m = d.getMinutes().toString().padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const m = d.getMinutes().toString().padStart(2, '0');
|
||||
return `${month}/${day} ${h}:${m}`;
|
||||
}
|
||||
|
||||
function AnalyticsPanel() {
|
||||
const inventory = useInventoryStore((state) => state.inventory);
|
||||
const historySummary = useInventoryStore((state) => state.historySummary);
|
||||
const itemHistory = useInventoryStore((state) => state.itemHistory);
|
||||
const fetchHistorySummary = useInventoryStore((state) => state.fetchHistorySummary);
|
||||
const fetchItemHistory = useInventoryStore((state) => state.fetchItemHistory);
|
||||
const smeltable = useInventoryStore((state) => state.smeltable);
|
||||
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
|
||||
|
||||
const [selectedHistoryItem, setSelectedHistoryItem] = useState('');
|
||||
const [historySearch, setHistorySearch] = useState('');
|
||||
|
||||
// Fetch summary on mount
|
||||
useEffect(() => {
|
||||
fetchHistorySummary();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchHistorySummary, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHistorySummary]);
|
||||
|
||||
// Fetch selected item history
|
||||
useEffect(() => {
|
||||
if (selectedHistoryItem) {
|
||||
fetchItemHistory(selectedHistoryItem);
|
||||
}
|
||||
}, [selectedHistoryItem, fetchItemHistory]);
|
||||
|
||||
// Top 10 items by count
|
||||
const topItems = useMemo(() => {
|
||||
const items = inventory.itemList || [];
|
||||
return [...items]
|
||||
.sort((a, b) => (b.count || 0) - (a.count || 0))
|
||||
.slice(0, 10)
|
||||
.map((item) => ({ name: item.name, value: item.count || 0 }));
|
||||
}, [inventory.itemList]);
|
||||
|
||||
// Storage history chart data
|
||||
const storageChartData = useMemo(() => {
|
||||
if (!historySummary || historySummary.length === 0) return [];
|
||||
return historySummary.map((h) => ({
|
||||
time: h.recordedAt,
|
||||
value: h.total,
|
||||
}));
|
||||
}, [historySummary]);
|
||||
|
||||
// Selected item history chart data
|
||||
const itemChartData = useMemo(() => {
|
||||
const history = itemHistory[selectedHistoryItem];
|
||||
if (!history || history.length === 0) return [];
|
||||
return history
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((h) => ({
|
||||
time: h.recordedAt,
|
||||
value: h.count,
|
||||
}));
|
||||
}, [itemHistory, selectedHistoryItem]);
|
||||
|
||||
// Filtered items for search dropdown
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!historySearch) return [];
|
||||
const q = historySearch.toLowerCase();
|
||||
return (inventory.itemList || [])
|
||||
.filter((item) => {
|
||||
const name = (item.displayName || item.name || '').toLowerCase();
|
||||
return name.includes(q);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [historySearch, inventory.itemList]);
|
||||
|
||||
// Smelting stats
|
||||
const smeltingStats = useMemo(() => {
|
||||
const entries = Object.entries(smeltable || {});
|
||||
const enabled = entries.filter(([k]) => !disabledRecipes[k]).length;
|
||||
const furnaces = Object.values(inventory.furnaceStatus || {});
|
||||
const activeFurnaces = furnaces.filter((f) => f && f.active).length;
|
||||
return {
|
||||
totalRecipes: entries.length,
|
||||
enabledRecipes: enabled,
|
||||
totalFurnaces: furnaces.length,
|
||||
activeFurnaces,
|
||||
};
|
||||
}, [smeltable, disabledRecipes, inventory.furnaceStatus]);
|
||||
|
||||
return (
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-header">
|
||||
<h2>📊 Analytics</h2>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="analytics-stats">
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Total Items</span>
|
||||
<span className="stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Item Types</span>
|
||||
<span className="stat-value">{(inventory.itemList || []).length}</span>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Capacity</span>
|
||||
<span className="stat-value">
|
||||
{inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Furnaces</span>
|
||||
<span className="stat-value">
|
||||
{smeltingStats.activeFurnaces}/{smeltingStats.totalFurnaces}
|
||||
</span>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Recipes</span>
|
||||
<span className="stat-value">
|
||||
{smeltingStats.enabledRecipes}/{smeltingStats.totalRecipes}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-grid">
|
||||
{/* Storage Over Time */}
|
||||
<div className="analytics-card analytics-card-wide">
|
||||
<h3>📈 Storage Over Time</h3>
|
||||
<LineChart data={storageChartData} width={600} height={150} color="#55ff55" label="Total Items" />
|
||||
</div>
|
||||
|
||||
{/* Top Items */}
|
||||
<div className="analytics-card">
|
||||
<h3>🏆 Top Items</h3>
|
||||
<BarChart data={topItems} width={340} height={260} color="#ffaa00" />
|
||||
</div>
|
||||
|
||||
{/* Item Trend Lookup */}
|
||||
<div className="analytics-card">
|
||||
<h3>🔍 Item Trend</h3>
|
||||
<div className="item-history-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for an item..."
|
||||
value={historySearch}
|
||||
onChange={(e) => setHistorySearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{filteredItems.length > 0 && historySearch && (
|
||||
<div className="item-history-dropdown">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="item-history-option"
|
||||
onClick={() => {
|
||||
setSelectedHistoryItem(item.name);
|
||||
setHistorySearch(formatItemName(item.name));
|
||||
}}
|
||||
>
|
||||
<ItemIcon itemName={item.name} size={16} />
|
||||
<span>{formatItemName(item.name)}</span>
|
||||
<span className="option-count">{formatCount(item.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedHistoryItem ? (
|
||||
<div className="item-history-chart">
|
||||
<div className="item-history-label">
|
||||
<ItemIcon itemName={selectedHistoryItem} size={20} />
|
||||
<span>{formatItemName(selectedHistoryItem)}</span>
|
||||
</div>
|
||||
<LineChart data={itemChartData} width={340} height={140} color="#55ffff" label="Count" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty" style={{ height: 160 }}>
|
||||
<span>Select an item above</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsPanel;
|
||||
@@ -70,6 +70,72 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Creative Inventory Category Tabs ===== */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 0.5rem;
|
||||
background: #2c2c2c;
|
||||
border-bottom: 3px solid var(--mc-inv-slot-border);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
background: #4a4a4a;
|
||||
border: 2px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gray);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
transition: background 0.05s;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.category-tab:hover {
|
||||
background: #5a5a5a;
|
||||
color: var(--mc-text-white);
|
||||
}
|
||||
|
||||
.category-tab.active {
|
||||
background: var(--mc-inv-bg);
|
||||
color: var(--mc-text-dark);
|
||||
border-color: var(--mc-inv-slot-border);
|
||||
text-shadow: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 0.5rem;
|
||||
color: var(--mc-text-gray);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.category-tab.active .category-count {
|
||||
color: var(--mc-text-dark);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.category-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.inventory-body {
|
||||
flex: 1;
|
||||
@@ -86,9 +152,9 @@
|
||||
|
||||
.item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 3px;
|
||||
padding: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
background: var(--mc-inv-bg);
|
||||
border: 3px solid var(--mc-dark);
|
||||
box-shadow:
|
||||
@@ -100,7 +166,6 @@
|
||||
.item-slot {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--mc-inv-slot);
|
||||
@@ -108,24 +173,33 @@
|
||||
border-color: var(--mc-inv-slot-border) var(--mc-inv-slot-light) var(--mc-inv-slot-light) var(--mc-inv-slot-border);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
overflow: visible;
|
||||
padding: 2px;
|
||||
transition: background 0.05s;
|
||||
}
|
||||
|
||||
.item-slot:hover {
|
||||
background: #aaa;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.item-slot.selected {
|
||||
background: #b8d4f0;
|
||||
box-shadow: 0 0 0 2px var(--mc-text-aqua);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.item-slot .item-icon-img,
|
||||
.item-slot .item-icon-emoji {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.item-slot-count {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 3px;
|
||||
bottom: 1px;
|
||||
right: 2px;
|
||||
color: white;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
@@ -135,21 +209,56 @@
|
||||
-1px 0 0 var(--mc-dark),
|
||||
0 1px 0 var(--mc-dark),
|
||||
0 -1px 0 var(--mc-dark);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.item-slot-name {
|
||||
/* ===== Minecraft-Style Tooltip ===== */
|
||||
.mc-tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
font-size: 0.35rem;
|
||||
color: var(--mc-text-dark);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #100010f0;
|
||||
padding: 5px 8px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #100010;
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 #5000ff50,
|
||||
inset 1px 0 0 0 #5000ff50,
|
||||
inset -1px 0 0 0 #28007f50,
|
||||
inset 0 -1px 0 0 #28007f50,
|
||||
0 0 0 1px #100010;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.item-slot:hover .mc-tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mc-tooltip-name {
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 #3e3e3e;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.mc-tooltip-count {
|
||||
color: #aaa;
|
||||
font-size: 0.55rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.mc-tooltip-id {
|
||||
color: #555;
|
||||
font-size: 0.45rem;
|
||||
font-family: 'Silkscreen', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.empty-grid {
|
||||
@@ -311,14 +420,22 @@
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(65px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.item-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(55px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName, getItemEmoji, formatCount } from '../utils/itemUtils';
|
||||
import { formatItemName, getItemEmoji, formatCount, ITEM_CATEGORIES, getItemCategory } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './InventoryGrid.css';
|
||||
|
||||
@@ -13,15 +13,23 @@ function InventoryGrid() {
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [orderAmount, setOrderAmount] = useState(1);
|
||||
const [sortBy, setSortBy] = useState('count'); // 'count', 'name'
|
||||
const [sortBy, setSortBy] = useState('count');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
|
||||
const items = getFilteredItems();
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
if (sortBy === 'count') return (b.count || 0) - (a.count || 0);
|
||||
if (sortBy === 'name') return (a.displayName || a.name || '').localeCompare(b.displayName || b.name || '');
|
||||
return 0;
|
||||
});
|
||||
const filteredByCategory = useMemo(() => {
|
||||
if (activeCategory === 'all') return items;
|
||||
return items.filter((item) => getItemCategory(item.name) === activeCategory);
|
||||
}, [items, activeCategory]);
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...filteredByCategory].sort((a, b) => {
|
||||
if (sortBy === 'count') return (b.count || 0) - (a.count || 0);
|
||||
if (sortBy === 'name') return (a.displayName || a.name || '').localeCompare(b.displayName || b.name || '');
|
||||
return 0;
|
||||
});
|
||||
}, [filteredByCategory, sortBy]);
|
||||
|
||||
const handleOrder = useCallback(async () => {
|
||||
if (!selectedItem || orderAmount <= 0) return;
|
||||
@@ -33,6 +41,17 @@ function InventoryGrid() {
|
||||
setOrderAmount(1);
|
||||
};
|
||||
|
||||
// Count items per category for badges
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts = { all: items.length };
|
||||
for (const cat of ITEM_CATEGORIES) {
|
||||
if (cat.id !== 'all') {
|
||||
counts[cat.id] = items.filter((item) => getItemCategory(item.name) === cat.id).length;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div className="inventory-panel">
|
||||
{/* Search & controls bar */}
|
||||
@@ -69,6 +88,24 @@ function InventoryGrid() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Creative inventory category tabs */}
|
||||
<div className="category-tabs">
|
||||
{ITEM_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`category-tab ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
title={cat.label}
|
||||
>
|
||||
<ItemIcon itemName={`minecraft:${cat.icon}`} size={20} />
|
||||
<span className="category-label">{cat.label}</span>
|
||||
{categoryCounts[cat.id] > 0 && (
|
||||
<span className="category-count">{categoryCounts[cat.id]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="inventory-body">
|
||||
{/* Item grid */}
|
||||
<div className="item-grid-wrapper">
|
||||
@@ -78,16 +115,20 @@ function InventoryGrid() {
|
||||
key={item.name}
|
||||
className={`item-slot ${selectedItem?.name === item.name ? 'selected' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
title={`${formatItemName(item.name)}\n${item.count} total`}
|
||||
>
|
||||
<ItemIcon itemName={item.name} size={32} />
|
||||
<ItemIcon itemName={item.name} size={48} />
|
||||
<span className="item-slot-count">{formatCount(item.count)}</span>
|
||||
<span className="item-slot-name">{formatItemName(item.name)}</span>
|
||||
{/* Minecraft-style tooltip */}
|
||||
<div className="mc-tooltip">
|
||||
<div className="mc-tooltip-name">{formatItemName(item.name)}</div>
|
||||
<div className="mc-tooltip-count">{(item.count || 0).toLocaleString()} items</div>
|
||||
<div className="mc-tooltip-id">{item.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sortedItems.length === 0 && (
|
||||
<div className="empty-grid">
|
||||
{searchQuery ? `No items matching "${searchQuery}"` : 'No items in storage'}
|
||||
{searchQuery ? `No items matching "${searchQuery}"` : activeCategory !== 'all' ? 'No items in this category' : 'No items in storage'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,12 @@ import './ItemIcon.css';
|
||||
// Minecraft assets CDN - actual game textures (16x16 pixel art)
|
||||
const MC_ASSETS_BASE = 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures';
|
||||
|
||||
// Mod texture CDN bases (GitHub raw URLs for known mod repos)
|
||||
const MOD_TEXTURE_BASES = {
|
||||
computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures',
|
||||
create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures',
|
||||
};
|
||||
|
||||
// Some items have texture names that differ from their registry name
|
||||
const TEXTURE_ALIASES = {
|
||||
// Crops / seeds
|
||||
@@ -102,26 +108,37 @@ const BLOCK_TEXTURE_SUFFIXES = {
|
||||
|
||||
/**
|
||||
* Attempt multiple texture URLs in order.
|
||||
* 1. item/{name}.png
|
||||
* 2. block/{name}.png (with optional suffix)
|
||||
* 3. emoji fallback
|
||||
* For vanilla: item/{name}.png → block/{name}.png
|
||||
* For mods: mod repo item/ → mod repo block/ → vanilla fallback
|
||||
*/
|
||||
function getTextureUrls(shortName) {
|
||||
function getTextureUrls(fullItemName) {
|
||||
// Parse namespace and short name
|
||||
const colonIdx = (fullItemName || '').indexOf(':');
|
||||
const namespace = colonIdx >= 0 ? fullItemName.substring(0, colonIdx) : 'minecraft';
|
||||
const shortName = colonIdx >= 0 ? fullItemName.substring(colonIdx + 1) : fullItemName;
|
||||
const alias = TEXTURE_ALIASES[shortName] || shortName;
|
||||
const urls = [];
|
||||
|
||||
// For non-minecraft mods, try mod-specific URLs first
|
||||
if (namespace !== 'minecraft') {
|
||||
const modBase = MOD_TEXTURE_BASES[namespace];
|
||||
if (modBase) {
|
||||
urls.push(`${modBase}/item/${shortName}.png`);
|
||||
urls.push(`${modBase}/block/${shortName}.png`);
|
||||
urls.push(`${modBase}/block/${shortName}_front.png`);
|
||||
urls.push(`${modBase}/block/${shortName}_side.png`);
|
||||
}
|
||||
}
|
||||
|
||||
// Vanilla texture URLs
|
||||
if (BLOCK_TEXTURES.has(shortName)) {
|
||||
// Known block — try block texture first
|
||||
const suffix = BLOCK_TEXTURE_SUFFIXES[shortName] || '';
|
||||
urls.push(`${MC_ASSETS_BASE}/block/${alias}${suffix}.png`);
|
||||
// Also try without suffix
|
||||
if (suffix) urls.push(`${MC_ASSETS_BASE}/block/${alias}.png`);
|
||||
}
|
||||
|
||||
// Always try item texture
|
||||
urls.push(`${MC_ASSETS_BASE}/item/${alias}.png`);
|
||||
|
||||
// If not a known block, also try block as fallback
|
||||
if (!BLOCK_TEXTURES.has(shortName)) {
|
||||
urls.push(`${MC_ASSETS_BASE}/block/${alias}.png`);
|
||||
}
|
||||
@@ -134,7 +151,7 @@ const iconCache = new Map();
|
||||
|
||||
/**
|
||||
* Renders a Minecraft item icon using the official game textures.
|
||||
* Cascading fallback: item texture → block texture → emoji
|
||||
* Cascading fallback: mod texture → item texture → block texture → emoji
|
||||
*/
|
||||
function ItemIcon({ itemName, size = 32 }) {
|
||||
const [urlIndex, setUrlIndex] = useState(0);
|
||||
@@ -144,11 +161,11 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
|
||||
}
|
||||
|
||||
// Strip namespace (minecraft:diamond → diamond)
|
||||
const shortName = itemName.replace(/^[a-z0-9_.-]+:/, '');
|
||||
// Use full item name as cache key (handles mod namespaces correctly)
|
||||
const cacheKey = itemName.replace(/^minecraft:/, '');
|
||||
|
||||
// Check if we already know this item has no texture
|
||||
if (iconCache.get(shortName) === 'none' || allFailed) {
|
||||
if (iconCache.get(cacheKey) === 'none' || allFailed) {
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
@@ -157,13 +174,13 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
}
|
||||
|
||||
// If we have a cached URL, use it directly
|
||||
const cachedUrl = iconCache.get(shortName);
|
||||
const cachedUrl = iconCache.get(cacheKey);
|
||||
if (cachedUrl && cachedUrl !== 'none') {
|
||||
return (
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={cachedUrl}
|
||||
alt={shortName}
|
||||
alt={cacheKey}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
@@ -171,11 +188,11 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
);
|
||||
}
|
||||
|
||||
const urls = getTextureUrls(shortName);
|
||||
const urls = getTextureUrls(itemName);
|
||||
const currentUrl = urls[urlIndex];
|
||||
|
||||
if (!currentUrl) {
|
||||
iconCache.set(shortName, 'none');
|
||||
iconCache.set(cacheKey, 'none');
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
@@ -187,18 +204,18 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={currentUrl}
|
||||
alt={shortName}
|
||||
alt={cacheKey}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onLoad={() => {
|
||||
iconCache.set(shortName, currentUrl);
|
||||
iconCache.set(cacheKey, currentUrl);
|
||||
}}
|
||||
onError={() => {
|
||||
if (urlIndex + 1 < urls.length) {
|
||||
setUrlIndex(urlIndex + 1);
|
||||
} else {
|
||||
iconCache.set(shortName, 'none');
|
||||
iconCache.set(cacheKey, 'none');
|
||||
setAllFailed(true);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -84,13 +84,67 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Recipe list */
|
||||
/* Recipe groups */
|
||||
.recipe-bulk-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.recipe-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recipe-group {
|
||||
border: 2px solid #333;
|
||||
background: #2e2e2e;
|
||||
}
|
||||
|
||||
.recipe-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #3a3a3a;
|
||||
cursor: pointer;
|
||||
transition: background 0.05s;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.recipe-group-header:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.recipe-group-toggle {
|
||||
font-size: 0.6rem;
|
||||
color: var(--mc-text-gold);
|
||||
flex-shrink: 0;
|
||||
width: 0.8rem;
|
||||
}
|
||||
|
||||
.recipe-group-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--mc-text-white);
|
||||
font-weight: 700;
|
||||
text-shadow: 1px 1px 0 var(--mc-dark);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipe-group-count {
|
||||
font-size: 0.55rem;
|
||||
color: var(--mc-text-gray);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-group-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
@@ -14,11 +14,36 @@ function SmeltingPanel() {
|
||||
const enableAllRecipes = useInventoryStore((state) => state.enableAllRecipes);
|
||||
const disableAllRecipes = useInventoryStore((state) => state.disableAllRecipes);
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||
|
||||
const furnaceStatus = inventory.furnaceStatus || {};
|
||||
const furnaceEntries = Object.entries(furnaceStatus);
|
||||
|
||||
const smeltableEntries = Object.entries(smeltable || {});
|
||||
|
||||
// Group recipes by output item
|
||||
const groupedRecipes = useMemo(() => {
|
||||
const groups = {};
|
||||
for (const [input, recipe] of smeltableEntries) {
|
||||
const result = (recipe && recipe.result) || 'unknown';
|
||||
if (!groups[result]) {
|
||||
groups[result] = [];
|
||||
}
|
||||
groups[result].push({ input, recipe });
|
||||
}
|
||||
// Sort groups by output name
|
||||
return Object.entries(groups).sort(([a], [b]) =>
|
||||
formatItemName(a).localeCompare(formatItemName(b))
|
||||
);
|
||||
}, [smeltableEntries]);
|
||||
|
||||
const toggleGroup = (groupKey) => {
|
||||
setCollapsedGroups((prev) => ({
|
||||
...prev,
|
||||
[groupKey]: !prev[groupKey],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="smelting-panel">
|
||||
{/* Header */}
|
||||
@@ -80,47 +105,74 @@ function SmeltingPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipe list */}
|
||||
{/* Recipe list - Grouped by output */}
|
||||
<div className="recipe-section detail-section">
|
||||
<h3>📋 Smelting Recipes ({smeltableEntries.length})</h3>
|
||||
<div className="recipe-bulk-controls">
|
||||
<button className="mc-btn green" onClick={enableAllRecipes}>✅ Enable All</button>
|
||||
<button className="mc-btn red" onClick={disableAllRecipes}>❌ Disable All</button>
|
||||
</div>
|
||||
<div className="recipe-list">
|
||||
{smeltableEntries.map(([input, recipe]) => {
|
||||
const isDisabled = disabledRecipes[input];
|
||||
const result = (recipe && recipe.result) || '';
|
||||
const furnaces = (recipe && recipe.furnaces) || [];
|
||||
|
||||
<div className="recipe-groups">
|
||||
{groupedRecipes.map(([outputName, recipes]) => {
|
||||
const isCollapsed = collapsedGroups[outputName];
|
||||
const enabledCount = recipes.filter((r) => !disabledRecipes[r.input]).length;
|
||||
return (
|
||||
<div
|
||||
key={input}
|
||||
className={`recipe-item ${isDisabled ? 'disabled' : 'enabled'}`}
|
||||
onClick={() => toggleRecipe(input)}
|
||||
>
|
||||
<div className="recipe-toggle">
|
||||
{isDisabled ? '⬜' : '✅'}
|
||||
</div>
|
||||
<div className="recipe-input">
|
||||
<ItemIcon itemName={input} size={20} />
|
||||
<span>{formatItemName(input)}</span>
|
||||
</div>
|
||||
<span className="recipe-arrow">→</span>
|
||||
<div className="recipe-output">
|
||||
<ItemIcon itemName={result} size={20} />
|
||||
<span>{formatItemName(result)}</span>
|
||||
</div>
|
||||
<div className="recipe-furnaces">
|
||||
{furnaces.map((f) => (
|
||||
<span key={f} className="furnace-tag">
|
||||
{(f || '').replace(/^minecraft:/, '')}
|
||||
</span>
|
||||
))}
|
||||
<div key={outputName} className="recipe-group">
|
||||
<div
|
||||
className="recipe-group-header"
|
||||
onClick={() => toggleGroup(outputName)}
|
||||
>
|
||||
<span className="recipe-group-toggle">
|
||||
{isCollapsed ? '▶' : '▼'}
|
||||
</span>
|
||||
<ItemIcon itemName={outputName} size={24} />
|
||||
<span className="recipe-group-name">
|
||||
{formatItemName(outputName)}
|
||||
</span>
|
||||
<span className="recipe-group-count">
|
||||
{enabledCount}/{recipes.length} enabled
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="recipe-group-items">
|
||||
{recipes.map(({ input, recipe }) => {
|
||||
const isDisabled = disabledRecipes[input];
|
||||
const furnaces = (recipe && recipe.furnaces) || [];
|
||||
return (
|
||||
<div
|
||||
key={input}
|
||||
className={`recipe-item ${isDisabled ? 'disabled' : 'enabled'}`}
|
||||
onClick={() => toggleRecipe(input)}
|
||||
>
|
||||
<div className="recipe-toggle">
|
||||
{isDisabled ? '⬜' : '✅'}
|
||||
</div>
|
||||
<div className="recipe-input">
|
||||
<ItemIcon itemName={input} size={20} />
|
||||
<span>{formatItemName(input)}</span>
|
||||
</div>
|
||||
<span className="recipe-arrow">→</span>
|
||||
<div className="recipe-output">
|
||||
<ItemIcon itemName={outputName} size={20} />
|
||||
</div>
|
||||
<div className="recipe-furnaces">
|
||||
{furnaces.map((f) => (
|
||||
<span key={f} className="furnace-tag">
|
||||
{(f || '').replace(/^minecraft:/, '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{smeltableEntries.length === 0 && (
|
||||
{groupedRecipes.length === 0 && (
|
||||
<div className="no-data">No smelting recipes loaded</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@ export const useInventoryStore = create((set, get) => ({
|
||||
lastUpdate: 0,
|
||||
searchQuery: '',
|
||||
commandResult: null,
|
||||
historySummary: [],
|
||||
itemHistory: {},
|
||||
|
||||
// Fetch state via HTTP API (fallback / initial load)
|
||||
fetchState: async () => {
|
||||
@@ -233,4 +235,30 @@ export const useInventoryStore = create((set, get) => ({
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Analytics - fetch aggregate storage history
|
||||
fetchHistorySummary: async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/history-summary`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
set({ historySummary: data.history || [] });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching history summary:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Analytics - fetch single item history
|
||||
fetchItemHistory: async (itemName) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/history/${encodeURIComponent(itemName)}?limit=200`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
set((state) => ({
|
||||
itemHistory: { ...state.itemHistory, [itemName]: data.history || [] },
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching item history:', error);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -12,6 +12,50 @@ export function formatItemName(name) {
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// ========== Creative Inventory Categories ==========
|
||||
|
||||
export const ITEM_CATEGORIES = [
|
||||
{ id: 'all', label: 'All', icon: 'chest' },
|
||||
{ id: 'blocks', label: 'Blocks', icon: 'bricks' },
|
||||
{ id: 'tools', label: 'Tools', icon: 'iron_pickaxe' },
|
||||
{ id: 'combat', label: 'Combat', icon: 'iron_sword' },
|
||||
{ id: 'food', label: 'Food', icon: 'apple' },
|
||||
{ id: 'redstone', label: 'Redstone', icon: 'redstone' },
|
||||
{ id: 'materials', label: 'Materials', icon: 'iron_ingot' },
|
||||
{ id: 'misc', label: 'Misc', icon: 'bone' },
|
||||
];
|
||||
|
||||
export function getItemCategory(itemName) {
|
||||
if (!itemName) return 'misc';
|
||||
const n = itemName.replace(/^[a-z0-9_.-]+:/, '');
|
||||
|
||||
// Redstone (check first - redstone_block should be redstone, not blocks)
|
||||
if (/redstone|repeater|comparator|piston|observer|dropper|dispenser|hopper|lever|tripwire|daylight|note_block|sculk|target/.test(n)) return 'redstone';
|
||||
|
||||
// Combat
|
||||
if (/sword|crossbow|trident|shield|helmet|chestplate|leggings|boots|horse_armor|arrow/.test(n)) return 'combat';
|
||||
if (/bow/.test(n) && !/bowl|elbow/.test(n)) return 'combat';
|
||||
|
||||
// Tools
|
||||
if (/pickaxe|shovel|_hoe|shears|fishing_rod|flint_and_steel|compass|clock|spyglass|bucket|name_tag|brush/.test(n)) return 'tools';
|
||||
if (/axe/.test(n) && !/pickaxe/.test(n)) return 'tools';
|
||||
|
||||
// Food
|
||||
if (/apple|bread|beef|porkchop|mutton|rabbit|salmon|potato|carrot|melon_slice|berries|cookie|pie|stew|soup|honey_bottle|dried_kelp|chorus_fruit|rotten_flesh|cooked_|baked_|golden_carrot/.test(n)) return 'food';
|
||||
if (/^cake$/.test(n)) return 'food';
|
||||
|
||||
// Materials
|
||||
if (/ingot|nugget|raw_iron|raw_gold|raw_copper|_shard|_dust|_powder|_scrap|bone_meal|slime_ball|magma_cream|ender_pearl|blaze|nether_star|ghast_tear|gunpowder|phantom_membrane|experience_bottle|ink_sac|nether_wart|_dye$/.test(n)) return 'materials';
|
||||
if (/^(diamond|emerald|coal|charcoal|quartz|bone|sugar|stick|flint|paper|wheat|egg|book|leather|string|feather)$/.test(n)) return 'materials';
|
||||
if (/seeds$|enchanted_book/.test(n)) return 'materials';
|
||||
|
||||
// Blocks
|
||||
if (/planks|_log|_wood|stone|brick|_slab|stairs|_wall|_fence|_door|trapdoor|wool|concrete|terracotta|glass|_pane|sandstone|deepslate|obsidian|ore|_block|leaves|torch|lantern|crafting_table|furnace|anvil|chest|barrel|sponge|carpet|banner/.test(n)) return 'blocks';
|
||||
if (/^(dirt|mud|clay|sand|red_sand|gravel|netherrack|tnt|cactus|pumpkin|melon|bamboo|cobblestone|ice|packed_ice|blue_ice)$/.test(n)) return 'blocks';
|
||||
|
||||
return 'misc';
|
||||
}
|
||||
|
||||
// Fallback emoji mapping for common item categories
|
||||
const CATEGORY_EMOJI = {
|
||||
ingot: '🪙',
|
||||
|
||||
@@ -124,6 +124,13 @@ const getItemHistory = db.prepare(`
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
const getHistorySummaryStmt = db.prepare(`
|
||||
SELECT recorded_at as recordedAt, SUM(count) as total
|
||||
FROM item_history
|
||||
GROUP BY recorded_at
|
||||
ORDER BY recorded_at ASC
|
||||
`);
|
||||
|
||||
// Cleanup old history (keep last 7 days)
|
||||
const cleanupHistory = db.prepare(`
|
||||
DELETE FROM item_history WHERE recorded_at < ?
|
||||
@@ -292,6 +299,13 @@ export function getHistory(itemName, limit = 100) {
|
||||
return getItemHistory.all(itemName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregate total item count over time
|
||||
*/
|
||||
export function getHistorySummary() {
|
||||
return getHistorySummaryStmt.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the full last-known inventory state from DB
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createServer } from 'http';
|
||||
import {
|
||||
loadFullState, saveFullState, recordItemHistory,
|
||||
saveItems, saveFurnaces, saveAlerts, saveState,
|
||||
getHistory, closeDb, flushPendingSave,
|
||||
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
||||
} from './db.js';
|
||||
|
||||
const app = express();
|
||||
@@ -118,6 +118,12 @@ app.get('/api/history/:itemName', (req, res) => {
|
||||
res.json({ item: req.params.itemName, history });
|
||||
});
|
||||
|
||||
// Get aggregate storage history (total items over time)
|
||||
app.get('/api/history-summary', (req, res) => {
|
||||
const summary = getHistorySummary();
|
||||
res.json({ history: summary });
|
||||
});
|
||||
|
||||
// Get recipes (smeltable + craftable)
|
||||
app.get('/api/recipes', (req, res) => {
|
||||
res.json({
|
||||
|
||||
Reference in New Issue
Block a user