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:
MayaTheShy
2026-03-21 19:07:17 -04:00
parent c25ef9f2cc
commit 29498a2f6a
15 changed files with 1244 additions and 150 deletions

View File

@@ -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;

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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>
);
}

View 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;
}
}

View 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;

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}
}}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);
}
},
}));

View File

@@ -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: '🪙',

View File

@@ -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
*/

View File

@@ -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({