init
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.git/
|
||||||
|
.env
|
||||||
|
certs/
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 SYSTEM MONITOR</span>
|
||||||
|
</RouterLink>
|
||||||
|
<span class="brand__sub mono">ComputerCraft · Live 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>
|
||||||
@@ -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`;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Vendored
+18
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user