This commit is contained in:
2026-05-26 12:27:23 +02:00
parent 39e23546e8
commit ffd8b3e5c9
26 changed files with 1514 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.git/
.env
certs/
*.md
+6
View File
@@ -0,0 +1,6 @@
# Base URL of the backend HTTP API.
VITE_API_BASE=https://localhost:3000
# Base URL of the backend websocket. Optional: when omitted it is derived
# from VITE_API_BASE by swapping the protocol (https -> wss).
VITE_WS_BASE=wss://localhost:3000
+7
View File
@@ -102,3 +102,10 @@ docs/_book
# TODO: where does this rule come from? # TODO: where does this rule come from?
test/ test/
node_modules/
dist/
*.local
.env
.DS_Store
certs/
*.tsbuildinfo
+32
View File
@@ -0,0 +1,32 @@
# ---- Build stage -----------------------------------------------------
FROM node:22-alpine AS build
WORKDIR /app
# Backend URLs are baked in at build time (Vite inlines VITE_* variables).
ARG VITE_API_BASE=https://localhost:3000
ARG VITE_WS_BASE
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_WS_BASE=$VITE_WS_BASE
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ---- Runtime stage ---------------------------------------------------
FROM nginx:1.27-alpine AS runtime
# openssl is needed to generate the self-signed certificate on first start.
RUN apk add --no-cache openssl
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/40-self-signed-ssl.sh /docker-entrypoint.d/40-self-signed-ssl.sh
RUN chmod +x /docker-entrypoint.d/40-self-signed-ssl.sh
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 443
# The base nginx image entrypoint runs every script in /docker-entrypoint.d.
+82 -1
View File
@@ -1,2 +1,83 @@
# mc-computer-craft-api-frontend # Minecraft ComputerCraft API Frontend
Vue 3 + TypeScript single page application for the
[ME System Monitor backend](../mc-cc-api-backend). It shows the live contents
of a Minecraft ME system over a websocket and renders per-item history charts.
## Features
- **Live view** over a websocket (`wss://…/api/ws/live`) updates in real time.
- **Persistent filters** text search, sort column/order and mod filter are
kept in `localStorage` and re-applied by the server on every update.
- **Item history** clicking an item opens a detail page with a Chart.js line
chart plus current / min / max / change statistics.
- **Abbreviated numbers** `12000 → 12k`, `43452542 → 43.45M`. The full value
is available as a tooltip.
- **HTTPS / WSS by default** via a self-signed certificate.
## Tech stack
| Concern | Choice |
| ---------- | ----------------------------------- |
| Framework | Vue 3 (`<script setup>`) + TypeScript |
| Build tool | Vite 6 |
| Routing | vue-router 4 |
| Charts | Chart.js 4 |
| Dev TLS | `@vitejs/plugin-basic-ssl` |
| Production | nginx (HTTPS, self-signed cert) |
## Quick start (development)
```bash
npm install
cp .env.example .env # adjust VITE_API_BASE / VITE_WS_BASE if needed
npm run dev
```
The dev server runs on `https://localhost:5173` with a self-signed
certificate. Your browser will warn about it once accept the exception.
> The backend also uses a self-signed certificate. Open
> `https://localhost:3000/api/docs` once and accept that certificate too,
> otherwise the browser blocks the API and websocket connections.
## Build & preview
```bash
npm run build # type-checks and builds into dist/
npm run preview # serves the build on https://localhost:4173
```
## Environment variables
| Variable | Default | Description |
| --------------- | ------------------------ | ------------------------------------ |
| `VITE_API_BASE` | `https://localhost:3000` | Backend HTTP API base URL |
| `VITE_WS_BASE` | derived from API base | Backend websocket base URL |
`VITE_*` variables are inlined at build time.
## Docker
```bash
docker build \
--build-arg VITE_API_BASE=https://localhost:3000 \
-t mc-cc-frontend .
docker run -p 8443:443 mc-cc-frontend
```
The container generates a self-signed certificate on first start and serves
the SPA on port 443 (mapped to `8443` above) → `https://localhost:8443`.
## Project structure
```
src/
api/ config, shared types, HTTP client
components/ FilterBar, ItemTable, HistoryChart
composables/ useLiveData (websocket + filter state)
router/ route definitions
utils/ number / date formatting
views/ LiveView, ItemHistoryView
```
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Generates a self-signed TLS certificate on first container start so the
# frontend can be served over HTTPS out of the box.
set -e
CERT_DIR=/etc/nginx/certs
CERT_FILE="$CERT_DIR/cert.pem"
KEY_FILE="$CERT_DIR/key.pem"
mkdir -p "$CERT_DIR"
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
echo "[ssl] existing certificate found, skipping generation"
exit 0
fi
echo "[ssl] generating self-signed certificate for localhost"
openssl req -x509 -nodes -newkey rsa:2048 \
-days 3650 \
-keyout "$KEY_FILE" \
-out "$CERT_FILE" \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
echo "[ssl] certificate written to $CERT_DIR"
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ME Terminal — ComputerCraft Storage</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
server {
listen 443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
root /usr/share/nginx/html;
index index.html;
# Single page application: unknown paths fall back to index.html so that
# client-side routes such as /item/minecraft:iron_ingot keep working.
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|gif|ico)$ {
expires 7d;
add_header Cache-Control "public";
}
}
# Redirect plain HTTP to HTTPS.
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "mc-cc-api-frontend",
"version": "1.0.0",
"description": "Vue 3 + TypeScript dashboard for the Minecraft ComputerCraft ME API.",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"chart.js": "^4.4.7",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-basic-ssl": "^2.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<div class="app">
<header class="app-header">
<div class="container app-header__inner">
<RouterLink to="/" class="brand">
<span class="brand__mark"></span>
<span class="brand__text">ME&nbsp;SYSTEM&nbsp;MONITOR</span>
</RouterLink>
<span class="brand__sub mono">ComputerCraft&nbsp;·&nbsp;Live&nbsp;Inventory</span>
</div>
</header>
<main class="container app-main">
<RouterView />
</main>
<footer class="container app-footer mono">
Minecraft ComputerCraft API · Vue + WebSocket client
</footer>
</div>
</template>
<style scoped>
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(47, 212, 196, 0.05), transparent);
}
.app-header__inner {
display: flex;
align-items: baseline;
gap: 16px;
padding-top: 18px;
padding-bottom: 18px;
flex-wrap: wrap;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 700;
font-size: 19px;
letter-spacing: 0.06em;
}
.brand:hover {
text-decoration: none;
}
.brand__mark {
color: var(--accent);
}
.brand__sub {
color: var(--text-dim);
font-size: 12px;
}
.app-main {
flex: 1;
padding-top: 28px;
padding-bottom: 40px;
width: 100%;
}
.app-footer {
color: var(--text-dim);
font-size: 12px;
padding-top: 16px;
padding-bottom: 24px;
border-top: 1px solid var(--border);
}
</style>
+23
View File
@@ -0,0 +1,23 @@
/**
* Resolves the backend HTTP and websocket base URLs.
*
* Both can be overridden at build time via VITE_API_BASE / VITE_WS_BASE.
* When VITE_WS_BASE is omitted it is derived from the HTTP base by swapping
* the protocol (http -> ws, https -> wss).
*/
/** Returns the trimmed value, or undefined for empty / missing env entries. */
function clean(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && trimmed.length > 0 ? trimmed.replace(/\/+$/, '') : undefined;
}
const DEFAULT_API_BASE = 'https://localhost:3000';
export const API_BASE: string = clean(import.meta.env.VITE_API_BASE) ?? DEFAULT_API_BASE;
export const WS_BASE: string =
clean(import.meta.env.VITE_WS_BASE) ?? API_BASE.replace(/^http/, 'ws');
/** Full websocket URL of the live data feed. */
export const WS_LIVE_URL = `${WS_BASE}/api/ws/live`;
+25
View File
@@ -0,0 +1,25 @@
import { API_BASE } from './config.js';
import type { HistoryResponse } from './types.js';
/**
* Fetches the historic amount data points for a single item.
*
* The item name is passed as a query parameter because item identifiers
* contain a colon (e.g. minecraft:iron_ingot).
*/
export async function fetchHistory(name: string, limit = 500): Promise<HistoryResponse> {
const url = new URL(`${API_BASE}/api/me-system/history`);
url.searchParams.set('name', name);
url.searchParams.set('limit', String(limit));
const response = await fetch(url, { headers: { accept: 'application/json' } });
if (response.status === 404) {
throw new Error(`Item not found: ${name}`);
}
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return (await response.json()) as HistoryResponse;
}
+47
View File
@@ -0,0 +1,47 @@
/* Types shared with the backend API. Kept in sync manually. */
export interface LiveItem {
name: string;
displayName: string | null;
mod: string;
amount: number;
updatedAt: string;
}
export interface ModInfo {
mod: string;
itemCount: number;
total: number;
}
export interface HistoryPoint {
amount: number;
recordedAt: string;
}
export type SortColumn = 'amount' | 'name' | 'displayName' | 'mod' | 'updatedAt';
export type SortOrder = 'asc' | 'desc';
/** Filter state that survives every live data update. */
export interface LiveFilters {
search: string;
mod: string | null;
sort: SortColumn;
order: SortOrder;
}
/** Messages pushed by the server over the live websocket. */
export type WsMessage =
| {
type: 'snapshot' | 'update';
items: LiveItem[];
mods: ModInfo[];
filters: LiveFilters;
}
| { type: 'error'; message: string };
/** Response body of GET /api/me-system/history. */
export interface HistoryResponse {
item: LiveItem;
points: HistoryPoint[];
}
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { LiveFilters, ModInfo, SortColumn } from '../api/types.js';
const props = defineProps<{
filters: LiveFilters;
mods: ModInfo[];
}>();
const emit = defineEmits<{
(e: 'update', value: Partial<LiveFilters>): void;
}>();
const SORT_OPTIONS: { value: SortColumn; label: string }[] = [
{ value: 'amount', label: 'Amount' },
{ value: 'displayName', label: 'Name' },
{ value: 'mod', label: 'Mod' },
{ value: 'updatedAt', label: 'Updated' },
];
/* Debounced search so each keystroke does not trigger a server round-trip. */
const searchText = ref(props.filters.search);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => props.filters.search,
(value) => {
if (value !== searchText.value) searchText.value = value;
},
);
function onSearchInput(event: Event): void {
searchText.value = (event.target as HTMLInputElement).value;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
emit('update', { search: searchText.value });
}, 250);
}
function onSortChange(event: Event): void {
emit('update', { sort: (event.target as HTMLSelectElement).value as SortColumn });
}
function onModChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
emit('update', { mod: value === '' ? null : value });
}
function toggleOrder(): void {
emit('update', { order: props.filters.order === 'asc' ? 'desc' : 'asc' });
}
<template>
<div class="filter-bar panel">
<div class="filter-bar__field filter-bar__search">
<label class="filter-bar__label">Search</label>
<input
class="field"
type="text"
placeholder="Filter by name or id…"
:value="searchText"
@input="onSearchInput"
/>
</div>
<div class="filter-bar__field">
<label class="filter-bar__label">Mod</label>
<select class="field" :value="filters.mod ?? ''" @change="onModChange">
<option value="">All mods</option>
<option v-for="m in mods" :key="m.mod" :value="m.mod">
{{ m.mod }} ({{ m.itemCount }})
</option>
</select>
</div>
<div class="filter-bar__field">
<label class="filter-bar__label">Sort by</label>
<select class="field" :value="filters.sort" @change="onSortChange">
<option v-for="opt in SORT_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="filter-bar__field">
<label class="filter-bar__label">Order</label>
<button class="btn filter-bar__order" type="button" @click="toggleOrder">
<span>{{ filters.order === 'asc' ? 'Ascending' : 'Descending' }}</span>
<span class="filter-bar__arrow">{{ filters.order === 'asc' ? '▲' : '▼' }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 16px;
align-items: flex-end;
}
.filter-bar__field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 150px;
}
.filter-bar__search {
flex: 1;
min-width: 220px;
}
.filter-bar__label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.filter-bar__field .field,
.filter-bar__order {
width: 100%;
}
.filter-bar__order {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.filter-bar__arrow {
color: var(--accent);
font-size: 11px;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import {
CategoryScale,
Chart,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
type ChartConfiguration,
} from 'chart.js';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { HistoryPoint } from '../api/types.js';
import { abbreviateNumber, formatTimestamp, fullNumber } from '../utils/format.js';
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Tooltip,
Filler,
);
const props = defineProps<{
points: HistoryPoint[];
}>();
const canvas = ref<HTMLCanvasElement | null>(null);
let chart: Chart<'line'> | null = null;
function buildConfig(): ChartConfiguration<'line'> {
const labels = props.points.map((p) => formatTimestamp(p.recordedAt));
const data = props.points.map((p) => p.amount);
return {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Amount',
data,
borderColor: '#2fd4c4',
backgroundColor: 'rgba(47, 212, 196, 0.15)',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 5,
pointBackgroundColor: '#2fd4c4',
tension: 0.25,
fill: true,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#16212b',
borderColor: '#34465a',
borderWidth: 1,
titleColor: '#c4d2dc',
bodyColor: '#2fd4c4',
padding: 10,
callbacks: {
label: (ctx) => ` ${fullNumber(ctx.parsed.y)}`,
},
},
},
scales: {
x: {
grid: { color: 'rgba(36, 49, 64, 0.6)' },
ticks: { color: '#6b7d8c', maxRotation: 0, autoSkipPadding: 16 },
},
y: {
grid: { color: 'rgba(36, 49, 64, 0.6)' },
ticks: {
color: '#6b7d8c',
callback: (value) => abbreviateNumber(Number(value)),
},
},
},
},
};
}
function render(): void {
if (!canvas.value) return;
chart?.destroy();
chart = new Chart(canvas.value, buildConfig());
}
onMounted(render);
watch(
() => props.points,
() => render(),
);
onBeforeUnmount(() => {
chart?.destroy();
chart = null;
});
</script>
<template>
<div class="chart panel">
<canvas ref="canvas"></canvas>
</div>
</template>
<style scoped>
.chart {
padding: 16px;
height: 360px;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<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;
}
</script>
<template>
<div class="table-wrap panel">
<table class="table">
<thead>
<tr>
<th class="table__col-item">Item</th>
<th class="table__col-mod">Mod</th>
<th class="table__col-amount">Amount</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.name"
class="table__row"
tabindex="0"
@click="openItem(item)"
@keydown.enter="openItem(item)"
>
<td class="table__col-item">
<span class="table__name">{{ label(item) }}</span>
<span class="table__id mono">{{ item.name }}</span>
</td>
<td class="table__col-mod">
<span class="tag mono">{{ item.mod }}</span>
</td>
<td class="table__col-amount mono" :title="fullNumber(item.amount)">
{{ abbreviateNumber(item.amount) }}
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="3" class="table__empty">No items match the current filters.</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.table-wrap {
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel-2);
}
.table__col-amount {
text-align: right;
}
.table__row {
cursor: pointer;
transition: background-color 0.12s ease;
}
.table__row td {
padding: 11px 16px;
border-bottom: 1px solid var(--border);
}
.table__row:last-child td {
border-bottom: none;
}
.table__row:hover,
.table__row:focus {
background: var(--accent-soft);
outline: none;
}
.table__name {
display: block;
font-weight: 500;
}
.table__id {
display: block;
font-size: 11px;
color: var(--text-dim);
}
.tag {
display: inline-block;
font-size: 12px;
padding: 2px 8px;
border: 1px solid var(--border-bright);
border-radius: 5px;
color: var(--accent);
background: var(--accent-soft);
}
.table__col-amount {
font-size: 15px;
color: var(--text);
}
.table__empty {
text-align: center;
color: var(--text-dim);
padding: 28px 16px !important;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
import { onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { WS_LIVE_URL } from '../api/config.js';
import type { LiveFilters, LiveItem, ModInfo, WsMessage } from '../api/types.js';
const STORAGE_KEY = 'mc-cc-live-filters';
const RECONNECT_DELAY_MS = 2000;
export type ConnectionStatus = 'connecting' | 'open' | 'closed';
function defaultFilters(): LiveFilters {
return { search: '', mod: null, sort: 'amount', order: 'desc' };
}
/** Loads persisted filters so the view stays filtered across reloads. */
function loadFilters(): LiveFilters {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultFilters();
const parsed = JSON.parse(raw) as Partial<LiveFilters>;
return { ...defaultFilters(), ...parsed };
} catch {
return defaultFilters();
}
}
/**
* Connects to the live data websocket and exposes reactive state.
*
* Filters are kept on the client, persisted to localStorage and sent to the
* server, which re-applies them on every push so the view stays filtered
* across data updates.
*/
export function useLiveData() {
const items = shallowRef<LiveItem[]>([]);
const mods = shallowRef<ModInfo[]>([]);
const filters = ref<LiveFilters>(loadFilters());
const status = ref<ConnectionStatus>('connecting');
const lastUpdate = ref<string | null>(null);
let socket: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
function send(message: unknown): void {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
}
function connect(): void {
if (disposed) return;
status.value = 'connecting';
socket = new WebSocket(WS_LIVE_URL);
socket.onopen = () => {
status.value = 'open';
// Re-send the persisted filters so the server applies them immediately.
send({ type: 'setFilters', filters: filters.value });
};
socket.onmessage = (event: MessageEvent<string>) => {
let message: WsMessage;
try {
message = JSON.parse(event.data) as WsMessage;
} catch {
return;
}
if (message.type === 'snapshot' || message.type === 'update') {
items.value = message.items;
mods.value = message.mods;
lastUpdate.value = new Date().toISOString();
}
};
socket.onclose = () => {
status.value = 'closed';
scheduleReconnect();
};
socket.onerror = () => {
socket?.close();
};
}
function scheduleReconnect(): void {
if (disposed || reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, RECONNECT_DELAY_MS);
}
/** Updates the active filters, persists them and notifies the server. */
function updateFilters(next: Partial<LiveFilters>): void {
filters.value = { ...filters.value, ...next };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters.value));
} catch {
/* ignore storage failures (private mode, quota, ...) */
}
send({ type: 'setFilters', filters: filters.value });
}
onMounted(connect);
onUnmounted(() => {
disposed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (socket) {
socket.onclose = null;
socket.close();
}
});
return { items, mods, filters, status, lastUpdate, updateFilters };
}
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Base URL of the backend HTTP API, e.g. https://localhost:3000 */
readonly VITE_API_BASE?: string;
/** Base URL of the backend websocket, e.g. wss://localhost:3000 */
readonly VITE_WS_BASE?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router/index.js';
import './style.css';
createApp(App).use(router).mount('#app');
+23
View File
@@ -0,0 +1,23 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import LiveView from '../views/LiveView.vue';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'live',
component: LiveView,
},
{
path: '/item/:name',
name: 'item',
// Item names contain a colon (e.g. minecraft:iron_ingot); the router
// matches them verbatim and passes the raw value through as a prop.
component: () => import('../views/ItemHistoryView.vue'),
props: true,
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
+138
View File
@@ -0,0 +1,138 @@
/* ------------------------------------------------------------------ */
/* Theme tokens */
/* ------------------------------------------------------------------ */
:root {
--bg: #0a0e13;
--bg-panel: #121a22;
--bg-panel-2: #16212b;
--border: #243140;
--border-bright: #34465a;
--text: #c4d2dc;
--text-dim: #6b7d8c;
--accent: #2fd4c4;
--accent-soft: rgba(47, 212, 196, 0.12);
--accent-2: #f0a500;
--danger: #e5484d;
--positive: #46c93a;
--font-display: 'Chakra Petch', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius: 8px;
--shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
background-color: var(--bg);
background-image:
linear-gradient(rgba(47, 212, 196, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(47, 212, 196, 0.025) 1px, transparent 1px);
background-size: 36px 36px;
color: var(--text);
font-family: var(--font-display);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100%;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1,
h2,
h3 {
font-weight: 600;
letter-spacing: 0.02em;
margin: 0;
}
button,
input,
select {
font-family: var(--font-display);
font-size: 14px;
color: var(--text);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border-bright);
border-radius: 5px;
}
/* ------------------------------------------------------------------ */
/* Shared building blocks */
/* ------------------------------------------------------------------ */
.panel {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.mono {
font-family: var(--font-mono);
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 20px;
}
.field {
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 9px 12px;
outline: none;
transition: border-color 0.15s ease;
}
.field:focus {
border-color: var(--accent);
}
.btn {
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 9px 14px;
cursor: pointer;
transition:
border-color 0.15s ease,
color 0.15s ease;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent);
}
+75
View File
@@ -0,0 +1,75 @@
/* ------------------------------------------------------------------ */
/* Number formatting */
/* ------------------------------------------------------------------ */
const UNITS: { value: number; suffix: string }[] = [
{ value: 1e12, suffix: 'T' },
{ value: 1e9, suffix: 'B' },
{ value: 1e6, suffix: 'M' },
{ value: 1e3, suffix: 'k' },
];
/**
* Abbreviates large numbers for compact display.
*
* 12000 -> "12k"
* 43452542 -> "43.45M"
* 999 -> "999"
*/
export function abbreviateNumber(input: number): string {
if (!Number.isFinite(input)) return '0';
const sign = input < 0 ? '-' : '';
const abs = Math.abs(input);
for (const { value, suffix } of UNITS) {
if (abs >= value) {
const scaled = abs / value;
// Two decimals, then strip any trailing zeros and a dangling dot.
const text = scaled.toFixed(2).replace(/\.?0+$/, '');
return `${sign}${text}${suffix}`;
}
}
return `${sign}${abs}`;
}
/** Full, thousands-separated representation, used for tooltips / titles. */
export function fullNumber(input: number): string {
return input.toLocaleString('en-US');
}
/* ------------------------------------------------------------------ */
/* Date formatting */
/* ------------------------------------------------------------------ */
/** Human-readable absolute timestamp. */
export function formatTimestamp(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
/** Short relative time, e.g. "12s ago", "4m ago", "2h ago". */
export function formatRelative(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import HistoryChart from '../components/HistoryChart.vue';
import { fetchHistory } from '../api/http.js';
import type { HistoryPoint, LiveItem } from '../api/types.js';
import { abbreviateNumber, fullNumber } from '../utils/format.js';
const props = defineProps<{
name: string;
}>();
const item = ref<LiveItem | null>(null);
const points = ref<HistoryPoint[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const title = computed(() => item.value?.displayName ?? props.name);
const amounts = computed(() => points.value.map((p) => p.amount));
const current = computed(() => item.value?.amount ?? 0);
const min = computed(() => (amounts.value.length ? Math.min(...amounts.value) : 0));
const max = computed(() => (amounts.value.length ? Math.max(...amounts.value) : 0));
const delta = computed(() => {
if (amounts.value.length < 2) return 0;
const first = amounts.value[0] ?? 0;
const last = amounts.value[amounts.value.length - 1] ?? 0;
return last - first;
});
const deltaClass = computed(() => {
if (delta.value > 0) return 'is-positive';
if (delta.value < 0) return 'is-negative';
return '';
});
function formatDelta(value: number): string {
const sign = value > 0 ? '+' : '';
return `${sign}${abbreviateNumber(value)}`;
}
onMounted(async () => {
try {
const data = await fetchHistory(props.name);
item.value = data.item;
points.value = data.points;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load item history.';
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="detail">
<RouterLink to="/" class="detail__back mono"> Back to live view</RouterLink>
<div v-if="loading" class="detail__state panel">Loading history</div>
<div v-else-if="error" class="detail__state panel detail__state--error">{{ error }}</div>
<template v-else>
<header class="detail__header">
<h1 class="detail__title">{{ title }}</h1>
<span class="detail__id mono">{{ name }}</span>
</header>
<section class="cards">
<div class="card panel">
<span class="card__label">Current</span>
<span class="card__value mono" :title="fullNumber(current)">
{{ abbreviateNumber(current) }}
</span>
</div>
<div class="card panel">
<span class="card__label">Minimum</span>
<span class="card__value mono" :title="fullNumber(min)">
{{ abbreviateNumber(min) }}
</span>
</div>
<div class="card panel">
<span class="card__label">Maximum</span>
<span class="card__value mono" :title="fullNumber(max)">
{{ abbreviateNumber(max) }}
</span>
</div>
<div class="card panel">
<span class="card__label">Change (range)</span>
<span class="card__value mono" :class="deltaClass" :title="fullNumber(delta)">
{{ formatDelta(delta) }}
</span>
</div>
</section>
<h2 class="detail__section">Amount over time</h2>
<HistoryChart v-if="points.length > 0" :points="points" />
<div v-else class="detail__state panel">
No history recorded yet for this item.
</div>
</template>
</div>
</template>
<style scoped>
.detail {
display: flex;
flex-direction: column;
gap: 18px;
}
.detail__back {
font-size: 13px;
}
.detail__header {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail__title {
font-size: 26px;
}
.detail__id {
font-size: 13px;
color: var(--text-dim);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
}
.card__label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.card__value {
font-size: 24px;
font-weight: 600;
}
.is-positive {
color: var(--positive);
}
.is-negative {
color: var(--danger);
}
.detail__section {
font-size: 14px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-dim);
}
.detail__state {
padding: 28px 16px;
text-align: center;
color: var(--text-dim);
}
.detail__state--error {
color: var(--danger);
border-color: var(--danger);
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from 'vue';
import FilterBar from '../components/FilterBar.vue';
import ItemTable from '../components/ItemTable.vue';
import { useLiveData } from '../composables/useLiveData.js';
import type { LiveFilters } from '../api/types.js';
import { abbreviateNumber, formatRelative } from '../utils/format.js';
const { items, mods, filters, status, lastUpdate, updateFilters } = useLiveData();
const totalAmount = computed(() =>
items.value.reduce((sum, item) => sum + item.amount, 0),
);
const statusLabel = computed(() => {
if (status.value === 'open') return 'Connected';
if (status.value === 'connecting') return 'Connecting…';
return 'Disconnected';
});
function onFilterUpdate(value: Partial<LiveFilters>): void {
updateFilters(value);
}
</script>
<template>
<div class="live">
<section class="stats">
<div class="stat panel">
<span class="stat__label">Items shown</span>
<span class="stat__value mono">{{ items.length }}</span>
</div>
<div class="stat panel">
<span class="stat__label">Total amount</span>
<span class="stat__value mono">{{ abbreviateNumber(totalAmount) }}</span>
</div>
<div class="stat panel">
<span class="stat__label">Mods</span>
<span class="stat__value mono">{{ mods.length }}</span>
</div>
<div class="stat panel">
<span class="stat__label">Connection</span>
<span class="stat__value stat__value--status" :class="`is-${status}`">
<span class="dot"></span>{{ statusLabel }}
</span>
</div>
</section>
<FilterBar :filters="filters" :mods="mods" @update="onFilterUpdate" />
<p class="live__meta mono">
<span v-if="lastUpdate">Last update {{ formatRelative(lastUpdate) }}</span>
<span v-else>Waiting for first update</span>
</p>
<ItemTable :items="items" />
</div>
</template>
<style scoped>
.live {
display: flex;
flex-direction: column;
gap: 18px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 14px;
}
.stat {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
}
.stat__label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
}
.stat__value {
font-size: 24px;
font-weight: 600;
}
.stat__value--status {
font-size: 16px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--text-dim);
}
.is-open .dot {
background: var(--positive);
box-shadow: 0 0 8px var(--positive);
}
.is-connecting .dot {
background: var(--accent-2);
}
.is-closed .dot {
background: var(--danger);
}
.live__meta {
margin: 0;
font-size: 12px;
color: var(--text-dim);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import basicSsl from '@vitejs/plugin-basic-ssl';
// `basicSsl` generates a self-signed certificate so the dev server and
// `vite preview` both serve over HTTPS out of the box.
export default defineConfig({
plugins: [vue(), basicSsl()],
server: {
host: true,
port: 5173,
},
preview: {
host: true,
port: 4173,
},
});