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?
|
||||
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