Simplify UI and add batch build endpoint

This commit is contained in:
klas
2026-02-10 09:58:11 +01:00
commit dee991a51a
83 changed files with 7043 additions and 0 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GeViSet Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1731
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "geviset-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

158
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { useState } from "react";
const API_BASE = (() => {
const override = import.meta.env.VITE_API_BASE;
if (override) return override;
if (window.location.port === "5173") {
return `${window.location.protocol}//${window.location.hostname}:8002`;
}
return window.location.origin;
})();
const BUILD_STAMP = new Date().toISOString();
type Slot = {
id: string;
label: string;
accept: string;
file: File | null;
setFile: (file: File | null) => void;
required?: boolean;
};
function formatSize(file: File) {
const kb = file.size / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}
export default function App() {
const [status, setStatus] = useState<string | null>(null);
const [busyLabel, setBusyLabel] = useState<string | null>(null);
const [baseFile, setBaseFile] = useState<File | null>(null);
const [serversFile, setServersFile] = useState<File | null>(null);
const [actionsFile, setActionsFile] = useState<File | null>(null);
const [outputsFile, setOutputsFile] = useState<File | null>(null);
const [keyboardsFile, setKeyboardsFile] = useState<File | null>(null);
const [loggingFile, setLoggingFile] = useState<File | null>(null);
const slots: Slot[] = [
{ id: "base", label: "Base .set", accept: ".set", file: baseFile, setFile: setBaseFile, required: true },
{ id: "servers", label: "Servers", accept: ".xlsx", file: serversFile, setFile: setServersFile },
{ id: "actions", label: "Actions", accept: ".xlsx", file: actionsFile, setFile: setActionsFile },
{ id: "outputs", label: "Outputs", accept: ".xlsx", file: outputsFile, setFile: setOutputsFile },
{ id: "keyboards", label: "Keyboards", accept: ".xlsx", file: keyboardsFile, setFile: setKeyboardsFile },
{ id: "logging", label: "Logging", accept: ".xlsx", file: loggingFile, setFile: setLoggingFile },
];
const handleBuild = async () => {
if (!baseFile) {
setStatus("Select a base .set file.");
return;
}
const body = new FormData();
body.append("base_set", baseFile);
if (serversFile) body.append("servers", serversFile);
if (actionsFile) body.append("actions", actionsFile);
if (outputsFile) body.append("outputs", outputsFile);
if (keyboardsFile) body.append("keyboards", keyboardsFile);
if (loggingFile) body.append("logging", loggingFile);
setStatus("Building combined .set...");
setBusyLabel("Building .set");
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 300000);
try {
const res = await fetch(`${API_BASE}/api/batch/build`, {
method: "POST",
body,
signal: controller.signal,
});
if (!res.ok) {
setStatus(await res.text());
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "combined.set";
link.click();
URL.revokeObjectURL(url);
setStatus("Combined .set downloaded.");
} catch (error: any) {
if (error?.name === "AbortError") {
setStatus("Build timed out.");
} else {
setStatus("Build failed.");
}
} finally {
window.clearTimeout(timeoutId);
setBusyLabel(null);
}
};
return (
<div className="shell simple">
<header className="topbar">
<div className="topbar__brand">
<div className="logo">GV</div>
<div>
<div className="topbar__title">GeViSet Builder</div>
<div className="topbar__subtitle">Batch .set generator <EFBFBD> {BUILD_STAMP}</div>
</div>
</div>
<div className="topbar__actions">
<div className="statusbar">
{busyLabel && <span className="spinner" />}
{busyLabel ?? status ?? "Ready."}
</div>
</div>
</header>
<main className="simple__body">
<section className="card">
<div className="card__header">
<div>
<h2>Build Combined .set</h2>
<p>Select the base .set and any Excel files you want to apply. Only selected files are used.</p>
</div>
<button className="chip" onClick={handleBuild}>
Build .set
</button>
</div>
<div className="card__content">
{slots.map((slot) => (
<div key={slot.id} className="file-block">
<label className="file-row">
{slot.label}{slot.required ? " *" : ""}
<input
type="file"
accept={slot.accept}
onChange={(e) => {
const file = e.target.files?.[0] || null;
slot.setFile(file);
e.target.value = "";
}}
/>
</label>
{slot.file ? (
<div className="file-meta">
<span className="file-name">{slot.file.name}</span>
<span className="file-size">{formatSize(slot.file)}</span>
<button className="chip chip--ghost" onClick={() => slot.setFile(null)}>
Clear
</button>
</div>
) : (
<div className="file-meta file-meta--empty">Not selected</div>
)}
</div>
))}
</div>
</section>
</main>
</div>
);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

531
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,531 @@
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap");
:root {
--bg: #0a0d12;
--panel: #0e141d;
--panel-2: #0b1118;
--grid: #1c2634;
--edge: #2a374a;
--text: #e6edf6;
--muted: #90a0b3;
--accent: #4de3b8;
--accent-2: #3aa0ff;
--danger: #ff5d5d;
--chip: #121a26;
--chip-border: #2b394f;
font-family: "IBM Plex Sans", sans-serif;
color: var(--text);
background-color: var(--bg);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(18, 25, 35, 0.7) 0%, rgba(8, 11, 16, 0.9) 100%),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.02) 0px,
rgba(255, 255, 255, 0.02) 1px,
transparent 1px,
transparent 28px
);
}
input,
button {
font-family: inherit;
color: inherit;
}
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 18px;
border-bottom: 1px solid var(--edge);
background: linear-gradient(180deg, #101723 0%, #0a0d12 100%);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.03);
}
.topbar__brand {
display: flex;
gap: 12px;
align-items: center;
}
.logo {
width: 36px;
height: 36px;
border-radius: 6px;
display: grid;
place-items: center;
font-weight: 600;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #041017;
letter-spacing: 0.04em;
}
.topbar__title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.topbar__subtitle {
font-size: 11px;
color: var(--muted);
}
.topbar__actions {
display: flex;
gap: 10px;
align-items: center;
}
.statusbar {
font-size: 11px;
color: var(--muted);
padding: 4px 10px;
border-radius: 4px;
border: 1px solid var(--edge);
background: rgba(10, 15, 22, 0.8);
text-transform: uppercase;
letter-spacing: 0.08em;
display: inline-flex;
align-items: center;
gap: 8px;
}
.spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid rgba(77, 227, 184, 0.25);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
.shell__body {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 0;
flex: 1;
}
.simple__body {
flex: 1;
padding: 24px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card {
width: min(900px, 100%);
background: var(--panel);
border: 1px solid var(--edge);
border-radius: 10px;
padding: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
}
.card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--edge);
padding-bottom: 12px;
}
.card__header h2 {
margin: 0;
font-size: 18px;
}
.card__header p {
margin: 6px 0 0;
color: var(--muted);
font-size: 12px;
}
.card__content {
margin-top: 16px;
display: grid;
gap: 14px;
}
.file-block {
display: grid;
gap: 6px;
}
.file-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--muted);
flex-wrap: wrap;
}
.file-meta--empty {
color: rgba(144, 160, 179, 0.7);
font-style: italic;
}
.file-name {
color: var(--text);
font-family: \"IBM Plex Mono\", monospace;
}
.file-size {
font-family: \"IBM Plex Mono\", monospace;
}
.sidebar {
border-right: 1px solid var(--edge);
padding: 14px;
background: var(--panel-2);
display: grid;
gap: 12px;
align-content: start;
}
.sidebar__section {
padding: 10px;
border-radius: 6px;
background: rgba(12, 18, 26, 0.9);
border: 1px solid var(--edge);
align-self: start;
}
.sidebar__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--muted);
margin-bottom: 10px;
}
.sidebar__hint {
font-size: 10px;
color: var(--muted);
margin-top: 8px;
line-height: 1.4;
}
.stat {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--edge);
font-size: 12px;
}
.stat:last-child {
border-bottom: none;
}
.stat strong {
color: var(--accent);
font-family: "IBM Plex Mono", monospace;
}
.workspace {
padding: 14px 16px;
display: grid;
gap: 12px;
overflow: auto;
}
.panel {
padding: 12px;
border-radius: 6px;
background: var(--panel);
border: 1px solid var(--edge);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.panel--dark {
background: #0a0f16;
}
.panel__header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.panel__header h2 {
margin: 0 0 2px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.panel__header p {
margin: 0;
color: var(--muted);
font-size: 11px;
}
.panel__actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.tab {
border: 1px solid var(--chip-border);
background: var(--chip);
color: var(--muted);
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.tab--active {
color: var(--text);
border-color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(77, 227, 184, 0.4);
}
.chip {
border: 1px solid var(--chip-border);
background: var(--chip);
color: var(--text);
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chip input,
.file-row input {
display: none;
}
.chip--ghost {
background: transparent;
border-color: var(--edge);
color: var(--muted);
}
.chip--danger {
border-color: rgba(255, 93, 93, 0.5);
color: #ffd1d1;
background: rgba(255, 93, 93, 0.15);
}
.chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
background: #0b121b;
border: 1px solid var(--edge);
font-size: 11px;
cursor: pointer;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.file-row--button {
width: 100%;
justify-content: flex-start;
gap: 6px;
color: var(--text);
}
.search {
border: 1px solid var(--edge);
border-radius: 4px;
padding: 5px 8px;
min-width: 160px;
background: #0b121b;
font-size: 11px;
}
.table {
margin-top: 10px;
display: grid;
gap: 4px;
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
}
.table__head,
.table__row {
display: grid;
gap: 4px;
}
.table--servers .table__head,
.table--servers .table__row {
grid-template-columns: 1.1fr 1fr 0.8fr 0.5fr 0.45fr 0.45fr 0.5fr;
}
.table--actions .table__head,
.table--actions .table__row {
grid-template-columns: 1.1fr 0.9fr 1.1fr 0.9fr 1.2fr;
}
.table--keyboards .table__head,
.table--keyboards .table__row {
grid-template-columns: 1.2fr 1fr 0.6fr;
}
.table--outputs .table__head,
.table--outputs .table__row {
grid-template-columns: 0.5fr 0.6fr 1.6fr;
}
.table__cell {
padding: 5px 6px;
border-radius: 4px;
background: #0b121b;
color: inherit;
border: 1px solid transparent;
text-align: left;
}
.table__cell--head {
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.1em;
cursor: pointer;
color: var(--muted);
background: transparent;
border: 1px solid var(--edge);
}
.table__cell--text {
background: transparent;
border: 1px dashed var(--edge);
}
.table__cell--actions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
background: transparent;
border: none;
padding: 0;
}
.input {
border: 1px solid transparent;
background: #0b121b;
}
.input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(77, 227, 184, 0.25);
}
.toggle {
display: flex;
align-items: center;
}
.toggle input {
display: none;
}
.toggle span {
width: 30px;
height: 16px;
border-radius: 999px;
background: #182232;
position: relative;
border: 1px solid var(--edge);
}
.toggle span::after {
content: "";
width: 12px;
height: 12px;
position: absolute;
top: 1px;
left: 1px;
border-radius: 50%;
background: #d7e3f1;
transition: transform 0.2s ease;
}
.toggle input:checked + span {
background: rgba(77, 227, 184, 0.3);
}
.toggle input:checked + span::after {
transform: translateX(14px);
background: var(--accent);
}
@media (max-width: 1024px) {
.shell__body {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--edge);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
@media (max-width: 900px) {
.table__head,
.table__row {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

File diff suppressed because it is too large Load Diff

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173
}
});