docs(reference): import Dune: Awakening server-manager references
Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
30
docs/reference-repos/icehunter/web/.gitignore
vendored
Normal file
30
docs/reference-repos/icehunter/web/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
superpowers/
|
||||
.superpowers/
|
||||
.wrangler/
|
||||
|
||||
settings.local.json
|
||||
1
docs/reference-repos/icehunter/web/.npmrc
Normal file
1
docs/reference-repos/icehunter/web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
auto-install-peers=true
|
||||
73
docs/reference-repos/icehunter/web/README.md
Normal file
73
docs/reference-repos/icehunter/web/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
40
docs/reference-repos/icehunter/web/eslint.config.js
Normal file
40
docs/reference-repos/icehunter/web/eslint.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
stylistic.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// Add/override style rules here
|
||||
"@stylistic/comma-dangle": ["error", "always-multiline"],
|
||||
"@stylistic/object-curly-spacing": ["error", "always"],
|
||||
"@stylistic/array-bracket-spacing": ["error", "never"],
|
||||
"@stylistic/arrow-parens": ["error", "always"],
|
||||
"@stylistic/max-len": [
|
||||
"warn",
|
||||
{
|
||||
code: 120,
|
||||
ignoreUrls: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
12
docs/reference-repos/icehunter/web/i18next-parser.config.ts
Normal file
12
docs/reference-repos/icehunter/web/i18next-parser.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const config = {
|
||||
input: ['src/**/*.{ts,tsx}'],
|
||||
output: 'src/locales/$LOCALE/translation.json',
|
||||
locales: ['en-US', 'de', 'fr', 'es', 'pt-BR', 'ru', 'pl', 'tr', 'zh-CN', 'ja'],
|
||||
defaultNamespace: 'translation',
|
||||
defaultValue: (locale: string) => (locale === 'en-US' ? '' : 'MISSING'),
|
||||
keySeparator: '.',
|
||||
namespaceSeparator: false,
|
||||
sort: true,
|
||||
}
|
||||
|
||||
export default config
|
||||
12
docs/reference-repos/icehunter/web/index.html
Normal file
12
docs/reference-repos/icehunter/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dune Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
68
docs/reference-repos/icehunter/web/package.json
Normal file
68
docs/reference-repos/icehunter/web/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/react": "^6.7.1",
|
||||
"@clerk/themes": "^2.4.57",
|
||||
"@codemirror/autocomplete": "^6.20.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.0",
|
||||
"@heroui/react": "^3.0.5",
|
||||
"@heroui/styles": "^3.0.5",
|
||||
"@iconify-json/lucide": "^1.2.109",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@uiw/codemirror-themes": "^4.25.10",
|
||||
"@uiw/react-codemirror": "^4.25.10",
|
||||
"i18next": "^26.3.0",
|
||||
"jotai": "^2.20.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tslib": "^2.8.1",
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"overrides": {
|
||||
"js-cookie": "3.0.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@internationalized/date": "3.12.2"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rolldown/binding-win32-x64-msvc": "^1.0.3",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
3785
docs/reference-repos/icehunter/web/pnpm-lock.yaml
generated
Normal file
3785
docs/reference-repos/icehunter/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
docs/reference-repos/icehunter/web/public/favicon.svg
Normal file
1
docs/reference-repos/icehunter/web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
BIN
docs/reference-repos/icehunter/web/public/fonts/Duneway-Bold.ttf
Normal file
BIN
docs/reference-repos/icehunter/web/public/fonts/Duneway-Bold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
docs/reference-repos/icehunter/web/public/fonts/ImperialFont.ttf
Normal file
BIN
docs/reference-repos/icehunter/web/public/fonts/ImperialFont.ttf
Normal file
Binary file not shown.
24
docs/reference-repos/icehunter/web/public/icons.svg
Normal file
24
docs/reference-repos/icehunter/web/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
184
docs/reference-repos/icehunter/web/src/App.css
Normal file
184
docs/reference-repos/icehunter/web/src/App.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
706
docs/reference-repos/icehunter/web/src/App.tsx
Normal file
706
docs/reference-repos/icehunter/web/src/App.tsx
Normal file
@@ -0,0 +1,706 @@
|
||||
import type React from 'react'
|
||||
import { memo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Show, SignInButton, UserButton, useAuth } from '@clerk/react'
|
||||
import { Button, Chip, Modal, Spinner, Tabs, Toast, ToggleButton, ToggleButtonGroup, toast } from '@heroui/react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStatus } from './hooks/useStatus'
|
||||
import { BackendUnreachable } from './components/BackendUnreachable'
|
||||
import { SettingsConfigForm } from './components/SettingsConfigForm'
|
||||
import { LanguageSelector } from './components/LanguageSelector'
|
||||
import { ThemeSelector } from './components/ThemeSelector'
|
||||
import { HelpMenu } from './components/HelpMenu'
|
||||
import { BattlegroupTab } from './tabs/BattlegroupTab'
|
||||
import { LiveMapTab } from './tabs/LiveMapTab'
|
||||
import { PlayersTab } from './tabs/PlayersTab'
|
||||
import { DatabaseTab } from './tabs/DatabaseTab'
|
||||
import { LogsTab } from './tabs/LogsTab'
|
||||
import { BlueprintsTab } from './tabs/BlueprintsTab'
|
||||
import { BasesTab } from './tabs/BasesTab'
|
||||
import { GuildsTab } from './tabs/GuildsTab'
|
||||
import { LandsraadTab } from './tabs/LandsraadTab'
|
||||
import { StorageTab } from './tabs/StorageTab'
|
||||
import { ServerSettingsTab } from './tabs/ServerSettingsTab'
|
||||
import { DirectorTab } from './tabs/DirectorTab'
|
||||
import { MarketTab } from './tabs/MarketTab'
|
||||
import { WelcomePackageTab } from './tabs/WelcomePackageTab'
|
||||
import { Icon, SideNav } from './dune-ui'
|
||||
import { api } from './api/client'
|
||||
import type { UpdateCheckResult } from './api/client'
|
||||
|
||||
const TAB_IDS = [
|
||||
'battlegroup',
|
||||
'players',
|
||||
'database',
|
||||
'logs',
|
||||
'blueprints',
|
||||
'bases',
|
||||
'guilds',
|
||||
'landsraad',
|
||||
'storage',
|
||||
'livemap',
|
||||
'server',
|
||||
'director',
|
||||
'market',
|
||||
'welcome',
|
||||
] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
const DEFAULT_TAB: TabId = 'battlegroup'
|
||||
|
||||
function currentTabFromPath(pathname: string): TabId {
|
||||
const seg = pathname.replace(/^\//, '').split('/')[0]
|
||||
return (TAB_IDS as readonly string[]).includes(seg) ? (seg as TabId) : DEFAULT_TAB
|
||||
}
|
||||
|
||||
type DbSection = 'backups' | 'tables' | 'describe' | 'sample' | 'search' | 'sql'
|
||||
type WelcomeSection = 'config' | 'packages' | 'grants'
|
||||
type LayoutMode = 'sidenav' | 'topnav'
|
||||
|
||||
// Memoized at module level so identity is stable — prevents all inactive tabs from
|
||||
// re-rendering whenever AppCore re-renders (e.g. router location change, useStatus poll).
|
||||
const MBattlegroupTab = memo(BattlegroupTab)
|
||||
const MLiveMapTab = memo(LiveMapTab)
|
||||
const MPlayersTab = memo(PlayersTab)
|
||||
const MDatabaseTab = memo(DatabaseTab)
|
||||
const MLogsTab = memo(LogsTab)
|
||||
const MBlueprintsTab = memo(BlueprintsTab)
|
||||
const MBasesTab = memo(BasesTab)
|
||||
const MGuildsTab = memo(GuildsTab)
|
||||
const MLandsraadTab = memo(LandsraadTab)
|
||||
const MStorageTab = memo(StorageTab)
|
||||
const MServerSettingsTab = memo(ServerSettingsTab)
|
||||
const MDirectorTab = memo(DirectorTab)
|
||||
const MMarketTab = memo(MarketTab)
|
||||
const MWelcomePackageTab = memo(WelcomePackageTab)
|
||||
|
||||
const hasClerk = !!import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
|
||||
|
||||
interface AppCoreProps {
|
||||
isSignedIn: boolean
|
||||
}
|
||||
|
||||
interface TabPaneProps {
|
||||
active: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface ConnectionBadgeProps {
|
||||
label: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
function AppWithAuth() {
|
||||
const { isSignedIn } = useAuth()
|
||||
return <AppCore isSignedIn={!!isSignedIn} />
|
||||
}
|
||||
|
||||
export const App: React.FC = () => {
|
||||
return hasClerk ? <AppWithAuth /> : <AppCore isSignedIn={true} />
|
||||
}
|
||||
|
||||
const AppCore: React.FC<AppCoreProps> = ({ isSignedIn }) => {
|
||||
const { status, state: connState } = useStatus()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { t, i18n } = useTranslation()
|
||||
const [reconnecting, setReconnecting] = useState(false)
|
||||
|
||||
const DB_SECTIONS: { key: string, label: string, depth: number }[] = [
|
||||
{ key: 'db:backups', label: `╰─ ${t('database.sections.backups')}`, depth: 1 },
|
||||
{ key: 'db:tables', label: `╰─ ${t('database.sections.tables')}`, depth: 1 },
|
||||
{ key: 'db:describe', label: `╰─ ${t('database.sections.describe')}`, depth: 1 },
|
||||
{ key: 'db:sample', label: `╰─ ${t('database.sections.sample')}`, depth: 1 },
|
||||
{ key: 'db:search', label: `╰─ ${t('database.sections.search')}`, depth: 1 },
|
||||
{ key: 'db:sql', label: `╰─ ${t('database.sections.sql')}`, depth: 1 },
|
||||
]
|
||||
|
||||
const WELCOME_SECTIONS: { key: string, label: string, depth: number }[] = [
|
||||
{ key: 'welcome:config', label: `╰─ ${t('welcome.sections.config')}`, depth: 1 },
|
||||
{ key: 'welcome:packages', label: `╰─ ${t('welcome.sections.packages')}`, depth: 1 },
|
||||
{ key: 'welcome:grants', label: `╰─ ${t('welcome.sections.grants')}`, depth: 1 },
|
||||
]
|
||||
|
||||
// Re-establish backend connections (DB + control plane) without a service
|
||||
// restart — used by the header Reconnect button when the DB shows disconnected
|
||||
// (e.g. dune-admin came up before the database was ready).
|
||||
const handleReconnect = async () => {
|
||||
setReconnecting(true)
|
||||
try {
|
||||
const s = await api.reconnect()
|
||||
if (s.db_connected) toast.success(t('app.reconnected'))
|
||||
else toast.danger(t('app.reconnectFailed', { error: 'database still unreachable' }))
|
||||
}
|
||||
catch (e) {
|
||||
toast.danger(t('app.reconnectFailed', { error: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
setReconnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Left-sidebar navigation, grouped to mirror the product's structure
|
||||
// (operator tooling today; a Player Portal group lands here later).
|
||||
const NAV_GROUPS: { title: string, items: { key: TabId, label: string }[] }[] = [
|
||||
{
|
||||
title: t('nav.groups.operations'),
|
||||
items: [
|
||||
{ key: 'battlegroup' as TabId, label: t('nav.battlegroup') },
|
||||
{ key: 'logs' as TabId, label: t('nav.logs') },
|
||||
{ key: 'database' as TabId, label: t('nav.database') },
|
||||
{ key: 'server' as TabId, label: t('nav.server') },
|
||||
{ key: 'director' as TabId, label: t('nav.director') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('nav.groups.playerWorld'),
|
||||
items: [
|
||||
{ key: 'players' as TabId, label: t('nav.players') },
|
||||
{ key: 'livemap' as TabId, label: t('nav.liveMap') },
|
||||
{ key: 'storage' as TabId, label: t('nav.storage') },
|
||||
{ key: 'bases' as TabId, label: t('nav.bases') },
|
||||
{ key: 'guilds' as TabId, label: t('nav.guilds') },
|
||||
{ key: 'landsraad' as TabId, label: t('nav.landsraad') },
|
||||
{ key: 'blueprints' as TabId, label: t('nav.blueprints') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('nav.groups.economy'),
|
||||
items: [
|
||||
{ key: 'market' as TabId, label: t('nav.market') },
|
||||
{ key: 'welcome' as TabId, label: t('nav.welcome') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>(
|
||||
() => (localStorage.getItem('dune_admin_layout') === 'topnav' ? 'topnav' : 'sidenav'),
|
||||
)
|
||||
const setLayout = useCallback((m: LayoutMode) => {
|
||||
localStorage.setItem('dune_admin_layout', m)
|
||||
setLayoutMode(m)
|
||||
}, [])
|
||||
const [dbSection, setDbSection] = useState<DbSection>('backups')
|
||||
const [welcomeSection, setWelcomeSection] = useState<WelcomeSection>('config')
|
||||
const [showBackendConfig, setShowBackendConfig] = useState(false)
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false)
|
||||
const [updateChecking, setUpdateChecking] = useState(false)
|
||||
const [updateApplying, setUpdateApplying] = useState(false)
|
||||
const [formSaving, setFormSaving] = useState(false)
|
||||
const formSaveRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const seg = location.pathname.replace(/^\//, '').split('/')[0]
|
||||
if (!seg || !(TAB_IDS as readonly string[]).includes(seg)) {
|
||||
navigate(`/${DEFAULT_TAB}`, { replace: true })
|
||||
}
|
||||
}, [location.pathname, navigate])
|
||||
|
||||
const currentTab = currentTabFromPath(location.pathname)
|
||||
|
||||
// Tracks which tabs have been visited at least once — they get mounted and stay
|
||||
// mounted (TabPane keeps them hidden), preserving in-tab state and the isActive
|
||||
// auto-refresh contract. Unvisited tabs never mount, avoiding the startup query storm.
|
||||
const [mounted, setMounted] = useState<Set<TabId>>(() => new Set<TabId>([currentTab]))
|
||||
useEffect(() => {
|
||||
setMounted((prev) => { // eslint-disable-line react-hooks/set-state-in-effect
|
||||
if (prev.has(currentTab)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(currentTab)
|
||||
return next
|
||||
})
|
||||
}, [currentTab])
|
||||
|
||||
// Check for a newer release via the backend (it knows this build's version and
|
||||
// returns the release-notes URL) — drives the clickable header update widget (#129).
|
||||
useEffect(() => {
|
||||
api.update.check().then(setUpdateInfo).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const checkUpdate = async () => {
|
||||
setUpdateChecking(true)
|
||||
try {
|
||||
setUpdateInfo(await api.update.check())
|
||||
}
|
||||
catch {
|
||||
// silently ignore — user can retry
|
||||
}
|
||||
finally {
|
||||
setUpdateChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyUpdate = async (force = false) => {
|
||||
setUpdateApplying(true)
|
||||
try {
|
||||
const result = await api.update.apply(force)
|
||||
if (result.updated) {
|
||||
toast.success(force ? t('app.reinstalled', { version: result.version ?? 'latest' }) : t('app.updated', { version: result.version ?? 'latest' }))
|
||||
setUpdateInfo(null)
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1500)
|
||||
}
|
||||
else {
|
||||
toast.info(result.message)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
toast.danger(t('app.updateFailed', { message: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
setUpdateApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderTab = (id: TabId, node: ReactNode) => (
|
||||
<TabPane active={currentTab === id}>
|
||||
{mounted.has(id) ? node : null}
|
||||
</TabPane>
|
||||
)
|
||||
|
||||
// #165: when the SPA never reached the backend, show an informative setup
|
||||
// screen instead of an empty, non-working dashboard.
|
||||
if (connState === 'error') {
|
||||
return <BackendUnreachable onRetry={() => window.location.reload()} />
|
||||
}
|
||||
|
||||
return (
|
||||
// Keyed on the active language so switching language remounts the content
|
||||
// subtree once. The module-level memo() tabs stay mounted and otherwise keep
|
||||
// stale-language text on a language change (their props don't change), until
|
||||
// an unrelated local state update forces them to re-render (#123).
|
||||
<div key={i18n.language} className="h-screen flex flex-col overflow-hidden bg-background">
|
||||
<Toast.Provider />
|
||||
|
||||
{/* Header */}
|
||||
<header
|
||||
className="flex items-center justify-between px-6 py-3 border-b border-border bg-surface shrink-0"
|
||||
style={{ background: 'linear-gradient(180deg, var(--surface-secondary) 0%, var(--surface) 100%)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xl font-bold uppercase tracking-[0.2em] text-accent px-0 h-auto min-w-0 hover:opacity-80"
|
||||
onPress={() => navigate(`/${DEFAULT_TAB}`)}
|
||||
aria-label={t('app.goHome')}
|
||||
>
|
||||
{t('app.title')}
|
||||
</Button>
|
||||
{status?.control && status.control !== 'none' && <span className="text-xs text-muted">{status.control}</span>}
|
||||
{status?.ssh_host && <span className="text-xs text-muted">{status.ssh_host}</span>}
|
||||
{status?.db_host && status.control !== 'kubectl' && (
|
||||
<span className="text-xs text-muted">{status.db_host}</span>
|
||||
)}
|
||||
{status?.version && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xs text-muted hover:text-foreground px-0 h-auto min-w-0"
|
||||
onPress={() => setShowBackendConfig(true)}
|
||||
aria-label={t('app.openSettings')}
|
||||
>
|
||||
v
|
||||
{status.version}
|
||||
</Button>
|
||||
)}
|
||||
{updateInfo?.needs_update && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateModal(true)}
|
||||
aria-label={t('app.updateAvailable')}
|
||||
className="cursor-pointer border-0 bg-transparent p-0"
|
||||
>
|
||||
<Chip size="sm" color="warning" variant="soft">
|
||||
↑
|
||||
{' '}
|
||||
{updateInfo.latest.replace(/^v/, '')}
|
||||
</Chip>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{status?.executor === 'ssh' && <ConnectionBadge label="SSH" connected={status.ssh_connected} />}
|
||||
<ConnectionBadge label="DB" connected={status?.db_connected ?? false} />
|
||||
{status && !status.db_connected && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={reconnecting}
|
||||
onPress={handleReconnect}
|
||||
>
|
||||
{reconnecting ? t('app.reconnecting') : t('app.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
{status?.pod_ns && (
|
||||
<span className="text-xs text-muted">
|
||||
ns:
|
||||
{status.pod_ns}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<HelpMenu status={status} />
|
||||
<ThemeSelector />
|
||||
<LanguageSelector />
|
||||
<ToggleButtonGroup
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
selectedKeys={[layoutMode]}
|
||||
onSelectionChange={(keys) => {
|
||||
const next = [...keys][0]
|
||||
if (next === 'sidenav' || next === 'topnav') setLayout(next)
|
||||
}}
|
||||
>
|
||||
<ToggleButton id="sidenav" isIconOnly aria-label={t('app.switchToSidenav')}>
|
||||
<Icon name="layout-panel-left" />
|
||||
</ToggleButton>
|
||||
<ToggleButton id="topnav" isIconOnly aria-label={t('app.switchToTopnav')}>
|
||||
<Icon name="layout-panel-top" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={t('app.configureBackend')}
|
||||
onPress={() => setShowBackendConfig((v) => !v)}
|
||||
className={showBackendConfig ? 'text-accent border-accent' : ''}
|
||||
>
|
||||
<Icon name="settings" />
|
||||
{' '}
|
||||
{t('app.settings')}
|
||||
</Button>
|
||||
|
||||
{hasClerk && (
|
||||
<>
|
||||
<Show when="signed-out">
|
||||
<SignInButton>
|
||||
<Button size="sm" variant="outline">
|
||||
{t('app.signIn')}
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<UserButton />
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Settings modal — structure mirrors BotControlPanel */}
|
||||
<Modal>
|
||||
<Modal.Backdrop isOpen={showBackendConfig} onOpenChange={(v) => !v && setShowBackendConfig(false)}>
|
||||
<Modal.Container size="cover" scroll="outside">
|
||||
<Modal.Dialog className="h-[92vh] flex flex-col">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<div className="flex items-baseline gap-6 flex-wrap">
|
||||
<Modal.Heading className="text-accent">{t('app.settings')}</Modal.Heading>
|
||||
{status && (
|
||||
<div className="flex items-center gap-4 text-xs text-muted">
|
||||
{status.version && (
|
||||
<span className="font-mono">
|
||||
v
|
||||
{status.version}
|
||||
</span>
|
||||
)}
|
||||
{status.control && status.control !== 'none' && <span>{status.control}</span>}
|
||||
{status.commit && status.commit !== 'unknown' && (
|
||||
<span className="font-mono opacity-60">{status.commit}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
|
||||
{/* Body scrolls; form fills it with its own internal tab scroll */}
|
||||
<Modal.Body className="flex flex-col overflow-y-auto flex-1 min-h-0 pr-1">
|
||||
{showBackendConfig && (
|
||||
<SettingsConfigForm saveRef={formSaveRef} onSavingChange={setFormSaving} />
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer className="flex items-center gap-2">
|
||||
{/* Left: update controls — fixed positions so buttons don't shift */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={checkUpdate}
|
||||
isDisabled={updateChecking || updateApplying}
|
||||
>
|
||||
{updateChecking
|
||||
? (
|
||||
<>
|
||||
<Spinner size="sm" color="current" />
|
||||
{' '}
|
||||
{t('common.checking')}
|
||||
</>
|
||||
)
|
||||
: t('app.checkUpdates')}
|
||||
</Button>
|
||||
{updateInfo && !updateInfo.needs_update && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => applyUpdate(true)}
|
||||
isDisabled={updateApplying}
|
||||
>
|
||||
{updateApplying ? <Spinner size="sm" color="current" /> : t('app.reinstall')}
|
||||
</Button>
|
||||
)}
|
||||
{updateInfo?.needs_update && (
|
||||
<Button size="sm" onPress={() => applyUpdate()} isDisabled={updateApplying}>
|
||||
{updateApplying
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<span className="font-mono text-xs">
|
||||
v
|
||||
{updateInfo.current}
|
||||
{' → '}
|
||||
v
|
||||
{updateInfo.latest.replace(/^v/, '')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<span className="flex-1" />
|
||||
|
||||
{/* Right: save + close */}
|
||||
<span className="text-xs text-muted">{t('app.changesNote')}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => formSaveRef.current?.()}
|
||||
isDisabled={formSaving}
|
||||
>
|
||||
{formSaving
|
||||
? (
|
||||
<>
|
||||
<Spinner size="sm" color="current" />
|
||||
{' '}
|
||||
{t('common.saving')}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Icon name="save" />
|
||||
{' '}
|
||||
{t('app.saveApply')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => setShowBackendConfig(false)}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
|
||||
{/* Update-available prompt — opened from the header release widget (#129).
|
||||
Reuses the backend update check for the release-notes link + Continue/Cancel. */}
|
||||
<Modal>
|
||||
<Modal.Backdrop isOpen={showUpdateModal} onOpenChange={(v) => !v && setShowUpdateModal(false)}>
|
||||
<Modal.Container size="sm">
|
||||
<Modal.Dialog>
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading className="text-accent">{t('app.updateAvailable')}</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">
|
||||
{t('app.updateAvailableBody', {
|
||||
current: updateInfo?.current ?? '',
|
||||
latest: updateInfo?.latest?.replace(/^v/, '') ?? '',
|
||||
})}
|
||||
</p>
|
||||
{updateInfo?.release_url && (
|
||||
<a
|
||||
href={updateInfo.release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-accent hover:opacity-80"
|
||||
>
|
||||
<Icon name="external-link" />
|
||||
{' '}
|
||||
{t('app.viewReleaseNotes')}
|
||||
</a>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => setShowUpdateModal(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
setShowUpdateModal(false)
|
||||
void applyUpdate()
|
||||
}}
|
||||
isDisabled={updateApplying}
|
||||
>
|
||||
{updateApplying ? <Spinner size="sm" color="current" /> : t('app.updateNow')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
|
||||
{/* Body: layout-conditional rendering.
|
||||
In sidenav mode: grouped left sidebar + content.
|
||||
In topnav mode: horizontal nav bar + full-width content.
|
||||
All tabs stay mounted (inactive hidden) so per-tab state and isActive
|
||||
auto-refresh behavior persist. */}
|
||||
{(() => {
|
||||
const databaseNode = layoutMode === 'topnav'
|
||||
? <MDatabaseTab section={dbSection} showSubnav onSectionChange={setDbSection} />
|
||||
: <MDatabaseTab section={dbSection} />
|
||||
|
||||
const welcomeNode = layoutMode === 'topnav'
|
||||
? <MWelcomePackageTab section={welcomeSection} showSubnav onSectionChange={setWelcomeSection} />
|
||||
: <MWelcomePackageTab section={welcomeSection} />
|
||||
|
||||
if (layoutMode === 'sidenav') {
|
||||
return (
|
||||
<div className="flex-1 flex gap-3 p-3 overflow-hidden min-h-0">
|
||||
<nav className="w-60 shrink-0 flex flex-col gap-2 overflow-y-auto">
|
||||
{/* Operations: rendered separately so Database can expand DB sub-items inline */}
|
||||
<SideNav
|
||||
width="w-full"
|
||||
title={NAV_GROUPS[0].title}
|
||||
items={[
|
||||
...NAV_GROUPS[0].items.slice(0, 3),
|
||||
...(currentTab === 'database' ? DB_SECTIONS : []),
|
||||
...NAV_GROUPS[0].items.slice(3),
|
||||
] as { key: string, label: string, depth?: number }[]}
|
||||
active={currentTab === 'database' ? `db:${dbSection}` : currentTab}
|
||||
onSelect={(k: string) => {
|
||||
if (k.startsWith('db:')) {
|
||||
setDbSection(k.slice(3) as DbSection)
|
||||
if (currentTab !== 'database') navigate('/database')
|
||||
}
|
||||
else {
|
||||
navigate(`/${k}`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Player World: unchanged */}
|
||||
<SideNav
|
||||
key={NAV_GROUPS[1].title}
|
||||
width="w-full"
|
||||
title={NAV_GROUPS[1].title}
|
||||
items={NAV_GROUPS[1].items}
|
||||
active={currentTab}
|
||||
onSelect={(k) => navigate(`/${k}`)}
|
||||
/>
|
||||
{/* Economy: expand Welcome sub-items inline */}
|
||||
<SideNav
|
||||
width="w-full"
|
||||
title={NAV_GROUPS[2].title}
|
||||
items={[
|
||||
...NAV_GROUPS[2].items,
|
||||
...(currentTab === 'welcome' ? WELCOME_SECTIONS : []),
|
||||
] as { key: string, label: string, depth?: number }[]}
|
||||
active={currentTab === 'welcome' ? `welcome:${welcomeSection}` : currentTab}
|
||||
onSelect={(k: string) => {
|
||||
if (k.startsWith('welcome:')) {
|
||||
setWelcomeSection(k.slice(8) as WelcomeSection)
|
||||
if (currentTab !== 'welcome') navigate('/welcome')
|
||||
}
|
||||
else {
|
||||
navigate(`/${k}`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</nav>
|
||||
<main className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{renderTab('battlegroup', <MBattlegroupTab isActive={currentTab === 'battlegroup'} />)}
|
||||
{renderTab('players', <MPlayersTab isActive={currentTab === 'players'} />)}
|
||||
{renderTab('database', databaseNode)}
|
||||
{renderTab('logs', <MLogsTab control={status?.control} />)}
|
||||
{renderTab('blueprints', <MBlueprintsTab isSignedIn={isSignedIn} />)}
|
||||
{renderTab('bases', <MBasesTab isSignedIn={isSignedIn} />)}
|
||||
{renderTab('guilds', <MGuildsTab isSignedIn={isSignedIn} />)}
|
||||
{renderTab('landsraad', <MLandsraadTab />)}
|
||||
{renderTab('storage', <MStorageTab />)}
|
||||
{renderTab('livemap', <MLiveMapTab isActive={currentTab === 'livemap'} />)}
|
||||
{renderTab('server', <MServerSettingsTab />)}
|
||||
{renderTab('director', <MDirectorTab />)}
|
||||
{renderTab('market', <MMarketTab />)}
|
||||
{renderTab('welcome', welcomeNode)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// topnav mode: horizontal nav bar + full-width content area
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
selectedKey={currentTab}
|
||||
onSelectionChange={(k) => navigate(`/${String(k)}`)}
|
||||
className="shrink-0 border-b border-border bg-surface"
|
||||
>
|
||||
<Tabs.ListContainer className="px-3 py-2 overflow-x-auto">
|
||||
<Tabs.List aria-label={t('app.title')}>
|
||||
{NAV_GROUPS.flatMap((g) => g.items).map((item) => (
|
||||
<Tabs.Tab key={item.key} id={item.key}>
|
||||
{item.label}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
</Tabs>
|
||||
<div className="flex-1 flex flex-col p-3 overflow-hidden min-h-0">
|
||||
<main className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{renderTab('battlegroup', <MBattlegroupTab isActive={currentTab === 'battlegroup'} />)}
|
||||
{renderTab('players', <MPlayersTab isActive={currentTab === 'players'} />)}
|
||||
{renderTab('database', databaseNode)}
|
||||
{renderTab('logs', <MLogsTab control={status?.control} />)}
|
||||
{renderTab('blueprints', <MBlueprintsTab isSignedIn={isSignedIn} />)}
|
||||
{renderTab('bases', <MBasesTab isSignedIn={isSignedIn} />)}
|
||||
{renderTab('guilds', <MGuildsTab isSignedIn={isSignedIn} />)}
|
||||
{renderTab('landsraad', <MLandsraadTab />)}
|
||||
{renderTab('storage', <MStorageTab />)}
|
||||
{renderTab('livemap', <MLiveMapTab isActive={currentTab === 'livemap'} />)}
|
||||
{renderTab('server', <MServerSettingsTab />)}
|
||||
{renderTab('director', <MDirectorTab />)}
|
||||
{renderTab('market', <MMarketTab />)}
|
||||
{renderTab('welcome', welcomeNode)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabPane({ active, children }: TabPaneProps) {
|
||||
return (
|
||||
<div className={`flex-1 min-h-0 ${active ? 'flex flex-col dune-tab-active' : 'hidden'}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionBadge({ label, connected }: ConnectionBadgeProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-success' : 'bg-muted/40'}`} />
|
||||
<span className={connected ? 'text-foreground' : 'text-muted'}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1092
docs/reference-repos/icehunter/web/src/api/client.ts
Normal file
1092
docs/reference-repos/icehunter/web/src/api/client.ts
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/reference-repos/icehunter/web/src/assets/hero.png
Normal file
BIN
docs/reference-repos/icehunter/web/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
docs/reference-repos/icehunter/web/src/assets/react.svg
Normal file
1
docs/reference-repos/icehunter/web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
docs/reference-repos/icehunter/web/src/assets/vite.svg
Normal file
1
docs/reference-repos/icehunter/web/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,35 @@
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@heroui/react'
|
||||
import { Icon, Panel } from '../dune-ui'
|
||||
import { currentBackendBase } from '../api/client'
|
||||
|
||||
// BackendUnreachable is shown when the SPA loaded but could never reach the
|
||||
// dune-admin backend API (#165) — instead of an empty, non-working dashboard.
|
||||
// It surfaces the backend target it's trying and how to fix a connection issue.
|
||||
export const BackendUnreachable: React.FC<{ onRetry: () => void }> = ({ onRetry }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background p-6">
|
||||
<Panel className="max-w-lg w-full flex flex-col items-center gap-4 py-8 text-center">
|
||||
<Icon name="triangle-alert" className="text-warning" />
|
||||
<h1 className="text-lg font-semibold text-foreground">{t('app.backendUnreachable.title')}</h1>
|
||||
<p className="text-sm text-muted">{t('app.backendUnreachable.body')}</p>
|
||||
<div className="text-xs text-muted flex flex-col items-center gap-0.5">
|
||||
<span>{t('app.backendUnreachable.targetLabel')}</span>
|
||||
<span className="font-mono text-foreground break-all">{currentBackendBase()}</span>
|
||||
</div>
|
||||
<ul className="text-sm text-muted text-left list-disc pl-5 space-y-1">
|
||||
<li>{t('app.backendUnreachable.hint1')}</li>
|
||||
<li>{t('app.backendUnreachable.hint2')}</li>
|
||||
<li>{t('app.backendUnreachable.hint3')}</li>
|
||||
</ul>
|
||||
<Button size="sm" onPress={onRetry}>
|
||||
<Icon name="refresh-cw" />
|
||||
{' '}
|
||||
{t('app.backendUnreachable.retry')}
|
||||
</Button>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type React from 'react'
|
||||
import { Dropdown, Button, toast } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Icon } from '../dune-ui'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import type { Status } from '../api/client'
|
||||
|
||||
const REPO = 'https://github.com/Icehunter/dune-admin'
|
||||
|
||||
function buildDiagnostics(status?: Status | null): string {
|
||||
return [
|
||||
`- dune-admin version: ${status?.version ?? 'unknown'}`,
|
||||
`- commit: ${status?.commit ?? 'unknown'}`,
|
||||
`- build: ${status?.build_time ?? 'unknown'}`,
|
||||
`- control plane: ${status?.control ?? 'unknown'}`,
|
||||
`- executor: ${status?.executor ?? 'unknown'}`,
|
||||
`- db connected: ${status?.db_connected ?? false}`,
|
||||
`- ssh connected: ${status?.ssh_connected ?? false}`,
|
||||
`- browser: ${typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export const HelpMenu: React.FC<{ status?: Status | null }> = ({ status }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const reportIssue = () => {
|
||||
const body = `## Describe the issue\n\n<!-- What happened? What did you expect? Steps to reproduce. -->\n\n## Environment (auto-filled by dune-admin)\n${buildDiagnostics(status)}\n`
|
||||
window.open(`${REPO}/issues/new?body=${encodeURIComponent(body)}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
const copyDiagnostics = () => {
|
||||
copyText(buildDiagnostics(status)).then((ok) => {
|
||||
if (ok) toast.success(t('help.copied'))
|
||||
else toast.danger(t('help.copyFailed'))
|
||||
})
|
||||
}
|
||||
const openRepo = () => {
|
||||
window.open(REPO, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ id: 'report', icon: 'bug', label: t('help.reportIssue'), action: reportIssue },
|
||||
{ id: 'copy', icon: 'clipboard', label: t('help.copyDiagnostics'), action: copyDiagnostics },
|
||||
{ id: 'repo', icon: 'github', label: t('help.viewOnGitHub'), action: openRepo },
|
||||
]
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('help.menu')}
|
||||
className="w-8 h-8 min-w-0 text-muted data-[hover=true]:text-foreground data-[hover=true]:bg-surface-secondary"
|
||||
>
|
||||
<Icon name="circle-help" />
|
||||
</Button>
|
||||
<Dropdown.Popover>
|
||||
<Dropdown.Menu
|
||||
aria-label={t('help.menu')}
|
||||
onAction={(key) => items.find((i) => i.id === String(key))?.action()}
|
||||
>
|
||||
{items.map((it) => (
|
||||
<Dropdown.Item key={it.id} id={it.id} textValue={it.label}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon name={it.icon} className="w-4 h-4 text-muted" />
|
||||
<span>{it.label}</span>
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Dropdown, Button } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LANGUAGES, setLocale, LOCALE_KEY, DEFAULT_LOCALE } from '../i18n'
|
||||
|
||||
export const LanguageSelector: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [current, setCurrent] = useState(
|
||||
localStorage.getItem(LOCALE_KEY) ?? DEFAULT_LOCALE,
|
||||
)
|
||||
|
||||
const selected = LANGUAGES.find((l) => l.code === current) ?? LANGUAGES[0]
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('app.selectLanguage')}
|
||||
className="w-8 h-8 min-w-0 text-base data-[hover=true]:bg-surface-secondary"
|
||||
>
|
||||
{selected.flag}
|
||||
</Button>
|
||||
<Dropdown.Popover>
|
||||
<Dropdown.Menu
|
||||
aria-label={t('app.selectLanguage')}
|
||||
selectionMode="single"
|
||||
selectedKeys={new Set([current])}
|
||||
onSelectionChange={(keys) => {
|
||||
if (keys === 'all') return
|
||||
const code = [...keys][0] as string
|
||||
if (code) {
|
||||
setLocale(code)
|
||||
setCurrent(code)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<Dropdown.Item key={lang.code} id={lang.code} textValue={lang.label}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Spinner, Switch, ToggleButton, ToggleButtonGroup, toast } from '@heroui/react'
|
||||
import { api } from '../api/client'
|
||||
import type { ScheduledRestarts, RestartRule } from '../api/client'
|
||||
import { Panel, SectionLabel, Icon, NumberInput, TimeInput } from '../dune-ui'
|
||||
import { TimezoneSelect } from './TimezoneSelect'
|
||||
|
||||
const DOW = [0, 1, 2, 3, 4, 5, 6] // Sun..Sat
|
||||
|
||||
// ScheduledRestartsCard (#145): configure weekday+time auto-restarts with a
|
||||
// native in-game countdown warning. Designed as a card to drop into the Server
|
||||
// Health page (#149); lives on the Battlegroup tab until that lands.
|
||||
export const ScheduledRestartsCard: React.FC = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [data, setData] = useState<ScheduledRestarts | null>(null)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [timezone, setTimezone] = useState('')
|
||||
const [warn, setWarn] = useState(10)
|
||||
const [rules, setRules] = useState<RestartRule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const apply = (d: ScheduledRestarts) => {
|
||||
setData(d)
|
||||
setEnabled(d.enabled)
|
||||
setTimezone(d.timezone)
|
||||
setWarn(d.warn_minutes || 10)
|
||||
setRules(d.rules ?? [])
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.scheduledRestarts.get())
|
||||
.then(apply)
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('restarts.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const save = () => {
|
||||
setSaving(true)
|
||||
api.scheduledRestarts.update({ enabled, timezone, rules, warn_minutes: warn })
|
||||
.then((res) => {
|
||||
toast.success(res.ok)
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('restarts.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const skip = () => {
|
||||
api.scheduledRestarts.skipNext()
|
||||
.then((res) => {
|
||||
toast.success(res.ok)
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('restarts.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
}
|
||||
|
||||
const addRule = () => setRules((r) => [...r, { days: [...DOW], time: '04:00' }])
|
||||
const removeRule = (i: number) => setRules((r) => r.filter((_, idx) => idx !== i))
|
||||
const setRuleTime = (i: number, time: string) =>
|
||||
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, time } : rule)))
|
||||
const setRuleDays = (i: number, days: number[]) =>
|
||||
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, days } : rule)))
|
||||
|
||||
// Localized short weekday label (Jan 1 2023 was a Sunday = day 0).
|
||||
const dowLabel = (d: number) =>
|
||||
new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }).format(new Date(Date.UTC(2023, 0, 1 + d)))
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<SectionLabel>{t('restarts.title')}</SectionLabel>
|
||||
<Switch isSelected={enabled} onChange={setEnabled} size="sm" className="text-xs text-muted">
|
||||
<Switch.Control><Switch.Thumb /></Switch.Control>
|
||||
<Switch.Content>{t('restarts.enable')}</Switch.Content>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <div className="py-4 flex justify-center"><Spinner size="sm" color="current" /></div>
|
||||
: (
|
||||
<>
|
||||
<div className="text-sm mb-3">
|
||||
{enabled && data?.next_restart
|
||||
? (
|
||||
<span className="text-success">
|
||||
{t('restarts.nextRestart', { when: new Date(data.next_restart).toLocaleString() })}
|
||||
</span>
|
||||
)
|
||||
: <span className="text-muted">{t('restarts.noneScheduled')}</span>}
|
||||
</div>
|
||||
|
||||
{rules.length === 0 && <div className="text-xs text-muted mb-2">{t('restarts.noRules')}</div>}
|
||||
{rules.map((rule, i) => (
|
||||
<div key={i} className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<ToggleButtonGroup
|
||||
selectionMode="multiple"
|
||||
selectedKeys={rule.days.map(String)}
|
||||
onSelectionChange={(keys) => {
|
||||
const days = [...keys].map(Number).sort((a, b) => a - b)
|
||||
setRuleDays(i, days)
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{DOW.map((d) => (
|
||||
<ToggleButton key={d} id={String(d)}>{dowLabel(d)}</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
<TimeInput value={rule.time} onChange={(v) => setRuleTime(i, v)} ariaLabel="time" />
|
||||
<Button size="sm" variant="ghost" isIconOnly aria-label={t('restarts.removeRule')} onPress={() => removeRule(i)}>
|
||||
<Icon name="x" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button size="sm" variant="outline" className="mb-3" onPress={addRule}>
|
||||
<Icon name="plus" />
|
||||
{' '}
|
||||
{t('restarts.addRule')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3 text-sm flex-wrap">
|
||||
<label className="flex items-center gap-2">
|
||||
{t('restarts.warnMinutes')}
|
||||
<NumberInput
|
||||
value={warn}
|
||||
onChange={(v) => setWarn(v || 10)}
|
||||
min={1}
|
||||
ariaLabel={t('restarts.warnMinutes')}
|
||||
className="w-16"
|
||||
showButtons={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 flex-1 min-w-[160px]">
|
||||
{t('restarts.timezone')}
|
||||
<TimezoneSelect value={timezone} onChange={setTimezone} className="flex-1" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onPress={save} isDisabled={saving}>
|
||||
{saving ? <Spinner size="sm" color="current" /> : t('restarts.save')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onPress={skip} isDisabled={!enabled || !data?.next_restart}>
|
||||
{t('restarts.skipNext')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import type React from 'react'
|
||||
import { useState, useEffect, type MutableRefObject } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Input, Select, ListBox, Spinner, Switch, Tabs, toast } from '@heroui/react'
|
||||
import { api, MASKED } from '../api/client'
|
||||
import type { AppConfig } from '../api/client'
|
||||
import { NumberInput, Panel, SectionLabel } from '../dune-ui'
|
||||
|
||||
// ── defaults (all empty — never show fake values) ─────────────────────────────
|
||||
|
||||
const EMPTY: AppConfig = {
|
||||
control: '',
|
||||
ssh_host: '', ssh_user: '', ssh_key: '',
|
||||
db_host: '', db_port: 0, db_user: '',
|
||||
db_pass: '', db_name: '', db_schema: '',
|
||||
control_namespace: '',
|
||||
docker_gameserver: '', docker_broker_game: '', docker_broker_admin: '', docker_db: '',
|
||||
cmd_start: '', cmd_stop: '', cmd_restart: '', cmd_status: '',
|
||||
broker_game_addr: '', broker_admin_addr: '', broker_tls: false,
|
||||
broker_user: '', broker_pass: '', broker_jwt_secret: '', broker_exec_prefix: '',
|
||||
backup_dir: '', server_ini_dir: '', default_ini_dir: '',
|
||||
amp_instance: '', amp_container: '', amp_user: '', amp_log_path: '',
|
||||
amp_use_container: false, amp_data_root: '',
|
||||
amp_api_user: '', amp_api_pass: '', amp_api_port: 0,
|
||||
director_url: '',
|
||||
market_bot_enabled: false,
|
||||
market_bot_cache_db: '', market_bot_item_data: '', market_bot_state: '',
|
||||
market_bot_buy_interval: '', market_bot_list_interval: '',
|
||||
market_bot_buy_threshold: 0, market_bot_max_buys: 0,
|
||||
market_bot_remote_url: '', market_bot_remote_token: '',
|
||||
listen_addr: '', scrip_currency: 0,
|
||||
}
|
||||
|
||||
// Pointer-backed boolean fields in the Go config: null means "use server
|
||||
// default" (effectively true). If the API returns null for these, coerce to
|
||||
// true so the checkbox reflects the real server default rather than silently
|
||||
// inheriting EMPTY's false and overwriting the default-on value on save.
|
||||
const pointerBoolFields = new Set<keyof AppConfig>(['amp_use_container', 'market_bot_enabled'])
|
||||
|
||||
function mergeConfig(fetched: Record<string, unknown>): AppConfig {
|
||||
const result: AppConfig = { ...EMPTY }
|
||||
for (const key of Object.keys(fetched) as (keyof AppConfig)[]) {
|
||||
const v = fetched[key]
|
||||
if (v !== null && v !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(result as any)[key] = v
|
||||
}
|
||||
else if (v === null && pointerBoolFields.has(key)) {
|
||||
// Null pointer-backed bool: the server field is unset (default-on).
|
||||
// Keep the EMPTY default only if it matches server intent (true = default).
|
||||
// Override EMPTY's false with true so the checkbox reflects the real default.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(result as any)[key] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── field primitives matching BotConfigEditor ─────────────────────────────────
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function F({ label, hint, children }: FieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted font-medium">
|
||||
{label}
|
||||
{hint && (
|
||||
<span className="opacity-60 font-normal">
|
||||
{' '}
|
||||
(
|
||||
{hint}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextInputProps {
|
||||
value: string | number
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
function TI({ value, onChange, placeholder, type = 'text' }: TextInputProps) {
|
||||
return (
|
||||
<Input
|
||||
className="font-mono"
|
||||
type={type}
|
||||
value={String(value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder ?? 'value'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
hint?: string
|
||||
}
|
||||
|
||||
function CB({ label, checked, onChange, hint }: CheckboxFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{hint && <p className="text-xs text-muted">{hint}</p>}
|
||||
<div className="flex flex-1 items-center">
|
||||
<Switch isSelected={!!checked} onChange={onChange} size="sm">
|
||||
<Switch.Control><Switch.Thumb /></Switch.Control>
|
||||
<Switch.Content>{label}</Switch.Content>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface GridRowProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function G2({ children }: GridRowProps) {
|
||||
return <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-1">{children}</div>
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SettingsConfigFormProps {
|
||||
saveRef?: MutableRefObject<(() => Promise<void>) | null>
|
||||
onSavingChange?: (saving: boolean) => void
|
||||
}
|
||||
|
||||
export const SettingsConfigForm: React.FC<SettingsConfigFormProps> = ({ saveRef, onSavingChange }) => {
|
||||
const { t } = useTranslation()
|
||||
const [cfg, setCfg] = useState<AppConfig>(EMPTY)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState('connection')
|
||||
const [backendUrl, setBackendUrl] = useState(() => localStorage.getItem('dune_admin_backend') || '')
|
||||
|
||||
useEffect(() => {
|
||||
api.config.get()
|
||||
.then((c) => setCfg(mergeConfig(c as Record<string, unknown>)))
|
||||
.catch((e) => toast.danger(t('settings.loadFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
const set = (key: keyof AppConfig) => (v: string) =>
|
||||
setCfg((prev) => ({
|
||||
...prev,
|
||||
[key]: key === 'db_port' || key === 'scrip_currency' || key === 'market_bot_max_buys' || key === 'amp_api_port'
|
||||
? (Number(v) || 0)
|
||||
: key === 'market_bot_buy_threshold'
|
||||
? (parseFloat(v) || 0)
|
||||
: v,
|
||||
}))
|
||||
|
||||
const setBool = (key: keyof AppConfig) => (v: boolean) =>
|
||||
setCfg((prev) => ({ ...prev, [key]: v }))
|
||||
|
||||
const save = async () => {
|
||||
onSavingChange?.(true)
|
||||
try {
|
||||
await api.config.save(cfg)
|
||||
toast.success(t('settings.configSaved'))
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(t('settings.saveFailed', { message: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
onSavingChange?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose save to the parent footer button only after config has loaded.
|
||||
// Clear the ref on unmount so a stale closure from a previous modal open
|
||||
// cannot fire after the form has been removed from the tree.
|
||||
useEffect(() => {
|
||||
if (saveRef && !loading) {
|
||||
saveRef.current = save
|
||||
return () => {
|
||||
saveRef.current = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-1 gap-2 text-muted">
|
||||
<Spinner size="sm" color="current" />
|
||||
<span className="text-sm">{t('settings.loadingConfig')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isKubectl = cfg.control === 'kubectl'
|
||||
const isDocker = cfg.control === 'docker'
|
||||
const isLocal = cfg.control === 'local'
|
||||
const isAmp = cfg.control === 'amp'
|
||||
|
||||
return (
|
||||
// Outer flex col: tabs + single save bar below
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-0">
|
||||
<Tabs
|
||||
selectedKey={tab}
|
||||
onSelectionChange={(k) => setTab(String(k))}
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
{/* Tab bar — never scrolls */}
|
||||
<Tabs.ListContainer className="shrink-0">
|
||||
<Tabs.List aria-label="Config sections" className="gap-1">
|
||||
<Tabs.Tab id="connection">
|
||||
{t('settings.tabs.connection')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="control">
|
||||
{t('settings.tabs.control')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="broker">
|
||||
{t('settings.tabs.broker')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="advanced">
|
||||
{t('settings.tabs.advanced')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
|
||||
{/* ── Connection ─────────────────────────────────────────────────── */}
|
||||
<Tabs.Panel id="connection" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.database')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.db.host')} hint={t('settings.db.hostHint')}>
|
||||
<TI value={cfg.db_host} onChange={set('db_host')} placeholder="127.0.0.1" />
|
||||
</F>
|
||||
<F label={t('settings.db.port')}>
|
||||
<NumberInput
|
||||
ariaLabel={t('settings.db.port')}
|
||||
value={Number(cfg.db_port) || 0}
|
||||
onChange={(v) => set('db_port')(String(v))}
|
||||
showButtons={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</F>
|
||||
<F label={t('settings.db.user')}>
|
||||
<TI value={cfg.db_user} onChange={set('db_user')} placeholder="dune" />
|
||||
</F>
|
||||
<F label={t('settings.db.password')} hint={t('settings.db.passwordHint')}>
|
||||
<TI value={cfg.db_pass} onChange={set('db_pass')} type="password" placeholder={MASKED} />
|
||||
</F>
|
||||
<F label={t('settings.db.name')}>
|
||||
<TI value={cfg.db_name} onChange={set('db_name')} placeholder="dune" />
|
||||
</F>
|
||||
<F label={t('settings.db.schema')}>
|
||||
<TI value={cfg.db_schema} onChange={set('db_schema')} placeholder="dune" />
|
||||
</F>
|
||||
</G2>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.ssh')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.ssh.hostPort')} hint={t('settings.ssh.hostPortHint')}>
|
||||
<TI value={cfg.ssh_host} onChange={set('ssh_host')} placeholder="192.168.0.72:22" />
|
||||
</F>
|
||||
<F label={t('settings.ssh.user')}>
|
||||
<TI value={cfg.ssh_user} onChange={set('ssh_user')} placeholder="dune" />
|
||||
</F>
|
||||
<F label={t('settings.ssh.privateKey')} hint={t('settings.ssh.privateKeyHint')}>
|
||||
<TI value={cfg.ssh_key} onChange={set('ssh_key')} placeholder="~/.ssh/id_ed25519" />
|
||||
</F>
|
||||
</G2>
|
||||
</Panel>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Control ────────────────────────────────────────────────────── */}
|
||||
<Tabs.Panel id="control" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.controlPlane')}</SectionLabel>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<Select
|
||||
selectedKey={cfg.control || 'local'}
|
||||
onSelectionChange={(k) => setCfg((prev) => ({ ...prev, control: String(k) }))}
|
||||
className="w-64"
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
<ListBox.Item id="kubectl" textValue="kubectl">
|
||||
{t('settings.control.kubectl')}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
<ListBox.Item id="docker" textValue="docker">
|
||||
{t('settings.control.docker')}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
<ListBox.Item id="local" textValue="local">
|
||||
{t('settings.control.local')}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
<ListBox.Item id="amp" textValue="amp">
|
||||
{t('settings.control.amp')}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
<p className="text-xs text-muted">{t('settings.control.modeHint')}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{isKubectl && (
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.kubernetes')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.k8s.namespace')} hint={t('settings.k8s.namespaceHint')}>
|
||||
<TI value={cfg.control_namespace} onChange={set('control_namespace')} placeholder="my-namespace" />
|
||||
</F>
|
||||
</G2>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{isDocker && (
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.dockerContainers')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.docker.gameServer')}><TI value={cfg.docker_gameserver} onChange={set('docker_gameserver')} placeholder="dune-gameserver" /></F>
|
||||
<F label={t('settings.docker.brokerGame')}><TI value={cfg.docker_broker_game} onChange={set('docker_broker_game')} placeholder="dune-mq-game" /></F>
|
||||
<F label={t('settings.docker.brokerAdmin')}><TI value={cfg.docker_broker_admin} onChange={set('docker_broker_admin')} placeholder="dune-mq-admin" /></F>
|
||||
<F label={t('settings.docker.database')}><TI value={cfg.docker_db} onChange={set('docker_db')} placeholder="dune-postgres" /></F>
|
||||
</G2>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{isLocal && (
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.serverCommands')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.cmd.start')}><TI value={cfg.cmd_start} onChange={set('cmd_start')} placeholder="service dune start" /></F>
|
||||
<F label={t('settings.cmd.stop')}><TI value={cfg.cmd_stop} onChange={set('cmd_stop')} placeholder="service dune stop" /></F>
|
||||
<F label={t('settings.cmd.restart')}><TI value={cfg.cmd_restart} onChange={set('cmd_restart')} placeholder="service dune restart" /></F>
|
||||
<F label={t('settings.cmd.status')}><TI value={cfg.cmd_status} onChange={set('cmd_status')} placeholder="service dune status" /></F>
|
||||
</G2>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{isAmp && (
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.amp')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.amp.instanceName')}><TI value={cfg.amp_instance} onChange={set('amp_instance')} placeholder="DuneAwakening01" /></F>
|
||||
<F label={t('settings.amp.containerName')} hint={t('settings.amp.containerNameHint')}><TI value={cfg.amp_container} onChange={set('amp_container')} placeholder="AMP_DuneAwakening01" /></F>
|
||||
<F label={t('settings.amp.user')}><TI value={cfg.amp_user} onChange={set('amp_user')} placeholder="amp" /></F>
|
||||
<F label={t('settings.amp.logPath')}><TI value={cfg.amp_log_path} onChange={set('amp_log_path')} placeholder="/logs" /></F>
|
||||
<F label={t('settings.amp.dataRoot')}><TI value={cfg.amp_data_root} onChange={set('amp_data_root')} placeholder="/AMP/duneawakening" /></F>
|
||||
<CB
|
||||
label={t('settings.amp.useContainer')}
|
||||
checked={cfg.amp_use_container}
|
||||
onChange={setBool('amp_use_container')}
|
||||
hint={t('settings.amp.useContainerHint')}
|
||||
/>
|
||||
</G2>
|
||||
<p className="text-xs text-muted mt-3">{t('settings.amp.apiHint')}</p>
|
||||
<G2>
|
||||
<F label={t('settings.amp.apiUser')}><TI value={cfg.amp_api_user} onChange={set('amp_api_user')} placeholder="admin" /></F>
|
||||
<F label={t('settings.amp.apiPassword')}><TI value={cfg.amp_api_pass} onChange={set('amp_api_pass')} type="password" placeholder={MASKED} /></F>
|
||||
<F label={t('settings.amp.apiPort')} hint="default 8081">
|
||||
<NumberInput
|
||||
ariaLabel={t('settings.amp.apiPort')}
|
||||
value={Number(cfg.amp_api_port) || 0}
|
||||
onChange={(v) => set('amp_api_port')(String(v))}
|
||||
showButtons={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</F>
|
||||
</G2>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!isKubectl && !isDocker && !isLocal && !isAmp && (
|
||||
<p className="text-xs text-muted pt-2">{t('settings.control.selectMode')}</p>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Broker ─────────────────────────────────────────────────────── */}
|
||||
<Tabs.Panel id="broker" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.rabbitmq')}</SectionLabel>
|
||||
<p className="text-xs text-muted -mt-1">{t('settings.broker.optionalHint')}</p>
|
||||
<G2>
|
||||
<F label={t('settings.broker.gameAddr')}><TI value={cfg.broker_game_addr} onChange={set('broker_game_addr')} placeholder="10.x.x.x:5672" /></F>
|
||||
<F label={t('settings.broker.adminAddr')}><TI value={cfg.broker_admin_addr} onChange={set('broker_admin_addr')} placeholder="10.x.x.x:5672" /></F>
|
||||
<F label={t('settings.broker.user')}><TI value={cfg.broker_user} onChange={set('broker_user')} placeholder="dune_cap" /></F>
|
||||
<F label={t('settings.broker.password')}><TI value={cfg.broker_pass} onChange={set('broker_pass')} type="password" placeholder={MASKED} /></F>
|
||||
<F label={t('settings.broker.jwtSecret')} hint={t('settings.broker.jwtSecretHint')}>
|
||||
<TI value={cfg.broker_jwt_secret} onChange={set('broker_jwt_secret')} type="password" placeholder={MASKED} />
|
||||
</F>
|
||||
<F label={t('settings.broker.execPrefix')} hint={t('settings.broker.execPrefixHint')}>
|
||||
<TI value={cfg.broker_exec_prefix} onChange={set('broker_exec_prefix')} placeholder="podman exec <container>" />
|
||||
</F>
|
||||
<div className="sm:col-span-2">
|
||||
<CB label={t('settings.broker.useTls')} checked={cfg.broker_tls} onChange={setBool('broker_tls')} />
|
||||
</div>
|
||||
</G2>
|
||||
</Panel>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Advanced ───────────────────────────────────────────────────── */}
|
||||
<Tabs.Panel id="advanced" className="pt-4 overflow-y-auto flex-1 pr-1 flex flex-col gap-4">
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.server')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.adv.listenAddr')} hint={t('settings.adv.listenAddrHint')}>
|
||||
<TI value={cfg.listen_addr} onChange={set('listen_addr')} placeholder=":8080" />
|
||||
</F>
|
||||
<F label={t('settings.adv.scripCurrency')}>
|
||||
<NumberInput
|
||||
ariaLabel={t('settings.adv.scripCurrency')}
|
||||
value={Number(cfg.scrip_currency) || 0}
|
||||
onChange={(v) => set('scrip_currency')(String(v))}
|
||||
showButtons={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</F>
|
||||
<F label={t('settings.adv.directorUrl')} hint={t('settings.adv.directorUrlHint')}>
|
||||
<TI value={cfg.director_url} onChange={set('director_url')} placeholder="http://127.0.0.1:11717" />
|
||||
</F>
|
||||
</G2>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.paths')}</SectionLabel>
|
||||
<G2>
|
||||
<F label={t('settings.adv.backupDir')}>
|
||||
<TI value={cfg.backup_dir} onChange={set('backup_dir')} placeholder="/path/to/backups" />
|
||||
</F>
|
||||
<F label={t('settings.adv.serverIniDir')} hint={t('settings.adv.serverIniDirHint')}>
|
||||
<TI value={cfg.server_ini_dir} onChange={set('server_ini_dir')} placeholder="/path/to/server/state" />
|
||||
</F>
|
||||
<F label={t('settings.adv.defaultIniDir')} hint={t('settings.adv.defaultIniDirHint')}>
|
||||
<TI value={cfg.default_ini_dir} onChange={set('default_ini_dir')} placeholder="/path/to/game/Config" />
|
||||
</F>
|
||||
</G2>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('settings.sections.backendUrlOverride')}</SectionLabel>
|
||||
<p className="text-xs text-muted -mt-1">
|
||||
{t('settings.adv.backendUrlHint')}
|
||||
</p>
|
||||
<G2>
|
||||
<F label={t('settings.adv.url')} hint={t('settings.adv.urlHint')}>
|
||||
<TI
|
||||
value={backendUrl}
|
||||
onChange={(v) => {
|
||||
setBackendUrl(v)
|
||||
localStorage.setItem('dune_admin_backend', v)
|
||||
}}
|
||||
placeholder="http://host:port"
|
||||
/>
|
||||
</F>
|
||||
</G2>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button size="sm" onPress={() => window.location.reload()}>{t('settings.adv.applyReload')}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
setBackendUrl('')
|
||||
localStorage.removeItem('dune_admin_backend')
|
||||
window.location.reload()
|
||||
}}
|
||||
>
|
||||
{t('settings.adv.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type React from 'react'
|
||||
import type { SortDir } from '../hooks/useTableSort'
|
||||
|
||||
interface SortIndicatorProps {
|
||||
active: boolean
|
||||
dir: SortDir
|
||||
}
|
||||
|
||||
export const SortIndicator: React.FC<SortIndicatorProps> = ({ active, dir }) => {
|
||||
return (
|
||||
<span style={{ marginLeft: 4, opacity: active ? 1 : 0.25 }}>
|
||||
{active ? (dir === 'asc' ? '▲' : '▼') : '▲'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Dropdown, Button } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Icon } from '../dune-ui'
|
||||
import { THEMES, applyTheme, loadTheme, type ThemeId } from '../theme'
|
||||
|
||||
export const ThemeSelector: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [current, setCurrent] = useState<ThemeId>(loadTheme)
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('app.selectTheme')}
|
||||
className="w-8 h-8 min-w-0 text-muted data-[hover=true]:text-foreground data-[hover=true]:bg-surface-secondary"
|
||||
>
|
||||
<Icon name="palette" />
|
||||
</Button>
|
||||
<Dropdown.Popover>
|
||||
<Dropdown.Menu
|
||||
aria-label={t('app.selectTheme')}
|
||||
selectionMode="single"
|
||||
selectedKeys={new Set([current])}
|
||||
onSelectionChange={(keys) => {
|
||||
if (keys === 'all') return
|
||||
const id = [...keys][0] as ThemeId
|
||||
if (id) {
|
||||
applyTheme(id)
|
||||
setCurrent(id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
<Dropdown.Item key={th.id} id={th.id} textValue={th.label}>
|
||||
<span className="flex items-center gap-2">
|
||||
{/* Literal color sample — intentional, not a themed element */}
|
||||
<span className="w-3 h-3 rounded-full border border-border shrink-0" style={{ background: th.swatch }} />
|
||||
<span>{th.label}</span>
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type React from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { SearchField } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// IANA timezone names from the browser when available (Chrome 99+/modern), with
|
||||
// a small fallback for older runtimes. Computed once at module load.
|
||||
function tzList(): string[] {
|
||||
const fn = (Intl as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf
|
||||
try {
|
||||
if (typeof fn === 'function') return fn('timeZone')
|
||||
}
|
||||
catch { /* fall through to fallback */ }
|
||||
return [
|
||||
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||
'America/Sao_Paulo', 'Europe/London', 'Europe/Berlin', 'Europe/Paris', 'Europe/Moscow',
|
||||
'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Sydney',
|
||||
]
|
||||
}
|
||||
|
||||
const ZONES = tzList()
|
||||
const MAX_VISIBLE = 60
|
||||
|
||||
// When closed, displayValue is derived from value prop — no local state needed.
|
||||
// When open, query drives the filter and the SearchField input.
|
||||
export const TimezoneSelect: React.FC<{
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
className?: string
|
||||
}> = ({ value, onChange, className }) => {
|
||||
const { t } = useTranslation()
|
||||
const hostLabel = t('common.tzHostLocal')
|
||||
|
||||
const allOptions = useMemo(
|
||||
() => [{ key: '', label: hostLabel }, ...ZONES.map((z) => ({ key: z, label: z }))],
|
||||
[hostLabel],
|
||||
)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// While closed, show the settled value; while open, show what the user is typing.
|
||||
const displayValue = open ? query : (value === '' ? hostLabel : value)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return allOptions.slice(0, MAX_VISIBLE)
|
||||
return allOptions.filter(({ label }) => label.toLowerCase().includes(q)).slice(0, MAX_VISIBLE)
|
||||
}, [query, allOptions])
|
||||
|
||||
const pick = (key: string) => {
|
||||
onChange(key)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
setQuery(value === '' ? hostLabel : value)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleChange = (v: string) => {
|
||||
setQuery(v)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className ?? ''}`}>
|
||||
<SearchField
|
||||
className="w-full"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
aria-label={t('common.timezone')}
|
||||
>
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input placeholder={hostLabel} />
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 rounded-[var(--radius)] border border-border bg-surface shadow-lg overflow-y-auto max-h-52">
|
||||
{filtered.map(({ key, label }) => (
|
||||
<div
|
||||
key={key || '__host__'}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pick(key)}
|
||||
className={`px-3 py-1.5 text-xs cursor-pointer hover:bg-surface-hover${key === value ? ' text-accent font-medium' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
docs/reference-repos/icehunter/web/src/data/itemData.ts
Normal file
8
docs/reference-repos/icehunter/web/src/data/itemData.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Compatibility shim — re-exports the item-data API from store.ts so existing
|
||||
* import sites (MarketTab/ItemDetail, GiveItemsModal) continue to work without
|
||||
* modification.
|
||||
*/
|
||||
|
||||
export type { ItemEntry, ItemDataFile } from './store'
|
||||
export { cdnBase, getItemData, getItemEntry } from './store'
|
||||
200
docs/reference-repos/icehunter/web/src/data/store.ts
Normal file
200
docs/reference-repos/icehunter/web/src/data/store.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Unified data store — fetches static JSON files from the Go backend first
|
||||
* (enabling local overrides), falls back to the CDN when Go returns 404 or
|
||||
* when running on a pure CDN deploy with no backend reachable.
|
||||
*
|
||||
* All atoms use jotai's implicit default store so cache persists until a hard
|
||||
* refresh. Components use useAtom(loadable(atomRef)); imperative callers use
|
||||
* getDefaultStore().get(atomRef) via the helper functions below.
|
||||
*/
|
||||
|
||||
import { atom, getDefaultStore } from 'jotai'
|
||||
import { loadable } from 'jotai/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import type { Atom } from 'jotai'
|
||||
import { apiBase, isCdnDeploy } from '../api/client'
|
||||
|
||||
// ── CDN base ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the CDN base URL, stripped of trailing slashes. */
|
||||
export const cdnBase = (): string =>
|
||||
((import.meta.env.VITE_CDN_BASE_URL as string) ?? 'https://assets.dune.layout.tools').replace(/\/$/, '')
|
||||
|
||||
// ── Fetch primitive ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches a named data file. On non-CDN deploys the Go backend is tried first
|
||||
* (allows local overrides); a non-ok response or network error causes
|
||||
* transparent fallback to the CDN. Throws only when both sources fail.
|
||||
*/
|
||||
async function fetchDataFile<T>(filename: string): Promise<T> {
|
||||
if (!isCdnDeploy) {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/data/${filename}`)
|
||||
if (res.ok) return res.json() as Promise<T>
|
||||
}
|
||||
catch {
|
||||
// fall through to CDN
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${cdnBase()}/${filename}`)
|
||||
if (!res.ok) throw new Error(`Failed to load ${filename}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ItemEntry = {
|
||||
name?: string
|
||||
category?: string
|
||||
tier?: number
|
||||
rarity?: string
|
||||
is_gradeable?: boolean
|
||||
armor_value?: number
|
||||
mitigation?: Record<string, number>
|
||||
}
|
||||
|
||||
export type ItemDataFile = {
|
||||
default_stack_max?: number
|
||||
default_volume?: number
|
||||
names?: Record<string, string>
|
||||
items: Record<string, ItemEntry>
|
||||
}
|
||||
|
||||
export type QualityData = {
|
||||
weapon_damage: number[]
|
||||
armor: number[]
|
||||
volume: number[]
|
||||
}
|
||||
|
||||
export type SkillModule = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type Vehicle = {
|
||||
id: string
|
||||
label: string
|
||||
actor_class: string
|
||||
templates: string[]
|
||||
}
|
||||
|
||||
export type CheatScript = {
|
||||
name: string
|
||||
danger: boolean
|
||||
}
|
||||
|
||||
export type PacksData = {
|
||||
packs: Record<string, {
|
||||
name: string
|
||||
category: string
|
||||
tier: number
|
||||
items: { template: string, qty: number, quality: number }[]
|
||||
}>
|
||||
}
|
||||
|
||||
// ── Atoms ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const itemDataAtom = atom<Promise<ItemDataFile>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<ItemDataFile>('item-data.json')
|
||||
}
|
||||
catch {
|
||||
return { items: {} }
|
||||
}
|
||||
})
|
||||
|
||||
export const tagsDataAtom = atom<Promise<unknown>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<unknown>('tags-data.json')
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
export const qualityDataAtom = atom<Promise<QualityData>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<QualityData>('quality-data.json')
|
||||
}
|
||||
catch {
|
||||
return { weapon_damage: [], armor: [], volume: [] }
|
||||
}
|
||||
})
|
||||
|
||||
export const gameplayTagsAtom = atom<Promise<string[]>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<string[]>('gameplayTags.json')
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
export const skillModulesAtom = atom<Promise<SkillModule[]>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<SkillModule[]>('skillModules.json')
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
export const vehiclesAtom = atom<Promise<Vehicle[]>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<Vehicle[]>('vehicles.json')
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
export const cheatScriptsAtom = atom<Promise<CheatScript[]>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<CheatScript[]>('cheatScripts.json')
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
export const packsAtom = atom<Promise<PacksData>>(async () => {
|
||||
try {
|
||||
return await fetchDataFile<PacksData>('packs.json')
|
||||
}
|
||||
catch {
|
||||
return { packs: {} }
|
||||
}
|
||||
})
|
||||
|
||||
// ── React hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Wraps a data atom with loadable so components can read it without Suspense. */
|
||||
export function useDataFile<T>(fileAtom: Atom<Promise<T>>): {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const [state] = useAtom(loadable(fileAtom))
|
||||
switch (state.state) {
|
||||
case 'loading':
|
||||
return { data: null, loading: true, error: null }
|
||||
case 'hasError':
|
||||
return { data: null, loading: false, error: String(state.error) }
|
||||
default:
|
||||
return { data: state.data, loading: false, error: null }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Imperative accessors (non-React call sites) ───────────────────────────────
|
||||
|
||||
/** Returns a Promise resolving to the full item data. Shares the jotai cache. */
|
||||
export function getItemData(): Promise<ItemDataFile> {
|
||||
return getDefaultStore().get(itemDataAtom)
|
||||
}
|
||||
|
||||
/** Returns the ItemEntry for the given template ID, or null if not found. */
|
||||
export async function getItemEntry(templateId: string): Promise<ItemEntry | null> {
|
||||
const data = await getItemData()
|
||||
return data.items[templateId] ?? null
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertDialog, Button } from '@heroui/react'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AlertDialog.Backdrop isOpen={open} onOpenChange={(v) => !v && onCancel()}>
|
||||
<AlertDialog.Container size="sm">
|
||||
<AlertDialog.Dialog>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Icon status="danger" />
|
||||
<AlertDialog.Heading>{title}</AlertDialog.Heading>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Body>
|
||||
<p className="text-sm text-muted">{description}</p>
|
||||
</AlertDialog.Body>
|
||||
<AlertDialog.Footer>
|
||||
<Button slot="close" variant="ghost" onPress={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button slot="close" variant="danger-soft" onPress={onConfirm}>{confirmLabel ?? t('common.confirm')}</Button>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Dialog>
|
||||
</AlertDialog.Container>
|
||||
</AlertDialog.Backdrop>
|
||||
)
|
||||
}
|
||||
203
docs/reference-repos/icehunter/web/src/dune-ui/DataTable.tsx
Normal file
203
docs/reference-repos/icehunter/web/src/dune-ui/DataTable.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState, useMemo, type ReactNode } from 'react'
|
||||
import { Skeleton, Table, TableLayout, Virtualizer } from '@heroui/react'
|
||||
import type { SortDescriptor } from '@heroui/react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export type Column<K extends string> = {
|
||||
key: K
|
||||
label: string
|
||||
/** Whether this column is sortable. Defaults to true. */
|
||||
sortable?: boolean
|
||||
/** Marks the row-header column. Typically the first one. */
|
||||
isRowHeader?: boolean
|
||||
/** Fixed column width (px). When omitted, the column takes remaining space. */
|
||||
width?: number
|
||||
/** Minimum width (px). Useful with `width` omitted for the stretchy column. */
|
||||
minWidth?: number
|
||||
}
|
||||
|
||||
type ColumnRenderProps = { sortDirection?: 'ascending' | 'descending' }
|
||||
|
||||
type DataTableProps<T, K extends string> = {
|
||||
/** Accessibility label, required by React Aria. */
|
||||
'aria-label': string
|
||||
'columns': Column<K>[]
|
||||
'rows': T[]
|
||||
/** Stable id extractor for each row. */
|
||||
'rowId': (row: T) => string
|
||||
/** Render the cell content for a given row + column key. */
|
||||
'renderCell': (row: T, key: K) => ReactNode
|
||||
/** Initial sort column + direction. */
|
||||
'initialSort'?: { column: K, direction: 'ascending' | 'descending' }
|
||||
/** Custom value getter for sorting (defaults to renderCell-as-string). */
|
||||
'sortValue'?: (row: T, key: K) => string | number | null | undefined
|
||||
/** Rendered when `rows` is empty. */
|
||||
'emptyState'?: ReactNode
|
||||
/** Shows skeleton rows instead of data while true. */
|
||||
'loading'?: boolean
|
||||
/** Number of skeleton rows to show while loading. Defaults to 5. */
|
||||
'skeletonRows'?: number
|
||||
/** Called when a row is clicked / activated. */
|
||||
'onRowAction'?: (row: T) => void
|
||||
/** Extra classes for the outer Table element. */
|
||||
'className'?: string
|
||||
/**
|
||||
* Opt into HeroUI's TableLayout virtualizer. Set when row count can be
|
||||
* large (>200). Only renders rows in the viewport; massive speedup for
|
||||
* filter typing on large datasets. Requires `rowHeight` to be the actual
|
||||
* rendered row height in px (default 32 matches our compact density).
|
||||
*
|
||||
* NOTE: virtualization requires row type `T` to be an **object** (React
|
||||
* Aria stores items in a WeakMap keyed by the row). Don't enable this if
|
||||
* your rows are primitives (strings/numbers).
|
||||
*/
|
||||
'virtualized'?: boolean
|
||||
'rowHeight'?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Opinionated HeroUI Table wrapper with built-in sort, consistent compact
|
||||
* styling (from global CSS), optional virtualization, and a column-driven
|
||||
* API so callers don't have to type out the Table.* compound tree by hand.
|
||||
*/
|
||||
export const DataTable = <T, K extends string>({
|
||||
'aria-label': ariaLabel,
|
||||
columns,
|
||||
rows,
|
||||
rowId,
|
||||
renderCell,
|
||||
initialSort,
|
||||
sortValue,
|
||||
emptyState,
|
||||
loading = false,
|
||||
skeletonRows = 5,
|
||||
onRowAction,
|
||||
className,
|
||||
virtualized = false,
|
||||
rowHeight = 32,
|
||||
}: DataTableProps<T, K>) => {
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>(
|
||||
initialSort ?? { column: columns[0].key, direction: 'ascending' },
|
||||
)
|
||||
|
||||
// React Aria requires at least one column with isRowHeader=true. If no
|
||||
// caller-supplied column has it, promote the first column.
|
||||
const cols = useMemo<Column<K>[]>(() => {
|
||||
if (columns.some((c) => c.isRowHeader)) return columns
|
||||
return columns.map((c, i) => i === 0 ? { ...c, isRowHeader: true } : c)
|
||||
}, [columns])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const col = sortDescriptor.column as K
|
||||
const dir = sortDescriptor.direction === 'descending' ? -1 : 1
|
||||
const get = sortValue ?? ((row: T, key: K) => String(renderCell(row, key)))
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = get(a, col)
|
||||
const bv = get(b, col)
|
||||
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
|
||||
return String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true }) * dir
|
||||
})
|
||||
}, [rows, sortDescriptor, sortValue, renderCell])
|
||||
|
||||
const tableJSX = (
|
||||
<Table className={`bg-transparent border-0 p-0 ${className ?? ''}`}>
|
||||
<Table.ScrollContainer className="p-0 border border-border/60 rounded-md">
|
||||
<Table.Content
|
||||
aria-label={ariaLabel}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
{...(!loading && onRowAction
|
||||
? {
|
||||
onRowAction: (key) => {
|
||||
const row = sorted.find((r) => rowId(r) === String(key))
|
||||
if (row) onRowAction(row)
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<Table.Header columns={cols}>
|
||||
{(col: Column<K>) => {
|
||||
const sortable = col.sortable !== false && !loading
|
||||
return (
|
||||
<Table.Column
|
||||
id={col.key}
|
||||
allowsSorting={sortable}
|
||||
{...(col.isRowHeader ? { isRowHeader: true } : {})}
|
||||
{...(col.width !== undefined ? { width: col.width } : {})}
|
||||
{...(col.minWidth !== undefined ? { minWidth: col.minWidth } : {})}
|
||||
>
|
||||
{({ sortDirection }: ColumnRenderProps) => (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex-1 truncate">{col.label}</span>
|
||||
{sortable && (
|
||||
<Icon
|
||||
name={
|
||||
sortDirection === 'ascending'
|
||||
? 'chevron-up'
|
||||
: sortDirection === 'descending'
|
||||
? 'chevron-down'
|
||||
: 'chevrons-up-down'
|
||||
}
|
||||
className={'size-3 shrink-0 ' + (sortDirection ? '' : 'opacity-30')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Table.Column>
|
||||
)
|
||||
}}
|
||||
</Table.Header>
|
||||
{loading
|
||||
? (
|
||||
<Table.Body>
|
||||
{Array.from({ length: skeletonRows }, (_, i) => (
|
||||
<Table.Row key={`skeleton-${i}`} id={`skeleton-${i}`}>
|
||||
{cols.map((c) => (
|
||||
<Table.Cell key={c.key}>
|
||||
<Skeleton className="h-3 w-full rounded" />
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
)
|
||||
: virtualized
|
||||
? (
|
||||
<Table.Body
|
||||
items={sorted as unknown as object[]}
|
||||
renderEmptyState={emptyState ? () => <>{emptyState}</> : undefined}
|
||||
>
|
||||
{((row: T) => (
|
||||
<Table.Row id={rowId(row)}>
|
||||
{cols.map((c) => (
|
||||
<Table.Cell key={c.key}>{renderCell(row, c.key)}</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
)) as unknown as (item: object) => ReactNode}
|
||||
</Table.Body>
|
||||
)
|
||||
: (
|
||||
<Table.Body
|
||||
renderEmptyState={emptyState ? () => <>{emptyState}</> : undefined}
|
||||
>
|
||||
{sorted.map((row) => (
|
||||
<Table.Row key={rowId(row)} id={rowId(row)}>
|
||||
{cols.map((c) => (
|
||||
<Table.Cell key={c.key}>{renderCell(row, c.key)}</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
)}
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
)
|
||||
|
||||
if (!virtualized) return tableJSX
|
||||
return (
|
||||
<Virtualizer layout={TableLayout} layoutOptions={{ headingHeight: rowHeight, rowHeight }}>
|
||||
{tableJSX}
|
||||
</Virtualizer>
|
||||
)
|
||||
}
|
||||
115
docs/reference-repos/icehunter/web/src/dune-ui/Dropzone.tsx
Normal file
115
docs/reference-repos/icehunter/web/src/dune-ui/Dropzone.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type React from 'react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Spinner, toast } from '@heroui/react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
type DropzoneProps = {
|
||||
/** Comma-separated list of accepted file extensions, e.g. ".json" or ".backup,.zip". */
|
||||
accept: string
|
||||
/** Called with the chosen file (drag-drop or click-to-pick). */
|
||||
onSelect: (file: File) => void
|
||||
/** Show this file's name + size as a "selected" state inside the dropzone. */
|
||||
file?: File | null
|
||||
/** Override the default prompt text shown when nothing is selected. */
|
||||
prompt?: ReactNode
|
||||
/** Spinner overlay — drive from parent state when an upload is in flight. */
|
||||
uploading?: boolean
|
||||
/** Compact (less vertical padding). */
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-and-drop file picker. Click to open native file dialog; drop a file
|
||||
* to select it. Validates the extension against `accept` and toasts on
|
||||
* mismatch. Used by BlueprintsTab Import and BattlegroupTab Restore.
|
||||
*/
|
||||
export const Dropzone: React.FC<DropzoneProps> = ({
|
||||
accept, onSelect, file, prompt, uploading, compact, className = '',
|
||||
}) => {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const validateAndSelect = (f: File | undefined | null) => {
|
||||
if (!f) return
|
||||
const exts = accept.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean)
|
||||
if (exts.length > 0) {
|
||||
const ok = exts.some((ext) => f.name.toLowerCase().endsWith(ext))
|
||||
if (!ok) {
|
||||
toast.warning(`Drop a ${accept} file`)
|
||||
return
|
||||
}
|
||||
}
|
||||
onSelect(f)
|
||||
}
|
||||
|
||||
const openPicker = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = accept
|
||||
input.onchange = () => validateAndSelect(input.files?.[0])
|
||||
input.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'rounded-[var(--radius)] flex flex-col items-center justify-center gap-2 text-sm cursor-pointer transition-all border-2 border-dashed '
|
||||
+ (compact ? 'py-2 px-3' : 'py-6 px-4') + ' '
|
||||
+ (dragging
|
||||
? 'border-warning bg-warning/10 text-warning'
|
||||
: 'border-border bg-background text-muted hover:border-warning/60 hover:text-warning')
|
||||
+ ' ' + className
|
||||
}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
validateAndSelect(e.dataTransfer.files[0])
|
||||
}}
|
||||
onClick={openPicker}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
openPicker()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{uploading
|
||||
? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="sm" color="current" />
|
||||
{' '}
|
||||
Uploading…
|
||||
</span>
|
||||
)
|
||||
: file
|
||||
? (
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-foreground">
|
||||
<Icon name="file-check" />
|
||||
{' '}
|
||||
<span className="font-mono">{file.name}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
{(file.size / 1024).toFixed(1)}
|
||||
{' '}
|
||||
KB · click to replace
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="upload" />
|
||||
{' '}
|
||||
{prompt ?? `Drop or click to upload a ${accept} file`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type React from 'react'
|
||||
import { Input } from '@heroui/react'
|
||||
|
||||
interface FieldInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
type?: 'text' | 'number' | 'password' | 'email' | 'url'
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const FieldInput: React.FC<FieldInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
className,
|
||||
ariaLabel,
|
||||
isDisabled,
|
||||
}) => (
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
disabled={isDisabled}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import type React from 'react'
|
||||
import { Select, ListBox } from '@heroui/react'
|
||||
|
||||
interface FieldSelectProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
options: string[]
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
// FieldSelect wraps HeroUI Select + ListBox for small, fixed option sets.
|
||||
// For large lists (e.g. 400 IANA timezones), keep native <select> for type-to-search.
|
||||
export const FieldSelect: React.FC<FieldSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
ariaLabel,
|
||||
isDisabled,
|
||||
}) => (
|
||||
<Select
|
||||
selectedKey={value}
|
||||
onSelectionChange={(k) => onChange(String(k))}
|
||||
aria-label={ariaLabel}
|
||||
isDisabled={isDisabled}
|
||||
className={className}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{options.map((opt) => (
|
||||
<ListBox.Item key={opt} id={opt} textValue={opt}>
|
||||
{opt}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
)
|
||||
18
docs/reference-repos/icehunter/web/src/dune-ui/Icon.tsx
Normal file
18
docs/reference-repos/icehunter/web/src/dune-ui/Icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type React from 'react'
|
||||
import { Icon as IconifyIcon } from '@iconify/react'
|
||||
|
||||
type IconProps = {
|
||||
/** Lucide icon name (without the `lucide:` prefix), e.g. "refresh-cw". */
|
||||
name: string
|
||||
/** Optional size class — defaults to `size-4` (1rem square). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around `@iconify/react` that defaults to the lucide icon set
|
||||
* and a sensible inline-text size. Use any lucide icon name from
|
||||
* https://lucide.dev/icons (kebab-case).
|
||||
*/
|
||||
export const Icon: React.FC<IconProps> = ({ name, className = 'size-4' }) => (
|
||||
<IconifyIcon icon={`lucide:${name}`} className={className} />
|
||||
)
|
||||
43
docs/reference-repos/icehunter/web/src/dune-ui/InfoCard.tsx
Normal file
43
docs/reference-repos/icehunter/web/src/dune-ui/InfoCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type CardProps = { children: ReactNode, className?: string }
|
||||
|
||||
type ItemProps = {
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
/** Optional explicit value text color (e.g. phase status color). */
|
||||
valueColor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Bordered, slightly-elevated label/value row card — the "Phase Reconciling
|
||||
* | Database Ready" health row pattern from BattlegroupTab.
|
||||
*/
|
||||
export const InfoCard: React.FC<CardProps> & { Item: React.FC<ItemProps> } = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-6 rounded-[var(--radius)] px-4 py-3 text-sm shrink-0 '
|
||||
+ 'bg-surface border border-border/60 dune-lift '
|
||||
+ className
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InfoCardItem: React.FC<ItemProps> = ({ label, value, valueColor }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted">{label}</span>
|
||||
<span className="font-semibold" style={valueColor ? { color: valueColor } : undefined}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Namespace alias kept for callers using <InfoCard.Item>
|
||||
InfoCard.Item = InfoCardItem
|
||||
@@ -0,0 +1,26 @@
|
||||
import type React from 'react'
|
||||
import { Spinner } from '@heroui/react'
|
||||
|
||||
type LoadingStateProps = {
|
||||
/** Vertical padding size. Defaults to 'lg' (py-12). */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** Fill available height with flex-1 (use inside a flex column). */
|
||||
fill?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PAD: Record<NonNullable<LoadingStateProps['size']>, string> = {
|
||||
sm: 'py-4',
|
||||
md: 'py-8',
|
||||
lg: 'py-12',
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard centered loading spinner. Use for full-tab / full-section loads so
|
||||
* every tab shows the same loading treatment.
|
||||
*/
|
||||
export const LoadingState: React.FC<LoadingStateProps> = ({ size = 'lg', fill = false, className = '' }) => (
|
||||
<div className={`flex justify-center ${PAD[size]} ${fill ? 'flex-1' : ''} ${className}`}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import type React from 'react'
|
||||
import { Label, NumberField } from '@heroui/react'
|
||||
|
||||
interface NumberInputProps {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
label?: string
|
||||
prefix?: string
|
||||
ariaLabel?: string
|
||||
isDisabled?: boolean
|
||||
className?: string
|
||||
showButtons?: boolean
|
||||
formatOptions?: Intl.NumberFormatOptions
|
||||
}
|
||||
|
||||
export const NumberInput: React.FC<NumberInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
label,
|
||||
prefix,
|
||||
ariaLabel,
|
||||
isDisabled,
|
||||
className,
|
||||
showButtons = true,
|
||||
formatOptions,
|
||||
}) => {
|
||||
const field = (
|
||||
<NumberField
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? min ?? 0)}
|
||||
minValue={min}
|
||||
maxValue={max}
|
||||
step={step}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={ariaLabel ?? label ?? prefix}
|
||||
variant="secondary"
|
||||
className={prefix ? 'flex-1 min-w-0' : className}
|
||||
formatOptions={formatOptions}
|
||||
>
|
||||
{label && <Label className="text-xs text-muted">{label}</Label>}
|
||||
<NumberField.Group
|
||||
className="w-full"
|
||||
style={prefix
|
||||
? { width: '100%', display: 'flex', alignItems: 'center', borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeft: 'none' }
|
||||
: { width: '100%', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{showButtons && <NumberField.DecrementButton />}
|
||||
<NumberField.Input className="flex-1" style={{ flexGrow: 1, minWidth: 40 }} />
|
||||
{showButtons && <NumberField.IncrementButton />}
|
||||
</NumberField.Group>
|
||||
</NumberField>
|
||||
)
|
||||
|
||||
if (!prefix) return field
|
||||
|
||||
return (
|
||||
<div className={`flex items-stretch ${className ?? ''}`}>
|
||||
<span className="px-2 text-xs text-muted shrink-0 flex items-center border border-r-0 border-border rounded-l-[var(--radius)] bg-surface-secondary">
|
||||
{prefix}
|
||||
</span>
|
||||
{field}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Spinner } from '@heroui/react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: ReactNode
|
||||
/** Optional descriptive subtitle below the title. */
|
||||
subtitle?: ReactNode
|
||||
/** When provided, a refresh button is rendered in the action slot. */
|
||||
onRefresh?: () => void
|
||||
/** Shows a spinner in the refresh button while true. */
|
||||
loading?: boolean
|
||||
/** Seconds until next auto-refresh — shown as a dim countdown beside "Refresh". */
|
||||
countdown?: number
|
||||
/** Additional action buttons / controls rendered on the right. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, onRefresh, loading, countdown, children }) => {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 shrink-0 border-b border-border/60 pb-3 mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-semibold text-accent truncate">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-muted mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
{(onRefresh != null || children) && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{children}
|
||||
{onRefresh != null && (
|
||||
<Button size="sm" variant="ghost" onPress={onRefresh} isDisabled={loading}>
|
||||
{loading
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
{countdown != null && (
|
||||
<span className="w-7 text-right tabular-nums text-muted/60 text-xs">
|
||||
{countdown}
|
||||
s
|
||||
</span>
|
||||
)}
|
||||
<Icon name="refresh-cw" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
docs/reference-repos/icehunter/web/src/dune-ui/Panel.tsx
Normal file
23
docs/reference-repos/icehunter/web/src/dune-ui/Panel.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type PanelProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Elevated bordered card. Use for content groups like the Progression Unlock
|
||||
* sub-panels in PlayerActionsModal.
|
||||
*/
|
||||
export const Panel: React.FC<PanelProps> = ({ children, className = '' }) => (
|
||||
<div
|
||||
className={
|
||||
'rounded-[var(--radius)] p-4 flex flex-col gap-2 '
|
||||
+ 'bg-surface-secondary border border-border dune-lift '
|
||||
+ className
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type SectionDividerProps = {
|
||||
title: ReactNode
|
||||
/** Optional action buttons rendered on the right side of the divider. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber section title with a top border + padding above to separate it from
|
||||
* the preceding section. Matches the "Server Control" divider in
|
||||
* BattlegroupTab.
|
||||
*/
|
||||
export const SectionDivider: React.FC<SectionDividerProps> = ({ title, children }) => (
|
||||
<div className="flex items-center gap-3 border-t border-(--accent-soft-border)/30 pt-3 mt-3 shrink-0">
|
||||
<h3 className="text-base font-semibold text-accent flex-1 border-l-2 border-(--accent-soft-border) pl-2">{title}</h3>
|
||||
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface SectionLabelProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase amber label — sub-section heading inside a Panel.
|
||||
* Pairs with [[PageHeader]] (top-level) and [[SectionDivider]] (mid-level).
|
||||
*/
|
||||
export const SectionLabel: React.FC<SectionLabelProps> = ({ children }) => {
|
||||
return (
|
||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-accent border-l-2 border-(--accent-soft-border) pl-2">
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
78
docs/reference-repos/icehunter/web/src/dune-ui/SideNav.tsx
Normal file
78
docs/reference-repos/icehunter/web/src/dune-ui/SideNav.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type Item<K extends string> = {
|
||||
key: K
|
||||
label: ReactNode
|
||||
/** Optional sub-label rendered below the main label (e.g. namespace, item count). */
|
||||
sublabel?: ReactNode
|
||||
/** Optional right-aligned hint (e.g. "18 items" count chip). */
|
||||
hint?: ReactNode
|
||||
/** Indentation level (0 = top-level, 1 = child item). */
|
||||
depth?: number
|
||||
}
|
||||
|
||||
type SideNavProps<K extends string> = {
|
||||
items: Item<K>[]
|
||||
active: K | null
|
||||
onSelect: (key: K) => void
|
||||
/** Header text shown above the list (e.g. "PODS", "CONTAINERS (70)"). */
|
||||
title?: ReactNode
|
||||
/** Action element rendered next to the title (e.g. a refresh button). */
|
||||
titleAction?: ReactNode
|
||||
/** Width of the side nav. Defaults to 240px (w-60). */
|
||||
width?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable left side-navigation panel: bordered card with a title row +
|
||||
* scrollable list of selectable items. Used by Players sidebar, Database
|
||||
* section nav, Logs pod list, Storage container list, etc.
|
||||
*
|
||||
* Pass arbitrary `children` to render extra content (search inputs, info
|
||||
* banners) between the title and the list.
|
||||
*/
|
||||
export const SideNav = <K extends string>({
|
||||
items, active, onSelect, title, titleAction, width, children,
|
||||
}: SideNavProps<K>) => {
|
||||
const w = width ?? 'w-60'
|
||||
return (
|
||||
<div className={`${w} shrink-0 flex flex-col rounded-[var(--radius)] bg-surface border border-border/60 dune-lift overflow-hidden`}>
|
||||
{(title || titleAction) && (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60 shrink-0 bg-gradient-to-b from-(--surface-secondary) to-transparent">
|
||||
{title && <span className="text-xs font-semibold uppercase tracking-widest text-accent">{title}</span>}
|
||||
{titleAction}
|
||||
</div>
|
||||
)}
|
||||
{children && <div className="px-2 py-1.5 shrink-0">{children}</div>}
|
||||
<div className="overflow-y-auto flex-1 flex flex-col gap-0.5 p-1">
|
||||
{items.map((item) => {
|
||||
const isActive = item.key === active
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => onSelect(item.key)}
|
||||
className={
|
||||
'text-left rounded-[var(--radius)] text-sm transition-colors flex items-start gap-2 '
|
||||
+ (item.depth ? 'pl-6 pr-3 py-1.5 ' : 'px-3 py-2 ')
|
||||
+ (isActive
|
||||
? 'bg-accent text-accent-foreground font-semibold'
|
||||
: 'text-foreground hover:bg-surface-hover')
|
||||
}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate">{item.label}</div>
|
||||
{item.sublabel && (
|
||||
<div className={'truncate text-xs ' + (isActive ? 'opacity-80' : 'text-muted')}>
|
||||
{item.sublabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.hint && <div className="shrink-0 text-xs">{item.hint}</div>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
docs/reference-repos/icehunter/web/src/dune-ui/TimeInput.tsx
Normal file
78
docs/reference-repos/icehunter/web/src/dune-ui/TimeInput.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type React from 'react'
|
||||
import { Time } from '@internationalized/date'
|
||||
import { TimeField, ToggleButton, ToggleButtonGroup } from '@heroui/react'
|
||||
|
||||
interface TimeInputProps {
|
||||
value: string // "HH:MM" in 24h
|
||||
onChange: (v: string) => void
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
function parseHHMM(s: string): Time | null {
|
||||
const parts = s.split(':')
|
||||
const h = Number(parts[0])
|
||||
const m = Number(parts[1])
|
||||
if (Number.isNaN(h) || Number.isNaN(m)) return null
|
||||
return new Time(h, m)
|
||||
}
|
||||
|
||||
function toHHMM(t: Time): string {
|
||||
return `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const TimeInput: React.FC<TimeInputProps> = ({ value, onChange, ariaLabel, className, isDisabled }) => {
|
||||
const timeValue = parseHHMM(value)
|
||||
const isAM = timeValue ? timeValue.hour < 12 : true
|
||||
|
||||
const handleTimeChange = (t: Time | null) => {
|
||||
if (t) onChange(toHHMM(t))
|
||||
}
|
||||
|
||||
const handlePeriodChange = (keys: Iterable<React.Key> | 'all') => {
|
||||
if (!timeValue) return
|
||||
const period = keys === 'all' ? null : [...keys][0]
|
||||
if (!period) return
|
||||
let { hour } = timeValue
|
||||
const { minute } = timeValue
|
||||
if (period === 'pm' && hour < 12) hour += 12
|
||||
else if (period === 'am' && hour >= 12) hour -= 12
|
||||
onChange(toHHMM(new Time(hour, minute)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className ?? ''}`}>
|
||||
<TimeField
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
hourCycle={12}
|
||||
granularity="minute"
|
||||
aria-label={ariaLabel}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<TimeField.Group variant="secondary">
|
||||
<TimeField.Input>
|
||||
{(segment) => (
|
||||
<TimeField.Segment
|
||||
segment={segment}
|
||||
className={segment.type === 'dayPeriod' ? 'hidden' : ''}
|
||||
/>
|
||||
)}
|
||||
</TimeField.Input>
|
||||
</TimeField.Group>
|
||||
</TimeField>
|
||||
<ToggleButtonGroup
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
selectedKeys={[isAM ? 'am' : 'pm']}
|
||||
onSelectionChange={handlePeriodChange}
|
||||
size="sm"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<ToggleButton id="am">AM</ToggleButton>
|
||||
<ToggleButton id="pm">PM</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
docs/reference-repos/icehunter/web/src/dune-ui/icons.ts
Normal file
9
docs/reference-repos/icehunter/web/src/dune-ui/icons.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Pre-load the full lucide icon collection so `<Icon icon="lucide:..." />`
|
||||
* works offline without runtime CDN fetches. Bundle cost: ~500KB gzipped.
|
||||
* If that becomes a problem, switch to per-icon imports via `addIcon`.
|
||||
*/
|
||||
import { addCollection } from '@iconify/react'
|
||||
import lucide from '@iconify-json/lucide/icons.json'
|
||||
|
||||
addCollection(lucide)
|
||||
26
docs/reference-repos/icehunter/web/src/dune-ui/index.ts
Normal file
26
docs/reference-repos/icehunter/web/src/dune-ui/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Dune Admin component library — opinionated wrappers around HeroUI v3 that
|
||||
* carry the project's amber/dark aesthetic. Import from here, not from
|
||||
* @heroui/react directly, when there's an equivalent dune-ui wrapper.
|
||||
*
|
||||
* Side effect: importing this module registers the lucide icon collection
|
||||
* with iconify so `<Icon name="..." />` works offline.
|
||||
*/
|
||||
import './icons'
|
||||
|
||||
export { Icon } from './Icon'
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { InfoCard } from './InfoCard'
|
||||
export { SectionDivider } from './SectionDivider'
|
||||
export { SectionLabel } from './SectionLabel'
|
||||
export { Panel } from './Panel'
|
||||
export { LoadingState } from './LoadingState'
|
||||
export { DataTable } from './DataTable'
|
||||
export type { Column } from './DataTable'
|
||||
export { Dropzone } from './Dropzone'
|
||||
export { SideNav } from './SideNav'
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
export { NumberInput } from './NumberInput'
|
||||
export { FieldInput } from './FieldInput'
|
||||
export { FieldSelect } from './FieldSelect'
|
||||
export { TimeInput } from './TimeInput'
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Polls `fn` every `intervalMs` while `active` is true.
|
||||
* Returns `countdown` (seconds until next auto-refresh) and a `refresh`
|
||||
* function for manual triggers — calling it fires `fn` and resets the timer.
|
||||
*/
|
||||
export function useAutoRefresh(
|
||||
fn: () => void,
|
||||
intervalMs: number,
|
||||
active: boolean,
|
||||
): { countdown: number, refresh: () => void } {
|
||||
const fnRef = useRef(fn)
|
||||
useEffect(() => {
|
||||
fnRef.current = fn
|
||||
})
|
||||
|
||||
const secsTotal = Math.round(intervalMs / 1000)
|
||||
const [countdown, setCountdown] = useState(secsTotal)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
Promise.resolve().then(() => setCountdown(secsTotal))
|
||||
return
|
||||
}
|
||||
|
||||
Promise.resolve().then(() => setCountdown(secsTotal))
|
||||
|
||||
const poll = setInterval(() => {
|
||||
fnRef.current()
|
||||
setCountdown(secsTotal)
|
||||
}, intervalMs)
|
||||
|
||||
const tick = setInterval(() => {
|
||||
setCountdown((s) => Math.max(0, s - 1))
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearInterval(poll)
|
||||
clearInterval(tick)
|
||||
}
|
||||
}, [active, intervalMs, secsTotal])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fnRef.current()
|
||||
setCountdown(secsTotal)
|
||||
}, [secsTotal])
|
||||
|
||||
return { countdown, refresh }
|
||||
}
|
||||
44
docs/reference-repos/icehunter/web/src/hooks/useStatus.ts
Normal file
44
docs/reference-repos/icehunter/web/src/hooks/useStatus.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import type { Status } from '../api/client'
|
||||
|
||||
// ConnState distinguishes the initial load from a hard "never reached the
|
||||
// backend" failure, so the UI can show a setup screen on real connection
|
||||
// failure without flickering during the first poll.
|
||||
export type ConnState = 'loading' | 'connected' | 'error'
|
||||
|
||||
export interface StatusResult {
|
||||
status: Status | null
|
||||
state: ConnState
|
||||
}
|
||||
|
||||
export function useStatus(): StatusResult {
|
||||
const [status, setStatus] = useState<Status | null>(null)
|
||||
const [state, setState] = useState<ConnState>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
let everConnected = false
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await api.status()
|
||||
everConnected = true
|
||||
setStatus(s)
|
||||
setState('connected')
|
||||
}
|
||||
catch {
|
||||
// Only surface the hard "can't reach backend" screen if we've NEVER
|
||||
// connected. A transient blip after a successful connect keeps the last
|
||||
// status — the header's DB/SSH badges already reflect dependency health.
|
||||
if (!everConnected) {
|
||||
setStatus(null)
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, 5000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return { status, state }
|
||||
}
|
||||
36
docs/reference-repos/icehunter/web/src/hooks/useTableSort.ts
Normal file
36
docs/reference-repos/icehunter/web/src/hooks/useTableSort.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
export type SortDir = 'asc' | 'desc'
|
||||
|
||||
export function useTableSort<T, K extends string>(
|
||||
rows: T[],
|
||||
initialKey: K,
|
||||
getValue: (row: T, key: K) => string | number | null | undefined,
|
||||
initialDir: SortDir = 'asc',
|
||||
) {
|
||||
const [sortKey, setSortKey] = useState<K>(initialKey)
|
||||
const [sortDir, setSortDir] = useState<SortDir>(initialDir)
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const out = [...rows]
|
||||
out.sort((a, b) => {
|
||||
const av = getValue(a, sortKey)
|
||||
const bv = getValue(b, sortKey)
|
||||
let cmp: number
|
||||
if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv
|
||||
else cmp = String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true })
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return out
|
||||
}, [rows, sortKey, sortDir, getValue])
|
||||
|
||||
const toggle = (key: K) => {
|
||||
if (key === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
return { sorted, sortKey, sortDir, toggle }
|
||||
}
|
||||
11
docs/reference-repos/icehunter/web/src/i18n/i18n.d.ts
vendored
Normal file
11
docs/reference-repos/icehunter/web/src/i18n/i18n.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'i18next'
|
||||
import type enUS from '../locales/en-US/translation.json'
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'translation'
|
||||
resources: {
|
||||
translation: typeof enUS
|
||||
}
|
||||
}
|
||||
}
|
||||
54
docs/reference-repos/icehunter/web/src/i18n/i18n.test.ts
Normal file
54
docs/reference-repos/icehunter/web/src/i18n/i18n.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import enUS from '../locales/en-US/translation.json'
|
||||
import de from '../locales/de/translation.json'
|
||||
import fr from '../locales/fr/translation.json'
|
||||
import es from '../locales/es/translation.json'
|
||||
import ptBR from '../locales/pt-BR/translation.json'
|
||||
import ru from '../locales/ru/translation.json'
|
||||
import pl from '../locales/pl/translation.json'
|
||||
import tr from '../locales/tr/translation.json'
|
||||
import zhCN from '../locales/zh-CN/translation.json'
|
||||
import ja from '../locales/ja/translation.json'
|
||||
|
||||
const LOCALES: Record<string, Record<string, unknown>> = {
|
||||
de,
|
||||
fr,
|
||||
es,
|
||||
'pt-BR': ptBR,
|
||||
ru,
|
||||
pl,
|
||||
tr,
|
||||
'zh-CN': zhCN,
|
||||
ja,
|
||||
}
|
||||
|
||||
function flatKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
return Object.entries(obj).flatMap(([k, v]) => {
|
||||
const key = prefix ? `${prefix}.${k}` : k
|
||||
return typeof v === 'object' && v !== null
|
||||
? flatKeys(v as Record<string, unknown>, key)
|
||||
: [key]
|
||||
})
|
||||
}
|
||||
|
||||
describe('i18n completeness', () => {
|
||||
const enKeys = flatKeys(enUS as Record<string, unknown>)
|
||||
|
||||
it('en-US has no empty values', () => {
|
||||
enKeys.forEach((key) => {
|
||||
const parts = key.split('.')
|
||||
let val: unknown = enUS
|
||||
for (const p of parts) val = (val as Record<string, unknown>)[p]
|
||||
expect(typeof val === 'string' && val.length > 0, `en-US key "${key}" is empty`).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Object.entries(LOCALES).forEach(([locale, translations]) => {
|
||||
it(`${locale} has all en-US keys`, () => {
|
||||
const localeKeys = new Set(flatKeys(translations))
|
||||
enKeys.forEach((key) => {
|
||||
expect(localeKeys.has(key), `${locale} missing key "${key}"`).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
58
docs/reference-repos/icehunter/web/src/i18n/index.ts
Normal file
58
docs/reference-repos/icehunter/web/src/i18n/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import enUS from '../locales/en-US/translation.json'
|
||||
import de from '../locales/de/translation.json'
|
||||
import fr from '../locales/fr/translation.json'
|
||||
import es from '../locales/es/translation.json'
|
||||
import ptBR from '../locales/pt-BR/translation.json'
|
||||
import ru from '../locales/ru/translation.json'
|
||||
import pl from '../locales/pl/translation.json'
|
||||
import tr from '../locales/tr/translation.json'
|
||||
import zhCN from '../locales/zh-CN/translation.json'
|
||||
import ja from '../locales/ja/translation.json'
|
||||
|
||||
export const LOCALE_KEY = 'dune_admin_locale'
|
||||
export const DEFAULT_LOCALE = 'en-US'
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ code: 'en-US', label: 'English', flag: '🇨🇦' },
|
||||
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'es', label: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'pt-BR', label: 'Português (BR)', flag: '🇧🇷' },
|
||||
{ code: 'ru', label: 'Русский', flag: '🇷🇺' },
|
||||
{ code: 'pl', label: 'Polski', flag: '🇵🇱' },
|
||||
{ code: 'tr', label: 'Türkçe', flag: '🇹🇷' },
|
||||
{ code: 'zh-CN', label: '中文 (简体)', flag: '🇨🇳' },
|
||||
{ code: 'ja', label: '日本語', flag: '🇯🇵' },
|
||||
] as const
|
||||
|
||||
const saved = localStorage.getItem(LOCALE_KEY) ?? DEFAULT_LOCALE
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
'en-US': { translation: enUS },
|
||||
'de': { translation: de },
|
||||
'fr': { translation: fr },
|
||||
'es': { translation: es },
|
||||
'pt-BR': { translation: ptBR },
|
||||
'ru': { translation: ru },
|
||||
'pl': { translation: pl },
|
||||
'tr': { translation: tr },
|
||||
'zh-CN': { translation: zhCN },
|
||||
'ja': { translation: ja },
|
||||
},
|
||||
lng: saved,
|
||||
fallbackLng: DEFAULT_LOCALE,
|
||||
interpolation: { escapeValue: false },
|
||||
})
|
||||
|
||||
export function setLocale(code: string): void {
|
||||
localStorage.setItem(LOCALE_KEY, code)
|
||||
void i18n.changeLanguage(code)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
424
docs/reference-repos/icehunter/web/src/index.css
Normal file
424
docs/reference-repos/icehunter/web/src/index.css
Normal file
@@ -0,0 +1,424 @@
|
||||
@import "tailwindcss";
|
||||
@import "@heroui/styles";
|
||||
|
||||
/* unicode-range restricts Duneway to the Latin/Cyrillic blocks it actually covers.
|
||||
Symbols, arrows, dingbats (U+2000+) fall through to the system font immediately,
|
||||
preventing garbled â□□ rendering for ✓ ✗ ✕ — ¾ ³ → ↑ ● etc. */
|
||||
@font-face {
|
||||
font-family: "Duneway";
|
||||
src: url("/fonts/Duneway-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Duneway";
|
||||
src: url("/fonts/Duneway-Medium.ttf") format("truetype");
|
||||
font-weight: 400 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Duneway";
|
||||
src: url("/fonts/Duneway-ExtraLight.ttf") format("truetype");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Duneway";
|
||||
src: url("/fonts/Duneway-Bold-Italic_MonoNumbers_Kommafix_Cyrfix.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Duneway";
|
||||
src: url("/fonts/Duneway-Italic_MonoNumbers_Kommafix_Cyrfix.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-024F, U+0400-04FF, U+1E00-1EFF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "ImperialFont";
|
||||
src: url("/fonts/ImperialFont.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Dune desert dark theme ──────────────────────────────────────────────────
|
||||
Single source of truth for the dune palette. We override HeroUI v3 semantic
|
||||
tokens directly so utility classes (`bg-surface`, `text-foreground`,
|
||||
`border-border`, `bg-accent`, etc.) and HeroUI components both pick up the
|
||||
amber/sand colors automatically — no need for inline style={{}} overrides.
|
||||
|
||||
`.dark` is applied on <html> in index.html.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--font-sans: "Duneway", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── Dune monochromatic amber ladder (light → dark) ──────────────────────
|
||||
#f0a830 — accent hover / link
|
||||
#c9820a — primary accent
|
||||
#9e6711 — secondary accent
|
||||
#754d13 — soft accent borders / scrollbar
|
||||
#4e3411 — borders / hover surface / elevated panels
|
||||
#2a1d0c — primary surface (cards, tables, modal body)
|
||||
#1a1610 — subtle stripe / field background
|
||||
#0d0b07 — page background (near-black warm) */
|
||||
|
||||
/* Base — surfaces use the DARKER half of the palette */
|
||||
--background: #0d0b07; /* page */
|
||||
--foreground: #e8dcc8;
|
||||
--muted: #8a7a60;
|
||||
|
||||
/* Surfaces (darker) */
|
||||
--surface: #1a1610; /* cards, tables, modal body */
|
||||
--surface-foreground: #e8dcc8;
|
||||
--surface-secondary: #2a1d0c; /* slightly elevated panels */
|
||||
--surface-tertiary: #4e3411; /* top elevation (table headers) */
|
||||
--overlay: #1a1610; /* modal body */
|
||||
--overlay-foreground: #e8dcc8;
|
||||
|
||||
--default: #1a1610;
|
||||
--default-foreground: #e8dcc8;
|
||||
|
||||
/* Accent — dune amber */
|
||||
--accent: #c9820a;
|
||||
--accent-foreground: #0d0b07;
|
||||
--focus: #f0a830;
|
||||
--link: #f0a830;
|
||||
|
||||
/* Form fields — recessed bg + BRIGHTER border for emphasis */
|
||||
--field-background: #0d0b07;
|
||||
--field-foreground: #e8dcc8;
|
||||
--field-placeholder: #8a7a60;
|
||||
--field-border: #754d13; /* brighter — buttons & inputs pop */
|
||||
|
||||
/* Border widths + radii — global override: thin border, near-square corners */
|
||||
--border-width: 0.1em;
|
||||
--field-border-width: 0.1em;
|
||||
--radius: 0.1em;
|
||||
--field-radius: 0.1em;
|
||||
|
||||
/* Status — hue-shifted from accent to stay in family */
|
||||
--success: #b0c90a; /* amber-green */
|
||||
--success-foreground: #0d0b07;
|
||||
--warning: #f0a830; /* lighter amber */
|
||||
--warning-foreground: #0d0b07;
|
||||
--danger: #c9230a; /* amber-red */
|
||||
--danger-foreground: #ffffff;
|
||||
|
||||
/* Item rarity tiers — used by Market grid/table to color names & borders */
|
||||
--rarity-common: var(--foreground);
|
||||
--rarity-uncommon: var(--success);
|
||||
--rarity-rare: #4a90e2; /* blue */
|
||||
--rarity-epic: #a855f7; /* purple */
|
||||
--rarity-legendary: #f0a830; /* amber */
|
||||
--rarity-unique: #f97316; /* orange */
|
||||
--rarity-memento: #f43f5e; /* rose */
|
||||
|
||||
/* Borders / separators — mid-tone */
|
||||
--border: #4e3411;
|
||||
--separator: #2a1d0c; /* subtle in-table dividers */
|
||||
|
||||
/* Scrollbar */
|
||||
--scrollbar: #754d13;
|
||||
|
||||
/* Dune-specific extras (not part of HeroUI's standard set) */
|
||||
--surface-alt: #14110b; /* striped table rows — between bg and surface */
|
||||
--surface-hover: #2a1d0c; /* hover state for rows / nav */
|
||||
--accent-soft-bg: #2a1d0c; /* faint amber pill background */
|
||||
--accent-soft-border: #754d13;
|
||||
|
||||
/* ── Legacy aliases ────────────────────────────────────────────────────────
|
||||
Existing markup uses `var(--color-*)` everywhere. Map those to the new
|
||||
semantic tokens so nothing breaks while we migrate tab-by-tab. */
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-alt: var(--surface-alt);
|
||||
--color-primary: var(--accent);
|
||||
--color-primary-hover: var(--link);
|
||||
--color-text: var(--foreground);
|
||||
--color-text-dim: var(--muted);
|
||||
--color-border: var(--border);
|
||||
--color-danger: var(--danger);
|
||||
--color-success: var(--success);
|
||||
}
|
||||
|
||||
/* ── Tailwind v4 utility surface ─────────────────────────────────────────────
|
||||
Expose the dune-specific extras as Tailwind colors so we can write
|
||||
`bg-surface-alt`, `border-border-strong`, etc. The standard HeroUI tokens
|
||||
(`bg-surface`, `bg-accent`, etc.) are wired by @heroui/styles already. */
|
||||
@theme {
|
||||
--color-surface-alt: var(--surface-alt);
|
||||
--color-surface-hover: var(--surface-hover);
|
||||
--color-accent-soft: var(--accent-soft-bg);
|
||||
--color-rarity-common: var(--rarity-common);
|
||||
--color-rarity-uncommon: var(--rarity-uncommon);
|
||||
--color-rarity-rare: var(--rarity-rare);
|
||||
--color-rarity-epic: var(--rarity-epic);
|
||||
--color-rarity-legendary: var(--rarity-legendary);
|
||||
--color-rarity-unique: var(--rarity-unique);
|
||||
--color-rarity-memento: var(--rarity-memento);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
min-height: 100vh;
|
||||
font-family: "Duneway", system-ui, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Prevent the page itself from scrolling — the app is bounded to h-screen.
|
||||
The !important beats React Aria's inline overflow:hidden on modal scroll-lock,
|
||||
keeping the scrollbar gutter stable so modals don't cause layout shift. */
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--surface);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ── HeroUI Checkbox + Switch — the root <label> has no position:relative,
|
||||
so the VisuallyHidden <input> (position:absolute, margin:-1px) can escape
|
||||
its container and cause scroll jumps when focused. We also clamp the
|
||||
VisuallyHidden wrapper span itself to ensure it can never affect layout. */
|
||||
.checkbox,
|
||||
.switch {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* The VisuallyHidden span wrapping the hidden <input> inside Checkbox/Switch.
|
||||
React Aria renders it as position:absolute with margin:-1px — without a
|
||||
positioned ancestor the margin can still influence scroll-extent calculations
|
||||
in some browsers. Force all layout-affecting properties to be inert. */
|
||||
.checkbox > span[style],
|
||||
.switch > span[style] {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0 0 0 0) !important;
|
||||
clip-path: inset(50%) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* ── HeroUI Table — compact density. HeroUI doesn't ship a size prop, so
|
||||
we override cell/header padding + font globally for a consistent look. */
|
||||
[role="columnheader"],
|
||||
[role="rowheader"],
|
||||
[role="gridcell"] {
|
||||
padding: 0.375rem 0.75rem !important; /* py-1.5 px-3 */
|
||||
font-size: 0.75rem; /* text-xs */
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Column headers — sit on the body bg with a hairline divider below.
|
||||
Subtle elevation; the amber text + uppercase is what makes them read. */
|
||||
[role="columnheader"] {
|
||||
background: linear-gradient(180deg, var(--surface-secondary) 0%, var(--surface) 100%);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Base cells = darkest (page bg); even rows get the lighter zebra stripe. */
|
||||
[role="rowheader"],
|
||||
[role="gridcell"] {
|
||||
background: var(--background) !important;
|
||||
}
|
||||
[role="row"]:nth-child(even) > [role="rowheader"],
|
||||
[role="row"]:nth-child(even) > [role="gridcell"] {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Accent left stripe on row hover — inset shadow avoids layout shift. */
|
||||
[role="row"]:hover > [role="rowheader"],
|
||||
[role="row"]:hover > [role="gridcell"]:first-of-type {
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ── Tabs.Panel — HeroUI's default panel bg is too amber for our theme.
|
||||
Force the page background so the content area reads dark, not brown. */
|
||||
[role="tabpanel"],
|
||||
.tabs__panel {
|
||||
background: var(--background) !important;
|
||||
}
|
||||
|
||||
/* Depth/hillshade overlay blends into the tile layer for perceived terrain depth. */
|
||||
.leaflet-depth-overlay {
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Leaflet's control elements (z-index: 1000) escape their container's stacking
|
||||
context. Override dialogs/modals to always appear above the map. */
|
||||
.alert-dialog__backdrop,
|
||||
.modal__backdrop {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* ── Top tabs — uppercase white labels, amber pill when selected.
|
||||
The selected-state pill is rendered by `Tabs.Indicator`, NOT the tab's
|
||||
own background, so we color that element directly to match the
|
||||
"DUNE ADMIN" logo (`--accent`). */
|
||||
[role="tab"] {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
background: transparent !important;
|
||||
width: fit-content;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tabs__indicator {
|
||||
background-color: var(--accent) !important;
|
||||
}
|
||||
.tabs__list {
|
||||
justify-content: space-between;
|
||||
}
|
||||
[role="tab"][data-selected="true"],
|
||||
[role="tab"][aria-selected="true"],
|
||||
[role="tab"][data-state="active"] {
|
||||
color: var(--accent-foreground) !important;
|
||||
}
|
||||
|
||||
/* ── Listbox option hover / focus / selected — used by every Select,
|
||||
Autocomplete, and standalone ListBox in the app. HeroUI doesn't give
|
||||
these a visible hover state by default in our dark theme. */
|
||||
[role="option"]:hover,
|
||||
[role="option"][data-hovered="true"],
|
||||
[role="option"][data-focused="true"],
|
||||
[role="option"][data-focus-visible="true"] {
|
||||
background-color: var(--surface-hover) !important;
|
||||
color: var(--foreground);
|
||||
}
|
||||
[role="option"][data-selected="true"],
|
||||
[role="option"][aria-selected="true"] {
|
||||
background-color: var(--accent-soft-bg) !important;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── HeroUI Tabs + Table containers leak brown bg from `--default` / their
|
||||
own internal tokens. Null them out so the page bg shows through. */
|
||||
.tabs,
|
||||
.tabs__list-container,
|
||||
.tabs__list,
|
||||
[role="tablist"],
|
||||
[role="tabpanel"],
|
||||
.tabs__panel,
|
||||
.table,
|
||||
.table__scroll-container,
|
||||
.table__content,
|
||||
[role="grid"],
|
||||
[role="row"],
|
||||
[role="rowgroup"] {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Cells: transparent by default; even-row cells get the subtle stripe. */
|
||||
[role="rowheader"],
|
||||
[role="gridcell"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── HeroUI modal dialog — gradient body matches the panel language. */
|
||||
[role="dialog"] {
|
||||
background: linear-gradient(170deg, var(--surface-secondary) 0%, var(--background) 100%) !important;
|
||||
}
|
||||
.dialog-surface-alt[role="dialog"] {
|
||||
background: var(--surface-alt) !important;
|
||||
}
|
||||
|
||||
/* ── Pre / monospace output blocks ─────────────────────────────────────── */
|
||||
pre,
|
||||
.font-mono[class*="border"] {
|
||||
border-radius: var(--radius) !important;
|
||||
}
|
||||
|
||||
/* ── Surface lift — deep shadow stack ───────────────────────────────────
|
||||
Adds perceived depth to card/panel surfaces: top-edge rim using the
|
||||
theme accent-soft-border, a gradient that darkens toward the bottom,
|
||||
and a stacked drop-shadow. Use alongside bg-surface* + border-border. */
|
||||
.dune-lift {
|
||||
background: linear-gradient(170deg, var(--surface-secondary) 0%, var(--background) 100%) !important;
|
||||
border-top-color: var(--accent-soft-border) !important;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.35),
|
||||
0 4px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Clerk modal backdrop — Clerk injects this via inline style so the
|
||||
appearance.elements className alone doesn't override it. */
|
||||
.cl-modalBackdrop,
|
||||
[class*="cl-modalBackdrop"] {
|
||||
background-color: rgba(13, 11, 7, 0.82) !important;
|
||||
backdrop-filter: blur(6px) !important;
|
||||
-webkit-backdrop-filter: blur(6px) !important;
|
||||
}
|
||||
|
||||
/* Clerk card — amber-tinted border so it reads as a deliberate surface
|
||||
against the dark scrim rather than a floating box with no edges. */
|
||||
.cl-cardBox,
|
||||
.cl-card,
|
||||
[class*="cl-cardBox"],
|
||||
[class*="cl-card "] {
|
||||
border: 1px solid rgba(201, 130, 10, 0.2) !important;
|
||||
box-shadow:
|
||||
0 18px 48px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02) inset !important;
|
||||
}
|
||||
|
||||
/* ── Tab content fade-in ──────────────────────────────────────────────────
|
||||
Triggered when a keep-alive TabPane becomes active (hidden → flex).
|
||||
Pure opacity fade — no transform to avoid layout thrash on content reloads. */
|
||||
@keyframes dune-tab-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dune-tab-active {
|
||||
animation: dune-tab-in 180ms ease-out both;
|
||||
}
|
||||
1372
docs/reference-repos/icehunter/web/src/locales/de/translation.json
Normal file
1372
docs/reference-repos/icehunter/web/src/locales/de/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1395
docs/reference-repos/icehunter/web/src/locales/es/translation.json
Normal file
1395
docs/reference-repos/icehunter/web/src/locales/es/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1395
docs/reference-repos/icehunter/web/src/locales/fr/translation.json
Normal file
1395
docs/reference-repos/icehunter/web/src/locales/fr/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1372
docs/reference-repos/icehunter/web/src/locales/ja/translation.json
Normal file
1372
docs/reference-repos/icehunter/web/src/locales/ja/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1418
docs/reference-repos/icehunter/web/src/locales/pl/translation.json
Normal file
1418
docs/reference-repos/icehunter/web/src/locales/pl/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1418
docs/reference-repos/icehunter/web/src/locales/ru/translation.json
Normal file
1418
docs/reference-repos/icehunter/web/src/locales/ru/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1372
docs/reference-repos/icehunter/web/src/locales/tr/translation.json
Normal file
1372
docs/reference-repos/icehunter/web/src/locales/tr/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
48
docs/reference-repos/icehunter/web/src/main.tsx
Normal file
48
docs/reference-repos/icehunter/web/src/main.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import { applyTheme, loadTheme } from './theme'
|
||||
import { App } from './App.tsx'
|
||||
import { ClerkProvider } from '@clerk/react'
|
||||
import { dark } from '@clerk/themes'
|
||||
|
||||
// Apply the saved appearance theme (#144) before first paint to avoid a flash.
|
||||
applyTheme(loadTheme())
|
||||
|
||||
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
|
||||
|
||||
// Match Clerk modals to the dune-admin dark amber theme.
|
||||
// Element class overrides are needed for the backdrop because Clerk injects
|
||||
// it via inline style — the appearance.elements className alone doesn't win.
|
||||
const clerkAppearance = {
|
||||
baseTheme: dark,
|
||||
variables: {
|
||||
colorPrimary: '#c9820a',
|
||||
colorDanger: '#c9230a',
|
||||
borderRadius: '2px',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
},
|
||||
elements: {
|
||||
formButtonPrimary:
|
||||
'bg-[#c9820a] hover:bg-[#d4900f] text-black font-bold shadow-none normal-case tracking-normal',
|
||||
footerActionLink: 'text-[#c9820a] hover:text-[#d4900f]',
|
||||
},
|
||||
} as const
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<HashRouter>
|
||||
{publishableKey
|
||||
? (
|
||||
<ClerkProvider publishableKey={publishableKey} afterSignOutUrl="/" appearance={clerkAppearance}>
|
||||
<App />
|
||||
</ClerkProvider>
|
||||
)
|
||||
: (
|
||||
<App />
|
||||
)}
|
||||
</HashRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
141
docs/reference-repos/icehunter/web/src/tabs/BasesTab.tsx
Normal file
141
docs/reference-repos/icehunter/web/src/tabs/BasesTab.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Card, Spinner, toast } from '@heroui/react'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type { BaseRow } from '../api/client'
|
||||
import { DataTable, Icon, PageHeader, type Column } from '../dune-ui'
|
||||
|
||||
type Key = 'id' | 'name' | 'pieces' | 'placeables' | 'actions'
|
||||
|
||||
interface BasesTabProps {
|
||||
isSignedIn?: boolean
|
||||
}
|
||||
|
||||
export const BasesTab: React.FC<BasesTabProps> = ({ isSignedIn = true }) => {
|
||||
const { t } = useTranslation()
|
||||
const [bases, setBases] = useState<BaseRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [unsupported, setUnsupported] = useState(false)
|
||||
|
||||
const COLUMNS: Column<Key>[] = [
|
||||
{ key: 'id', label: t('bases.columns.id'), width: 80 },
|
||||
{ key: 'name', label: t('bases.columns.name'), minWidth: 220 },
|
||||
{ key: 'pieces', label: t('bases.columns.pieces'), width: 100 },
|
||||
{ key: 'placeables', label: t('bases.columns.placeables'), width: 110 },
|
||||
{ key: 'actions', label: '', width: 120, sortable: false },
|
||||
]
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
setLoading(true)
|
||||
setUnsupported(false)
|
||||
})
|
||||
.then(() => api.bases.list())
|
||||
.then(setBases)
|
||||
.catch((e: unknown) => {
|
||||
if (e instanceof ApiError && e.status === 404) setUnsupported(true)
|
||||
else toast.danger(t('bases.failedToLoad', { message: e instanceof Error ? e.message : String(e) }))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
{!isSignedIn && (
|
||||
<div className="shrink-0 rounded-[var(--radius)] px-4 py-2 text-xs font-medium bg-danger/10 border border-danger/40 text-danger flex items-center gap-2">
|
||||
<Icon name="triangle-alert" />
|
||||
<span>
|
||||
A
|
||||
{' '}
|
||||
<strong>{t('bases.layoutAccountStrong')}</strong>
|
||||
{' '}
|
||||
account is required to export bases. Sign in using the button in the top
|
||||
right.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title={t('bases.title', { count: bases.length })}
|
||||
subtitle={t('bases.subtitle')}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
|
||||
{loading
|
||||
? (
|
||||
<Spinner size="sm" color="current" />
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Icon name="refresh-cw" />
|
||||
{' '}
|
||||
{t('common.refresh')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{unsupported
|
||||
? (
|
||||
<Card className="self-center max-w-sm">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-accent text-sm">{t('bases.featureNotAvailable')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p className="text-xs text-muted text-center">
|
||||
{t('bases.featureNotAvailableDesc')}
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<DataTable<BaseRow, Key>
|
||||
aria-label={t('bases.ariaLabel')}
|
||||
className="min-h-0 max-h-full"
|
||||
columns={COLUMNS}
|
||||
rows={bases}
|
||||
loading={loading}
|
||||
rowId={(b) => String(b.id)}
|
||||
initialSort={{ column: 'id', direction: 'ascending' }}
|
||||
sortValue={(b, k) => (k === 'actions' ? '' : (b as unknown as Record<string, string | number>)[k])}
|
||||
emptyState={<div className="py-8 text-center text-muted">{t('bases.noBasesFound')}</div>}
|
||||
renderCell={(b, key) => {
|
||||
switch (key) {
|
||||
case 'id':
|
||||
return <span className="font-mono text-muted">{b.id}</span>
|
||||
case 'name':
|
||||
return b.name || <span className="text-muted">—</span>
|
||||
case 'pieces':
|
||||
return <span className="text-muted">{b.pieces}</span>
|
||||
case 'placeables':
|
||||
return <span className="text-muted">{b.placeables}</span>
|
||||
case 'actions':
|
||||
return isSignedIn
|
||||
? (
|
||||
<a href={api.bases.exportUrl(b.id)} download={b.name ? `${b.name}.json` : `base-${b.id}.json`}>
|
||||
<Button size="sm" variant="outline" className="w-full">
|
||||
<Icon name="download" />
|
||||
{' '}
|
||||
{t('bases.export')}
|
||||
</Button>
|
||||
</a>
|
||||
)
|
||||
: (
|
||||
<Button size="sm" variant="outline" className="w-full" isDisabled>
|
||||
<Icon name="download" />
|
||||
{' '}
|
||||
{t('bases.export')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DataTable, Icon } from '../../dune-ui'
|
||||
import { phaseColor } from './helpers'
|
||||
import { formatUptime } from './uptime'
|
||||
import { getServerColumns, type ServerRow, type ServerSortKey } from './types'
|
||||
|
||||
type ServersTableProps = {
|
||||
servers: ServerRow[]
|
||||
isInitializing: boolean
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
export const ServersTable: React.FC<ServersTableProps> = ({ servers, isInitializing, emptyMessage }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<DataTable<ServerRow, ServerSortKey>
|
||||
aria-label={t('nav.battlegroup')}
|
||||
className="min-h-0 max-h-full"
|
||||
columns={getServerColumns(t)}
|
||||
rows={servers}
|
||||
rowId={(s) => `${s.map}-${s.dimension}-${s.partition}`}
|
||||
initialSort={{ column: 'map', direction: 'ascending' }}
|
||||
sortValue={(r, k) => {
|
||||
if (k === 'ready') return r.ready ? 1 : 0
|
||||
if (k === 'age') return r.ageSeconds ?? 0
|
||||
return r[k] as string | number
|
||||
}}
|
||||
emptyState={emptyMessage && <div className="py-8 text-center text-muted">{emptyMessage}</div>}
|
||||
renderCell={(s, key) => {
|
||||
switch (key) {
|
||||
case 'map':
|
||||
return <span className="font-mono">{s.map}</span>
|
||||
case 'phase':
|
||||
return (
|
||||
<span className="font-semibold" style={{ color: phaseColor(s.phase) }}>
|
||||
{s.phase || '—'}
|
||||
{isInitializing && s.phase === 'Running' && (
|
||||
<span className="ml-1 font-normal text-warning">{t('battlegroup.initializing')}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
case 'players':
|
||||
return (
|
||||
<span className="font-semibold" style={{ color: s.players > 0 ? 'var(--success)' : 'var(--muted)' }}>
|
||||
{s.players}
|
||||
{s.playerHardCap > 0 && (
|
||||
<span className="font-normal text-muted">{`/${s.playerHardCap}`}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
case 'queue':
|
||||
return (
|
||||
<span style={{ color: s.queue > 0 ? 'var(--warning)' : 'var(--muted)' }}>
|
||||
{s.queue}
|
||||
</span>
|
||||
)
|
||||
case 'ready':
|
||||
return (
|
||||
<Icon
|
||||
name={s.ready ? 'check' : 'x'}
|
||||
className={`size-4 ${s.ready ? 'text-success' : 'text-danger'}`}
|
||||
/>
|
||||
)
|
||||
case 'dimension': return <span className="text-muted">{s.dimension}</span>
|
||||
case 'partition': return <span className="text-muted">{s.partition}</span>
|
||||
case 'age': return <span className="font-mono text-muted">{formatUptime(s.ageSeconds)}</span>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Chip, Spinner, toast } from '@heroui/react'
|
||||
import { Icon, SectionLabel, FieldInput } from '../../../dune-ui'
|
||||
import { copyText } from '../../../utils/clipboard'
|
||||
import { api } from '../../../api/client'
|
||||
import type { Status, WebInterface } from '../../../api/client'
|
||||
import type { BGInfo, ServerRow } from '../types'
|
||||
import { phaseColor, phaseChipColor, bgUptimeSeconds, allServersReady } from '../helpers'
|
||||
import { formatUptime, portRange } from '../uptime'
|
||||
|
||||
// Cards that read both the battlegroup status and the connection status share
|
||||
// this prop shape.
|
||||
type HealthProps = { bg?: BGInfo, servers: ServerRow[], status: Status | null }
|
||||
|
||||
// ── Card wrapper ──────────────────────────────────────────────────────────────
|
||||
// HealthCard is the titled panel shell every Server Health card shares: an
|
||||
// uppercase section label (optionally icon-led) with an optional right-aligned
|
||||
// accessory, over the card body.
|
||||
export const HealthCard: React.FC<{
|
||||
title: string
|
||||
icon?: string
|
||||
accessory?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}> = ({ title, icon, accessory, className = '', children }) => (
|
||||
<div className={`rounded-[var(--radius)] p-4 flex flex-col gap-3 bg-surface-secondary border border-border dune-lift ${className}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <Icon name={icon} className="size-4 text-accent" />}
|
||||
<SectionLabel>{title}</SectionLabel>
|
||||
</div>
|
||||
{accessory}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── Top status-chip bar ─────────────────────────────────────────────────────
|
||||
export const HealthChips: React.FC<HealthProps> = ({ bg, servers, status }) => {
|
||||
const { t } = useTranslation()
|
||||
const ports = portRange(servers.map((s) => s.port ?? 0))
|
||||
// listen_addr is like ":9090" or "0.0.0.0:9090" — show just the port.
|
||||
const webPort = (status?.listen_addr ?? '').split(':').pop() || '—'
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Chip size="sm" variant="soft" color="default">
|
||||
<Icon name="network" className="size-3" />
|
||||
{' '}
|
||||
{t('serverHealth.gamePorts')}
|
||||
{': '}
|
||||
{ports}
|
||||
</Chip>
|
||||
<Chip size="sm" variant="soft" color="default">
|
||||
<Icon name="globe" className="size-3" />
|
||||
{' '}
|
||||
{t('serverHealth.webPort')}
|
||||
{': '}
|
||||
{webPort}
|
||||
</Chip>
|
||||
<div className="flex-1" />
|
||||
<Chip size="sm" variant="soft" color={phaseChipColor(status?.control && status.control !== 'none' ? 'running' : 'stopped')}>
|
||||
{t('serverHealth.vm')}
|
||||
{' · '}
|
||||
{status?.control && status.control !== 'none' ? t('serverHealth.up') : t('serverHealth.down')}
|
||||
</Chip>
|
||||
<Chip size="sm" variant="soft" color={phaseChipColor(bg?.phase ?? '')}>
|
||||
{t('serverHealth.bg')}
|
||||
{' · '}
|
||||
{bg?.phase || '—'}
|
||||
</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Battlegroup + VM headline card ───────────────────────────────────────────
|
||||
export const BgVmCard: React.FC<{ bg?: BGInfo, servers: ServerRow[] }> = ({ bg, servers }) => {
|
||||
const { t } = useTranslation()
|
||||
const uptime = bgUptimeSeconds(servers)
|
||||
return (
|
||||
<HealthCard title={t('serverHealth.bgVm')} icon="activity">
|
||||
<div className="text-3xl font-semibold" style={{ color: phaseColor(bg?.phase ?? '') }}>
|
||||
{bg?.phase || '—'}
|
||||
</div>
|
||||
<div className="text-sm text-muted">
|
||||
{uptime > 0 ? t('serverHealth.upFor', { uptime: formatUptime(uptime) }) : t('serverHealth.noUptime')}
|
||||
</div>
|
||||
</HealthCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component-health rows ─────────────────────────────────────────────────────
|
||||
const HealthRow: React.FC<{ label: string, value: string, color?: string }> = ({ label, value, color }) => (
|
||||
<div className="flex items-center justify-between py-1 border-b border-border/40 last:border-0">
|
||||
<span className="text-muted text-sm">{label}</span>
|
||||
<span className="font-semibold text-sm" style={color ? { color } : undefined}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const ComponentHealthCard: React.FC<HealthProps> = ({ bg, servers, status }) => {
|
||||
const { t } = useTranslation()
|
||||
const uptime = bgUptimeSeconds(servers)
|
||||
const directorSet = !!status?.director_url
|
||||
return (
|
||||
<HealthCard title={t('serverHealth.components')} icon="server">
|
||||
<div className="flex flex-col">
|
||||
<HealthRow label={t('serverHealth.bgState')} value={bg?.phase || '—'} color={phaseColor(bg?.phase ?? '')} />
|
||||
<HealthRow label={t('serverHealth.database')} value={bg?.database || '—'} color={phaseColor(bg?.database ?? '')} />
|
||||
<HealthRow
|
||||
label={t('serverHealth.director')}
|
||||
value={directorSet ? t('serverHealth.configured') : t('serverHealth.notConfigured')}
|
||||
color={directorSet ? 'var(--success)' : 'var(--muted)'}
|
||||
/>
|
||||
<HealthRow label={t('serverHealth.uptime')} value={formatUptime(uptime)} />
|
||||
</div>
|
||||
</HealthCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Game ready state ──────────────────────────────────────────────────────────
|
||||
export const GameReadyCard: React.FC<{ bg?: BGInfo, servers: ServerRow[] }> = ({ bg, servers }) => {
|
||||
const { t } = useTranslation()
|
||||
const ready = allServersReady(bg?.phase, servers)
|
||||
return (
|
||||
<HealthCard title={t('serverHealth.readyState')} icon="heart-pulse">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name={ready ? 'circle-check' : 'circle-x'} className={`size-6 ${ready ? 'text-success' : 'text-muted'}`} />
|
||||
<span className="text-2xl font-semibold" style={{ color: ready ? 'var(--success)' : 'var(--muted)' }}>
|
||||
{ready ? t('serverHealth.ready') : t('serverHealth.notReady')}
|
||||
</span>
|
||||
</div>
|
||||
</HealthCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Web interfaces (#155: operator-configurable list) ────────────────────────
|
||||
const InterfaceRow: React.FC<{ item: WebInterface }> = ({ item }) => {
|
||||
const { t } = useTranslation()
|
||||
const copy = () => {
|
||||
copyText(item.url).then((ok) =>
|
||||
(ok ? toast.success(t('serverHealth.copied')) : toast.danger(t('serverHealth.copyFailed'))))
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="external-link" className="size-4 text-accent" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-semibold">{item.label}</span>
|
||||
<span className="text-xs text-muted font-mono truncate">{item.url}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" isIconOnly aria-label={t('serverHealth.copy')} onPress={copy}>
|
||||
<Icon name="copy" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onPress={() => window.open(item.url, '_blank', 'noopener')}>
|
||||
{t('serverHealth.open')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DirectorRow is the automatic, read-only entry shown when director_url is set:
|
||||
// the Director usually binds to loopback on the host, so "Open" goes through the
|
||||
// same-origin /director/ reverse proxy. The configured target is shown for context.
|
||||
const DirectorRow: React.FC<{ directorURL: string }> = ({ directorURL }) => {
|
||||
const { t } = useTranslation()
|
||||
const copy = () => {
|
||||
copyText(`${window.location.origin}/director/`).then((ok) =>
|
||||
(ok ? toast.success(t('serverHealth.copied')) : toast.danger(t('serverHealth.copyFailed'))))
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="external-link" className="size-4 text-accent" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('serverHealth.director')}
|
||||
{' '}
|
||||
<span className="text-xs font-normal text-muted">{t('serverHealth.directorProxied')}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted font-mono truncate">{directorURL}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" isIconOnly aria-label={t('serverHealth.copy')} onPress={copy}>
|
||||
<Icon name="copy" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onPress={() => window.open('/director/', '_blank', 'noopener')}>
|
||||
{t('serverHealth.open')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WebInterfacesCard: React.FC<{ status: Status | null }> = ({ status }) => {
|
||||
const { t } = useTranslation()
|
||||
const [items, setItems] = useState<WebInterface[]>([])
|
||||
const [draft, setDraft] = useState<WebInterface[]>([])
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const director = status?.director_url
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.webInterfaces.get())
|
||||
.then((res) => setItems(res.interfaces ?? []))
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('serverHealth.ifaceLoadFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(items.length ? items.map((i) => ({ ...i })) : [{ label: '', url: '' }])
|
||||
setEditing(true)
|
||||
}
|
||||
const setField = (i: number, key: 'label' | 'url', v: string) =>
|
||||
setDraft((d) => d.map((row, idx) => (idx === i ? { ...row, [key]: v } : row)))
|
||||
|
||||
const save = () => {
|
||||
const clean = draft.filter((r) => r.label.trim() && r.url.trim())
|
||||
setSaving(true)
|
||||
api.webInterfaces.update(clean)
|
||||
.then((res) => {
|
||||
toast.success(res.ok)
|
||||
setEditing(false)
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('serverHealth.ifaceSaveFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const editBtn = (
|
||||
<Button size="sm" variant="ghost" isIconOnly aria-label={t('serverHealth.editInterfaces')} onPress={startEdit}>
|
||||
<Icon name="pencil" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<HealthCard title={t('serverHealth.webInterfaces')} icon="layout" accessory={!editing && !loading ? editBtn : undefined}>
|
||||
{loading && <div className="py-2 flex justify-center"><Spinner size="sm" color="current" /></div>}
|
||||
|
||||
{!loading && director && <DirectorRow directorURL={director} />}
|
||||
|
||||
{!loading && editing && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{draft.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<FieldInput
|
||||
value={row.label}
|
||||
placeholder={t('serverHealth.ifaceLabel')}
|
||||
onChange={(v) => setField(i, 'label', v)}
|
||||
ariaLabel={t('serverHealth.ifaceLabel')}
|
||||
className="w-32"
|
||||
/>
|
||||
<FieldInput
|
||||
value={row.url}
|
||||
placeholder={t('serverHealth.ifaceUrl')}
|
||||
onChange={(v) => setField(i, 'url', v)}
|
||||
ariaLabel={t('serverHealth.ifaceUrl')}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isIconOnly
|
||||
aria-label={t('serverHealth.removeInterface')}
|
||||
onPress={() => setDraft((d) => d.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<Icon name="trash-2" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onPress={() => setDraft((d) => [...d, { label: '', url: '' }])}>
|
||||
<Icon name="plus" />
|
||||
{' '}
|
||||
{t('serverHealth.addInterface')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="ghost" onPress={() => setEditing(false)}>{t('common.cancel')}</Button>
|
||||
<Button size="sm" onPress={save} isDisabled={saving}>
|
||||
{saving ? <Spinner size="sm" color="current" /> : t('serverHealth.saveInterfaces')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !editing && !director && items.length === 0 && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted">{t('serverHealth.noInterfaces')}</span>
|
||||
<Button size="sm" variant="outline" onPress={startEdit}>
|
||||
<Icon name="plus" />
|
||||
{' '}
|
||||
{t('serverHealth.addInterface')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !editing && items.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((it) => <InterfaceRow key={`${it.label}|${it.url}`} item={it} />)}
|
||||
</div>
|
||||
)}
|
||||
</HealthCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Map a server / battlegroup phase string to a CSS color from our semantic
|
||||
* tokens. Used for the inline-text phase label in the InfoCard and the
|
||||
* Phase column of the servers table.
|
||||
*/
|
||||
export function phaseColor(phase: string): string {
|
||||
switch (phase?.toLowerCase()) {
|
||||
case 'running': return 'var(--success)'
|
||||
case 'reconciling':
|
||||
case 'starting':
|
||||
case 'initializing': return 'var(--warning)'
|
||||
case 'stopping':
|
||||
case 'preshutdown':
|
||||
case 'terminating': return 'var(--danger)'
|
||||
case 'stopped':
|
||||
case 'terminated': return 'var(--muted)'
|
||||
default: return 'var(--muted)'
|
||||
}
|
||||
}
|
||||
|
||||
export type ChipColor = 'default' | 'success' | 'warning' | 'danger'
|
||||
|
||||
/**
|
||||
* Map a phase string to a HeroUI Chip colour (the chip variant of [[phaseColor]]).
|
||||
* Used for the Server Health status chips and component-health rows.
|
||||
*/
|
||||
export function phaseChipColor(phase: string): ChipColor {
|
||||
switch (phase?.toLowerCase()) {
|
||||
case 'running':
|
||||
case 'ready':
|
||||
case 'connected':
|
||||
case 'healthy': return 'success'
|
||||
case 'reconciling':
|
||||
case 'starting':
|
||||
case 'initializing': return 'warning'
|
||||
case 'stopping':
|
||||
case 'preshutdown':
|
||||
case 'terminating':
|
||||
case 'disconnected': return 'danger'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
/** BG uptime = the oldest running game process's age (0 when unknown). */
|
||||
export function bgUptimeSeconds(servers: { ageSeconds?: number }[]): number {
|
||||
return servers.reduce((max, s) => Math.max(max, s.ageSeconds ?? 0), 0)
|
||||
}
|
||||
|
||||
/** Game is "ready" only when every running server reports ready. */
|
||||
export function allServersReady(phase: string | undefined, servers: { ready: boolean }[]): boolean {
|
||||
return servers.length > 0 && phase === 'Running' && servers.every((s) => s.ready)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import type React from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
|
||||
import { Button, Input, Select, ListBox, Spinner, toast, TextField } from '@heroui/react'
|
||||
import { api } from '../../api/client'
|
||||
import type { BackupFile } from '../../api/client'
|
||||
import { NumberInput, PageHeader, SectionDivider, Icon } from '../../dune-ui'
|
||||
import { ScheduledRestartsCard } from '../../components/ScheduledRestartsCard'
|
||||
import { useStatus } from '../../hooks/useStatus'
|
||||
|
||||
import { ACTIONS, INIT_WARN_MS, type ActionDef, type DetailedStatus } from './types'
|
||||
import { ServersTable } from './ServersTable'
|
||||
import {
|
||||
HealthCard, HealthChips, BgVmCard, ComponentHealthCard, GameReadyCard, WebInterfacesCard,
|
||||
} from './components/ServerHealth'
|
||||
import { ConfirmDialog } from './modals/ConfirmDialog'
|
||||
import { CommandOutputModal } from './modals/CommandOutputModal'
|
||||
import { RestoreModal } from './modals/RestoreModal'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
|
||||
interface BattlegroupTabProps {
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const BattlegroupTab: React.FC<BattlegroupTabProps> = ({ isActive = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const { status: connStatus } = useStatus()
|
||||
const [status, setStatus] = useState<DetailedStatus | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(false)
|
||||
|
||||
// Command lifecycle
|
||||
const [runningCmd, setRunningCmd] = useState<string | null>(null)
|
||||
const [cmdOutput, setCmdOutput] = useState<string | null>(null)
|
||||
const [cmdDone, setCmdDone] = useState(false)
|
||||
const [confirmCmd, setConfirmCmd] = useState<ActionDef | null>(null)
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null)
|
||||
const [lastBackupFile, setLastBackupFile] = useState<string | null>(null)
|
||||
|
||||
// Broadcasts
|
||||
const [broadcastTitle, setBroadcastTitle] = useState('')
|
||||
const [broadcastBody, setBroadcastBody] = useState('')
|
||||
const [broadcastDuration, setBroadcastDuration] = useState(30)
|
||||
const [broadcastBusy, setBroadcastBusy] = useState(false)
|
||||
const [shutdownType, setShutdownType] = useState('Restart')
|
||||
const [shutdownDelay, setShutdownDelay] = useState(10)
|
||||
const [shutdownBusy, setShutdownBusy] = useState(false)
|
||||
|
||||
// Restore modal
|
||||
const [showRestore, setShowRestore] = useState(false)
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
const [backupFilesLoading, setBackupFilesLoading] = useState(false)
|
||||
|
||||
const fetchStatus = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setStatusLoading(true))
|
||||
.then(() => api.battlegroup.status() as Promise<unknown>)
|
||||
.then((res) => setStatus(res as DetailedStatus))
|
||||
.catch((e: unknown) => toast.danger(t('battlegroup.statusFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setStatusLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
const { countdown, refresh: refreshStatus } = useAutoRefresh(fetchStatus, POLL_MS, isActive)
|
||||
|
||||
// isInitializing tracks whether we're inside the post-start warning window.
|
||||
// We use a boolean state rather than computing from Date.now() in render (impure).
|
||||
const [isInitializing, setIsInitializing] = useState(false)
|
||||
useEffect(() => {
|
||||
if (startedAt === null) {
|
||||
const t = setTimeout(() => setIsInitializing(false), 0)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
const remaining = INIT_WARN_MS - (Date.now() - startedAt)
|
||||
if (remaining <= 0) {
|
||||
const t = setTimeout(() => setStartedAt(null), 0)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
const tStart = setTimeout(() => setIsInitializing(true), 0)
|
||||
const tEnd = setTimeout(() => {
|
||||
setStartedAt(null)
|
||||
setIsInitializing(false)
|
||||
}, remaining)
|
||||
return () => {
|
||||
clearTimeout(tStart)
|
||||
clearTimeout(tEnd)
|
||||
}
|
||||
}, [startedAt])
|
||||
|
||||
const runCmd = async (action: ActionDef) => {
|
||||
setConfirmCmd(null)
|
||||
setRunningCmd(action.cmd)
|
||||
setCmdOutput(null)
|
||||
setCmdDone(false)
|
||||
try {
|
||||
const res = await api.battlegroup.exec(action.cmd)
|
||||
setCmdOutput(res.output || t('battlegroup.noOutput'))
|
||||
setCmdDone(true)
|
||||
if (action.cmd === 'start' || action.cmd === 'restart') setStartedAt(Date.now())
|
||||
if (action.cmd === 'backup') {
|
||||
const match = (res.output || '').match(/database-dumps\/[^/]+\/([^\s]+\.backup)/)
|
||||
if (match) setLastBackupFile(match[1])
|
||||
}
|
||||
toast.success(t('battlegroup.cmdCompleted', { label: t(`battlegroup.actions.${action.cmd}` as never) }))
|
||||
fetchStatus()
|
||||
}
|
||||
catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setCmdOutput(`Error: ${msg}`)
|
||||
setCmdDone(true)
|
||||
toast.danger(t('battlegroup.cmdFailed', { label: t(`battlegroup.actions.${action.cmd}` as never), message: msg }))
|
||||
}
|
||||
}
|
||||
|
||||
const openRestore = () => {
|
||||
setBackupFilesLoading(true)
|
||||
setBackupFiles([])
|
||||
setShowRestore(true)
|
||||
api.battlegroup.backupFiles()
|
||||
.then(setBackupFiles)
|
||||
.catch(() => toast.danger(t('battlegroup.backupLoadFailed')))
|
||||
.finally(() => setBackupFilesLoading(false))
|
||||
}
|
||||
|
||||
const bg = status?.battlegroup
|
||||
const servers = status?.servers ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
|
||||
{/* ── Header ───────────────────────────────────────────────────── */}
|
||||
<PageHeader
|
||||
title={t('serverHealth.title')}
|
||||
subtitle={t('serverHealth.subtitle')}
|
||||
onRefresh={refreshStatus}
|
||||
loading={statusLoading}
|
||||
countdown={isActive ? countdown : undefined}
|
||||
/>
|
||||
|
||||
<HealthChips bg={bg} servers={servers} status={connStatus} />
|
||||
|
||||
{isInitializing && (
|
||||
<div className="rounded-[var(--radius)] px-3 py-2 text-sm flex items-center gap-2 bg-warning/10 text-warning border border-warning/40 shrink-0">
|
||||
<Icon name="triangle-alert" />
|
||||
<span>{t('battlegroup.initWarning')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scrollable health body ───────────────────────────────────── */}
|
||||
<div className="flex-1 min-h-0 overflow-auto flex flex-col gap-3 pr-1">
|
||||
|
||||
{/* Health card grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<BgVmCard bg={bg} servers={servers} />
|
||||
<GameReadyCard bg={bg} servers={servers} />
|
||||
<ComponentHealthCard bg={bg} servers={servers} status={connStatus} />
|
||||
<WebInterfacesCard status={connStatus} />
|
||||
</div>
|
||||
|
||||
{/* Game servers */}
|
||||
<HealthCard
|
||||
title={t('serverHealth.gameServers')}
|
||||
icon="boxes"
|
||||
accessory={<span className="text-xs text-muted tabular-nums">{t('serverHealth.pods', { n: servers.length })}</span>}
|
||||
>
|
||||
{statusLoading && !status
|
||||
? (
|
||||
<div className="flex items-center gap-2 py-4 text-muted">
|
||||
<Spinner size="sm" color="current" />
|
||||
<span className="text-sm">{t('battlegroup.loadingStatus')}</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ServersTable
|
||||
servers={servers}
|
||||
isInitializing={isInitializing}
|
||||
emptyMessage={status ? t('battlegroup.noGameServers') : t('battlegroup.clickRefresh')}
|
||||
/>
|
||||
)}
|
||||
</HealthCard>
|
||||
|
||||
{/* ── Server Control ───────────────────────────────────────────── */}
|
||||
<SectionDivider title={t('battlegroup.serverControl')} />
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
{ACTIONS.map((action) => (
|
||||
<Button
|
||||
key={action.cmd}
|
||||
variant={action.danger ? 'danger-soft' : 'outline'}
|
||||
onPress={() => setConfirmCmd(action)}
|
||||
isDisabled={runningCmd !== null}
|
||||
size="sm"
|
||||
>
|
||||
{t(`battlegroup.actions.${action.cmd}` as never)}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="danger-soft" size="sm" isDisabled={runningCmd !== null} onPress={openRestore}>
|
||||
{t('battlegroup.restoreLabel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Broadcasts ──────────────────────────────────────────────── */}
|
||||
<SectionDivider title={t('battlegroup.broadcasts')} />
|
||||
<div className="flex flex-wrap gap-3 shrink-0">
|
||||
|
||||
{/* Generic broadcast */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-w-64 rounded-[var(--radius)] border border-border bg-surface p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-widest text-accent">{t('battlegroup.genericMessage')}</div>
|
||||
<TextField aria-label={t('battlegroup.titlePlaceholder')}>
|
||||
<Input placeholder={t('battlegroup.titlePlaceholder')} value={broadcastTitle} onChange={(e) => setBroadcastTitle(e.target.value)} />
|
||||
</TextField>
|
||||
<TextField aria-label={t('battlegroup.bodyPlaceholder')}>
|
||||
<Input placeholder={t('battlegroup.bodyPlaceholder')} value={broadcastBody} onChange={(e) => setBroadcastBody(e.target.value)} />
|
||||
</TextField>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted shrink-0">{t('battlegroup.durationLabel')}</label>
|
||||
<NumberInput
|
||||
ariaLabel={t('battlegroup.durationLabel')}
|
||||
min={5}
|
||||
max={300}
|
||||
value={broadcastDuration}
|
||||
onChange={setBroadcastDuration}
|
||||
showButtons={false}
|
||||
className="w-24"
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
isDisabled={broadcastBusy || !broadcastTitle}
|
||||
onPress={async () => {
|
||||
setBroadcastBusy(true)
|
||||
try {
|
||||
await api.broadcast.send([{ Key: 'en', Title: broadcastTitle, Body: broadcastBody }], broadcastDuration)
|
||||
toast.success(t('battlegroup.broadcastSent'))
|
||||
setBroadcastTitle('')
|
||||
setBroadcastBody('')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
finally { setBroadcastBusy(false) }
|
||||
}}
|
||||
>
|
||||
{broadcastBusy
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
<Icon name="megaphone" />
|
||||
{' '}
|
||||
{t('common.send')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shutdown broadcast */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-w-64 rounded-[var(--radius)] border border-border bg-surface p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-widest text-accent">{t('battlegroup.shutdownBroadcast')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted shrink-0">{t('battlegroup.shutdownType')}</label>
|
||||
<Select selectedKey={shutdownType} onSelectionChange={(k) => setShutdownType(String(k))} className="flex-1" aria-label={t('battlegroup.shutdownTypeLabel')}>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{['Restart', 'Maintenance', 'Update'].map((t) => (
|
||||
<ListBox.Item key={t} id={t} textValue={t}>
|
||||
{t}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted shrink-0">{t('battlegroup.shutdownDelay')}</label>
|
||||
<NumberInput
|
||||
ariaLabel={t('battlegroup.shutdownDelayLabel')}
|
||||
min={1}
|
||||
max={120}
|
||||
value={shutdownDelay}
|
||||
onChange={setShutdownDelay}
|
||||
showButtons={false}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
isDisabled={shutdownBusy}
|
||||
onPress={async () => {
|
||||
setShutdownBusy(true)
|
||||
try {
|
||||
await api.broadcast.shutdown(shutdownType, shutdownDelay)
|
||||
toast.success(t('battlegroup.shutdownSent', { delay: shutdownDelay }))
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
finally { setShutdownBusy(false) }
|
||||
}}
|
||||
>
|
||||
{shutdownBusy
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
<Icon name="triangle-alert" />
|
||||
{' '}
|
||||
{t('battlegroup.broadcastBtn')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={shutdownBusy}
|
||||
onPress={async () => {
|
||||
setShutdownBusy(true)
|
||||
try {
|
||||
await api.broadcast.shutdown(shutdownType, 0, true)
|
||||
toast.success(t('battlegroup.shutdownCancelled'))
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
finally { setShutdownBusy(false) }
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Scheduled Restarts (#145) ──────────────────────────────── */}
|
||||
<SectionDivider title={t('restarts.title')} />
|
||||
<ScheduledRestartsCard />
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Modals ───────────────────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
action={confirmCmd}
|
||||
onConfirm={runCmd}
|
||||
onClose={() => setConfirmCmd(null)}
|
||||
/>
|
||||
<CommandOutputModal
|
||||
runningCmd={runningCmd}
|
||||
cmdOutput={cmdOutput}
|
||||
cmdDone={cmdDone}
|
||||
lastBackupFile={lastBackupFile}
|
||||
onClose={() => {
|
||||
setRunningCmd(null)
|
||||
setCmdOutput(null)
|
||||
}}
|
||||
/>
|
||||
<RestoreModal
|
||||
open={showRestore}
|
||||
backupFiles={backupFiles}
|
||||
backupFilesLoading={backupFilesLoading}
|
||||
setBackupFiles={setBackupFiles}
|
||||
onClose={() => setShowRestore(false)}
|
||||
onRestoreComplete={(output) => {
|
||||
setCmdOutput(output)
|
||||
setCmdDone(true)
|
||||
setRunningCmd('restore')
|
||||
setShowRestore(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Modal, Spinner } from '@heroui/react'
|
||||
import { api } from '../../../api/client'
|
||||
import { Icon } from '../../../dune-ui'
|
||||
|
||||
type CommandOutputModalProps = {
|
||||
runningCmd: string | null
|
||||
cmdOutput: string | null
|
||||
cmdDone: boolean
|
||||
lastBackupFile: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const CommandOutputModal: React.FC<CommandOutputModalProps> = ({
|
||||
runningCmd, cmdOutput, cmdDone, lastBackupFile, onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Modal>
|
||||
<Modal.Backdrop isOpen={runningCmd !== null} onOpenChange={(v) => { if (!v && cmdDone) onClose() }}>
|
||||
<Modal.Container>
|
||||
<Modal.Dialog>
|
||||
<Modal.Header><Modal.Heading>{runningCmd ? t(`battlegroup.actions.${runningCmd}` as never) : ''}</Modal.Heading></Modal.Header>
|
||||
<Modal.Body>
|
||||
{!cmdDone
|
||||
? (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-muted">
|
||||
{t('battlegroup.runningCmd', { cmd: runningCmd?.toLowerCase() ?? '' })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="rounded-[var(--radius)] p-3 font-mono text-xs overflow-auto max-h-60 bg-background border border-border text-success">
|
||||
<pre className="m-0 whitespace-pre-wrap">{cmdOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
{cmdDone && (
|
||||
<Modal.Footer>
|
||||
{lastBackupFile && runningCmd === 'backup' && (
|
||||
<a
|
||||
href={api.battlegroup.backupDownloadUrl(lastBackupFile)}
|
||||
download={lastBackupFile.replace('.backup', '.zip')}
|
||||
className="text-sm px-3 py-1.5 rounded-[var(--radius)] inline-flex items-center gap-1.5 bg-success/10 text-success border border-success/40 no-underline hover:bg-success/20"
|
||||
>
|
||||
<Icon name="download" />
|
||||
{' '}
|
||||
{t('battlegroup.modal.download')}
|
||||
</a>
|
||||
)}
|
||||
<Button onPress={onClose}>{t('common.close')}</Button>
|
||||
</Modal.Footer>
|
||||
)}
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertDialog, Button } from '@heroui/react'
|
||||
import type { ActionDef } from '../types'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
action: ActionDef | null
|
||||
onConfirm: (a: ActionDef) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ action, onConfirm, onClose }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AlertDialog.Backdrop isOpen={action !== null} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<AlertDialog.Container size="sm">
|
||||
<AlertDialog.Dialog>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Icon status={action?.danger ? 'danger' : 'accent'} />
|
||||
<AlertDialog.Heading>
|
||||
{action ? t(`battlegroup.actions.${action.cmd}` as never) : ''}
|
||||
{' '}
|
||||
{t('battlegroup.confirm.serverSuffix')}
|
||||
</AlertDialog.Heading>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Body>
|
||||
<p className="text-sm text-muted">
|
||||
{action ? t(`battlegroup.actions.${action.cmd}Msg` as never) : ''}
|
||||
</p>
|
||||
</AlertDialog.Body>
|
||||
<AlertDialog.Footer>
|
||||
<Button slot="close" variant="ghost" onPress={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
slot="close"
|
||||
variant={action?.danger ? 'danger-soft' : 'primary'}
|
||||
onPress={() => action && onConfirm(action)}
|
||||
>
|
||||
{t('battlegroup.confirm.confirmPrefix')}
|
||||
{' '}
|
||||
{action ? t(`battlegroup.actions.${action.cmd}` as never) : ''}
|
||||
</Button>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Dialog>
|
||||
</AlertDialog.Container>
|
||||
</AlertDialog.Backdrop>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Modal, Spinner, toast } from '@heroui/react'
|
||||
import { api } from '../../../api/client'
|
||||
import type { BackupFile } from '../../../api/client'
|
||||
import { Dropzone, Icon } from '../../../dune-ui'
|
||||
|
||||
type RestoreModalProps = {
|
||||
open: boolean
|
||||
backupFiles: BackupFile[]
|
||||
backupFilesLoading: boolean
|
||||
setBackupFiles: (files: BackupFile[]) => void
|
||||
onClose: () => void
|
||||
onRestoreComplete: (output: string) => void
|
||||
}
|
||||
|
||||
export const RestoreModal: React.FC<RestoreModalProps> = ({
|
||||
open, backupFiles, backupFilesLoading, setBackupFiles, onClose, onRestoreComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedFile, setSelectedFile] = useState('')
|
||||
const [restoreRunning, setRestoreRunning] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await api.battlegroup.backupUpload(file)
|
||||
toast.success(t('battlegroup.restore.uploaded', { name: res.name }))
|
||||
const updated = await api.battlegroup.backupFiles()
|
||||
setBackupFiles(updated)
|
||||
setSelectedFile(res.name)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<Modal.Backdrop isOpen={open} onOpenChange={(v) => { if (!v && !restoreRunning) onClose() }}>
|
||||
<Modal.Container>
|
||||
<Modal.Dialog className="w-[640px] max-w-[90vw]">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header><Modal.Heading>{t('battlegroup.restore.title')}</Modal.Heading></Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-sm mb-3 text-danger flex items-center gap-1.5">
|
||||
<Icon name="triangle-alert" />
|
||||
{' '}
|
||||
{t('battlegroup.restore.warning')}
|
||||
</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<Dropzone
|
||||
accept=".backup,.zip"
|
||||
uploading={uploading}
|
||||
onSelect={uploadFile}
|
||||
prompt={t('battlegroup.restore.dropzone')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{backupFilesLoading
|
||||
? (
|
||||
<div className="flex justify-center py-4"><Spinner /></div>
|
||||
)
|
||||
: backupFiles.length === 0
|
||||
? (
|
||||
<p className="text-sm text-muted">{t('battlegroup.restore.noBackups')}</p>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col gap-1">
|
||||
{backupFiles.map((f) => {
|
||||
const isSelected = selectedFile === f.name
|
||||
return (
|
||||
<label
|
||||
key={f.name}
|
||||
className={
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 cursor-pointer border '
|
||||
+ (isSelected
|
||||
? 'bg-success/10 border-success/40'
|
||||
: 'bg-background border-border hover:border-warning/60')
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="restore-file"
|
||||
value={f.name}
|
||||
checked={isSelected}
|
||||
onChange={() => setSelectedFile(f.name)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-mono">{f.name}</div>
|
||||
<div className="text-xs flex items-center gap-2 text-muted">
|
||||
<span>
|
||||
{(f.size_bytes / 1024 / 1024).toFixed(1)}
|
||||
{' '}
|
||||
MB ·
|
||||
{' '}
|
||||
{f.modified}
|
||||
</span>
|
||||
{f.has_yaml && (
|
||||
<span className="px-1 rounded bg-success/10 text-success text-[10px] border border-success/30">+yaml</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={api.battlegroup.backupDownloadUrl(f.name)}
|
||||
download={f.name.replace('.backup', '.zip')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs px-2 py-1 rounded bg-accent/10 text-accent border border-accent/30 no-underline hover:bg-accent/20"
|
||||
aria-label="Download"
|
||||
>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={onClose} isDisabled={restoreRunning}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
isDisabled={!selectedFile || restoreRunning || backupFilesLoading}
|
||||
onPress={async () => {
|
||||
setRestoreRunning(true)
|
||||
try {
|
||||
const res = await api.battlegroup.restore(selectedFile)
|
||||
toast.success(t('battlegroup.restore.restoreCompleted'))
|
||||
onRestoreComplete(res.output || '(done)')
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
finally {
|
||||
setRestoreRunning(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{restoreRunning
|
||||
? <Spinner size="sm" color="current" />
|
||||
: t('battlegroup.restore.restoreBtn', { file: selectedFile ? selectedFile.slice(-20) : '' })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { Column } from '../../dune-ui'
|
||||
|
||||
export type ServerSortKey = 'map' | 'phase' | 'players' | 'queue' | 'ready' | 'dimension' | 'partition' | 'age'
|
||||
|
||||
export type ServerRow = {
|
||||
map: string
|
||||
sietch: string
|
||||
dimension: number
|
||||
partition: number
|
||||
phase: string
|
||||
ready: boolean
|
||||
players: number
|
||||
playerHardCap: number
|
||||
queue: number
|
||||
port?: number
|
||||
ageSeconds?: number
|
||||
}
|
||||
|
||||
export type BGInfo = {
|
||||
name: string
|
||||
title: string
|
||||
phase: string
|
||||
database: string
|
||||
}
|
||||
|
||||
export type DetailedStatus = {
|
||||
battlegroup: BGInfo
|
||||
servers: ServerRow[]
|
||||
}
|
||||
|
||||
export type ActionDef = {
|
||||
label: string
|
||||
cmd: string
|
||||
danger: boolean
|
||||
msg: string
|
||||
}
|
||||
|
||||
export function getServerColumns(t: TFunction): Column<ServerSortKey>[] {
|
||||
return [
|
||||
{ key: 'map', label: t('battlegroup.columns.map'), isRowHeader: true },
|
||||
{ key: 'phase', label: t('battlegroup.columns.phase'), width: 100 },
|
||||
{ key: 'players', label: t('battlegroup.columns.players'), width: 80 },
|
||||
{ key: 'queue', label: t('battlegroup.columns.queue'), width: 70 },
|
||||
{ key: 'ready', label: t('battlegroup.columns.ready'), width: 70 },
|
||||
{ key: 'dimension', label: t('battlegroup.columns.dim'), width: 60 },
|
||||
{ key: 'partition', label: t('battlegroup.columns.part'), width: 60 },
|
||||
{ key: 'age', label: t('battlegroup.columns.age'), width: 80 },
|
||||
]
|
||||
}
|
||||
|
||||
export const ACTIONS: ActionDef[] = [
|
||||
{ label: 'start', cmd: 'start', danger: false, msg: 'startMsg' },
|
||||
{ label: 'stop', cmd: 'stop', danger: true, msg: 'stopMsg' },
|
||||
{ label: 'restart', cmd: 'restart', danger: false, msg: 'restartMsg' },
|
||||
{ label: 'update', cmd: 'update', danger: false, msg: 'updateMsg' },
|
||||
{ label: 'backup', cmd: 'backup', danger: false, msg: 'backupMsg' },
|
||||
]
|
||||
|
||||
export const INIT_WARN_MS = 3 * 60 * 1000
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* formatUptime turns an elapsed-seconds count into a compact human label
|
||||
* ("73s", "15m", "1h 15m", "18d 4h"). Pure — takes the value, never reads the
|
||||
* clock — so it's stable in render and trivially testable. Returns "—" for 0 /
|
||||
* missing values (e.g. control planes that don't source process age).
|
||||
*/
|
||||
export function formatUptime(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) return '—'
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
if (m > 0) return `${m}m`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* portRange collapses the running servers' UDP ports into a single label —
|
||||
* "7777" for one, "7777–7810" for a span, "—" when none are known.
|
||||
*/
|
||||
export function portRange(ports: number[]): string {
|
||||
const valid = ports.filter((p) => p > 0).sort((a, b) => a - b)
|
||||
if (valid.length === 0) return '—'
|
||||
const lo = valid[0]
|
||||
const hi = valid[valid.length - 1]
|
||||
return lo === hi ? `${lo}` : `${lo}–${hi}`
|
||||
}
|
||||
284
docs/reference-repos/icehunter/web/src/tabs/BlueprintsTab.tsx
Normal file
284
docs/reference-repos/icehunter/web/src/tabs/BlueprintsTab.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
ListBox,
|
||||
ListLayout,
|
||||
Modal,
|
||||
Select,
|
||||
Spinner,
|
||||
TextField,
|
||||
Virtualizer,
|
||||
toast,
|
||||
} from '@heroui/react'
|
||||
import { api } from '../api/client'
|
||||
import type { BlueprintRow, Player } from '../api/client'
|
||||
import { DataTable, Dropzone, Icon, PageHeader, type Column } from '../dune-ui'
|
||||
|
||||
type Key = 'id' | 'owner_name' | 'name' | 'item_id' | 'pieces' | 'placeables' | 'actions'
|
||||
|
||||
interface BlueprintsTabProps {
|
||||
isSignedIn?: boolean
|
||||
}
|
||||
|
||||
export const BlueprintsTab: React.FC<BlueprintsTabProps> = ({ isSignedIn = true }) => {
|
||||
const { t } = useTranslation()
|
||||
const [blueprints, setBlueprints] = useState<BlueprintRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
|
||||
const COLUMNS: Column<Key>[] = [
|
||||
{ key: 'id', label: t('blueprints.columns.id'), width: 80 },
|
||||
{ key: 'owner_name', label: t('blueprints.columns.owner'), minWidth: 140 },
|
||||
{ key: 'name', label: t('blueprints.columns.name'), minWidth: 200 },
|
||||
{ key: 'item_id', label: t('blueprints.columns.itemId'), minWidth: 200 },
|
||||
{ key: 'pieces', label: t('blueprints.columns.pieces'), width: 100 },
|
||||
{ key: 'placeables', label: t('blueprints.columns.placeables'), width: 110 },
|
||||
{ key: 'actions', label: '', width: 110, sortable: false },
|
||||
]
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.blueprints.list())
|
||||
.then(setBlueprints)
|
||||
.catch((e: unknown) => toast.danger(t('blueprints.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
{!isSignedIn && (
|
||||
<div className="shrink-0 rounded-[var(--radius)] px-4 py-2 text-xs font-medium bg-danger/10 border border-danger/40 text-danger flex items-center gap-2">
|
||||
<Icon name="triangle-alert" />
|
||||
<span>
|
||||
A
|
||||
{' '}
|
||||
<strong>{t('blueprints.layoutAccountStrong')}</strong>
|
||||
{' '}
|
||||
account is required to export or import blueprints. Sign in using the button
|
||||
in the top right.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title={t('blueprints.title', { count: blueprints.length })}
|
||||
subtitle={t('blueprints.subtitle')}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
|
||||
{loading
|
||||
? (
|
||||
<Spinner size="sm" color="current" />
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Icon name="refresh-cw" />
|
||||
{' '}
|
||||
{t('common.refresh')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" onPress={() => setShowImport(true)} isDisabled={!isSignedIn}>
|
||||
<Icon name="upload" />
|
||||
{' '}
|
||||
{t('blueprints.importBlueprint')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<DataTable<BlueprintRow, Key>
|
||||
aria-label={t('blueprints.ariaLabel')}
|
||||
className="min-h-0 max-h-full"
|
||||
columns={COLUMNS}
|
||||
rows={blueprints}
|
||||
loading={loading}
|
||||
rowId={(b) => String(b.id)}
|
||||
initialSort={{ column: 'id', direction: 'ascending' }}
|
||||
sortValue={(b, k) => (k === 'actions' ? '' : (b as unknown as Record<string, string | number>)[k])}
|
||||
emptyState={<div className="py-8 text-center text-muted">{t('blueprints.noBlueprintsFound')}</div>}
|
||||
renderCell={(b, key) => {
|
||||
switch (key) {
|
||||
case 'id':
|
||||
return <span className="font-mono text-muted">{b.id}</span>
|
||||
case 'owner_name':
|
||||
return b.owner_name
|
||||
case 'name':
|
||||
return b.name || <span className="text-muted">—</span>
|
||||
case 'item_id':
|
||||
return <span className="font-mono text-muted">{b.item_id}</span>
|
||||
case 'pieces':
|
||||
return <span className="text-muted">{b.pieces}</span>
|
||||
case 'placeables':
|
||||
return <span className="text-muted">{b.placeables}</span>
|
||||
case 'actions':
|
||||
return isSignedIn
|
||||
? (
|
||||
<a
|
||||
href={api.blueprints.exportUrl(b.id)}
|
||||
download={b.name ? `${b.name.replace(/[/\\:*?"<>|]/g, '_')}.json` : `blueprint_${b.id}.json`}
|
||||
>
|
||||
<Button size="sm" variant="outline" className="w-full">
|
||||
<Icon name="download" />
|
||||
{' '}
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
</a>
|
||||
)
|
||||
: (
|
||||
<Button size="sm" variant="outline" className="w-full" isDisabled>
|
||||
<Icon name="download" />
|
||||
{' '}
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImportModal
|
||||
open={showImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
onSuccess={() => {
|
||||
setShowImport(false)
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function ImportModal({ open, onClose, onSuccess }: ImportModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
const [selectedPlayerId, setSelectedPlayerId] = useState<number | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
setFile(null)
|
||||
setSelectedPlayerId(null)
|
||||
})
|
||||
.then(() => api.players.list())
|
||||
.then(setPlayers)
|
||||
.catch(() => {})
|
||||
}, [open])
|
||||
|
||||
const selectedPlayer = players.find((p) => p.id === selectedPlayerId) ?? null
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
toast.warning(t('blueprints.selectFile'))
|
||||
return
|
||||
}
|
||||
if (!selectedPlayer) {
|
||||
toast.warning(t('blueprints.selectPlayer'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await api.blueprints.import(file, selectedPlayer.id)
|
||||
if (res.ok) {
|
||||
toast.success(t('blueprints.importSuccess'))
|
||||
onSuccess()
|
||||
}
|
||||
else {
|
||||
toast.danger(t('blueprints.importFailed', { message: res.error ?? 'unknown error' }))
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(t('blueprints.importFailed', { message: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<Modal.Backdrop isOpen={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<Modal.Container>
|
||||
<Modal.Dialog>
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading className="text-accent">{t('blueprints.importModal.title')}</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-4">
|
||||
<TextField>
|
||||
<Label>{t('blueprints.importModal.blueprintFile')}</Label>
|
||||
<Dropzone
|
||||
accept=".json"
|
||||
file={file}
|
||||
onSelect={setFile}
|
||||
prompt={t('blueprints.importModal.dropzone')}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>{t('blueprints.importModal.playerLabel')}</Label>
|
||||
<Select
|
||||
aria-label={t('blueprints.importModal.playerLabel')}
|
||||
placeholder={t('blueprints.importModal.playerPlaceholder')}
|
||||
selectedKey={selectedPlayerId !== null ? String(selectedPlayerId) : null}
|
||||
onSelectionChange={(k) => setSelectedPlayerId(k ? Number(k) : null)}
|
||||
className="w-full"
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover className="!w-[320px] !max-w-[90vw]">
|
||||
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight: 36 }}>
|
||||
<ListBox
|
||||
aria-label={t('blueprints.importModal.playersLabel')}
|
||||
className="overflow-y-auto"
|
||||
style={{ height: Math.min(players.length * 36 + 8, 320) }}
|
||||
items={players.map((p) => ({ id: String(p.id), name: p.name, actorId: p.id }))}
|
||||
>
|
||||
{(item: { id: string, name: string, actorId: number }) => (
|
||||
<ListBox.Item id={item.id} textValue={item.name}>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span>{item.name}</span>
|
||||
<span className="text-xs text-muted font-mono">
|
||||
#
|
||||
{item.actorId}
|
||||
</span>
|
||||
</span>
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
)}
|
||||
</ListBox>
|
||||
</Virtualizer>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</TextField>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" slot="close">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onPress={handleSubmit} isDisabled={submitting || !file || !selectedPlayer}>
|
||||
{submitting ? <Spinner size="sm" color="current" /> : <Icon name="upload" />}
|
||||
{t('blueprints.importModal.import')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Spinner, Switch, ToggleButton, ToggleButtonGroup, toast } from '@heroui/react'
|
||||
import { api } from '../../../api/client'
|
||||
import type { DBBackupFile, ScheduledBackups, BackupRule } from '../../../api/client'
|
||||
import { Panel, SectionLabel, PageHeader, Icon, ConfirmDialog, NumberInput, TimeInput } from '../../../dune-ui'
|
||||
import { TimezoneSelect } from '../../../components/TimezoneSelect'
|
||||
|
||||
const DOW = [0, 1, 2, 3, 4, 5, 6] // Sun..Sat
|
||||
|
||||
function fmtSize(b: number): string {
|
||||
if (b < 1024) return `${b} B`
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(b / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
// ── Backup schedule card (self-contained, mirrors ScheduledRestartsCard) ──────
|
||||
const ScheduleCard: React.FC = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [data, setData] = useState<ScheduledBackups | null>(null)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [timezone, setTimezone] = useState('')
|
||||
const [keepN, setKeepN] = useState(0)
|
||||
const [rules, setRules] = useState<BackupRule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const apply = (d: ScheduledBackups) => {
|
||||
setData(d)
|
||||
setEnabled(d.enabled)
|
||||
setTimezone(d.timezone)
|
||||
setKeepN(d.keep_n || 0)
|
||||
setRules(d.rules ?? [])
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.scheduledBackups.get())
|
||||
.then(apply)
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('backups.loadFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const save = () => {
|
||||
setSaving(true)
|
||||
api.scheduledBackups.update({ enabled, timezone, rules, keep_n: keepN })
|
||||
.then((res) => {
|
||||
toast.success(res.ok)
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('backups.schedule.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const addRule = () => setRules((r) => [...r, { days: [...DOW], time: '04:00' }])
|
||||
const removeRule = (i: number) => setRules((r) => r.filter((_, idx) => idx !== i))
|
||||
const setRuleTime = (i: number, time: string) =>
|
||||
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, time } : rule)))
|
||||
const setRuleDays = (i: number, days: number[]) =>
|
||||
setRules((r) => r.map((rule, idx) => (idx === i ? { ...rule, days } : rule)))
|
||||
|
||||
const dowLabel = (d: number) =>
|
||||
new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }).format(new Date(Date.UTC(2023, 0, 1 + d)))
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<SectionLabel>{t('backups.schedule.title')}</SectionLabel>
|
||||
<Switch isSelected={enabled} onChange={setEnabled} size="sm" className="text-xs text-muted">
|
||||
<Switch.Control><Switch.Thumb /></Switch.Control>
|
||||
<Switch.Content>{t('backups.schedule.enable')}</Switch.Content>
|
||||
</Switch>
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-2">{t('backups.schedule.desc')}</p>
|
||||
|
||||
{loading
|
||||
? <div className="py-3 flex justify-center"><Spinner size="sm" color="current" /></div>
|
||||
: (
|
||||
<>
|
||||
<div className="text-sm mb-2">
|
||||
{enabled && data?.next_backup
|
||||
? (
|
||||
<span className="text-success">
|
||||
{t('backups.schedule.nextBackup', { when: new Date(data.next_backup).toLocaleString() })}
|
||||
</span>
|
||||
)
|
||||
: <span className="text-muted">{t('backups.schedule.noneScheduled')}</span>}
|
||||
</div>
|
||||
|
||||
{rules.length === 0 && <div className="text-xs text-muted mb-2">{t('backups.schedule.noRules')}</div>}
|
||||
{rules.map((rule, i) => (
|
||||
<div key={i} className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<ToggleButtonGroup
|
||||
selectionMode="multiple"
|
||||
selectedKeys={rule.days.map(String)}
|
||||
onSelectionChange={(keys) => {
|
||||
const days = [...keys].map(Number).sort((a, b) => a - b)
|
||||
setRuleDays(i, days)
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{DOW.map((d) => (
|
||||
<ToggleButton key={d} id={String(d)}>{dowLabel(d)}</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
<TimeInput value={rule.time} onChange={(v) => setRuleTime(i, v)} ariaLabel="time" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isIconOnly
|
||||
aria-label={t('backups.schedule.removeRule')}
|
||||
onPress={() => removeRule(i)}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button size="sm" variant="outline" className="mb-3" onPress={addRule}>
|
||||
<Icon name="plus" />
|
||||
{' '}
|
||||
{t('backups.schedule.addRule')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3 text-sm flex-wrap">
|
||||
<label className="flex items-center gap-2">
|
||||
{t('backups.schedule.keepN')}
|
||||
<NumberInput
|
||||
value={keepN}
|
||||
onChange={setKeepN}
|
||||
min={0}
|
||||
ariaLabel={t('backups.schedule.keepN')}
|
||||
className="w-20"
|
||||
showButtons={false}
|
||||
/>
|
||||
<span className="text-xs text-muted">{t('backups.schedule.keepHint')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 flex-1 min-w-[160px]">
|
||||
{t('backups.schedule.timezone')}
|
||||
<TimezoneSelect value={timezone} onChange={setTimezone} className="flex-1" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onPress={save} isDisabled={saving}>
|
||||
{saving ? <Spinner size="sm" color="current" /> : t('backups.schedule.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Backups view ─────────────────────────────────────────────────────────────
|
||||
export const BackupsView: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [backups, setBackups] = useState<DBBackupFile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [taking, setTaking] = useState(false)
|
||||
const [restoreTarget, setRestoreTarget] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.dbBackups.list())
|
||||
.then((res) => setBackups(res.backups ?? []))
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('backups.loadFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const take = () => {
|
||||
setTaking(true)
|
||||
api.dbBackups.take()
|
||||
.then((res) => {
|
||||
toast.success(t('backups.taken', { name: res.name }))
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('backups.takeFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setTaking(false))
|
||||
}
|
||||
|
||||
const doRestore = () => {
|
||||
if (!restoreTarget) return
|
||||
const file = restoreTarget
|
||||
setRestoreTarget(null)
|
||||
setBusy(true)
|
||||
api.dbBackups.restore(file)
|
||||
.then((res) => toast.success(res.ok))
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('backups.restoreFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
const doDelete = () => {
|
||||
if (!deleteTarget) return
|
||||
const file = deleteTarget
|
||||
setDeleteTarget(null)
|
||||
setBusy(true)
|
||||
api.dbBackups.remove(file)
|
||||
.then((res) => {
|
||||
toast.success(res.ok)
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('backups.deleteFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col gap-3">
|
||||
<PageHeader title={t('database.sections.backups')} onRefresh={load} loading={loading} />
|
||||
|
||||
<div className="rounded-[var(--radius)] px-3 py-2 text-sm flex items-start gap-2 bg-warning/10 text-warning border border-warning/40 shrink-0">
|
||||
<Icon name="triangle-alert" className="size-4 mt-0.5 shrink-0" />
|
||||
<span>{t('backups.warning')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto flex flex-col gap-3 pr-1">
|
||||
{/* Take Backup */}
|
||||
<Panel>
|
||||
<SectionLabel>{t('backups.take.title')}</SectionLabel>
|
||||
<p className="text-xs text-muted">{t('backups.take.desc')}</p>
|
||||
<div>
|
||||
<Button size="sm" onPress={take} isDisabled={taking}>
|
||||
{taking
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
<Icon name="database-backup" />
|
||||
{' '}
|
||||
{t('backups.take.btn')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<ScheduleCard />
|
||||
|
||||
{/* Recent backups */}
|
||||
<Panel>
|
||||
<SectionLabel>{t('backups.recent.title')}</SectionLabel>
|
||||
{loading
|
||||
? <div className="py-3 flex justify-center"><Spinner size="sm" color="current" /></div>
|
||||
: backups.length === 0
|
||||
? <div className="text-sm text-muted py-2">{t('backups.recent.empty')}</div>
|
||||
: (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-3 px-2 text-xs uppercase tracking-wide text-muted">
|
||||
<span>{t('backups.col.name')}</span>
|
||||
<span className="text-right">{t('backups.col.size')}</span>
|
||||
<span>{t('backups.col.modified')}</span>
|
||||
<span />
|
||||
</div>
|
||||
{backups.map((b) => (
|
||||
<div
|
||||
key={b.name}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 items-center px-2 py-1.5 rounded bg-surface border border-border/40"
|
||||
>
|
||||
<span className="font-mono text-sm truncate" title={b.name}>{b.name}</span>
|
||||
<span className="text-sm text-muted text-right tabular-nums">{fmtSize(b.size_bytes)}</span>
|
||||
<span className="text-sm text-muted">{new Date(b.modified).toLocaleString()}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<a href={api.dbBackups.downloadUrl(b.name)} download>
|
||||
<Button size="sm" variant="ghost" isIconOnly aria-label={t('backups.download')}>
|
||||
<Icon name="download" />
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={busy}
|
||||
onPress={() => setRestoreTarget(b.name)}
|
||||
>
|
||||
{t('backups.restoreLabel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isIconOnly
|
||||
aria-label={t('backups.deleteLabel')}
|
||||
isDisabled={busy}
|
||||
onPress={() => setDeleteTarget(b.name)}
|
||||
>
|
||||
<Icon name="trash-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={restoreTarget !== null}
|
||||
title={t('backups.restoreConfirmTitle')}
|
||||
description={t('backups.restoreConfirmDesc', { name: restoreTarget ?? '' })}
|
||||
confirmLabel={t('backups.restoreLabel')}
|
||||
onConfirm={doRestore}
|
||||
onCancel={() => setRestoreTarget(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
title={t('backups.deleteConfirmTitle')}
|
||||
description={t('backups.deleteConfirmDesc', { name: deleteTarget ?? '' })}
|
||||
confirmLabel={t('backups.deleteLabel')}
|
||||
onConfirm={doDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DataTable, type Column } from '../../../dune-ui'
|
||||
|
||||
type TableData = { headers: string[], rows: string[][] }
|
||||
|
||||
export function ResultTable({ headers, rows }: TableData) {
|
||||
const { t } = useTranslation()
|
||||
const safeHeaders = headers ?? []
|
||||
const safeRows = rows ?? []
|
||||
if (safeRows.length === 0 || safeHeaders.length === 0) {
|
||||
return <p className="text-sm text-muted">{t('database.noResults')}</p>
|
||||
}
|
||||
const columns: Column<string>[] = safeHeaders.map((h, i) => ({
|
||||
key: `c${i}`,
|
||||
label: h,
|
||||
}))
|
||||
type Row = { _id: string, values: string[] }
|
||||
const items: Row[] = safeRows.map((r, i) => ({ _id: String(i), values: r ?? [] }))
|
||||
return (
|
||||
<DataTable<Row, string>
|
||||
aria-label={t('database.resultLabel')}
|
||||
className="min-h-0 max-h-full"
|
||||
columns={columns}
|
||||
rows={items}
|
||||
rowId={(r) => r._id}
|
||||
initialSort={{ column: columns[0].key, direction: 'ascending' }}
|
||||
sortValue={(r, k) => {
|
||||
const idx = Number(k.slice(1))
|
||||
const v = r.values[idx] ?? ''
|
||||
const n = Number(v)
|
||||
return !isNaN(n) && v !== '' ? n : v
|
||||
}}
|
||||
renderCell={(r, k) => {
|
||||
const idx = Number(k.slice(1))
|
||||
return <span className="font-mono whitespace-nowrap">{r.values[idx] ?? ''}</span>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { SearchField } from '@heroui/react'
|
||||
|
||||
interface TableSearchInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onRun: () => void
|
||||
tableNames: string[]
|
||||
ariaLabel: string
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
export function TableSearchInput(
|
||||
{ value, onChange, onRun, tableNames, ariaLabel, placeholder }: TableSearchInputProps,
|
||||
) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = value.toLowerCase().trim()
|
||||
if (!q) return tableNames.slice(0, 40)
|
||||
return tableNames.filter((n) => n.toLowerCase().includes(q))
|
||||
}, [value, tableNames])
|
||||
|
||||
const pick = (name: string) => {
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex-1 max-w-md"
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchField
|
||||
className="w-full"
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input
|
||||
placeholder={placeholder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setOpen(false)
|
||||
onRun()
|
||||
}
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'ArrowDown') setOpen(true)
|
||||
}}
|
||||
/>
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 rounded-[var(--radius)] border border-border bg-surface overflow-y-auto max-h-52 shadow-lg">
|
||||
{filtered.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-1.5 text-xs cursor-pointer hover:bg-surface-hover"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
pick(n)
|
||||
}}
|
||||
>
|
||||
<span className="text-muted mr-0.5">dune.</span>
|
||||
<span className="font-mono">{n}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createTheme } from '@uiw/codemirror-themes'
|
||||
import { tags as hlTags } from '@lezer/highlight'
|
||||
|
||||
export const duneTheme = createTheme({
|
||||
theme: 'dark',
|
||||
settings: {
|
||||
background: 'var(--field-background)',
|
||||
foreground: 'var(--field-foreground)',
|
||||
caret: 'var(--accent)',
|
||||
selection: 'rgba(201,130,10,0.25)',
|
||||
selectionMatch: 'rgba(201,130,10,0.12)',
|
||||
lineHighlight: 'var(--surface)',
|
||||
gutterBackground: 'var(--surface)',
|
||||
gutterForeground: 'var(--muted)',
|
||||
gutterBorder: 'transparent',
|
||||
gutterActiveForeground: 'var(--accent)',
|
||||
},
|
||||
styles: [
|
||||
{ tag: hlTags.comment, color: 'var(--muted)', fontStyle: 'italic' },
|
||||
{ tag: hlTags.lineComment, color: 'var(--muted)', fontStyle: 'italic' },
|
||||
{ tag: hlTags.blockComment, color: 'var(--muted)', fontStyle: 'italic' },
|
||||
{ tag: hlTags.keyword, color: 'var(--accent)', fontWeight: 'bold' },
|
||||
{ tag: hlTags.definitionKeyword, color: 'var(--accent)' },
|
||||
{ tag: hlTags.modifier, color: 'var(--accent)' },
|
||||
{ tag: hlTags.operatorKeyword, color: 'var(--accent)' },
|
||||
{ tag: hlTags.string, color: 'var(--success)' },
|
||||
{ tag: hlTags.number, color: 'var(--warning)' },
|
||||
{ tag: hlTags.bool, color: 'var(--warning)' },
|
||||
{ tag: hlTags.null, color: 'var(--danger)' },
|
||||
{ tag: hlTags.operator, color: 'var(--foreground)' },
|
||||
{ tag: hlTags.punctuation, color: 'var(--muted)' },
|
||||
{ tag: hlTags.name, color: 'var(--foreground)' },
|
||||
{ tag: hlTags.typeName, color: 'var(--warning)' },
|
||||
{ tag: hlTags.function(hlTags.variableName), color: 'var(--warning)' },
|
||||
{ tag: hlTags.special(hlTags.name), color: 'var(--accent)' },
|
||||
],
|
||||
})
|
||||
|
||||
export type Section = 'backups' | 'tables' | 'describe' | 'sample' | 'search' | 'sql'
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { sql as sqlLang, PostgreSQL } from '@codemirror/lang-sql'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { Prec } from '@codemirror/state'
|
||||
import { acceptCompletion } from '@codemirror/autocomplete'
|
||||
import { Button, SearchField, Spinner, toast } from '@heroui/react'
|
||||
import { api } from '../../api/client'
|
||||
import { Icon, LoadingState, NumberInput, PageHeader, SideNav } from '../../dune-ui'
|
||||
import { duneTheme, type Section } from './constants'
|
||||
import { ResultTable } from './components/ResultTable'
|
||||
import { TableSearchInput } from './components/TableSearchInput'
|
||||
import { BackupsView } from './components/BackupsView'
|
||||
|
||||
type TableData = { headers: string[], rows: string[][] }
|
||||
|
||||
interface DatabaseTabProps {
|
||||
showSubnav?: boolean
|
||||
section?: Section
|
||||
onSectionChange?: (s: Section) => void
|
||||
}
|
||||
|
||||
export const DatabaseTab: React.FC<DatabaseTabProps> = ({
|
||||
section = 'backups',
|
||||
onSectionChange,
|
||||
showSubnav,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const SECTIONS = useMemo<{ key: Section, label: string }[]>(() => [
|
||||
{ key: 'backups', label: t('database.sections.backups') },
|
||||
{ key: 'tables', label: t('database.sections.tables') },
|
||||
{ key: 'describe', label: t('database.sections.describe') },
|
||||
{ key: 'sample', label: t('database.sections.sample') },
|
||||
{ key: 'search', label: t('database.sections.search') },
|
||||
{ key: 'sql', label: t('database.sections.sql') },
|
||||
], [t])
|
||||
|
||||
const [tableInput, setTableInput] = useState('')
|
||||
const [limitInput, setLimitInput] = useState(20)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [sqlInput, setSqlInput] = useState('')
|
||||
const [result, setResult] = useState<TableData | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [tableNames, setTableNames] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const sqlExtension = useMemo(() => sqlLang({
|
||||
dialect: PostgreSQL,
|
||||
upperCaseKeywords: true,
|
||||
schema: Object.fromEntries(tableNames.map((n) => [n, []])),
|
||||
defaultSchema: 'dune',
|
||||
}), [tableNames])
|
||||
|
||||
// Promise-chain form (not async) so react-hooks/set-state-in-effect does not
|
||||
// flag the useEffect that calls it — matches the BasesTab pattern.
|
||||
const fetchTables = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
setTruncated(false)
|
||||
setError(null)
|
||||
})
|
||||
.then(() => api.database.tables())
|
||||
.then((rows) => {
|
||||
setTableNames(rows.map((r) => r.name))
|
||||
setResult({
|
||||
headers: [t('database.tableColumn'), t('database.rowsColumn')],
|
||||
rows: rows.map((r) => [r.name, String(r.row_count)]),
|
||||
})
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
toast.danger(t('database.failed', { message: msg }))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
// Reset results and re-fetch whenever the section changes (driven by the left nav).
|
||||
useEffect(() => {
|
||||
setTruncated(false) // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setError(null)
|
||||
setResult(null)
|
||||
if (section === 'tables') fetchTables()
|
||||
}, [section, fetchTables])
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (section === 'tables') {
|
||||
fetchTables()
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
setTruncated(false)
|
||||
setError(null)
|
||||
try {
|
||||
if (section === 'describe') {
|
||||
if (!tableInput.trim()) {
|
||||
toast.warning(t('database.enterTableName'))
|
||||
return
|
||||
}
|
||||
const r = await api.database.describe(tableInput.trim())
|
||||
setResult({
|
||||
headers: [t('database.columnColumn'), t('database.typeColumn'), t('database.nullableColumn')],
|
||||
rows: r.columns.map((c) => [c.name, c.data_type, c.nullable]),
|
||||
})
|
||||
}
|
||||
else if (section === 'sample') {
|
||||
if (!tableInput.trim()) {
|
||||
toast.warning(t('database.enterTableName'))
|
||||
return
|
||||
}
|
||||
const r = await api.database.sample(tableInput.trim(), limitInput)
|
||||
setResult({ headers: r.headers, rows: r.rows })
|
||||
}
|
||||
else if (section === 'search') {
|
||||
if (!searchInput.trim()) {
|
||||
toast.warning(t('database.enterSearchTerm'))
|
||||
return
|
||||
}
|
||||
const r = await api.database.search(searchInput.trim())
|
||||
setResult({ headers: r.headers, rows: r.rows })
|
||||
}
|
||||
else {
|
||||
if (!sqlInput.trim()) {
|
||||
toast.warning(t('database.enterSQL'))
|
||||
return
|
||||
}
|
||||
const r = await api.database.sql(sqlInput.trim())
|
||||
setResult({ headers: r.headers, rows: r.rows })
|
||||
setTruncated(r.truncated)
|
||||
}
|
||||
}
|
||||
catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
toast.danger(t('database.failed', { message: msg }))
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [section, fetchTables, limitInput, searchInput, sqlInput, tableInput, t])
|
||||
|
||||
const editorKeymap = useMemo(() => [
|
||||
Prec.highest(keymap.of([
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: () => {
|
||||
void run()
|
||||
return true
|
||||
},
|
||||
},
|
||||
// Must be Prec.highest to beat basicSetup's indent binding
|
||||
{ key: 'Tab', run: acceptCompletion },
|
||||
])),
|
||||
], [run])
|
||||
|
||||
const activeLabel = SECTIONS.find((s) => s.key === section)?.label ?? ''
|
||||
|
||||
const innerContent = (
|
||||
<>
|
||||
<PageHeader title={activeLabel}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isIconOnly
|
||||
onPress={() => void run()}
|
||||
isDisabled={loading}
|
||||
aria-label={t('database.refreshLabel')}
|
||||
>
|
||||
{loading ? <Spinner size="sm" color="current" /> : <Icon name="refresh-cw" />}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{(section === 'describe' || section === 'sample') && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<TableSearchInput
|
||||
value={tableInput}
|
||||
onChange={setTableInput}
|
||||
onRun={() => void run()}
|
||||
tableNames={tableNames}
|
||||
ariaLabel={t('database.tableNameLabel')}
|
||||
placeholder={t('database.tablePlaceholder')}
|
||||
/>
|
||||
{section === 'sample' && (
|
||||
<NumberInput
|
||||
ariaLabel={t('database.limitLabel')}
|
||||
min={1}
|
||||
max={1000}
|
||||
value={limitInput}
|
||||
onChange={setLimitInput}
|
||||
showButtons={false}
|
||||
className="w-28"
|
||||
/>
|
||||
)}
|
||||
<Button onPress={() => void run()} isDisabled={loading} size="sm">
|
||||
{loading ? <Spinner size="sm" color="current" /> : <Icon name="play" />}
|
||||
{' '}
|
||||
{t('database.runBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'search' && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<SearchField
|
||||
className="flex-1 max-w-md"
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
aria-label={t('database.searchLabel')}
|
||||
>
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input
|
||||
placeholder={t('database.searchPlaceholder')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void run()}
|
||||
/>
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
<Button onPress={() => void run()} isDisabled={loading} size="sm">
|
||||
{loading ? <Spinner size="sm" color="current" /> : <Icon name="search" />}
|
||||
{' '}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'sql' && (
|
||||
<div className="flex flex-col gap-2 shrink-0">
|
||||
<div
|
||||
className="rounded-[var(--radius)] overflow-hidden border"
|
||||
style={{ borderColor: 'var(--field-border)' }}
|
||||
>
|
||||
<CodeMirror
|
||||
value={sqlInput}
|
||||
onChange={setSqlInput}
|
||||
extensions={editorKeymap.concat(sqlExtension)}
|
||||
theme={duneTheme}
|
||||
height="140px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
autocompletion: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
}}
|
||||
placeholder={t('database.sqlPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onPress={() => void run()} isDisabled={loading} size="sm">
|
||||
{loading ? <Spinner size="sm" color="current" /> : <Icon name="play" />}
|
||||
{' '}
|
||||
{t('database.runQuery')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted">{t('database.runHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<LoadingState size="md" className="shrink-0" />
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="rounded-[var(--radius)] p-4 bg-danger/10 border border-danger/40 text-danger shrink-0">
|
||||
<strong>{t('common.error')}</strong>
|
||||
{' '}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !loading && !error && (
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-1">
|
||||
<ResultTable headers={result.headers} rows={result.rows} />
|
||||
{truncated && (
|
||||
<p className="text-xs text-muted shrink-0">{t('database.rowsLimited')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
// The Backups section is self-contained (loads its own data); every other
|
||||
// section shares the query/inspect shell above.
|
||||
const body = section === 'backups' ? <BackupsView /> : innerContent
|
||||
|
||||
if (showSubnav) {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex gap-3">
|
||||
<SideNav
|
||||
title={t('database.sideNavTitle')}
|
||||
items={SECTIONS}
|
||||
active={section ?? 'backups'}
|
||||
onSelect={(key) => onSectionChange?.(key)}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-3">
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col gap-3">
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
227
docs/reference-repos/icehunter/web/src/tabs/DirectorTab.tsx
Normal file
227
docs/reference-repos/icehunter/web/src/tabs/DirectorTab.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Spinner, toast } from '@heroui/react'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type { DirectorConfig } from '../api/client'
|
||||
import { PageHeader, Panel, SectionLabel, Icon, FieldInput, FieldSelect } from '../dune-ui'
|
||||
|
||||
// ── field-type inference (#157) ──────────────────────────────────────────────
|
||||
// The director config is untyped INI text, so we infer an editor from the value
|
||||
// + comment (data-driven, no hardcoded enum tables): booleans → a dropdown,
|
||||
// numbers → a number input, and enums from either a "Alternatives: a, b, c"
|
||||
// comment or the distinct values used across the [InstancingModes] section.
|
||||
const numberRe = /^-?\d+(\.\d+)?$/
|
||||
|
||||
function parseAlternatives(comment?: string): string[] {
|
||||
const m = comment?.match(/alternatives?:\s*(.+)/i)
|
||||
return m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : []
|
||||
}
|
||||
|
||||
type FieldKind = { kind: 'bool' } | { kind: 'number' } | { kind: 'enum', options: string[] } | { kind: 'text' }
|
||||
|
||||
function fieldKind(
|
||||
section: string, value: string, comment: string | undefined, instancingOptions: string[],
|
||||
): FieldKind {
|
||||
if (section === 'InstancingModes' && instancingOptions.length > 1) return { kind: 'enum', options: instancingOptions }
|
||||
const alt = parseAlternatives(comment)
|
||||
if (alt.length > 1) return { kind: 'enum', options: alt }
|
||||
const v = value.trim().toLowerCase()
|
||||
if (v === 'true' || v === 'false') return { kind: 'bool' }
|
||||
if (numberRe.test(value.trim())) return { kind: 'number' }
|
||||
return { kind: 'text' }
|
||||
}
|
||||
|
||||
const DirectorEditor: React.FC<{
|
||||
kind: FieldKind
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}> = ({ kind, value, onChange }) => {
|
||||
if (kind.kind === 'bool') {
|
||||
return (
|
||||
<FieldSelect
|
||||
className="w-full"
|
||||
value={value.trim().toLowerCase()}
|
||||
onChange={onChange}
|
||||
options={['true', 'false']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (kind.kind === 'enum') {
|
||||
// Keep the current value selectable even if it isn't in the derived option set.
|
||||
const opts = kind.options.includes(value) ? kind.options : [value, ...kind.options]
|
||||
return (
|
||||
<FieldSelect
|
||||
className="w-full"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={opts}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (kind.kind === 'number') {
|
||||
return <FieldInput type="number" className="w-full" value={value} onChange={onChange} />
|
||||
}
|
||||
return <FieldInput className="w-full" value={value} onChange={onChange} />
|
||||
}
|
||||
|
||||
// DirectorTab (#147): view/edit the Battlegroup Director config
|
||||
// (director_config.ini). [InstancingModes] controls map persistence; [Database]
|
||||
// and [RMQ*] are read-only (launch-overridden + secrets). AMP control plane only.
|
||||
export const DirectorTab: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [data, setData] = useState<DirectorConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [unsupported, setUnsupported] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<Map<string, string>>(new Map())
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setUnsupported(false)
|
||||
})
|
||||
.then(() => api.director.get())
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
setPending(new Map())
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (e instanceof ApiError && e.status === 501) setUnsupported(true)
|
||||
else setError(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// The [InstancingModes] section's keys all share one enum domain (map →
|
||||
// instancing mode), so its distinct values ARE the option set for each key.
|
||||
const instancingOptions = useMemo(() => {
|
||||
const sec = data?.sections.find((s) => s.name === 'InstancingModes')
|
||||
if (!sec) return []
|
||||
return Array.from(new Set(sec.lines.map((l) => l.value.trim()).filter(Boolean)))
|
||||
}, [data])
|
||||
|
||||
const pk = (section: string, key: string) => `${section}|${key}`
|
||||
const setVal = (section: string, key: string, value: string) =>
|
||||
setPending((prev) => {
|
||||
const n = new Map(prev)
|
||||
n.set(pk(section, key), value)
|
||||
return n
|
||||
})
|
||||
|
||||
const save = () => {
|
||||
if (pending.size === 0) return
|
||||
const updates: Record<string, Record<string, string>> = {}
|
||||
for (const [k, v] of pending) {
|
||||
const [section, key] = k.split('|')
|
||||
if (!updates[section]) updates[section] = {}
|
||||
updates[section][key] = v
|
||||
}
|
||||
setSaving(true)
|
||||
api.director.update(updates)
|
||||
.then((res) => {
|
||||
toast.success(res.ok)
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('director.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-2 text-muted">
|
||||
<Spinner size="sm" color="current" />
|
||||
<span className="text-sm">{t('director.loading')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsupported) {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<PageHeader title={t('director.title')} />
|
||||
<div className="text-sm text-muted py-8 text-center">{t('director.unsupported')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<PageHeader title={t('director.title')} />
|
||||
<div className="rounded px-4 py-3 text-sm bg-danger/10 border border-danger/40 text-danger">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const dirty = pending.size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
<PageHeader title={t('director.title')} subtitle={t('director.subtitle')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading || saving}>
|
||||
<Icon name="refresh-cw" />
|
||||
</Button>
|
||||
<Button size="sm" onPress={save} isDisabled={dirty === 0 || saving}>
|
||||
{saving
|
||||
? <Spinner size="sm" color="current" />
|
||||
: dirty > 0 ? t('director.saveWithCount', { count: dirty }) : t('director.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<p className="text-xs text-warning shrink-0">{t('director.restartNote')}</p>
|
||||
{data?.path && <p className="text-xs text-muted shrink-0 font-mono truncate">{data.path}</p>}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 pb-6 pr-1">
|
||||
{data?.sections.map((sec) => (
|
||||
<Panel key={sec.name}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>{sec.name}</SectionLabel>
|
||||
{sec.read_only && (
|
||||
<span className="text-xs text-muted border border-border rounded px-1.5 py-0.5">
|
||||
{t('director.readOnly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{sec.lines.map((line) => {
|
||||
const editable = !sec.read_only && !line.secret
|
||||
const cur = pending.get(pk(sec.name, line.key)) ?? line.value
|
||||
return (
|
||||
<div
|
||||
key={line.key}
|
||||
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] items-center gap-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground truncate" title={line.key}>{line.key}</div>
|
||||
{line.comment && <div className="text-xs text-muted truncate" title={line.comment}>{line.comment}</div>}
|
||||
</div>
|
||||
{editable
|
||||
? (
|
||||
<DirectorEditor
|
||||
kind={fieldKind(sec.name, line.value, line.comment, instancingOptions)}
|
||||
value={cur}
|
||||
onChange={(v) => setVal(sec.name, line.key, v)}
|
||||
/>
|
||||
)
|
||||
: <span className="text-muted font-mono truncate">{line.secret ? '••••••••' : line.value}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
docs/reference-repos/icehunter/web/src/tabs/GuildsTab.tsx
Normal file
284
docs/reference-repos/icehunter/web/src/tabs/GuildsTab.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Chip, Modal, Spinner, toast } from '@heroui/react'
|
||||
import { api } from '../api/client'
|
||||
import type { GuildSummary, GuildDetail } from '../api/client'
|
||||
import { DataTable, Icon, PageHeader, SectionLabel, type Column } from '../dune-ui'
|
||||
|
||||
type Key = 'name' | 'faction' | 'members' | 'description' | 'actions'
|
||||
|
||||
// Faction names are the stable dune.factions enum (Atreides/Harkonnen/None/
|
||||
// Smuggler), so colour-coding by name is safe. Unknown/None → default.
|
||||
const FACTION_COLOR: Record<string, 'accent' | 'danger' | 'warning' | 'default'> = {
|
||||
Atreides: 'accent',
|
||||
Harkonnen: 'danger',
|
||||
Smuggler: 'warning',
|
||||
}
|
||||
|
||||
// Confirmed guild role ids (dune guild procs): 100 = admin, 50 = member.
|
||||
const ROLE_ADMIN = 100
|
||||
const ROLE_MEMBER = 50
|
||||
|
||||
interface GuildsTabProps {
|
||||
isSignedIn?: boolean
|
||||
}
|
||||
|
||||
export const GuildsTab: React.FC<GuildsTabProps> = ({ isSignedIn = true }) => {
|
||||
const { t } = useTranslation()
|
||||
const [guilds, setGuilds] = useState<GuildSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<GuildDetail | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDesc, setEditDesc] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [roleBusy, setRoleBusy] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.guilds.list())
|
||||
.then(setGuilds)
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('guilds.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const applyDetail = (d: GuildDetail) => {
|
||||
setDetail(d)
|
||||
setEditName(d.name)
|
||||
setEditDesc(d.description)
|
||||
}
|
||||
|
||||
const openDetail = (id: number) => {
|
||||
setOpen(true)
|
||||
setDetail(null)
|
||||
setDetailLoading(true)
|
||||
api.guilds.get(id)
|
||||
.then(applyDetail)
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('guilds.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setDetailLoading(false))
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
api.guilds.update(detail.guild_id, { name: editName.trim(), description: editDesc })
|
||||
.then((d) => {
|
||||
applyDetail(d)
|
||||
toast.success(t('guilds.saved'))
|
||||
load()
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('guilds.saveFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const makeAdmin = (playerId: number) => {
|
||||
if (!detail) return
|
||||
setRoleBusy(true)
|
||||
api.guilds.setRole(detail.guild_id, playerId, ROLE_ADMIN)
|
||||
.then(() => api.guilds.get(detail.guild_id))
|
||||
.then((d) => {
|
||||
applyDetail(d)
|
||||
toast.success(t('guilds.roleChanged'))
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('guilds.roleChangeFailed', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setRoleBusy(false))
|
||||
}
|
||||
|
||||
const roleLabel = (id: number) =>
|
||||
id === ROLE_ADMIN ? t('guilds.roleAdmin') : id === ROLE_MEMBER ? t('guilds.roleMember') : t('guilds.roleN', { id })
|
||||
|
||||
const COLUMNS: Column<Key>[] = [
|
||||
{ key: 'name', label: t('guilds.columns.name'), minWidth: 200 },
|
||||
{ key: 'faction', label: t('guilds.columns.faction'), width: 150 },
|
||||
{ key: 'members', label: t('guilds.columns.members'), width: 110 },
|
||||
{ key: 'description', label: t('guilds.columns.description'), minWidth: 240 },
|
||||
{ key: 'actions', label: '', width: 120, sortable: false },
|
||||
]
|
||||
|
||||
const inputCls = 'w-full bg-surface text-foreground border border-border rounded px-2 py-1 text-sm'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
<PageHeader title={t('guilds.title', { count: guilds.length })} subtitle={t('guilds.subtitle')}>
|
||||
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
|
||||
{loading
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
<Icon name="refresh-cw" />
|
||||
{' '}
|
||||
{t('common.refresh')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<DataTable<GuildSummary, Key>
|
||||
aria-label={t('guilds.title', { count: guilds.length })}
|
||||
className="min-h-0 max-h-full"
|
||||
columns={COLUMNS}
|
||||
rows={guilds}
|
||||
loading={loading}
|
||||
rowId={(g) => String(g.guild_id)}
|
||||
initialSort={{ column: 'name', direction: 'ascending' }}
|
||||
sortValue={(g, k) => {
|
||||
switch (k) {
|
||||
case 'name': return g.name
|
||||
case 'faction': return g.faction_name
|
||||
case 'members': return g.member_count
|
||||
case 'description': return g.description
|
||||
default: return ''
|
||||
}
|
||||
}}
|
||||
emptyState={<div className="py-8 text-center text-muted">{t('guilds.empty')}</div>}
|
||||
renderCell={(g, key) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return g.name || <span className="text-muted">—</span>
|
||||
case 'faction':
|
||||
return (
|
||||
<Chip size="sm" variant="soft" color={FACTION_COLOR[g.faction_name] ?? 'default'}>
|
||||
{g.faction_name || '—'}
|
||||
</Chip>
|
||||
)
|
||||
case 'members':
|
||||
return <span className="text-muted">{g.member_count}</span>
|
||||
case 'description':
|
||||
return g.description
|
||||
? <span className="text-muted">{g.description}</span>
|
||||
: <span className="text-muted">—</span>
|
||||
case 'actions':
|
||||
return (
|
||||
<Button size="sm" variant="outline" className="w-full" onPress={() => openDetail(g.guild_id)}>
|
||||
<Icon name="users" />
|
||||
{' '}
|
||||
{isSignedIn ? t('guilds.manage') : t('guilds.view')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal>
|
||||
<Modal.Backdrop isOpen={open} onOpenChange={(v) => !v && setOpen(false)}>
|
||||
<Modal.Container size="lg" scroll="outside">
|
||||
<Modal.Dialog className="max-h-[85vh] flex flex-col">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<div className="flex items-baseline gap-3 flex-wrap">
|
||||
<Modal.Heading className="text-accent">{detail?.name || t('guilds.title', { count: 0 })}</Modal.Heading>
|
||||
{detail && (
|
||||
<Chip size="sm" variant="soft" color={FACTION_COLOR[detail.faction_name] ?? 'default'}>
|
||||
{detail.faction_name || '—'}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-4 overflow-y-auto">
|
||||
{detailLoading && (
|
||||
<div className="flex items-center justify-center py-8 gap-2 text-muted">
|
||||
<Spinner size="sm" color="current" />
|
||||
</div>
|
||||
)}
|
||||
{!detailLoading && detail && (
|
||||
<>
|
||||
{isSignedIn
|
||||
? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SectionLabel>{t('guilds.editGuild')}</SectionLabel>
|
||||
<div>
|
||||
<label className="text-xs text-muted">{t('guilds.nameLabel')}</label>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted">{t('guilds.descLabel')}</label>
|
||||
<textarea
|
||||
className={inputCls}
|
||||
rows={2}
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="sm" onPress={save} isDisabled={saving || editName.trim() === ''}>
|
||||
{saving ? <Spinner size="sm" color="current" /> : t('guilds.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: detail.description && <p className="text-sm text-muted">{detail.description}</p>}
|
||||
|
||||
<div>
|
||||
<SectionLabel>{t('guilds.members')}</SectionLabel>
|
||||
{detail.members.length === 0
|
||||
? <div className="text-xs text-muted py-1">{t('guilds.noMembers')}</div>
|
||||
: (
|
||||
<div className="mt-1">
|
||||
{detail.members.map((m) => (
|
||||
<div
|
||||
key={m.player_id}
|
||||
className="flex items-center justify-between py-1.5 border-b border-border/40 text-sm gap-2"
|
||||
>
|
||||
<span className="text-foreground flex-1 truncate">{m.character_name}</span>
|
||||
<Chip size="sm" variant="soft" color={m.role_id === ROLE_ADMIN ? 'accent' : 'default'}>
|
||||
{roleLabel(m.role_id)}
|
||||
</Chip>
|
||||
{isSignedIn && m.role_id !== ROLE_ADMIN && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={roleBusy}
|
||||
onPress={() => makeAdmin(m.player_id)}
|
||||
>
|
||||
{t('guilds.makeAdmin')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionLabel>{t('guilds.invites')}</SectionLabel>
|
||||
{detail.invites.length === 0
|
||||
? <div className="text-xs text-muted py-1">{t('guilds.noInvites')}</div>
|
||||
: (
|
||||
<div className="mt-1">
|
||||
{detail.invites.map((iv) => (
|
||||
<div
|
||||
key={iv.invite_id}
|
||||
className="flex items-center justify-between py-1.5 border-b border-border/40 text-sm"
|
||||
>
|
||||
<span className="text-foreground">{iv.character_name}</span>
|
||||
<span className="text-xs text-muted">{t('guilds.invitedBy', { name: iv.sender_name })}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
docs/reference-repos/icehunter/web/src/tabs/LandsraadTab.tsx
Normal file
168
docs/reference-repos/icehunter/web/src/tabs/LandsraadTab.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Chip, Spinner, toast } from '@heroui/react'
|
||||
import { api } from '../api/client'
|
||||
import type { LandsraadOverview, LandsraadTask } from '../api/client'
|
||||
import { DataTable, Icon, PageHeader, Panel, SectionLabel, type Column } from '../dune-ui'
|
||||
|
||||
type TaskKey = 'board_index' | 'house' | 'goal_amount' | 'completed' | 'sysselraad'
|
||||
|
||||
const Field: React.FC<{ label: string, value: string }> = ({ label, value }) => (
|
||||
<div>
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
<div className="text-foreground">{value}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const LandsraadTab: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [data, setData] = useState<LandsraadOverview | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.landsraad.get())
|
||||
.then(setData)
|
||||
.catch((e: unknown) =>
|
||||
toast.danger(t('landsraad.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const term = data?.term ?? null
|
||||
const decrees = data?.decrees ?? []
|
||||
const tasks = data?.tasks ?? []
|
||||
|
||||
const fmtDate = (s: string) => {
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? s : d.toLocaleString()
|
||||
}
|
||||
const dash = (s: string) => s || '—'
|
||||
|
||||
const TASK_COLUMNS: Column<TaskKey>[] = [
|
||||
{ key: 'board_index', label: t('landsraad.tasks.index'), width: 70 },
|
||||
{ key: 'house', label: t('landsraad.tasks.house'), minWidth: 160 },
|
||||
{ key: 'goal_amount', label: t('landsraad.tasks.goal'), width: 120 },
|
||||
{ key: 'completed', label: t('landsraad.tasks.completed'), width: 120 },
|
||||
{ key: 'sysselraad', label: t('landsraad.tasks.sysselraad'), width: 120 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
<PageHeader title={t('landsraad.title')} subtitle={t('landsraad.subtitle')}>
|
||||
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
|
||||
{loading
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
<Icon name="refresh-cw" />
|
||||
{' '}
|
||||
{t('common.refresh')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 pb-6 pr-1">
|
||||
<Panel>
|
||||
<SectionLabel>{t('landsraad.currentTerm')}</SectionLabel>
|
||||
{term
|
||||
? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-2 text-sm">
|
||||
<Field label={t('landsraad.term.id')} value={`#${term.term_id}`} />
|
||||
<Field
|
||||
label={t('landsraad.term.window')}
|
||||
value={`${fmtDate(term.start_time)} → ${fmtDate(term.end_time)}`}
|
||||
/>
|
||||
<Field label={t('landsraad.term.reigning')} value={dash(term.reigning_faction)} />
|
||||
<Field label={t('landsraad.term.activeDecree')} value={dash(term.active_decree)} />
|
||||
<Field label={t('landsraad.term.electedDecree')} value={dash(term.elected_decree)} />
|
||||
<Field label={t('landsraad.term.winning')} value={dash(term.winning_faction)} />
|
||||
</div>
|
||||
{term.test_term && (
|
||||
<Chip size="sm" variant="soft" color="warning" className="mt-2">{t('landsraad.testTerm')}</Chip>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: <div className="text-xs text-muted mt-2">{t('landsraad.noTerm')}</div>}
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('landsraad.decrees')}</SectionLabel>
|
||||
<div className="text-xs text-muted mb-2">{t('landsraad.decreesDesc')}</div>
|
||||
{decrees.length === 0
|
||||
? <div className="text-xs text-muted">{t('landsraad.noDecrees')}</div>
|
||||
: (
|
||||
<div className="mt-1">
|
||||
{decrees.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center justify-between py-1.5 border-b border-border/40 text-sm"
|
||||
>
|
||||
<span className="text-foreground">{d.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">{t('landsraad.weight', { weight: d.weight })}</span>
|
||||
<Chip size="sm" variant="soft" color={d.disabled ? 'danger' : 'success'}>
|
||||
{d.disabled ? t('landsraad.disabled') : t('landsraad.enabled')}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div>
|
||||
<SectionLabel>{t('landsraad.taskBoard')}</SectionLabel>
|
||||
<div className="text-xs text-muted mb-2">{t('landsraad.taskBoardDesc')}</div>
|
||||
<DataTable<LandsraadTask, TaskKey>
|
||||
aria-label={t('landsraad.taskBoard')}
|
||||
className="min-h-0"
|
||||
columns={TASK_COLUMNS}
|
||||
rows={tasks}
|
||||
loading={loading}
|
||||
rowId={(tk) => String(tk.id)}
|
||||
initialSort={{ column: 'board_index', direction: 'ascending' }}
|
||||
sortValue={(tk, k) => {
|
||||
switch (k) {
|
||||
case 'board_index': return tk.board_index
|
||||
case 'house': return tk.house
|
||||
case 'goal_amount': return tk.goal_amount
|
||||
case 'completed': return tk.completed ? 1 : 0
|
||||
case 'sysselraad': return tk.sysselraad ? 1 : 0
|
||||
default: return ''
|
||||
}
|
||||
}}
|
||||
emptyState={<div className="py-8 text-center text-muted">{t('landsraad.noTasks')}</div>}
|
||||
renderCell={(tk, key) => {
|
||||
switch (key) {
|
||||
case 'board_index':
|
||||
return <span className="font-mono text-muted">{tk.board_index}</span>
|
||||
case 'house':
|
||||
return tk.house || <span className="text-muted">—</span>
|
||||
case 'goal_amount':
|
||||
return <span className="text-muted">{tk.goal_amount.toLocaleString()}</span>
|
||||
case 'completed':
|
||||
return (
|
||||
<Chip size="sm" variant="soft" color={tk.completed ? 'success' : 'default'}>
|
||||
{tk.completed ? t('landsraad.tasks.done') : t('landsraad.tasks.open')}
|
||||
</Chip>
|
||||
)
|
||||
case 'sysselraad':
|
||||
return tk.sysselraad
|
||||
? <Chip size="sm" variant="soft" color="accent">{t('landsraad.tasks.yes')}</Chip>
|
||||
: <span className="text-muted">—</span>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Checkbox, SearchField } from '@heroui/react'
|
||||
import { Icon, Panel, SectionLabel } from '../../../dune-ui'
|
||||
import { LIVE_TYPES, CATEGORY_GROUPS, CAT_COLOR, TYPE_LABELS, ICON_POS, HEATMAP_BOUNDS, HEATMAP_TYPES, HEATMAP_COLORS } from '../constants'
|
||||
import { filterKey, heatmapFilterKey } from '../utils'
|
||||
import { SpriteIcon } from './SpriteIcon'
|
||||
import type { FilterPanelProps } from '../types'
|
||||
|
||||
export function FilterPanel({
|
||||
filter, onToggle, onClear, spawns, mapKey, heatmapMode, onHeatmapToggle,
|
||||
}: FilterPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
const typesByCategory = useMemo(() => {
|
||||
const map: Record<string, Map<string, { label: string, count: number }>> = {}
|
||||
spawns.forEach((s) => {
|
||||
const cat = s.category
|
||||
if (!map[cat]) map[cat] = new Map()
|
||||
const key = filterKey(s.type)
|
||||
const label = TYPE_LABELS[key] ?? s.label ?? s.type.replace(/_/g, ' ')
|
||||
const existing = map[cat].get(key)
|
||||
map[cat].set(key, { label, count: (existing?.count ?? 0) + 1 })
|
||||
})
|
||||
return map
|
||||
}, [spawns])
|
||||
|
||||
const LIVE_LABELS: Record<string, string> = {
|
||||
players: t('liveMap.players'),
|
||||
vehicles: t('liveMap.vehicles'),
|
||||
bases: t('liveMap.filterBases'),
|
||||
}
|
||||
|
||||
type TypeRowProps = { typeKey: string, label: string, count: number, category: string }
|
||||
function TypeRow({ typeKey, label, count, category }: TypeRowProps) {
|
||||
const isOn = filter[typeKey] ?? false
|
||||
return (
|
||||
<Checkbox
|
||||
isSelected={isOn}
|
||||
onChange={() => onToggle(typeKey, isOn)}
|
||||
className="flex items-center gap-2 py-1.5 px-3 hover:bg-surface-secondary rounded-[var(--radius)] w-full max-w-none"
|
||||
>
|
||||
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
|
||||
<SpriteIcon type={typeKey} size={18} />
|
||||
{!ICON_POS[typeKey] && (
|
||||
<span style={{ color: CAT_COLOR[category] }} className="shrink-0">●</span>
|
||||
)}
|
||||
<span className="flex-1 text-xs text-foreground truncate">{label}</span>
|
||||
<span className="text-xs text-muted tabular-nums shrink-0">{count.toLocaleString()}</span>
|
||||
</Checkbox>
|
||||
)
|
||||
}
|
||||
|
||||
type CategorySectionProps = { group: (typeof CATEGORY_GROUPS)[number] }
|
||||
function CategorySection({ group }: CategorySectionProps) {
|
||||
const items = typesByCategory[group.id]
|
||||
if (!items?.size) return null
|
||||
const isExpanded = expanded[group.id] ?? false
|
||||
const allOn = [...items.keys()].every((k) => filter[k] ?? false)
|
||||
const anyOn = [...items.keys()].some((k) => filter[k] ?? false)
|
||||
const q = search.toLowerCase()
|
||||
const filteredItems = q
|
||||
? [...items.entries()].filter(([k, v]) => v.label.toLowerCase().includes(q) || k.toLowerCase().includes(q))
|
||||
: [...items.entries()]
|
||||
|
||||
if (q && filteredItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div className="flex items-center gap-1 px-2 py-1.5">
|
||||
<Checkbox
|
||||
isSelected={allOn}
|
||||
isIndeterminate={!allOn && anyOn}
|
||||
onChange={(v) => { [...items.keys()].forEach((k) => onToggle(k, !v)) }}
|
||||
>
|
||||
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
|
||||
</Checkbox>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-1.5 text-left"
|
||||
onClick={() => setExpanded((e) => ({ ...e, [group.id]: !e[group.id] }))}
|
||||
>
|
||||
<span style={{ color: CAT_COLOR[group.id] }} className="text-xs shrink-0">●</span>
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">{t(group.labelKey as never)}</span>
|
||||
<span className="text-xs text-muted/60 ml-1">
|
||||
{[...items.values()].reduce((s, v) => s + v.count, 0).toLocaleString()}
|
||||
</span>
|
||||
<Icon
|
||||
name={isExpanded || q ? 'chevron-down' : 'chevron-right'}
|
||||
className="size-3 text-muted ml-auto"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{(isExpanded || !!q) && (
|
||||
<div className="ml-1">
|
||||
{filteredItems.map(([key, { label, count }]) => (
|
||||
<TypeRow key={key} typeKey={key} label={label} count={count} category={group.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-60 shrink-0 min-h-0 overflow-hidden border-r border-border bg-background">
|
||||
<div className="px-2 pt-2 pb-1 shrink-0">
|
||||
<SearchField
|
||||
aria-label={t('liveMap.filter')}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
>
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input placeholder={t('liveMap.filterSearch')} />
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
</div>
|
||||
<div className="px-2 pb-1 shrink-0 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xs text-muted hover:text-accent px-1 h-auto min-w-0"
|
||||
onPress={onClear}
|
||||
>
|
||||
{t('liveMap.clearFilters')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{!search && (
|
||||
<Panel className="mb-2 mt-1">
|
||||
<SectionLabel>{t('liveMap.filterLive')}</SectionLabel>
|
||||
{LIVE_TYPES.map((id) => (
|
||||
<Checkbox
|
||||
key={id}
|
||||
isSelected={filter[id] ?? false}
|
||||
onChange={() => onToggle(id, filter[id] ?? false)}
|
||||
className="flex items-center gap-2 py-1.5 px-1 hover:bg-surface-secondary rounded-[var(--radius)] w-full max-w-none"
|
||||
>
|
||||
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
|
||||
<span style={{ color: CAT_COLOR[id] }} className="text-xs shrink-0">●</span>
|
||||
<span className="flex-1 text-xs text-foreground">{LIVE_LABELS[id]}</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!search && HEATMAP_BOUNDS[mapKey] && (
|
||||
<Panel className="mb-2">
|
||||
<SectionLabel>{t('liveMap.filterDensity')}</SectionLabel>
|
||||
<Checkbox
|
||||
isSelected={heatmapMode}
|
||||
onChange={onHeatmapToggle}
|
||||
className="flex items-center gap-2 py-1.5 px-1 hover:bg-surface-secondary rounded-[var(--radius)] w-full max-w-none"
|
||||
>
|
||||
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
|
||||
<Icon name="layers" className="text-accent shrink-0" />
|
||||
<span className="flex-1 text-xs text-foreground">{t('liveMap.densityOverlay')}</span>
|
||||
</Checkbox>
|
||||
{heatmapMode && (() => {
|
||||
const active = (HEATMAP_TYPES[mapKey] ?? []).filter((type) => filter[heatmapFilterKey(type)] ?? false)
|
||||
if (!active.length) return (
|
||||
<p className="text-xs text-muted px-1 pb-1">{t('liveMap.densityNoneSelected')}</p>
|
||||
)
|
||||
return (
|
||||
<div className="px-1 pb-1 flex flex-col gap-0.5">
|
||||
{active.map((type) => (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-sm shrink-0 opacity-80" style={{ background: HEATMAP_COLORS[type] ?? '#888' }} />
|
||||
<span className="text-xs text-muted truncate">{TYPE_LABELS[type] ?? type.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{CATEGORY_GROUPS.map((group) => (
|
||||
<CategorySection key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import { IMAGE_BOUNDS } from '../constants'
|
||||
import type { FitBoundsControllerProps } from '../types'
|
||||
|
||||
export function FitBoundsController({ fitRef }: FitBoundsControllerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
fitRef.current = () => map.fitBounds(IMAGE_BOUNDS, { animate: true })
|
||||
}, [map, fitRef])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import { HEATMAP_BOUNDS, HEATMAP_PREFIX, HEATMAP_TYPES } from '../constants'
|
||||
import { worldToLatLng, heatmapFilterKey, mapUrl } from '../utils'
|
||||
import type { HeatmapCanvasLayerProps } from '../types'
|
||||
|
||||
export function HeatmapCanvasLayer({
|
||||
mapKey, effCfg, filter,
|
||||
}: HeatmapCanvasLayerProps) {
|
||||
const map = useMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const imageCache = useRef(new Map<string, HTMLImageElement | null>())
|
||||
const pendingRef = useRef(new Set<string>())
|
||||
|
||||
const bounds = HEATMAP_BOUNDS[mapKey]
|
||||
const prefix = HEATMAP_PREFIX[mapKey]
|
||||
const types = useMemo(() => HEATMAP_TYPES[mapKey] ?? [], [mapKey])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !bounds) return
|
||||
const mapSize = map.getSize()
|
||||
canvas.width = mapSize.x
|
||||
canvas.height = mapSize.y
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, mapSize.x, mapSize.y)
|
||||
|
||||
const [tlLat, tlLng] = worldToLatLng(bounds.minX, bounds.maxY, effCfg)
|
||||
const [brLat, brLng] = worldToLatLng(bounds.maxX, bounds.minY, effCfg)
|
||||
const tl = map.latLngToContainerPoint([tlLat, tlLng])
|
||||
const br = map.latLngToContainerPoint([brLat, brLng])
|
||||
const dw = br.x - tl.x
|
||||
const dh = br.y - tl.y
|
||||
|
||||
ctx.globalAlpha = 0.65
|
||||
for (const type of types) {
|
||||
if (!(filter[heatmapFilterKey(type)] ?? false)) continue
|
||||
const img = imageCache.current.get(type)
|
||||
if (img) ctx.drawImage(img, tl.x, tl.y, dw, dh)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}, [map, bounds, effCfg, filter, types])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefix) return
|
||||
for (const type of types) {
|
||||
if (!(filter[heatmapFilterKey(type)] ?? false)) continue
|
||||
if (imageCache.current.has(type) || pendingRef.current.has(type)) continue
|
||||
pendingRef.current.add(type)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
imageCache.current.set(type, img)
|
||||
pendingRef.current.delete(type)
|
||||
draw()
|
||||
}
|
||||
img.onerror = () => {
|
||||
imageCache.current.set(type, null)
|
||||
pendingRef.current.delete(type)
|
||||
}
|
||||
img.src = mapUrl(`map-data/${prefix}-heatmap-${type}.png`)
|
||||
}
|
||||
}, [filter, types, prefix, draw])
|
||||
|
||||
useEffect(() => {
|
||||
const container = map.getContainer()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:498'
|
||||
container.appendChild(canvas)
|
||||
canvasRef.current = canvas
|
||||
return () => {
|
||||
canvas.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
|
||||
useEffect(() => {
|
||||
map.on('move zoom moveend zoomend viewreset resize', draw)
|
||||
draw()
|
||||
return () => {
|
||||
map.off('move zoom moveend zoomend viewreset resize', draw)
|
||||
}
|
||||
}, [map, draw])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import { IMAGE_BOUNDS } from '../constants'
|
||||
import type { InvalidateOnActiveProps } from '../types'
|
||||
|
||||
export function InvalidateOnActive({ active }: InvalidateOnActiveProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
const id = setTimeout(() => {
|
||||
map.invalidateSize()
|
||||
map.fitBounds(IMAGE_BOUNDS)
|
||||
}, 50)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [active, map])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useMapEvents } from 'react-leaflet'
|
||||
import type { MapClickCaptureProps } from '../types'
|
||||
|
||||
export function MapClickCapture({ active, onPick }: MapClickCaptureProps) {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
if (active) onPick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { TILE_CDN } from '../constants'
|
||||
import type { MapTileLayerProps } from '../types'
|
||||
|
||||
export function MapTileLayer({ tileId }: MapTileLayerProps) {
|
||||
const map = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
const layer = new L.TileLayer('', {
|
||||
tileSize: 512,
|
||||
minZoom: -3,
|
||||
maxZoom: 4,
|
||||
maxNativeZoom: 1,
|
||||
noWrap: true,
|
||||
attribution: '',
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(layer as any).getTileUrl = (coords: L.Coords): string => {
|
||||
const cdnZ = Math.min(4, Math.max(0, coords.z + 3))
|
||||
const scale = Math.pow(2, coords.z + 3 - cdnZ)
|
||||
const cdnX = Math.floor(coords.x / scale)
|
||||
const cdnY = Math.floor(Math.pow(2, cdnZ) + coords.y / scale)
|
||||
const maxTile = Math.pow(2, cdnZ)
|
||||
if (cdnX < 0 || cdnX >= maxTile || cdnY < 0 || cdnY >= maxTile) return ''
|
||||
return `${TILE_CDN}/${tileId}/${cdnZ}/${cdnY}/${cdnX}.webp`
|
||||
}
|
||||
|
||||
layer.addTo(map)
|
||||
return () => {
|
||||
layer.remove()
|
||||
}
|
||||
}, [map, tileId])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import { ICON_POS, CAT_COLOR, SPRITE_CELL, SPRITE_URL } from '../constants'
|
||||
import { worldToLatLng, filterKey } from '../utils'
|
||||
import type { SpawnCanvasLayerProps } from '../types'
|
||||
|
||||
export function SpawnCanvasLayer({
|
||||
spawns, effCfg, filter, heatmapMode,
|
||||
}: SpawnCanvasLayerProps) {
|
||||
const map = useMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const spriteRef = useRef<HTMLImageElement | null>(null)
|
||||
const spriteReady = useRef(false)
|
||||
|
||||
const visible = useMemo(
|
||||
() => spawns.filter((s) => {
|
||||
if (!(filter[filterKey(s.type)] ?? false)) return false
|
||||
if (heatmapMode && (s.category === 'resources' || s.category === 'hazards')) return false
|
||||
return true
|
||||
}),
|
||||
[spawns, filter, heatmapMode],
|
||||
)
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const mapSize = map.getSize()
|
||||
canvas.width = mapSize.x
|
||||
canvas.height = mapSize.y
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, mapSize.x, mapSize.y)
|
||||
|
||||
const sprite = spriteRef.current
|
||||
|
||||
for (const s of visible) {
|
||||
const isDense = s.category === 'resources' || s.category === 'static'
|
||||
|
||||
const [lat, lng] = worldToLatLng(s.x, s.y, effCfg)
|
||||
const pt = map.latLngToContainerPoint([lat, lng])
|
||||
|
||||
if (pt.x < -32 || pt.x > mapSize.x + 32 || pt.y < -32 || pt.y > mapSize.y + 32) continue
|
||||
|
||||
const typeKey = filterKey(s.type)
|
||||
const pos = ICON_POS[typeKey]
|
||||
const iconSize = isDense ? 20 : 28
|
||||
|
||||
if (sprite && spriteReady.current && pos) {
|
||||
const [col, row] = pos
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
col * SPRITE_CELL, row * SPRITE_CELL,
|
||||
SPRITE_CELL, SPRITE_CELL,
|
||||
pt.x - iconSize / 2, pt.y - iconSize / 2,
|
||||
iconSize, iconSize,
|
||||
)
|
||||
}
|
||||
else {
|
||||
ctx.beginPath()
|
||||
ctx.arc(pt.x, pt.y, isDense ? 3 : 5, 0, Math.PI * 2)
|
||||
ctx.fillStyle = CAT_COLOR[s.category] ?? '#888'
|
||||
ctx.globalAlpha = 0.65
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
}, [map, visible, effCfg])
|
||||
|
||||
useEffect(() => {
|
||||
const container = map.getContainer()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:499'
|
||||
container.appendChild(canvas)
|
||||
canvasRef.current = canvas
|
||||
|
||||
const img = new Image()
|
||||
img.src = SPRITE_URL
|
||||
img.onload = () => {
|
||||
spriteRef.current = img
|
||||
spriteReady.current = true
|
||||
draw()
|
||||
}
|
||||
|
||||
return () => {
|
||||
canvas.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
}, [map]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
map.on('move zoom moveend zoomend viewreset resize', draw)
|
||||
draw()
|
||||
return () => {
|
||||
map.off('move zoom moveend zoomend viewreset resize', draw)
|
||||
}
|
||||
}, [map, draw])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ICON_POS, SPRITE_URL, SPRITE_COLS, SPRITE_ROWS, SPRITE_CELL } from '../constants'
|
||||
import type { SpriteIconProps } from '../types'
|
||||
|
||||
export function SpriteIcon({ type, size = 22 }: SpriteIconProps) {
|
||||
const pos = ICON_POS[type]
|
||||
if (!pos) return null
|
||||
const [col, row] = pos
|
||||
const scale = size / SPRITE_CELL
|
||||
const bw = SPRITE_COLS * SPRITE_CELL * scale
|
||||
const bh = SPRITE_ROWS * SPRITE_CELL * scale
|
||||
const bx = -(col * SPRITE_CELL * scale)
|
||||
const by = -(row * SPRITE_CELL * scale)
|
||||
return (
|
||||
<span
|
||||
className="inline-block shrink-0"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundImage: `url(${SPRITE_URL})`,
|
||||
backgroundPosition: `${bx}px ${by}px`,
|
||||
backgroundSize: `${bw}px ${bh}px`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import { DD_COLS, DD_ROWS } from '../constants'
|
||||
import { worldToLatLng } from '../utils'
|
||||
import type { ZoneGridLayerProps } from '../types'
|
||||
|
||||
export function ZoneGridLayer({ effCfg }: ZoneGridLayerProps) {
|
||||
const map = useMap()
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const mapSize = map.getSize()
|
||||
canvas.width = mapSize.x
|
||||
canvas.height = mapSize.y
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, mapSize.x, mapSize.y)
|
||||
|
||||
const b = { minX: effCfg.minX, maxX: effCfg.maxX, minY: effCfg.minY, maxY: effCfg.maxY }
|
||||
const cellW = (b.maxX - b.minX) / 9
|
||||
const cellH = (b.maxY - b.minY) / 9
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.25)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
for (let ci = 0; ci <= 9; ci++) {
|
||||
const x = b.minX + ci * cellW
|
||||
const [latB, lngB] = worldToLatLng(x, b.minY, effCfg)
|
||||
const [latT, lngT] = worldToLatLng(x, b.maxY, effCfg)
|
||||
const ptB = map.latLngToContainerPoint([latB, lngB])
|
||||
const ptT = map.latLngToContainerPoint([latT, lngT])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(ptB.x, ptB.y)
|
||||
ctx.lineTo(ptT.x, ptT.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
for (let ri = 0; ri <= 9; ri++) {
|
||||
const y = b.minY + ri * cellH
|
||||
const [latL, lngL] = worldToLatLng(b.minX, y, effCfg)
|
||||
const [latR, lngR] = worldToLatLng(b.maxX, y, effCfg)
|
||||
const ptL = map.latLngToContainerPoint([latL, lngL])
|
||||
const ptR = map.latLngToContainerPoint([latR, lngR])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(ptL.x, ptL.y)
|
||||
ctx.lineTo(ptR.x, ptR.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
for (let ci = 0; ci < 9; ci++) {
|
||||
for (let ri = 0; ri < 9; ri++) {
|
||||
const cx = b.minX + (ci + 0.5) * cellW
|
||||
const cy = b.minY + (ri + 0.5) * cellH
|
||||
const [lat, lng] = worldToLatLng(cx, cy, effCfg)
|
||||
const pt = map.latLngToContainerPoint([lat, lng])
|
||||
if (pt.x < -20 || pt.x > mapSize.x + 20 || pt.y < -20 || pt.y > mapSize.y + 20) continue
|
||||
const label = `${DD_ROWS[ri]}${DD_COLS[ci]}`
|
||||
ctx.fillText(label, pt.x, pt.y)
|
||||
}
|
||||
}
|
||||
}, [map, effCfg])
|
||||
|
||||
useEffect(() => {
|
||||
const container = map.getContainer()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:497'
|
||||
container.appendChild(canvas)
|
||||
canvasRef.current = canvas
|
||||
return () => {
|
||||
canvas.remove()
|
||||
canvasRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
|
||||
useEffect(() => {
|
||||
map.on('move zoom moveend zoomend viewreset resize', draw)
|
||||
draw()
|
||||
return () => {
|
||||
map.off('move zoom moveend zoomend viewreset resize', draw)
|
||||
}
|
||||
}, [map, draw])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { LatLngBoundsExpression } from 'leaflet'
|
||||
import type { MapCfg } from './types'
|
||||
import { mapUrl } from './utils'
|
||||
|
||||
const MAP_BASE = ((import.meta.env.VITE_CDN_BASE_URL as string) ?? 'https://assets.dune.layout.tools').replace(/\/$/, '')
|
||||
|
||||
const TILE_CDN = 'https://cdn.th.gl/dune-awakening/map-tiles'
|
||||
|
||||
const IMG_W = 4096
|
||||
const IMG_H = 4096
|
||||
const IMAGE_BOUNDS: LatLngBoundsExpression = [[0, 0], [IMG_H, IMG_W]]
|
||||
const POLL_MS = 30000
|
||||
|
||||
const SPRITE_URL = mapUrl('map-data/map-icons.webp')
|
||||
const SPRITE_COLS = 11
|
||||
const SPRITE_ROWS = 12
|
||||
const SPRITE_CELL = 64
|
||||
|
||||
const ICON_POS: Record<string, [number, number]> = {
|
||||
basic: [3, 0], vbasic: [3, 0], wbasic: [3, 0], ebasic: [3, 0], rbasic: [3, 0], srbasic: [3, 0],
|
||||
rare: [1, 0], vrare: [1, 0], wrare: [1, 0], drare: [1, 0],
|
||||
ultra_rare: [1, 1],
|
||||
small_ultra_rare: [6, 0],
|
||||
ammo: [2, 1], vammo: [2, 1], wammo: [2, 1], uammo: [2, 1], dammo: [2, 1],
|
||||
medical: [3, 1],
|
||||
weapon: [9, 0],
|
||||
corpse: [2, 0], vcorpse: [2, 0], fcorpse: [2, 0],
|
||||
fuel: [1, 2], vfuel: [1, 2], wfuel: [1, 2], dfuel: [1, 2], ufuel: [1, 2], owfuel: [1, 2],
|
||||
contract: [8, 0],
|
||||
refinery: [4, 3],
|
||||
water_tank: [1, 3],
|
||||
buried_treasure: [4, 9],
|
||||
treasure_loot_container: [3, 0],
|
||||
cave: [0, 0],
|
||||
intel_point: [4, 1],
|
||||
enemy_camp: [4, 0],
|
||||
primitive: [5, 0], kirab_camp: [5, 0],
|
||||
shipwreck: [7, 0],
|
||||
trading_post: [9, 1],
|
||||
taxi: [4, 6],
|
||||
bank: [10, 6],
|
||||
discoverable: [6, 7],
|
||||
exploration: [9, 2],
|
||||
buggy: [2, 3], ebuggy: [2, 3],
|
||||
bike: [10, 2],
|
||||
bene_gesserit_trainer: [1, 4],
|
||||
mentat: [9, 4],
|
||||
planetologist: [8, 2],
|
||||
swordmaster: [1, 5],
|
||||
trooper: [10, 1],
|
||||
blue_id_band: [6, 2],
|
||||
green_id_band: [0, 1],
|
||||
orange_id_band: [5, 2],
|
||||
purple_id_band: [10, 0],
|
||||
red_id_band: [5, 1],
|
||||
spice_field_small: [10, 7],
|
||||
spice_field_medium: [0, 8],
|
||||
spice_field_large: [1, 8],
|
||||
agave_seeds: [1, 9],
|
||||
azurite: [8, 8], azurite_pickup: [8, 8],
|
||||
basalt: [5, 8], basalt_pickup: [5, 8],
|
||||
bauxite: [7, 8], bauxite_pickup: [7, 8],
|
||||
dolomite: [10, 8], dolomite_pickup: [10, 8],
|
||||
erythrite: [0, 9], erythrite_pickup: [0, 9],
|
||||
fiber_plant: [6, 8], plant_fiber: [6, 8],
|
||||
fuel_cells: [5, 9],
|
||||
jasmium: [3, 9], jasmium_crystal: [3, 9],
|
||||
magnetite: [2, 9], magnetite_pickup: [2, 9],
|
||||
primrose_field: [9, 7],
|
||||
rhyolite: [9, 8], rhyolite_pickup: [9, 8],
|
||||
scrap_electronics: [7, 9],
|
||||
scrap_metal: [6, 9],
|
||||
stravidium: [4, 8],
|
||||
titanium_ore: [3, 8],
|
||||
barkeep: [0, 7],
|
||||
base_vendor: [6, 6],
|
||||
landsraad_vendor: [2, 7],
|
||||
scrap_trader: [9, 6],
|
||||
spice_merchant: [7, 6],
|
||||
vehicle_vendor: [5, 6],
|
||||
water_seller: [8, 6],
|
||||
weapons_merchant: [1, 7],
|
||||
banker: [10, 6],
|
||||
atreides_npc: [3, 4],
|
||||
harkonnen_npc: [8, 4],
|
||||
fremen_npc: [0, 6],
|
||||
bene_gesserit_npc: [10, 5],
|
||||
choam_npc: [7, 5],
|
||||
bandits_npc: [3, 5],
|
||||
sardaukar_npc: [9, 5],
|
||||
smugglers_npc: [6, 5],
|
||||
spacing_guild_npc: [8, 5],
|
||||
unaffiliated_npc: [7, 1],
|
||||
alexin: [1, 6], argosaz: [0, 2], dyvetz: [7, 4], ecaz: [10, 4],
|
||||
hagal: [4, 4], hurata: [9, 3], imota: [5, 5], kenola: [3, 3],
|
||||
lindaren: [5, 4], maros: [8, 7], mikarrol: [4, 5], moritani: [6, 4],
|
||||
mutelli: [4, 7], novebruns: [8, 3], richese: [2, 4], sor: [2, 5],
|
||||
spinette: [2, 6], taligari: [0, 3], thorvald: [0, 5], tseida: [7, 3],
|
||||
varota: [3, 7], vernius: [0, 4], wallach: [5, 7], wayku: [7, 7], wydras: [8, 1],
|
||||
aluminum_ore: [7, 8],
|
||||
copper_ore: [8, 8],
|
||||
carbon_fiber: [10, 8],
|
||||
iron_ore: [2, 9],
|
||||
stone: [9, 8],
|
||||
fiber: [6, 8],
|
||||
cistanche: [1, 9],
|
||||
saguaro_cactus: [1, 9],
|
||||
t6_resource_a: [4, 8],
|
||||
t6_resource_b: [3, 8],
|
||||
sandworm_territory: [4, 0],
|
||||
enemycamp: [4, 0], enemyoutpost: [6, 1], enemylaboroutpost: [10, 3],
|
||||
wreck: [2, 2], tradingpost: [9, 1], sietch: [0, 2], ecolab: [7, 2],
|
||||
small_shipwreck: [7, 0], atreides: [3, 4], harkonnen: [8, 4], poi: [6, 7],
|
||||
npc_harkonnen: [8, 5], npc_atreides: [3, 5], npc_bandits: [3, 5],
|
||||
npc_unaffiliated: [7, 1], npc_choam: [7, 5], npc_fremen: [0, 6],
|
||||
npc_sardaukar: [9, 5], npc_smugglers: [6, 5], npc_spacingguild: [8, 5],
|
||||
trainersswordmaster: [1, 5], trainersmentat: [9, 4], trainersbenegesserit: [1, 4],
|
||||
trainersplanetologist: [8, 2], trainerstrooper: [10, 1],
|
||||
sandbike: [10, 2],
|
||||
}
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
player: '#3b9dff', vehicle: '#5fd35a', base: '#e0a13a',
|
||||
resources: '#f5a623', locations: '#9b59b6', npcs: '#e74c3c',
|
||||
vendors: '#2ecc71', landsraad: '#e91e8c', static: '#7f8c8d',
|
||||
hazards: '#ff5020',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
basic: 'Basic', vbasic: 'Basic', wbasic: 'Basic', ebasic: 'Basic', rbasic: 'Basic', srbasic: 'Basic',
|
||||
rare: 'Rare', vrare: 'Rare', wrare: 'Rare', drare: 'Rare',
|
||||
ultra_rare: 'Ultra Rare', ammo: 'Ammo', vammo: 'Ammo', wammo: 'Ammo', uammo: 'Ammo', dammo: 'Ammo',
|
||||
medical: 'Medical', weapon: 'Weapon', corpse: 'Corpse', vcorpse: 'Corpse', fcorpse: 'Corpse',
|
||||
fuel: 'Fuel', vfuel: 'Fuel', wfuel: 'Fuel', dfuel: 'Fuel', ufuel: 'Fuel', owfuel: 'Fuel',
|
||||
contract: 'Contract', refinery: 'Refinery', water_tank: 'Water Tank',
|
||||
treasure_loot_container: 'Loot Container',
|
||||
enemy_camp: 'Enemy Camp', primitive: 'Primitive Camp', kirab_camp: 'Kirab Camp',
|
||||
intel_point: 'Intel Point', buggy: 'Buggy', ebuggy: 'Buggy',
|
||||
spice_field_small: 'Small Spice', spice_field_medium: 'Medium Spice', spice_field_large: 'Large Spice',
|
||||
basalt: 'Basalt Stone', basalt_pickup: 'Basalt (Node)',
|
||||
fiber_plant: 'Plant Fiber', plant_fiber: 'Plant Fiber',
|
||||
bauxite: 'Aluminum Ore', bauxite_pickup: 'Aluminum (Node)',
|
||||
agave_seeds: 'Agave Seeds',
|
||||
erythrite: 'Erythrite Crystal', erythrite_pickup: 'Erythrite (Node)',
|
||||
jasmium: 'Jasmium Crystal', jasmium_crystal: 'Jasmium Crystal',
|
||||
scrap_electronics: 'Scrap Electronics', scrap_metal: 'Scrap Metal',
|
||||
fuel_cells: 'Fuel Cells',
|
||||
azurite: 'Copper Ore', azurite_pickup: 'Copper (Node)',
|
||||
dolomite: 'Carbon Ore', dolomite_pickup: 'Carbon (Node)',
|
||||
magnetite: 'Iron Ore', magnetite_pickup: 'Iron (Node)',
|
||||
rhyolite: 'Granite Stone', rhyolite_pickup: 'Granite (Node)',
|
||||
primrose_field: 'Primrose Field', stravidium: 'Stravidium', titanium_ore: 'Titanium',
|
||||
aluminum_ore: 'Aluminum Ore', copper_ore: 'Copper Ore', carbon_fiber: 'Carbon Fiber',
|
||||
iron_ore: 'Iron Ore', stone: 'Stone', fiber: 'Plant Fiber',
|
||||
cistanche: 'Cistanche', saguaro_cactus: 'Saguaro Cactus',
|
||||
t6_resource_a: 'T6 Resource A', t6_resource_b: 'T6 Resource B',
|
||||
sandworm_territory: 'Sandworm Territory', buried_treasure: 'Buried Treasure',
|
||||
static: 'Static Object',
|
||||
enemycamp: 'Enemy Camp', enemyoutpost: 'Enemy Outpost', enemylaboroutpost: 'Enemy Lab Outpost',
|
||||
cave: 'Cave', wreck: 'Wreck', tradingpost: 'Trading Post', sietch: 'Sietch',
|
||||
ecolab: 'Eco Lab', secret_door: 'Secret Door', shipwreck: 'Shipwreck',
|
||||
small_shipwreck: 'Small Shipwreck', atreides: 'Atreides', harkonnen: 'Harkonnen', poi: 'Point of Interest',
|
||||
npc_harkonnen: 'Harkonnen NPC', npc_atreides: 'Atreides NPC', npc_bandits: 'Bandits',
|
||||
npc_unaffiliated: 'Unaffiliated', npc_choam: 'CHOAM', npc_fremen: 'Fremen',
|
||||
npc_sardaukar: 'Sardaukar', npc_smugglers: 'Smugglers', npc_spacingguild: 'Spacing Guild',
|
||||
trainersswordmaster: 'Swordmaster', trainersmentat: 'Mentat', trainersbenegesserit: 'Bene Gesserit',
|
||||
trainersplanetologist: 'Planetologist', trainerstrooper: 'Trooper',
|
||||
purple_id_band: 'Purple ID Band', green_id_band: 'Green ID Band',
|
||||
red_id_band: 'Red ID Band', orange_id_band: 'Orange ID Band', blue_id_band: 'Blue ID Band',
|
||||
sandbike: 'Sandbike',
|
||||
}
|
||||
|
||||
const TYPE_MERGE_KEY: Record<string, string> = {
|
||||
vbasic: 'basic', wbasic: 'basic', ebasic: 'basic', rbasic: 'basic', srbasic: 'basic',
|
||||
vrare: 'rare', wrare: 'rare', drare: 'rare',
|
||||
vammo: 'ammo', wammo: 'ammo', uammo: 'ammo', dammo: 'ammo',
|
||||
vcorpse: 'corpse', fcorpse: 'corpse',
|
||||
vfuel: 'fuel', wfuel: 'fuel', dfuel: 'fuel', ufuel: 'fuel', owfuel: 'fuel',
|
||||
ebuggy: 'buggy',
|
||||
basalt_pickup: 'basalt',
|
||||
bauxite_pickup: 'bauxite',
|
||||
erythrite_pickup: 'erythrite',
|
||||
jasmium_crystal: 'jasmium',
|
||||
azurite_pickup: 'azurite',
|
||||
dolomite_pickup: 'dolomite',
|
||||
magnetite_pickup: 'magnetite',
|
||||
rhyolite_pickup: 'rhyolite',
|
||||
plant_fiber: 'fiber_plant',
|
||||
}
|
||||
|
||||
const MAPS: MapCfg[] = [
|
||||
{
|
||||
key: 'HaggaBasin', label: 'Hagga Basin', image: 'hagga-basin.webp', spawnFile: 'hagga',
|
||||
tileId: 'survival_1-0c70ddebb3e41cf49915b22e103e94ed',
|
||||
depthFile: 'hagga-depth.webp',
|
||||
hasLiveData: true,
|
||||
minX: -437871, maxX: 350539, minY: -462011, maxY: 376267, flipY: true,
|
||||
},
|
||||
{
|
||||
key: 'DeepDesert', label: 'Deep Desert', image: 'deepdesert.webp', spawnFile: 'deepdesert',
|
||||
tileId: 'deepdesert_1-40f176fc4cce018dff08f3cd66b52f08',
|
||||
depthFile: 'deepdesert-depth.webp',
|
||||
hasLiveData: true,
|
||||
minX: -1300000, maxX: 1200000, minY: -1300000, maxY: 1200000,
|
||||
},
|
||||
{
|
||||
key: 'Arrakeen', label: 'Arrakeen', image: 'arrakeen.webp', spawnFile: 'arrakeen',
|
||||
hasLiveData: false,
|
||||
minX: -32000, maxX: 17000, minY: -10000, maxY: 9500, flipY: true,
|
||||
},
|
||||
{
|
||||
key: 'HarkoVillage', label: 'Harko Village', image: 'harko.webp', spawnFile: 'harko',
|
||||
hasLiveData: false,
|
||||
minX: -5000, maxX: 14500, minY: -5500, maxY: 32000,
|
||||
},
|
||||
]
|
||||
|
||||
const CALIB_LS_KEY = 'dune_admin_livemap_calib'
|
||||
|
||||
const HEATMAP_BOUNDS: Record<string, { minX: number, maxX: number, minY: number, maxY: number }> = {
|
||||
HaggaBasin: { minX: -457200, maxX: 355600, minY: -457200, maxY: 355600 },
|
||||
DeepDesert: { minX: -1270000, maxX: 1168400, minY: -1270000, maxY: 1168400 },
|
||||
}
|
||||
|
||||
const HEATMAP_PREFIX: Record<string, string> = {
|
||||
HaggaBasin: 'hagga',
|
||||
DeepDesert: 'deepdesert',
|
||||
}
|
||||
|
||||
const HEATMAP_TO_FILTER: Record<string, string> = {
|
||||
aluminum_ore: 'bauxite',
|
||||
copper_ore: 'azurite',
|
||||
carbon_fiber: 'dolomite',
|
||||
iron_ore: 'magnetite',
|
||||
stone: 'rhyolite',
|
||||
fiber: 'fiber_plant',
|
||||
}
|
||||
|
||||
const HEATMAP_COLORS: Record<string, string> = {
|
||||
aluminum_ore: 'rgb(201,130,10)', copper_ore: 'rgb(184,115,51)',
|
||||
carbon_fiber: 'rgb(90,90,90)', iron_ore: 'rgb(130,130,145)',
|
||||
stone: 'rgb(160,145,120)', basalt: 'rgb(150,100,50)',
|
||||
scrap_metal: 'rgb(100,120,145)', fuel: 'rgb(255,200,50)',
|
||||
fiber: 'rgb(120,200,80)', cistanche: 'rgb(60,180,120)',
|
||||
saguaro_cactus: 'rgb(40,160,80)', primrose_field: 'rgb(200,200,60)',
|
||||
jasmium: 'rgb(180,100,220)', erythrite: 'rgb(220,60,60)',
|
||||
t6_resource_a: 'rgb(100,220,220)', t6_resource_b: 'rgb(60,180,220)',
|
||||
sandworm_territory: 'rgb(255,80,30)',
|
||||
}
|
||||
|
||||
const DD_ROWS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
|
||||
const DD_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
|
||||
const HEATMAP_TYPES: Record<string, string[]> = {
|
||||
HaggaBasin: [
|
||||
'aluminum_ore', 'basalt', 'carbon_fiber', 'cistanche', 'copper_ore',
|
||||
'erythrite', 'fiber', 'fuel', 'iron_ore', 'jasmium',
|
||||
'primrose_field', 'saguaro_cactus', 'sandworm_territory', 'scrap_metal', 'stone',
|
||||
],
|
||||
DeepDesert: [
|
||||
'aluminum_ore', 'basalt', 'carbon_fiber', 'copper_ore',
|
||||
'fiber', 'fuel', 'iron_ore',
|
||||
'sandworm_territory', 'scrap_metal', 'stone', 't6_resource_a', 't6_resource_b',
|
||||
],
|
||||
}
|
||||
|
||||
const LIVE_TYPES = ['players', 'vehicles', 'bases'] as const
|
||||
|
||||
const CATEGORY_GROUPS: { id: string, labelKey: string }[] = [
|
||||
{ id: 'locations', labelKey: 'liveMap.filterLocations' },
|
||||
{ id: 'resources', labelKey: 'liveMap.filterResources' },
|
||||
{ id: 'npcs', labelKey: 'liveMap.filterNPCs' },
|
||||
{ id: 'vendors', labelKey: 'liveMap.filterVendors' },
|
||||
{ id: 'trainers', labelKey: 'liveMap.filterTrainers' },
|
||||
{ id: 'landsraad', labelKey: 'liveMap.filterLandsraad' },
|
||||
{ id: 'pentashield_keys', labelKey: 'liveMap.filterKeys' },
|
||||
{ id: 'vehicles', labelKey: 'liveMap.vehicles' },
|
||||
{ id: 'static', labelKey: 'liveMap.filterStaticObjects' },
|
||||
{ id: 'hazards', labelKey: 'liveMap.filterHazards' },
|
||||
]
|
||||
|
||||
export {
|
||||
MAP_BASE,
|
||||
TILE_CDN,
|
||||
IMG_W,
|
||||
IMG_H,
|
||||
IMAGE_BOUNDS,
|
||||
POLL_MS,
|
||||
SPRITE_URL,
|
||||
SPRITE_COLS,
|
||||
SPRITE_ROWS,
|
||||
SPRITE_CELL,
|
||||
ICON_POS,
|
||||
CAT_COLOR,
|
||||
TYPE_LABELS,
|
||||
TYPE_MERGE_KEY,
|
||||
MAPS,
|
||||
CALIB_LS_KEY,
|
||||
HEATMAP_BOUNDS,
|
||||
HEATMAP_PREFIX,
|
||||
HEATMAP_TO_FILTER,
|
||||
HEATMAP_COLORS,
|
||||
DD_ROWS,
|
||||
DD_COLS,
|
||||
HEATMAP_TYPES,
|
||||
LIVE_TYPES,
|
||||
CATEGORY_GROUPS,
|
||||
}
|
||||
606
docs/reference-repos/icehunter/web/src/tabs/LiveMapTab/index.tsx
Normal file
606
docs/reference-repos/icehunter/web/src/tabs/LiveMapTab/index.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Select, ListBox, Spinner, toast } from '@heroui/react'
|
||||
import { MapContainer, ImageOverlay, CircleMarker, Marker, Tooltip } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { CRS } from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { api, ApiError } from '../../api/client'
|
||||
import type { MapMarker, Player } from '../../api/client'
|
||||
import { ConfirmDialog, Icon, PageHeader } from '../../dune-ui'
|
||||
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
|
||||
import { InvalidateOnActive } from './components/InvalidateOnActive'
|
||||
import { MapClickCapture } from './components/MapClickCapture'
|
||||
import { SpawnCanvasLayer } from './components/SpawnCanvasLayer'
|
||||
import { HeatmapCanvasLayer } from './components/HeatmapCanvasLayer'
|
||||
import { MapTileLayer } from './components/MapTileLayer'
|
||||
import { ZoneGridLayer } from './components/ZoneGridLayer'
|
||||
import { FitBoundsController } from './components/FitBoundsController'
|
||||
import { FilterPanel } from './components/FilterPanel'
|
||||
import {
|
||||
MAPS, CAT_COLOR, IMAGE_BOUNDS, POLL_MS, IMG_H, IMG_W,
|
||||
} from './constants'
|
||||
import {
|
||||
worldToLatLng, latLngToWorld, solveBounds, loadCalib, loadFilter, saveFilter, mapUrl,
|
||||
} from './utils'
|
||||
import type { LiveMapTabProps, SpawnEntry, SpawnFile, CalibPoint, MapCfg, Bounds } from './types'
|
||||
|
||||
export const LiveMapTab: React.FC<LiveMapTabProps> = ({ isActive = true }) => {
|
||||
const { t } = useTranslation()
|
||||
const [mapKey, setMapKey] = useState<string>('HaggaBasin')
|
||||
const [markers, setMarkers] = useState<MapMarker[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [unsupported, setUnsupported] = useState(false)
|
||||
const [updatedLabel, setUpdatedLabel] = useState<string>('')
|
||||
const [calibrating, setCalibrating] = useState(false)
|
||||
const [calibPoints, setCalibPoints] = useState<CalibPoint[]>([])
|
||||
const [calibOverride, setCalibOverride] = useState<Record<string, Bounds>>(() => loadCalib())
|
||||
|
||||
const [spawns, setSpawns] = useState<SpawnEntry[]>([])
|
||||
const loadedSpawnKey = useRef<string>('')
|
||||
const isDragging = useRef(false)
|
||||
|
||||
const [filter, setFilter] = useState<Record<string, boolean>>(loadFilter)
|
||||
const [selectedFlsId, setSelectedFlsId] = useState<string>('')
|
||||
const [dragConfirm, setDragConfirm] = useState<{
|
||||
flsId: string
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
} | null>(null)
|
||||
|
||||
const [heatmapMode, setHeatmapMode] = useState(false)
|
||||
const fitBoundsRef = useRef<(() => void) | null>(null)
|
||||
const [teleportMode, setTeleportMode] = useState(false)
|
||||
const [teleportDest, setTeleportDest] = useState<{ x: number, y: number } | null>(null)
|
||||
const [teleportFlsId, setTeleportFlsId] = useState<string>('')
|
||||
const [allPlayers, setAllPlayers] = useState<Player[]>([])
|
||||
const [teleporting, setTeleporting] = useState(false)
|
||||
|
||||
const baseCfg = MAPS.find((m) => m.key === mapKey) ?? MAPS[0]
|
||||
const effCfg: MapCfg = useMemo(
|
||||
() => ({ ...baseCfg, ...(calibOverride[mapKey] ?? {}) }),
|
||||
[baseCfg, calibOverride, mapKey],
|
||||
)
|
||||
|
||||
const load = useCallback((key: string) => {
|
||||
if (isDragging.current) return
|
||||
const cfg = MAPS.find((m) => m.key === key)
|
||||
if (!cfg?.hasLiveData) {
|
||||
setMarkers([])
|
||||
setUnsupported(false)
|
||||
setUpdatedLabel(new Date().toLocaleTimeString())
|
||||
return
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
if (isDragging.current) return
|
||||
setLoading(true)
|
||||
setUnsupported(false)
|
||||
})
|
||||
.then(() => api.map.markers(key))
|
||||
.then((rows) => {
|
||||
if (isDragging.current) return
|
||||
setMarkers(rows)
|
||||
setUpdatedLabel(new Date().toLocaleTimeString())
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (isDragging.current) return
|
||||
if (e instanceof ApiError && e.status === 404) setUnsupported(true)
|
||||
else toast.danger(t('liveMap.failedToLoad', { message: e instanceof Error ? e.message : String(e) }))
|
||||
setMarkers([])
|
||||
})
|
||||
.finally(() => { if (!isDragging.current) setLoading(false) })
|
||||
}, [t])
|
||||
|
||||
const loadCurrent = useCallback(() => load(mapKey), [load, mapKey])
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
const id = setTimeout(loadCurrent, 0)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [isActive, loadCurrent])
|
||||
const { countdown, refresh } = useAutoRefresh(loadCurrent, POLL_MS, isActive)
|
||||
|
||||
useEffect(() => {
|
||||
const cfg = MAPS.find((m) => m.key === mapKey)
|
||||
if (!cfg?.spawnFile || loadedSpawnKey.current === mapKey) return
|
||||
loadedSpawnKey.current = mapKey
|
||||
fetch(mapUrl(`map-data/${cfg.spawnFile}-spawns.json`))
|
||||
.then((r) => r.json() as Promise<SpawnFile>)
|
||||
.then((d) => setSpawns(d.spawns))
|
||||
.catch(() => setSpawns([]))
|
||||
}, [mapKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (teleportMode && allPlayers.length === 0) {
|
||||
api.players.list().then(setAllPlayers).catch(() => {})
|
||||
}
|
||||
}, [teleportMode, allPlayers.length])
|
||||
|
||||
const playerCount = markers.filter((m) => m.type === 'player').length
|
||||
const vehicleCount = markers.filter((m) => m.type === 'vehicle').length
|
||||
const baseCount = markers.filter((m) => m.type === 'base').length
|
||||
const orderedLive = useMemo(
|
||||
() => [...markers]
|
||||
.sort((a, b) => (a.type === 'player' ? 1 : 0) - (b.type === 'player' ? 1 : 0))
|
||||
.map((m) => {
|
||||
const isPlayer = m.type === 'player'
|
||||
const isBase = m.type === 'base'
|
||||
const size = isPlayer ? 32 : isBase ? 28 : 24
|
||||
const baseColor = CAT_COLOR[m.type] ?? CAT_COLOR.base
|
||||
const label = isPlayer ? (m.name?.[0]?.toUpperCase() ?? '?') : isBase ? '🏠' : '🚗'
|
||||
const cursor = isPlayer ? 'grab' : 'default'
|
||||
const makeHtml = (color: string) =>
|
||||
`<div style="width:${size}px;height:${size}px;border-radius:50%;background:${color};border:2.5px solid #0b0b0b;box-shadow:0 0 0 1.5px ${color}40;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;color:#0b0b0b;line-height:1;cursor:${cursor}">${label}</div>`
|
||||
const iconOpts = { iconSize: [size, size] as L.PointTuple, iconAnchor: [size / 2, size / 2] as L.PointTuple, className: '' }
|
||||
return {
|
||||
...m,
|
||||
center: worldToLatLng(m.x, m.y, effCfg) as L.LatLngTuple,
|
||||
isPlayer,
|
||||
isBase,
|
||||
size,
|
||||
icon: L.divIcon({ ...iconOpts, html: makeHtml(baseColor) }),
|
||||
selectedIcon: L.divIcon({ ...iconOpts, html: makeHtml('#f59e0b') }),
|
||||
}
|
||||
}),
|
||||
[markers, effCfg],
|
||||
)
|
||||
|
||||
const handleMapClick = useCallback((lat: number, lng: number) => {
|
||||
if (calibrating) {
|
||||
const player = markers.find((m) => m.type === 'player')
|
||||
if (!player) {
|
||||
toast.danger(t('liveMap.calibNoPlayer'))
|
||||
return
|
||||
}
|
||||
setCalibPoints((prev) => {
|
||||
const next = [...prev, { wx: player.x, wy: player.y, fracX: lng / IMG_W, fracYup: lat / IMG_H }]
|
||||
const solved = solveBounds(next)
|
||||
if (solved) {
|
||||
setCalibOverride((c) => {
|
||||
const merged = { ...c, [mapKey]: solved }
|
||||
try {
|
||||
localStorage.setItem('dune_admin_livemap_calib', JSON.stringify(merged))
|
||||
}
|
||||
catch { /* quota */ }
|
||||
return merged
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
if (teleportMode) {
|
||||
const { x, y } = latLngToWorld(lat, lng, effCfg)
|
||||
setTeleportDest({ x: Math.round(x), y: Math.round(y) })
|
||||
}
|
||||
}, [calibrating, teleportMode, markers, mapKey, effCfg, t])
|
||||
|
||||
const clearCalib = useCallback(() => {
|
||||
setCalibPoints([])
|
||||
setCalibOverride((c) => {
|
||||
const merged = { ...c }
|
||||
delete merged[mapKey]
|
||||
try {
|
||||
localStorage.setItem('dune_admin_livemap_calib', JSON.stringify(merged))
|
||||
}
|
||||
catch { /* quota */ }
|
||||
return merged
|
||||
})
|
||||
}, [mapKey])
|
||||
|
||||
const solvedStr = useMemo(() => {
|
||||
const b = calibOverride[mapKey]
|
||||
return b
|
||||
? `minX: ${Math.round(b.minX)}, maxX: ${Math.round(b.maxX)}, minY: ${Math.round(b.minY)}, maxY: ${Math.round(b.maxY)}, flipY: ${!!b.flipY}`
|
||||
: ''
|
||||
}, [calibOverride, mapKey])
|
||||
|
||||
const doTeleport = useCallback(async () => {
|
||||
if (!teleportDest || !teleportFlsId) return
|
||||
setTeleporting(true)
|
||||
try {
|
||||
await api.players.teleportCoords(teleportFlsId, teleportDest.x, teleportDest.y, 5000)
|
||||
toast.success(t('liveMap.teleportSent'))
|
||||
setTeleportDest(null)
|
||||
}
|
||||
catch (e) {
|
||||
toast.danger(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
finally {
|
||||
setTeleporting(false)
|
||||
}
|
||||
}, [teleportDest, teleportFlsId, t])
|
||||
|
||||
const toggleFilter = useCallback((key: string, currentVisual: boolean) => {
|
||||
setFilter((f) => {
|
||||
const next = { ...f, [key]: !currentVisual }
|
||||
saveFilter(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilter((f) => {
|
||||
const next: Record<string, boolean> = {}
|
||||
Object.keys(f).forEach((k) => {
|
||||
next[k] = false
|
||||
})
|
||||
Object.assign(next, { players: true, vehicles: true, bases: true })
|
||||
saveFilter(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const mapCursor = calibrating || teleportMode ? 'crosshair' : 'grab'
|
||||
const currentMap = MAPS.find((m) => m.key === mapKey) ?? MAPS[0]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
|
||||
<PageHeader title={t('liveMap.title')} subtitle={t('liveMap.subtitle')}>
|
||||
<Button size="sm" variant="ghost" onPress={refresh} isDisabled={loading}>
|
||||
{loading
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
{isActive && currentMap.hasLiveData && (
|
||||
<span className="w-7 text-right tabular-nums text-muted/60 text-xs">
|
||||
{countdown}
|
||||
s
|
||||
</span>
|
||||
)}
|
||||
<Icon name="refresh-cw" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="shrink-0 flex items-start gap-2 rounded-[var(--radius)] border border-border bg-surface px-3 py-2 text-xs">
|
||||
<Icon name="flask-conical" className="size-4 shrink-0 mt-0.5 text-accent" />
|
||||
<div>
|
||||
<span className="font-medium text-accent">{t('liveMap.betaTitle')}</span>
|
||||
{' '}
|
||||
<span className="text-muted">{t('liveMap.betaBody')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Select
|
||||
aria-label={t('liveMap.title')}
|
||||
selectedKey={mapKey}
|
||||
onSelectionChange={(k) => {
|
||||
const key = String(k)
|
||||
loadedSpawnKey.current = ''
|
||||
setMapKey(key)
|
||||
setSpawns([])
|
||||
setTeleportDest(null)
|
||||
setCalibrating(false)
|
||||
}}
|
||||
className="w-44"
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Icon name="map" className="size-3.5 text-muted shrink-0 mr-1" />
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{MAPS.map((m) => (
|
||||
<ListBox.Item key={m.key} id={m.key} textValue={m.label}>
|
||||
{m.label}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
|
||||
<div className="h-4 border-l border-border mx-0.5" />
|
||||
|
||||
<Button size="sm" variant="outline" onPress={() => fitBoundsRef.current?.()}>
|
||||
<Icon name="home" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={teleportMode ? 'primary' : 'outline'}
|
||||
onPress={() => {
|
||||
setTeleportMode((v) => !v)
|
||||
setTeleportDest(null)
|
||||
}}
|
||||
>
|
||||
<Icon name="navigation" />
|
||||
{' '}
|
||||
{t('liveMap.teleportMode')}
|
||||
</Button>
|
||||
<Button size="sm" variant={calibrating ? 'primary' : 'outline'} onPress={() => setCalibrating((v) => !v)}>
|
||||
<Icon name="crosshair" />
|
||||
{' '}
|
||||
{t('liveMap.calibrate')}
|
||||
</Button>
|
||||
{calibrating && (
|
||||
<Button size="sm" variant="outline" onPress={clearCalib}>{t('liveMap.clear')}</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 shrink-0 text-xs text-muted">
|
||||
{currentMap.hasLiveData && (
|
||||
<>
|
||||
<span>
|
||||
<span style={{ color: CAT_COLOR.player }}>●</span>
|
||||
{' '}
|
||||
{t('liveMap.players')}
|
||||
{': '}
|
||||
{playerCount}
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: CAT_COLOR.vehicle }}>●</span>
|
||||
{' '}
|
||||
{t('liveMap.vehicles')}
|
||||
{': '}
|
||||
{vehicleCount}
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: CAT_COLOR.base }}>●</span>
|
||||
{' '}
|
||||
{t('liveMap.filterBases')}
|
||||
{': '}
|
||||
{baseCount}
|
||||
</span>
|
||||
<span>
|
||||
{t('liveMap.total')}
|
||||
{': '}
|
||||
{markers.length}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{spawns.length > 0 && <span>{t('liveMap.spawnsLoaded', { count: spawns.length })}</span>}
|
||||
{updatedLabel !== '' && <span className="ml-auto">{t('liveMap.updated', { time: updatedLabel })}</span>}
|
||||
</div>
|
||||
|
||||
{teleportMode && (
|
||||
<div className="shrink-0 rounded-[var(--radius)] border border-accent/40 bg-surface px-3 py-2 text-xs flex flex-wrap items-center gap-3">
|
||||
<div className="text-accent font-medium">
|
||||
<Icon name="navigation" className="size-3 inline mr-1" />
|
||||
{teleportDest
|
||||
? t('liveMap.spawnTooltipCoords', { x: teleportDest.x, y: teleportDest.y })
|
||||
: t('liveMap.teleportModeActive')}
|
||||
</div>
|
||||
{teleportDest && (
|
||||
<>
|
||||
<Select
|
||||
aria-label={t('liveMap.teleportPlayer')}
|
||||
placeholder={t('liveMap.teleportSelectPlayer')}
|
||||
selectedKey={teleportFlsId || null}
|
||||
onSelectionChange={(k) => setTeleportFlsId(k ? String(k) : '')}
|
||||
className="w-56"
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{allPlayers.map((p) => (
|
||||
<ListBox.Item key={p.fls_id} id={p.fls_id} textValue={p.name}>
|
||||
{p.name}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
<Button size="sm" isDisabled={!teleportFlsId || teleporting} onPress={doTeleport}>
|
||||
{teleporting ? <Spinner size="sm" color="current" /> : t('liveMap.teleportHere')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onPress={() => setTeleportDest(null)}>✕</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calibrating && (
|
||||
<div className="shrink-0 rounded-[var(--radius)] border border-border bg-surface px-3 py-2 text-xs">
|
||||
<div className="text-accent">{t('liveMap.calibActive')}</div>
|
||||
<div className="text-muted">{t('liveMap.calibPoints', { n: calibPoints.length })}</div>
|
||||
{solvedStr && <div className="mt-1 font-mono text-foreground break-all">{solvedStr}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0 gap-0 overflow-hidden">
|
||||
<FilterPanel
|
||||
filter={filter}
|
||||
onToggle={toggleFilter}
|
||||
onClear={clearFilters}
|
||||
spawns={spawns}
|
||||
mapKey={mapKey}
|
||||
heatmapMode={heatmapMode}
|
||||
onHeatmapToggle={() => setHeatmapMode((v) => !v)}
|
||||
/>
|
||||
{unsupported
|
||||
? <div className="flex-1 py-8 text-center text-sm text-muted">{t('liveMap.unsupported')}</div>
|
||||
: (
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden rounded-[var(--radius)] border border-border">
|
||||
<MapContainer
|
||||
crs={CRS.Simple}
|
||||
bounds={IMAGE_BOUNDS}
|
||||
minZoom={-3}
|
||||
maxZoom={4}
|
||||
zoomSnap={0.25}
|
||||
attributionControl={false}
|
||||
style={{ height: '100%', width: '100%', background: 'var(--color-surface)', cursor: mapCursor }}
|
||||
>
|
||||
<InvalidateOnActive active={isActive} />
|
||||
<MapClickCapture active={calibrating || teleportMode} onPick={handleMapClick} />
|
||||
{effCfg.tileId
|
||||
? <MapTileLayer key={mapKey} tileId={effCfg.tileId} />
|
||||
: effCfg.image && (
|
||||
<ImageOverlay
|
||||
key={mapKey}
|
||||
url={mapUrl(`map-data/${effCfg.image}`)}
|
||||
bounds={IMAGE_BOUNDS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{effCfg.depthFile && (
|
||||
<ImageOverlay
|
||||
key={`depth-${mapKey}`}
|
||||
url={mapUrl(`map-data/${effCfg.depthFile}`)}
|
||||
bounds={IMAGE_BOUNDS}
|
||||
className="leaflet-depth-overlay"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FitBoundsController fitRef={fitBoundsRef} />
|
||||
|
||||
{mapKey === 'DeepDesert' && (
|
||||
<ZoneGridLayer effCfg={effCfg} />
|
||||
)}
|
||||
|
||||
{heatmapMode && (
|
||||
<HeatmapCanvasLayer
|
||||
mapKey={mapKey}
|
||||
effCfg={effCfg}
|
||||
filter={filter}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SpawnCanvasLayer
|
||||
spawns={spawns}
|
||||
effCfg={effCfg}
|
||||
filter={filter}
|
||||
heatmapMode={heatmapMode}
|
||||
/>
|
||||
|
||||
{(filter.players || filter.vehicles) && orderedLive
|
||||
.filter((m) => m.type === 'player' ? filter.players : m.type === 'vehicle' ? filter.vehicles : false)
|
||||
.map((m) => {
|
||||
const { center, isPlayer, size, icon, selectedIcon } = m
|
||||
const isSelected = m.fls_id === selectedFlsId
|
||||
return (
|
||||
<Marker
|
||||
key={`${m.type}-${m.id}`}
|
||||
position={center}
|
||||
icon={isSelected ? selectedIcon : icon}
|
||||
draggable={isPlayer}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
if (m.fls_id) {
|
||||
setSelectedFlsId((prev) => prev === m.fls_id ? '' : m.fls_id!)
|
||||
setTeleportFlsId(m.fls_id!)
|
||||
}
|
||||
},
|
||||
dragstart: () => { isDragging.current = true },
|
||||
dragend: (e) => {
|
||||
isDragging.current = false
|
||||
if (!m.fls_id) return
|
||||
const marker = e.target as L.Marker
|
||||
const { lat, lng } = marker.getLatLng()
|
||||
marker.setLatLng(center)
|
||||
const { x, y } = latLngToWorld(lat, lng, effCfg)
|
||||
setDragConfirm({
|
||||
flsId: m.fls_id!,
|
||||
name: m.name || m.fls_id!,
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
})
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -(size / 2)]}>
|
||||
<div className="font-medium">{m.name || `${m.type} ${m.id}`}</div>
|
||||
<div className="text-xs opacity-70">
|
||||
{m.type}
|
||||
{m.online_status ? ` · ${m.online_status}` : ''}
|
||||
</div>
|
||||
<div className="text-xs font-mono">
|
||||
{Math.round(m.x)}
|
||||
{', '}
|
||||
{Math.round(m.y)}
|
||||
</div>
|
||||
{isPlayer && <div className="text-xs text-accent mt-0.5">Drag to teleport</div>}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
|
||||
{filter.bases && orderedLive
|
||||
.filter((m) => m.type === 'base')
|
||||
.map((m) => {
|
||||
const { center, size, icon } = m
|
||||
return (
|
||||
<Marker
|
||||
key={`base-${m.id}`}
|
||||
position={center}
|
||||
icon={icon}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -(size / 2)]}>
|
||||
<div className="font-medium">{m.name || `Base ${m.id}`}</div>
|
||||
<div className="text-xs opacity-70">base</div>
|
||||
<div className="text-xs font-mono">
|
||||
{Math.round(m.x)}
|
||||
{', '}
|
||||
{Math.round(m.y)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
|
||||
{teleportDest && (
|
||||
<CircleMarker
|
||||
center={worldToLatLng(teleportDest.x, teleportDest.y, effCfg)}
|
||||
radius={10}
|
||||
pathOptions={{ color: '#ffffff', weight: 2, fillColor: '#f59e0b', fillOpacity: 0.85 }}
|
||||
>
|
||||
<Tooltip permanent>
|
||||
<span className="text-xs">
|
||||
{teleportDest.x}
|
||||
,
|
||||
{' '}
|
||||
{teleportDest.y}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
)}
|
||||
|
||||
{calibrating && calibPoints.map((p, i) => (
|
||||
<CircleMarker
|
||||
key={`calib-${i}`}
|
||||
center={[p.fracYup * IMG_H, p.fracX * IMG_W]}
|
||||
radius={5}
|
||||
pathOptions={{ color: '#ffffff', weight: 2, fillColor: '#ff2bd6', fillOpacity: 0.9 }}
|
||||
>
|
||||
<Tooltip>{`calib ${i + 1}`}</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={dragConfirm !== null}
|
||||
title={t('liveMap.dragTeleportTitle', { name: dragConfirm?.name ?? '' })}
|
||||
description={t('liveMap.dragTeleportDesc', { x: dragConfirm?.x ?? 0, y: dragConfirm?.y ?? 0 })}
|
||||
confirmLabel={t('liveMap.teleportHere')}
|
||||
onConfirm={async () => {
|
||||
if (!dragConfirm) return
|
||||
try {
|
||||
await api.players.teleportCoords(dragConfirm.flsId, dragConfirm.x, dragConfirm.y, 5000)
|
||||
toast.success(t('liveMap.teleportSent'))
|
||||
}
|
||||
catch (err) {
|
||||
toast.danger(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
setDragConfirm(null)
|
||||
}}
|
||||
onCancel={() => setDragConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export type Bounds = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
flipX?: boolean
|
||||
flipY?: boolean
|
||||
}
|
||||
|
||||
export type MapCfg = Bounds & {
|
||||
key: string
|
||||
label: string
|
||||
image?: string
|
||||
spawnFile?: string
|
||||
hasLiveData?: boolean
|
||||
tileId?: string
|
||||
depthFile?: string
|
||||
}
|
||||
|
||||
export type CalibPoint = {
|
||||
wx: number
|
||||
wy: number
|
||||
fracX: number
|
||||
fracYup: number
|
||||
}
|
||||
|
||||
export type SpawnEntry = {
|
||||
type: string
|
||||
label?: string
|
||||
category: string
|
||||
x: number
|
||||
y: number
|
||||
z?: number
|
||||
density?: number
|
||||
}
|
||||
|
||||
export type SpawnFile = {
|
||||
spawns: SpawnEntry[]
|
||||
}
|
||||
|
||||
export interface InvalidateOnActiveProps {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface MapClickCaptureProps {
|
||||
active: boolean
|
||||
onPick: (lat: number, lng: number) => void
|
||||
}
|
||||
|
||||
export interface SpriteIconProps {
|
||||
type: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface SpawnCanvasLayerProps {
|
||||
spawns: SpawnEntry[]
|
||||
effCfg: MapCfg
|
||||
filter: Record<string, boolean>
|
||||
heatmapMode: boolean
|
||||
}
|
||||
|
||||
export interface HeatmapCanvasLayerProps {
|
||||
mapKey: string
|
||||
effCfg: MapCfg
|
||||
filter: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface MapTileLayerProps {
|
||||
tileId: string
|
||||
}
|
||||
|
||||
export interface ZoneGridLayerProps {
|
||||
effCfg: MapCfg
|
||||
}
|
||||
|
||||
export interface FitBoundsControllerProps {
|
||||
fitRef: React.MutableRefObject<(() => void) | null>
|
||||
}
|
||||
|
||||
export interface FilterPanelProps {
|
||||
filter: Record<string, boolean>
|
||||
onToggle: (key: string, currentVisual: boolean) => void
|
||||
onClear: () => void
|
||||
spawns: SpawnEntry[]
|
||||
mapKey: string
|
||||
heatmapMode: boolean
|
||||
onHeatmapToggle: () => void
|
||||
}
|
||||
|
||||
export interface LiveMapTabProps {
|
||||
isActive?: boolean
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Bounds, CalibPoint } from './types'
|
||||
import { TYPE_MERGE_KEY, IMG_W, IMG_H, HEATMAP_TO_FILTER } from './constants'
|
||||
|
||||
const MAP_BASE = ((import.meta.env.VITE_CDN_BASE_URL as string) ?? 'https://assets.dune.layout.tools').replace(/\/$/, '')
|
||||
|
||||
export function mapUrl(path: string): string {
|
||||
return `${MAP_BASE}/${path}`
|
||||
}
|
||||
|
||||
export function filterKey(type: string): string {
|
||||
return TYPE_MERGE_KEY[type] ?? type
|
||||
}
|
||||
|
||||
export function heatmapFilterKey(type: string): string {
|
||||
return HEATMAP_TO_FILTER[type] ?? type
|
||||
}
|
||||
|
||||
export function clamp01(v: number): number {
|
||||
if (v < 0) return 0
|
||||
if (v > 1) return 1
|
||||
return v
|
||||
}
|
||||
|
||||
export function worldToLatLng(x: number, y: number, cfg: Bounds): [number, number] {
|
||||
const normX = (x - cfg.minX) / (cfg.maxX - cfg.minX)
|
||||
const normY = (y - cfg.minY) / (cfg.maxY - cfg.minY)
|
||||
const fracX = clamp01(cfg.flipX ? 1 - normX : normX)
|
||||
const fracYup = clamp01(cfg.flipY ? 1 - normY : normY)
|
||||
return [fracYup * IMG_H, fracX * IMG_W]
|
||||
}
|
||||
|
||||
export function latLngToWorld(lat: number, lng: number, cfg: Bounds): { x: number, y: number } {
|
||||
const fracX = lng / IMG_W
|
||||
const fracYup = lat / IMG_H
|
||||
const rawX = cfg.flipX ? 1 - fracX : fracX
|
||||
const rawY = cfg.flipY ? 1 - fracYup : fracYup
|
||||
return {
|
||||
x: rawX * (cfg.maxX - cfg.minX) + cfg.minX,
|
||||
y: rawY * (cfg.maxY - cfg.minY) + cfg.minY,
|
||||
}
|
||||
}
|
||||
|
||||
export function solveBounds(pts: CalibPoint[]): Bounds | null {
|
||||
if (pts.length < 2) return null
|
||||
const a = pts[0]
|
||||
const b = pts[pts.length - 1]
|
||||
if (b.wx === a.wx || b.wy === a.wy || b.fracX === a.fracX || b.fracYup === a.fracYup) return null
|
||||
const sX = (b.fracX - a.fracX) / (b.wx - a.wx)
|
||||
const iX = a.fracX - sX * a.wx
|
||||
const sY = (b.fracYup - a.fracYup) / (b.wy - a.wy)
|
||||
const iY = a.fracYup - sY * a.wy
|
||||
const flipY = sY < 0
|
||||
const minX = -iX / sX
|
||||
const maxX = (1 - iX) / sX
|
||||
const R = flipY ? -1 / sY : 1 / sY
|
||||
const minY = flipY ? (iY - 1) * R : -iY * R
|
||||
return { minX, maxX, minY, maxY: minY + R, flipY }
|
||||
}
|
||||
|
||||
const CALIB_LS_KEY = 'dune_admin_livemap_calib'
|
||||
|
||||
export function loadCalib(): Record<string, Bounds> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(CALIB_LS_KEY) ?? '{}') as Record<string, Bounds>
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const LIVE_FILTER_DEFAULTS: Record<string, boolean> = {
|
||||
players: true, vehicles: true, bases: true,
|
||||
}
|
||||
const FILTER_LS_KEY = 'dune_admin_livemap_filter'
|
||||
|
||||
export function loadFilter(): Record<string, boolean> {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(FILTER_LS_KEY) ?? '{}') as Record<string, boolean>
|
||||
return { ...LIVE_FILTER_DEFAULTS, ...saved }
|
||||
}
|
||||
catch {
|
||||
return LIVE_FILTER_DEFAULTS
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFilter(f: Record<string, boolean>): void {
|
||||
try {
|
||||
localStorage.setItem(FILTER_LS_KEY, JSON.stringify(f))
|
||||
}
|
||||
catch { /* quota */ }
|
||||
}
|
||||
329
docs/reference-repos/icehunter/web/src/tabs/LogsTab.tsx
Normal file
329
docs/reference-repos/icehunter/web/src/tabs/LogsTab.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Chip, Spinner, Switch, toast } from '@heroui/react'
|
||||
import { api, getWsBase } from '../api/client'
|
||||
import type { LogPod, CheatEntry } from '../api/client'
|
||||
import { DataTable, Icon, LoadingState, SideNav, type Column } from '../dune-ui'
|
||||
|
||||
type ActiveView = 'pod' | 'cheats'
|
||||
type NavKey = 'cheats' | `pod:${string}`
|
||||
|
||||
type CheatKey = 'time' | 'character' | 'cheat_type'
|
||||
|
||||
interface LogsTabProps {
|
||||
control?: string
|
||||
}
|
||||
|
||||
export const LogsTab: React.FC<LogsTabProps> = ({ control }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Control planes that surface log files (amp, docker, local) get
|
||||
// file-oriented labels; kubectl keeps "Pods".
|
||||
const isFileBased = control === 'amp' || control === 'docker' || control === 'local'
|
||||
const sourceLabel = isFileBased ? t('logs.logFiles') : t('logs.pods')
|
||||
const itemLabel = isFileBased ? t('logs.logFileSingular') : t('logs.podSingular')
|
||||
|
||||
const CHEAT_COLUMNS: Column<CheatKey>[] = [
|
||||
{ key: 'time', label: t('logs.columns.time'), width: 180 },
|
||||
{ key: 'character', label: t('logs.columns.character'), minWidth: 200 },
|
||||
{ key: 'cheat_type', label: t('logs.columns.cheatType'), minWidth: 200 },
|
||||
]
|
||||
|
||||
const [pods, setPods] = useState<LogPod[]>([])
|
||||
const [podsLoading, setPodsLoading] = useState(false)
|
||||
const [selectedPod, setSelectedPod] = useState<LogPod | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [displayLines, setDisplayLines] = useState<string[]>([])
|
||||
const [activeView, setActiveView] = useState<ActiveView>('pod')
|
||||
const [cheats, setCheats] = useState<CheatEntry[]>([])
|
||||
const [cheatsLoading, setCheatsLoading] = useState(false)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const linesRef = useRef<string[]>([])
|
||||
const flushTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const logContainerRef = useRef<HTMLPreElement | null>(null)
|
||||
|
||||
const loadPods = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setPodsLoading(true))
|
||||
.then(() => api.logs.pods())
|
||||
.then(setPods)
|
||||
.catch((e: unknown) => toast.danger(t('logs.failedToLoad', { message: e instanceof Error ? e.message : String(e) })))
|
||||
.finally(() => setPodsLoading(false))
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
loadPods()
|
||||
}, [loadPods])
|
||||
|
||||
const startFlush = useCallback(() => {
|
||||
if (flushTimerRef.current) return
|
||||
flushTimerRef.current = setInterval(() => {
|
||||
if (linesRef.current.length > 0) {
|
||||
setDisplayLines((prev) => {
|
||||
const combined = [...prev, ...linesRef.current]
|
||||
return combined.length > 5000 ? combined.slice(combined.length - 5000) : combined
|
||||
})
|
||||
linesRef.current = []
|
||||
}
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
const stopFlush = useCallback(() => {
|
||||
if (flushTimerRef.current) {
|
||||
clearInterval(flushTimerRef.current)
|
||||
flushTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [displayLines, autoScroll])
|
||||
|
||||
const connectPod = useCallback((pod: LogPod) => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
stopFlush()
|
||||
linesRef.current = []
|
||||
setDisplayLines([])
|
||||
setConnected(false)
|
||||
setSelectedPod(pod)
|
||||
setActiveView('pod')
|
||||
|
||||
const url = `${getWsBase()}/logs/stream?ns=${encodeURIComponent(pod.namespace)}&pod=${encodeURIComponent(pod.name)}`
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
ws.onopen = () => {
|
||||
setConnected(true)
|
||||
startFlush()
|
||||
}
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
linesRef.current.push(event.data as string)
|
||||
}
|
||||
ws.onerror = () => {
|
||||
toast.danger(t('logs.wsError'))
|
||||
}
|
||||
ws.onclose = () => {
|
||||
setConnected(false)
|
||||
stopFlush()
|
||||
if (linesRef.current.length > 0) {
|
||||
setDisplayLines((prev) => [...prev, ...linesRef.current])
|
||||
linesRef.current = []
|
||||
}
|
||||
}
|
||||
}, [startFlush, stopFlush, t])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
stopFlush()
|
||||
setConnected(false)
|
||||
}, [stopFlush])
|
||||
|
||||
useEffect(() => () => {
|
||||
disconnect()
|
||||
}, [disconnect])
|
||||
|
||||
const exportLogs = () => {
|
||||
const blob = new Blob([displayLines.join('\n')], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedPod?.name ?? 'logs'}-${new Date().toISOString()}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const loadCheats = async () => {
|
||||
setCheatsLoading(true)
|
||||
try {
|
||||
setCheats(await api.logs.cheats())
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
finally {
|
||||
setCheatsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ key: 'cheats' as NavKey, label: t('logs.cheats7d'), sublabel: t('logs.antiCheatLog') },
|
||||
...pods.map((p) => ({
|
||||
key: `pod:${p.namespace}/${p.name}` as NavKey,
|
||||
label: <span className="font-mono">{p.name}</span>,
|
||||
sublabel: p.namespace,
|
||||
})),
|
||||
]
|
||||
const activeKey: NavKey | null = activeView === 'cheats'
|
||||
? 'cheats'
|
||||
: selectedPod ? `pod:${selectedPod.namespace}/${selectedPod.name}` : null
|
||||
|
||||
const handleNavSelect = (key: NavKey) => {
|
||||
if (key === 'cheats') {
|
||||
setSelectedPod(null)
|
||||
setActiveView('cheats')
|
||||
loadCheats()
|
||||
}
|
||||
else {
|
||||
const id = key.slice(4) // strip "pod:"
|
||||
const pod = pods.find((p) => `${p.namespace}/${p.name}` === id)
|
||||
if (pod) connectPod(pod)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-3 min-h-0">
|
||||
<SideNav
|
||||
items={navItems}
|
||||
active={activeKey}
|
||||
onSelect={handleNavSelect}
|
||||
title={t('logs.sourceTitle', { label: sourceLabel, count: pods.length })}
|
||||
titleAction={(
|
||||
<Button size="sm" variant="ghost" isDisabled={podsLoading} onPress={loadPods}>
|
||||
{podsLoading ? <Spinner size="sm" color="current" /> : <Icon name="refresh-cw" />}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden gap-3 min-h-0">
|
||||
{activeView === 'cheats'
|
||||
? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<h3 className="text-base font-semibold text-accent flex-1">{t('logs.antiCheatTitle')}</h3>
|
||||
<span className="text-xs text-muted">
|
||||
{t('logs.eventsCount', { count: cheats.length })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onPress={loadCheats} isDisabled={cheatsLoading}>
|
||||
{cheatsLoading
|
||||
? <Spinner size="sm" color="current" />
|
||||
: (
|
||||
<>
|
||||
<Icon name="refresh-cw" />
|
||||
{' '}
|
||||
{t('common.refresh')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cheatsLoading
|
||||
? (
|
||||
<LoadingState />
|
||||
)
|
||||
: (
|
||||
<DataTable<CheatEntry, CheatKey>
|
||||
aria-label={t('logs.antiCheatLabel')}
|
||||
className="min-h-0 max-h-full"
|
||||
columns={CHEAT_COLUMNS}
|
||||
rows={cheats}
|
||||
rowId={(c) => `${c.fls_id}-${c.event_time}-${c.cheat_type}`}
|
||||
initialSort={{ column: 'time', direction: 'descending' }}
|
||||
sortValue={(c, k) => {
|
||||
if (k === 'time') return c.event_time
|
||||
if (k === 'character') return c.character_name
|
||||
return c.cheat_type
|
||||
}}
|
||||
emptyState={<div className="py-8 text-center text-muted">{t('logs.noCheatEvents')}</div>}
|
||||
renderCell={(c, key) => {
|
||||
switch (key) {
|
||||
case 'time': return <span className="font-mono text-muted">{c.event_time}</span>
|
||||
case 'character': return c.character_name
|
||||
case 'cheat_type': {
|
||||
const suspicious = /dup|negative/i.test(c.cheat_type)
|
||||
return (
|
||||
<Chip size="sm" color={suspicious ? 'danger' : 'default'} variant="soft">
|
||||
{c.cheat_type}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Chip
|
||||
size="sm"
|
||||
color={connected ? 'success' : 'default'}
|
||||
variant="soft"
|
||||
>
|
||||
{connected
|
||||
? t('logs.connectedPod', { pod: selectedPod?.name })
|
||||
: selectedPod
|
||||
? t('logs.disconnected')
|
||||
: t('logs.selectSource', { label: itemLabel })}
|
||||
</Chip>
|
||||
<div className="flex-1" />
|
||||
<Switch isSelected={autoScroll} onChange={setAutoScroll} size="sm">
|
||||
<Switch.Control><Switch.Thumb /></Switch.Control>
|
||||
<Switch.Content>{t('logs.autoScroll')}</Switch.Content>
|
||||
</Switch>
|
||||
{selectedPod && connected && (
|
||||
<Button size="sm" variant="danger-soft" onPress={disconnect}>
|
||||
<Icon name="square" />
|
||||
{' '}
|
||||
{t('logs.stop')}
|
||||
</Button>
|
||||
)}
|
||||
{selectedPod && !connected && (
|
||||
<Button size="sm" variant="outline" onPress={() => connectPod(selectedPod)}>
|
||||
<Icon name="play" />
|
||||
{' '}
|
||||
{t('logs.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
{displayLines.length > 0 && (
|
||||
<Button size="sm" variant="ghost" onPress={exportLogs}>
|
||||
<Icon name="download" />
|
||||
{' '}
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
)}
|
||||
{displayLines.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setDisplayLines([])
|
||||
linesRef.current = []
|
||||
}}
|
||||
>
|
||||
<Icon name="trash-2" />
|
||||
{' '}
|
||||
{t('logs.clear')}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-muted">
|
||||
{t('logs.linesCount', { count: displayLines.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-auto p-4 text-xs font-mono m-0 whitespace-pre-wrap break-all rounded-[var(--radius)] border border-border/60 bg-background text-success"
|
||||
>
|
||||
{displayLines.length === 0
|
||||
? (selectedPod
|
||||
? (connected ? t('logs.waitingForLines') : t('logs.disconnectedState'))
|
||||
: t('logs.selectFromPanel', { label: itemLabel }))
|
||||
: displayLines.join('\n')}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user