Add SQLite persistence + official Minecraft item icons
Database (better-sqlite3): - Persist items, furnaces, alerts, recipes, settings to SQLite - Auto-restore last known state when server restarts or bridge disconnects - Item count history tracking (5-min snapshots, 7-day retention) - /api/history/:itemName endpoint for item count history - Docker volume for database file persistence - Graceful shutdown with DB connection cleanup Icons: - Replace mc-heads.net with official Minecraft game textures via CDN - Cascading fallback: item texture -> block texture -> emoji - In-memory URL cache to avoid redundant network requests - Block texture suffix mapping (furnace_front, barrel_top, etc.) - Crisp pixel-art rendering with image-rendering: pixelated
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
.item-icon-img {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
display: block;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
/* Slight border to separate from dark backgrounds */
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.item-icon-emoji {
|
||||
@@ -8,4 +14,5 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -2,22 +2,180 @@ import React, { useState } from 'react';
|
||||
import { getItemEmoji } from '../utils/itemUtils';
|
||||
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';
|
||||
|
||||
// Some items have texture names that differ from their registry name
|
||||
const TEXTURE_ALIASES = {
|
||||
// Crops / seeds
|
||||
wheat_seeds: 'wheat_seeds',
|
||||
melon_seeds: 'melon_seeds',
|
||||
pumpkin_seeds: 'pumpkin_seeds',
|
||||
beetroot_seeds: 'beetroot_seeds',
|
||||
// Potions and such
|
||||
experience_bottle: 'experience_bottle',
|
||||
// Renamed textures
|
||||
golden_apple: 'golden_apple',
|
||||
enchanted_golden_apple: 'enchanted_golden_apple',
|
||||
};
|
||||
|
||||
// Items whose texture lives in the block/ folder instead of item/
|
||||
const BLOCK_TEXTURES = new Set([
|
||||
'stone', 'granite', 'polished_granite', 'diorite', 'polished_diorite', 'andesite',
|
||||
'polished_andesite', 'cobblestone', 'oak_planks', 'spruce_planks', 'birch_planks',
|
||||
'jungle_planks', 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', 'cherry_planks',
|
||||
'bamboo_planks', 'crimson_planks', 'warped_planks', 'oak_log', 'spruce_log', 'birch_log',
|
||||
'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log', 'bamboo_block',
|
||||
'stripped_oak_log', 'stripped_spruce_log', 'stripped_birch_log', 'stripped_jungle_log',
|
||||
'stripped_acacia_log', 'stripped_dark_oak_log', 'stripped_mangrove_log', 'stripped_cherry_log',
|
||||
'sand', 'red_sand', 'gravel', 'dirt', 'coarse_dirt', 'rooted_dirt', 'mud',
|
||||
'cobblestone', 'mossy_cobblestone', 'obsidian', 'crying_obsidian',
|
||||
'netherrack', 'soul_sand', 'soul_soil', 'basalt', 'polished_basalt', 'smooth_basalt',
|
||||
'glowstone', 'glass', 'tinted_glass',
|
||||
'oak_slab', 'spruce_slab', 'birch_slab', 'jungle_slab', 'acacia_slab', 'dark_oak_slab',
|
||||
'stone_slab', 'cobblestone_slab', 'brick_slab', 'stone_brick_slab',
|
||||
'oak_stairs', 'spruce_stairs', 'birch_stairs', 'jungle_stairs', 'acacia_stairs',
|
||||
'stone_stairs', 'cobblestone_stairs', 'brick_stairs', 'stone_brick_stairs',
|
||||
'bricks', 'stone_bricks', 'mossy_stone_bricks', 'cracked_stone_bricks',
|
||||
'chiseled_stone_bricks', 'deepslate_bricks', 'nether_bricks', 'red_nether_bricks',
|
||||
'bookshelf', 'clay', 'pumpkin', 'carved_pumpkin', 'jack_o_lantern', 'melon',
|
||||
'sponge', 'wet_sponge', 'sandstone', 'red_sandstone', 'prismarine', 'dark_prismarine',
|
||||
'sea_lantern', 'hay_block', 'terracotta', 'packed_ice', 'blue_ice',
|
||||
'snow_block', 'ice', 'mycelium', 'podzol', 'grass_block', 'moss_block',
|
||||
'deepslate', 'cobbled_deepslate', 'polished_deepslate', 'calcite', 'tuff', 'dripstone_block',
|
||||
'coal_ore', 'iron_ore', 'gold_ore', 'diamond_ore', 'emerald_ore', 'lapis_ore', 'redstone_ore',
|
||||
'copper_ore', 'nether_gold_ore', 'nether_quartz_ore', 'ancient_debris',
|
||||
'deepslate_coal_ore', 'deepslate_iron_ore', 'deepslate_gold_ore', 'deepslate_diamond_ore',
|
||||
'deepslate_emerald_ore', 'deepslate_lapis_ore', 'deepslate_redstone_ore', 'deepslate_copper_ore',
|
||||
'coal_block', 'iron_block', 'gold_block', 'diamond_block', 'emerald_block',
|
||||
'lapis_block', 'redstone_block', 'copper_block', 'raw_iron_block', 'raw_gold_block',
|
||||
'raw_copper_block', 'netherite_block', 'amethyst_block', 'quartz_block',
|
||||
'tnt', 'end_stone', 'end_stone_bricks', 'purpur_block', 'purpur_pillar',
|
||||
'magma_block', 'bone_block', 'dried_kelp_block', 'honeycomb_block',
|
||||
'slime_block', 'honey_block', 'note_block', 'jukebox',
|
||||
'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', 'yellow_wool',
|
||||
'lime_wool', 'pink_wool', 'gray_wool', 'light_gray_wool', 'cyan_wool',
|
||||
'purple_wool', 'blue_wool', 'brown_wool', 'green_wool', 'red_wool', 'black_wool',
|
||||
'white_concrete', 'orange_concrete', 'magenta_concrete', 'light_blue_concrete',
|
||||
'yellow_concrete', 'lime_concrete', 'pink_concrete', 'gray_concrete',
|
||||
'light_gray_concrete', 'cyan_concrete', 'purple_concrete', 'blue_concrete',
|
||||
'brown_concrete', 'green_concrete', 'red_concrete', 'black_concrete',
|
||||
'white_terracotta', 'orange_terracotta', 'magenta_terracotta', 'light_blue_terracotta',
|
||||
'yellow_terracotta', 'lime_terracotta', 'pink_terracotta', 'gray_terracotta',
|
||||
'light_gray_terracotta', 'cyan_terracotta', 'purple_terracotta', 'blue_terracotta',
|
||||
'brown_terracotta', 'green_terracotta', 'red_terracotta', 'black_terracotta',
|
||||
'white_glazed_terracotta', 'orange_glazed_terracotta', 'magenta_glazed_terracotta',
|
||||
'white_stained_glass', 'orange_stained_glass', 'magenta_stained_glass',
|
||||
'crafting_table', 'furnace', 'blast_furnace', 'smoker', 'smithing_table',
|
||||
'fletching_table', 'cartography_table', 'loom', 'stonecutter', 'grindstone',
|
||||
'anvil', 'chipped_anvil', 'damaged_anvil', 'enchanting_table',
|
||||
'brewing_stand', 'cauldron', 'composter', 'barrel', 'chest', 'trapped_chest',
|
||||
'ender_chest', 'shulker_box', 'dispenser', 'dropper', 'hopper', 'observer',
|
||||
'piston', 'sticky_piston', 'redstone_lamp', 'target', 'lever',
|
||||
'beacon', 'conduit', 'bell', 'lodestone', 'respawn_anchor',
|
||||
'cactus', 'sugar_cane', 'bamboo',
|
||||
'mushroom_stem', 'brown_mushroom_block', 'red_mushroom_block',
|
||||
'oak_leaves', 'spruce_leaves', 'birch_leaves', 'jungle_leaves',
|
||||
'acacia_leaves', 'dark_oak_leaves', 'mangrove_leaves', 'cherry_leaves', 'azalea_leaves',
|
||||
]);
|
||||
|
||||
// Some blocks need a specific texture suffix (e.g. furnace_front, oak_log_top)
|
||||
// We use _front, _top, or _side variants for recognizable look
|
||||
const BLOCK_TEXTURE_SUFFIXES = {
|
||||
furnace: '_front',
|
||||
blast_furnace: '_front',
|
||||
smoker: '_front',
|
||||
dispenser: '_front',
|
||||
dropper: '_front',
|
||||
observer: '_front',
|
||||
piston: '_front',
|
||||
sticky_piston: '_front',
|
||||
barrel: '_top',
|
||||
crafting_table: '_top',
|
||||
cartography_table: '_top',
|
||||
fletching_table: '_top',
|
||||
smithing_table: '_top',
|
||||
grass_block: '_top',
|
||||
mycelium: '_top',
|
||||
podzol: '_top',
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a Minecraft item icon.
|
||||
* First tries to load the actual item sprite from mc-heads.net.
|
||||
* Falls back to an emoji representation if the image fails.
|
||||
* Attempt multiple texture URLs in order.
|
||||
* 1. item/{name}.png
|
||||
* 2. block/{name}.png (with optional suffix)
|
||||
* 3. emoji fallback
|
||||
*/
|
||||
function getTextureUrls(shortName) {
|
||||
const alias = TEXTURE_ALIASES[shortName] || shortName;
|
||||
const 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`);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Cache of resolved icon URLs (avoid re-fetching on every render)
|
||||
const iconCache = new Map();
|
||||
|
||||
/**
|
||||
* Renders a Minecraft item icon using the official game textures.
|
||||
* Cascading fallback: item texture → block texture → emoji
|
||||
*/
|
||||
function ItemIcon({ itemName, size = 32 }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [urlIndex, setUrlIndex] = useState(0);
|
||||
const [allFailed, setAllFailed] = useState(false);
|
||||
|
||||
if (!itemName) {
|
||||
return <span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>📦</span>;
|
||||
}
|
||||
|
||||
// Strip namespace for the icon URL
|
||||
const shortName = itemName.replace(/^[a-z0-9_]+:/, '');
|
||||
// Strip namespace (minecraft:diamond → diamond)
|
||||
const shortName = itemName.replace(/^[a-z0-9_.-]+:/, '');
|
||||
|
||||
if (imgError) {
|
||||
// Check if we already know this item has no texture
|
||||
if (iconCache.get(shortName) === 'none' || allFailed) {
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// If we have a cached URL, use it directly
|
||||
const cachedUrl = iconCache.get(shortName);
|
||||
if (cachedUrl && cachedUrl !== 'none') {
|
||||
return (
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={cachedUrl}
|
||||
alt={shortName}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const urls = getTextureUrls(shortName);
|
||||
const currentUrl = urls[urlIndex];
|
||||
|
||||
if (!currentUrl) {
|
||||
iconCache.set(shortName, 'none');
|
||||
return (
|
||||
<span className="item-icon-emoji" style={{ fontSize: size * 0.7 }}>
|
||||
{getItemEmoji(itemName)}
|
||||
@@ -28,13 +186,22 @@ function ItemIcon({ itemName, size = 32 }) {
|
||||
return (
|
||||
<img
|
||||
className="item-icon-img"
|
||||
src={`https://mc-heads.net/item/${shortName}/${size}`}
|
||||
src={currentUrl}
|
||||
alt={shortName}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onError={() => setImgError(true)}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
onLoad={() => {
|
||||
iconCache.set(shortName, currentUrl);
|
||||
}}
|
||||
onError={() => {
|
||||
if (urlIndex + 1 < urls.length) {
|
||||
setUrlIndex(urlIndex + 1);
|
||||
} else {
|
||||
iconCache.set(shortName, 'none');
|
||||
setAllFailed(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user