...
Build and Deploy / build (push) Successful in 34s
Build and Deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-05-29 19:17:46 +02:00
parent 4cc545478c
commit 6d506c98e6
2 changed files with 184 additions and 1 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
# Generated files # Generated files
.idea/**/contentModel.xml .idea/**/contentModel.xml
.idea
# Sensitive or high-churn files # Sensitive or high-churn files
.idea/**/dataSources/ .idea/**/dataSources/
.idea/**/dataSources.ids .idea/**/dataSources.ids
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import type { LiveItem } from '../api/types.js';
import { abbreviateNumber, fullNumber } from '../utils/format.js';
defineProps<{
items: LiveItem[];
}>();
const router = useRouter();
function openItem(item: LiveItem): void {
router.push({ name: 'item', params: { name: item.name } });
}
function label(item: LiveItem): string {
return item.displayName ?? item.name;
}
/* Deterministic hue from the mod id so each mod has its own fallback color. */
function modHue(mod: string): number {
let hash = 0;
for (let i = 0; i < mod.length; i++) {
hash = (hash * 31 + mod.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
/* Two-letter glyph for the icon fallback — first letters of display name. */
function fallbackGlyph(item: LiveItem): string {
const source = item.displayName ?? item.name.split(':').pop() ?? item.name;
const cleaned = source.replace(/[^a-zA-Z0-9]+/g, ' ').trim();
if (!cleaned) return '?';
const parts = cleaned.split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
function onIconError(event: Event): void {
const img = event.target as HTMLImageElement;
img.dataset.failed = 'true';
}
</script>
<template>
<div class="grid panel">
<div v-if="items.length === 0" class="grid__empty">
No items match the current filters.
</div>
<div v-else class="grid__cells">
<button
v-for="item in items"
:key="item.name"
type="button"
class="cell"
:title="`${label(item)}\n${item.name}\n${fullNumber(item.amount)}`"
@click="openItem(item)"
>
<span class="cell__icon">
<img
v-if="item.icon"
:src="item.icon"
:alt="label(item)"
loading="lazy"
decoding="async"
@error="onIconError"
/>
<span
v-else
class="cell__fallback"
:style="{
background: `hsl(${modHue(item.mod)}, 45%, 30%)`,
borderColor: `hsl(${modHue(item.mod)}, 55%, 45%)`,
}"
>
{{ fallbackGlyph(item) }}
</span>
</span>
<span class="cell__count mono">{{ abbreviateNumber(item.amount) }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.grid {
padding: 14px;
}
.grid__cells {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 6px;
}
.grid__empty {
text-align: center;
color: var(--text-dim);
padding: 40px 16px;
font-size: 14px;
}
.cell {
position: relative;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: #1a232c;
border: 1px solid #2a3744;
border-radius: 4px;
cursor: pointer;
transition:
border-color 0.1s ease,
background-color 0.1s ease,
transform 0.05s ease;
overflow: hidden;
}
.cell:hover,
.cell:focus-visible {
border-color: var(--accent);
background: #1f2c37;
outline: none;
z-index: 1;
}
.cell:active {
transform: scale(0.97);
}
.cell__icon {
display: flex;
align-items: center;
justify-content: center;
width: 70%;
height: 70%;
}
.cell__icon img {
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: pixelated;
}
.cell__icon img[data-failed='true'] {
display: none;
}
.cell__fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border: 1px solid;
border-radius: 3px;
font-family: var(--font-mono, monospace);
font-weight: 700;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 0.04em;
}
.cell__count {
position: absolute;
right: 3px;
bottom: 1px;
font-size: 12px;
font-weight: 700;
color: #ffffff;
text-shadow:
1px 1px 0 #000,
-1px 1px 0 #000,
1px -1px 0 #000,
-1px -1px 0 #000;
pointer-events: none;
line-height: 1;
}
</style>