...
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user