From 29498a2f6a82fe055a7b8f036fa3306e9eebdb1f Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 21 Mar 2026 19:07:17 -0400 Subject: [PATCH] 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 --- web/client/src/App.css | 51 +++ web/client/src/App.jsx | 37 ++- web/client/src/components/AlertsPanel.css | 69 +++- web/client/src/components/AlertsPanel.jsx | 122 ++++--- web/client/src/components/AnalyticsPanel.css | 243 ++++++++++++++ web/client/src/components/AnalyticsPanel.jsx | 329 +++++++++++++++++++ web/client/src/components/InventoryGrid.css | 159 +++++++-- web/client/src/components/InventoryGrid.jsx | 65 +++- web/client/src/components/ItemIcon.jsx | 55 ++-- web/client/src/components/SmeltingPanel.css | 56 +++- web/client/src/components/SmeltingPanel.jsx | 114 +++++-- web/client/src/store/inventoryStore.js | 28 ++ web/client/src/utils/itemUtils.js | 44 +++ web/server/db.js | 14 + web/server/server.js | 8 +- 15 files changed, 1244 insertions(+), 150 deletions(-) create mode 100644 web/client/src/components/AnalyticsPanel.css create mode 100644 web/client/src/components/AnalyticsPanel.jsx diff --git a/web/client/src/App.css b/web/client/src/App.css index f4673e4..20692ec 100644 --- a/web/client/src/App.css +++ b/web/client/src/App.css @@ -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; diff --git a/web/client/src/App.jsx b/web/client/src/App.jsx index 068413d..df7a37f 100644 --- a/web/client/src/App.jsx +++ b/web/client/src/App.jsx @@ -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 ; case 'crafting': return ; - case 'alerts': - return ; + case 'analytics': + return ; default: return ; } }; + const triggeredAlerts = alerts.filter((a) => a.triggered !== false); + return (

⛏️ Inventory Manager

-
- - {connected ? 'Connected' : 'Disconnected'} +
+ {/* Alerts bell button */} + +
+ + {connected ? 'Connected' : 'Disconnected'} +
@@ -49,6 +67,9 @@ function App() {
)} + {/* Alerts popup overlay */} + setShowAlerts(false)} /> +
@@ -76,10 +97,10 @@ function App() { 🔨 Crafting
diff --git a/web/client/src/components/AlertsPanel.css b/web/client/src/components/AlertsPanel.css index 4c51491..43a973a 100644 --- a/web/client/src/components/AlertsPanel.css +++ b/web/client/src/components/AlertsPanel.css @@ -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 { diff --git a/web/client/src/components/AlertsPanel.jsx b/web/client/src/components/AlertsPanel.jsx index 4febba2..b198f7e 100644 --- a/web/client/src/components/AlertsPanel.jsx +++ b/web/client/src/components/AlertsPanel.jsx @@ -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 ( -
-
-

🔔 Low-Stock Alerts

- - {alerts.length} alert{alerts.length !== 1 ? 's' : ''} - -
+
+
+
+

🔔 Low-Stock Alerts

+ + {alerts.length} alert{alerts.length !== 1 ? 's' : ''} + + +
- {alerts.length > 0 ? ( -
- {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 ( -
-
- {isTriggered ? '🔴' : '🟢'} -
-
-
- - {formatItemName(itemName)} +
+ {alerts.length > 0 ? ( +
+ {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 ( +
+
+ {isTriggered ? '🔴' : '🟢'} +
+
+
+ + {formatItemName(itemName)} +
+
+ + Stock: {current} + + + Min: {threshold} + +
+
+
+ {isTriggered ? 'LOW' : 'OK'} +
-
- - Stock: {current} - - - Min: {threshold} - -
-
-
- {isTriggered ? 'LOW' : 'OK'} -
-
- ); - })} + ); + })} +
+ ) : ( +
+ + All stock levels are OK +
+ )}
- ) : ( -
- - No alerts configured or all stock levels are OK -
- )} +
); } diff --git a/web/client/src/components/AnalyticsPanel.css b/web/client/src/components/AnalyticsPanel.css new file mode 100644 index 0000000..981a68b --- /dev/null +++ b/web/client/src/components/AnalyticsPanel.css @@ -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; + } +} diff --git a/web/client/src/components/AnalyticsPanel.jsx b/web/client/src/components/AnalyticsPanel.jsx new file mode 100644 index 0000000..969cb39 --- /dev/null +++ b/web/client/src/components/AnalyticsPanel.jsx @@ -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 ( +
+ Not enough data yet + History is recorded every 5 minutes +
+ ); + } + + 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 ( + + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((frac) => ( + + ))} + {/* Y axis labels */} + {yLabels.map((l, i) => ( + + {l.text} + + ))} + {/* X axis labels */} + {xLabels.map((l, i) => ( + + {l.text} + + ))} + {/* Area fill */} + + {/* Line */} + + {/* Label */} + {label && ( + + {label} + + )} + + ); +} + +// ===== Horizontal Bar Chart ===== +function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) { + if (!data || data.length === 0) { + return ( +
+ No data +
+ ); + } + + const maxVal = Math.max(...data.map((d) => d.value)) || 1; + const barHeight = Math.min(20, (height - 8) / data.length); + + return ( +
+ {data.map((d, i) => ( +
+
+ + {formatItemName(d.name)} +
+
+
+
+ {formatCount(d.value)} +
+ ))} +
+ ); +} + +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 ( +
+
+

📊 Analytics

+
+ + {/* Quick Stats */} +
+
+ Total Items + {(inventory.grandTotal || 0).toLocaleString()} +
+
+ Item Types + {(inventory.itemList || []).length} +
+
+ Capacity + + {inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0}% + +
+
+ Furnaces + + {smeltingStats.activeFurnaces}/{smeltingStats.totalFurnaces} + +
+
+ Recipes + + {smeltingStats.enabledRecipes}/{smeltingStats.totalRecipes} + +
+
+ +
+ {/* Storage Over Time */} +
+

📈 Storage Over Time

+ +
+ + {/* Top Items */} +
+

🏆 Top Items

+ +
+ + {/* Item Trend Lookup */} +
+

🔍 Item Trend

+
+ setHistorySearch(e.target.value)} + className="search-input" + /> + {filteredItems.length > 0 && historySearch && ( +
+ {filteredItems.map((item) => ( +
{ + setSelectedHistoryItem(item.name); + setHistorySearch(formatItemName(item.name)); + }} + > + + {formatItemName(item.name)} + {formatCount(item.count)} +
+ ))} +
+ )} +
+ {selectedHistoryItem ? ( +
+
+ + {formatItemName(selectedHistoryItem)} +
+ +
+ ) : ( +
+ Select an item above +
+ )} +
+
+
+ ); +} + +export default AnalyticsPanel; diff --git a/web/client/src/components/InventoryGrid.css b/web/client/src/components/InventoryGrid.css index 4dd1683..c80ce4a 100644 --- a/web/client/src/components/InventoryGrid.css +++ b/web/client/src/components/InventoryGrid.css @@ -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; + } } diff --git a/web/client/src/components/InventoryGrid.jsx b/web/client/src/components/InventoryGrid.jsx index 3268a0f..24e56c7 100644 --- a/web/client/src/components/InventoryGrid.jsx +++ b/web/client/src/components/InventoryGrid.jsx @@ -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 (
{/* Search & controls bar */} @@ -69,6 +88,24 @@ function InventoryGrid() {
+ {/* Creative inventory category tabs */} +
+ {ITEM_CATEGORIES.map((cat) => ( + + ))} +
+
{/* Item grid */}
@@ -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`} > - + {formatCount(item.count)} - {formatItemName(item.name)} + {/* Minecraft-style tooltip */} +
+
{formatItemName(item.name)}
+
{(item.count || 0).toLocaleString()} items
+
{item.name}
+
))} {sortedItems.length === 0 && (
- {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'}
)}
diff --git a/web/client/src/components/ItemIcon.jsx b/web/client/src/components/ItemIcon.jsx index aa17a18..fe66ce7 100644 --- a/web/client/src/components/ItemIcon.jsx +++ b/web/client/src/components/ItemIcon.jsx @@ -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 📦; } - // 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 ( {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 ( {shortName} {getItemEmoji(itemName)} @@ -187,18 +204,18 @@ function ItemIcon({ itemName, size = 32 }) { {shortName} { - 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); } }} diff --git a/web/client/src/components/SmeltingPanel.css b/web/client/src/components/SmeltingPanel.css index efd70e7..330d591 100644 --- a/web/client/src/components/SmeltingPanel.css +++ b/web/client/src/components/SmeltingPanel.css @@ -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; diff --git a/web/client/src/components/SmeltingPanel.jsx b/web/client/src/components/SmeltingPanel.jsx index 3fa3547..6724f61 100644 --- a/web/client/src/components/SmeltingPanel.jsx +++ b/web/client/src/components/SmeltingPanel.jsx @@ -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 (
{/* Header */} @@ -80,47 +105,74 @@ function SmeltingPanel() { )}
- {/* Recipe list */} + {/* Recipe list - Grouped by output */}

📋 Smelting Recipes ({smeltableEntries.length})

-
- {smeltableEntries.map(([input, recipe]) => { - const isDisabled = disabledRecipes[input]; - const result = (recipe && recipe.result) || ''; - const furnaces = (recipe && recipe.furnaces) || []; + +
+ {groupedRecipes.map(([outputName, recipes]) => { + const isCollapsed = collapsedGroups[outputName]; + const enabledCount = recipes.filter((r) => !disabledRecipes[r.input]).length; return ( -
toggleRecipe(input)} - > -
- {isDisabled ? '⬜' : '✅'} -
-
- - {formatItemName(input)} -
- -
- - {formatItemName(result)} -
-
- {furnaces.map((f) => ( - - {(f || '').replace(/^minecraft:/, '')} - - ))} +
+
toggleGroup(outputName)} + > + + {isCollapsed ? '▶' : '▼'} + + + + {formatItemName(outputName)} + + + {enabledCount}/{recipes.length} enabled +
+ + {!isCollapsed && ( +
+ {recipes.map(({ input, recipe }) => { + const isDisabled = disabledRecipes[input]; + const furnaces = (recipe && recipe.furnaces) || []; + return ( +
toggleRecipe(input)} + > +
+ {isDisabled ? '⬜' : '✅'} +
+
+ + {formatItemName(input)} +
+ +
+ +
+
+ {furnaces.map((f) => ( + + {(f || '').replace(/^minecraft:/, '')} + + ))} +
+
+ ); + })} +
+ )}
); })} - {smeltableEntries.length === 0 && ( + {groupedRecipes.length === 0 && (
No smelting recipes loaded
)}
diff --git a/web/client/src/store/inventoryStore.js b/web/client/src/store/inventoryStore.js index 720eb0b..7bbc44c 100644 --- a/web/client/src/store/inventoryStore.js +++ b/web/client/src/store/inventoryStore.js @@ -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); + } + }, })); diff --git a/web/client/src/utils/itemUtils.js b/web/client/src/utils/itemUtils.js index e943edc..db00541 100644 --- a/web/client/src/utils/itemUtils.js +++ b/web/client/src/utils/itemUtils.js @@ -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: '🪙', diff --git a/web/server/db.js b/web/server/db.js index e707977..3c20817 100644 --- a/web/server/db.js +++ b/web/server/db.js @@ -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 */ diff --git a/web/server/server.js b/web/server/server.js index 181456e..e435b2c 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -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({