+
+
🔔 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 (
+
+ );
+}
+
+// ===== 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() {
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 (