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:
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Drawer, Spinner } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtom } from 'jotai'
|
||||
import { loadable } from 'jotai/utils'
|
||||
import { api } from '../../api/client'
|
||||
import type { MarketItem, MarketListing } from '../../api/client'
|
||||
import { Panel, SectionLabel } from '../../dune-ui'
|
||||
import { iconUrl, qualityLabel } from '../../utils/icons'
|
||||
import { getItemEntry } from '../../data/itemData'
|
||||
import { qualityDataAtom } from '../../data/store'
|
||||
|
||||
type ItemEntry = {
|
||||
is_gradeable?: boolean
|
||||
armor_value?: number
|
||||
mitigation?: Record<string, number>
|
||||
}
|
||||
|
||||
const QUALITY_LABELS = ['Standard', 'Refined', 'Superior', 'Masterwork', 'Pristine', 'Flawless']
|
||||
|
||||
const MITIGATION_LABELS: Record<string, string> = {
|
||||
melee: 'Melee',
|
||||
physical: 'Physical',
|
||||
energy: 'Energy',
|
||||
explosive: 'Explosive',
|
||||
heat: 'Heat',
|
||||
cold: 'Cold',
|
||||
poison: 'Poison',
|
||||
radiation: 'Radiation',
|
||||
sandstorm1: 'Sandstorm I',
|
||||
sandstorm2: 'Sandstorm II',
|
||||
sandstorm3: 'Sandstorm III',
|
||||
}
|
||||
|
||||
interface ItemDetailProps {
|
||||
item: MarketItem | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
label: string
|
||||
value: string
|
||||
accent?: boolean
|
||||
wrap?: boolean
|
||||
}
|
||||
|
||||
export const ItemDetail: React.FC<ItemDetailProps> = ({ item, onClose }) => {
|
||||
const { t } = useTranslation()
|
||||
const [listings, setListings] = useState<MarketListing[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [entry, setEntry] = useState<ItemEntry | null>(null)
|
||||
const [qualityState] = useAtom(loadable(qualityDataAtom))
|
||||
const qualityData = qualityState.state === 'hasData' ? qualityState.data : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) return
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
setListings([])
|
||||
setEntry(null)
|
||||
setLoading(true)
|
||||
})
|
||||
.then(() => Promise.all([
|
||||
api.market.listings(item.template_id),
|
||||
getItemEntry(item.template_id),
|
||||
]))
|
||||
.then(([ls, e]) => {
|
||||
setListings(ls)
|
||||
setEntry(e)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [item])
|
||||
|
||||
const img = item ? iconUrl(item.template_id, 'thumb') : null
|
||||
const byQuality = listings.reduce<Record<number, MarketListing[]>>((acc, l) => {
|
||||
;(acc[l.quality] ??= []).push(l)
|
||||
return acc
|
||||
}, {})
|
||||
const qualities = Object.keys(byQuality).map(Number).sort((a, b) => a - b)
|
||||
|
||||
const isArmor = !!entry?.armor_value
|
||||
const isWeapon = item?.category?.startsWith('items/weapons')
|
||||
const isGradeable = entry?.is_gradeable
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<Drawer.Backdrop variant="opaque" isOpen={!!item} onOpenChange={(v) => !v && onClose()}>
|
||||
<Drawer.Content placement="right">
|
||||
<Drawer.Dialog className="w-[480px] max-w-[95vw] flex flex-col">
|
||||
<Drawer.Header>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border w-full">
|
||||
{img && (
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-7 h-7 object-contain shrink-0 rounded"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
<Drawer.Heading className="font-semibold text-sm text-accent truncate flex-1">
|
||||
{item?.display_name || item?.template_id || ''}
|
||||
</Drawer.Heading>
|
||||
<Drawer.CloseTrigger />
|
||||
</div>
|
||||
</Drawer.Header>
|
||||
|
||||
<Drawer.Body className="flex flex-col gap-3 p-3">
|
||||
{item && (
|
||||
<>
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.itemDetail.itemInfo')}</SectionLabel>
|
||||
<Row label={t('market.itemDetail.category')} value={item.category || '—'} wrap />
|
||||
<Row label={t('market.itemDetail.tierLabel')} value={item.tier > 0 ? String(item.tier) : '—'} />
|
||||
<Row label={t('market.itemDetail.rarityLabel')} value={item.rarity || '—'} />
|
||||
<Row label={t('market.itemDetail.totalStock')} value={item.total_stock.toLocaleString()} />
|
||||
<Row label={t('market.itemDetail.botStock')} value={item.bot_stock.toLocaleString()} />
|
||||
<Row label={t('market.itemDetail.listingsLabel')} value={String(item.listing_count)} />
|
||||
<Row label={t('market.itemDetail.lowestPrice')} value={item.lowest_price.toLocaleString()} accent />
|
||||
</Panel>
|
||||
|
||||
{isArmor && (
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.itemDetail.armorStats')}</SectionLabel>
|
||||
{isGradeable
|
||||
? (
|
||||
<>
|
||||
<div className="text-xs text-muted mb-1">{t('market.itemDetail.armorValueByQuality')}</div>
|
||||
<table className="w-full text-xs mb-2">
|
||||
<thead>
|
||||
<tr className="text-muted">
|
||||
{QUALITY_LABELS.map((ql, i) => (
|
||||
<th key={i} className="text-center pb-1 font-normal">{ql.slice(0, 3)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{(qualityData?.armor ?? []).map((mult, i) => (
|
||||
<td key={i} className="text-center font-mono text-foreground">
|
||||
{Math.round(entry!.armor_value! * mult)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<Row label={t('market.itemDetail.armorValue')} value={String(entry!.armor_value)} />
|
||||
)}
|
||||
{entry?.mitigation && Object.keys(entry.mitigation).length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-muted mt-1 mb-1">{t('market.itemDetail.resistances')}</div>
|
||||
{Object.entries(entry.mitigation).map(([k, v]) => (
|
||||
<Row
|
||||
key={k}
|
||||
label={MITIGATION_LABELS[k] ?? k}
|
||||
value={`${Math.round(v * 100)}%`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{isWeapon && isGradeable && (
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.itemDetail.weaponQualityScaling')}</SectionLabel>
|
||||
<div className="text-xs text-muted mb-1">{t('market.itemDetail.damageMultiplierByQuality')}</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted">
|
||||
{QUALITY_LABELS.map((ql, i) => (
|
||||
<th key={i} className="text-center pb-1 font-normal">{ql.slice(0, 3)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{(qualityData?.weapon_damage ?? []).map((mult, i) => (
|
||||
<td key={i} className="text-center font-mono text-foreground">
|
||||
{mult.toFixed(2)}
|
||||
×
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.itemDetail.activeListings')}</SectionLabel>
|
||||
{loading
|
||||
? (
|
||||
<div className="flex justify-center py-4"><Spinner size="sm" /></div>
|
||||
)
|
||||
: listings.length === 0
|
||||
? (
|
||||
<p className="text-xs text-muted">{t('market.itemDetail.noActiveListings')}</p>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col gap-3">
|
||||
{qualities.map((q) => (
|
||||
<div key={q}>
|
||||
<div className="text-xs font-medium text-muted mb-1">{qualityLabel(q)}</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted">
|
||||
<th className="text-left pb-1 font-normal">{t('market.itemDetail.seller')}</th>
|
||||
<th className="text-right pb-1 font-normal">{t('market.itemDetail.stockCol')}</th>
|
||||
<th className="text-right pb-1 font-normal">{t('market.itemDetail.priceCol')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byQuality[q].sort((a, b) => a.price - b.price).map((l) => (
|
||||
<tr key={l.order_id} className="border-t border-border/40">
|
||||
<td className={`py-0.5 ${l.owner_type === 'bot' ? 'text-accent' : 'text-foreground'}`}>
|
||||
{l.owner_name}
|
||||
</td>
|
||||
<td className="py-0.5 text-right text-muted">{l.stock.toLocaleString()}</td>
|
||||
<td className="py-0.5 text-right font-mono">{l.price.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</Drawer.Body>
|
||||
</Drawer.Dialog>
|
||||
</Drawer.Content>
|
||||
</Drawer.Backdrop>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, accent, wrap }: RowProps) {
|
||||
return (
|
||||
<div className={`flex text-xs py-0.5 ${wrap ? 'flex-col gap-0.5' : 'items-center justify-between'}`}>
|
||||
<span className="text-muted shrink-0">{label}</span>
|
||||
<span className={accent ? 'font-mono text-accent font-semibold' : 'text-foreground'}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MarketItem } from '../../api/client'
|
||||
import { iconUrl, categoryColor, qualityLabel } from '../../utils/icons'
|
||||
|
||||
const RARITY_BORDER: Record<string, string> = {
|
||||
common: 'border-border',
|
||||
uncommon: 'border-rarity-uncommon/60',
|
||||
rare: 'border-rarity-rare/60',
|
||||
epic: 'border-rarity-epic/60',
|
||||
legendary: 'border-rarity-legendary/60',
|
||||
unique: 'border-rarity-unique/60',
|
||||
memento: 'border-rarity-memento/60',
|
||||
}
|
||||
|
||||
const RARITY_TEXT: Record<string, string> = {
|
||||
common: 'text-foreground',
|
||||
uncommon: 'text-rarity-uncommon',
|
||||
rare: 'text-rarity-rare',
|
||||
epic: 'text-rarity-epic',
|
||||
legendary: 'text-rarity-legendary',
|
||||
unique: 'text-rarity-unique',
|
||||
memento: 'text-rarity-memento',
|
||||
}
|
||||
|
||||
type MarketGridProps = {
|
||||
items: MarketItem[]
|
||||
onSelect: (item: MarketItem) => void
|
||||
}
|
||||
|
||||
export const MarketGrid: React.FC<MarketGridProps> = ({ items, onSelect }: MarketGridProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="flex-1 py-8 text-center text-muted">{t('market.table.noItemsFound')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3 pb-3">
|
||||
{items.map((item) => {
|
||||
const key = `${item.template_id}:${item.quality}`
|
||||
const rarity = item.rarity?.toLowerCase()
|
||||
const border = RARITY_BORDER[rarity] ?? 'border-border'
|
||||
const textColor = RARITY_TEXT[rarity] ?? 'text-foreground'
|
||||
const img = iconUrl(item.template_id, 'thumb')
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={`flex flex-col rounded-[var(--radius)] border ${border} bg-surface hover:bg-surface/80 text-left transition-colors overflow-hidden`}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{/* Icon area */}
|
||||
<div
|
||||
className="w-full aspect-square flex items-center justify-center shrink-0"
|
||||
style={{ background: img ? undefined : categoryColor(item.category) }}
|
||||
>
|
||||
{img
|
||||
? (
|
||||
<img
|
||||
src={img}
|
||||
alt={item.display_name}
|
||||
className="w-full h-full object-contain p-2"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span className="text-3xl text-white/20 font-bold uppercase select-none">
|
||||
{item.display_name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div className="p-2 flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-xs font-medium leading-tight line-clamp-2 text-foreground">
|
||||
{item.display_name}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-1 mt-0.5">
|
||||
{item.quality > 0 && (
|
||||
<span className="text-[10px] text-muted truncate">{qualityLabel(item.quality)}</span>
|
||||
)}
|
||||
{item.rarity && (
|
||||
<span className={`text-[10px] capitalize shrink-0 ${textColor}`}>{item.rarity}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 mt-1">
|
||||
<span className="text-xs font-mono text-accent font-semibold truncate">
|
||||
{item.lowest_price.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted shrink-0">
|
||||
×
|
||||
{item.total_stock.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ListBox, SearchField, Select, Button } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Icon } from '../../dune-ui'
|
||||
|
||||
export type MarketFilters = {
|
||||
search: string
|
||||
category: string
|
||||
owner: '' | 'bot' | 'player'
|
||||
}
|
||||
|
||||
type MarketSearchProps = {
|
||||
filters: MarketFilters
|
||||
onChange: (f: MarketFilters) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export const MarketSearch: React.FC<MarketSearchProps> = ({ filters, onChange, onReset }: MarketSearchProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchDraft, setSearchDraft] = useState(filters.search)
|
||||
|
||||
// Sync draft when filters are reset externally.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearchDraft(filters.search), 0)
|
||||
return () => clearTimeout(t)
|
||||
}, [filters.search])
|
||||
|
||||
// Debounce: commit search text 350ms after the user stops typing.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (searchDraft !== filters.search) {
|
||||
onChange({ ...filters, search: searchDraft })
|
||||
}
|
||||
}, 350)
|
||||
return () => clearTimeout(t)
|
||||
}, [searchDraft]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const set = (patch: Partial<MarketFilters>) => onChange({ ...filters, ...patch })
|
||||
const hasFilters = filters.search || filters.category || filters.owner
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SearchField
|
||||
aria-label={t('market.search.ariaLabel')}
|
||||
className="flex-1 min-w-[200px]"
|
||||
value={searchDraft}
|
||||
onChange={setSearchDraft}
|
||||
>
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input placeholder={t('market.search.searchPlaceholder')} />
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
|
||||
<Select
|
||||
selectedKey={filters.owner || 'all'}
|
||||
onSelectionChange={(k) => set({ owner: k === 'all' ? '' : k as MarketFilters['owner'] })}
|
||||
className="w-36"
|
||||
aria-label={t('market.search.sellerAriaLabel')}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
<ListBox.Item id="all" textValue={t('market.search.allSellers')}>
|
||||
{t('market.search.allSellers')}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
<ListBox.Item id="bot" textValue={t('market.search.botOnly')}>
|
||||
{t('market.search.botOnly')}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
<ListBox.Item id="player" textValue={t('market.search.playersOnly')}>
|
||||
{t('market.search.playersOnly')}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button size="sm" variant="ghost" onPress={onReset}>
|
||||
<Icon name="x" />
|
||||
{' '}
|
||||
{t('market.search.clearFilters')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Icon } from '../../dune-ui'
|
||||
|
||||
type MarketSidebarProps = {
|
||||
categories: string[]
|
||||
selected: string
|
||||
onSelect: (cat: string) => void
|
||||
}
|
||||
|
||||
type Node = {
|
||||
label: string
|
||||
path: string // full path used for filtering
|
||||
displayPath: string // path used as tree key (items/ stripped)
|
||||
children: Node[]
|
||||
}
|
||||
|
||||
function buildTree(categories: string[]): { items: Node[], schematics: Node[] } {
|
||||
const itemRoot: Node[] = []
|
||||
const schematicRoot: Node[] = []
|
||||
|
||||
for (const cat of [...categories].sort()) {
|
||||
const isSchematic = cat.startsWith('schematics/')
|
||||
// Strip the top-level prefix before splitting so we don't create a spurious
|
||||
// "Schematics" parent node inside the schematics section (or "Items" inside items).
|
||||
const stripped = isSchematic
|
||||
? cat.replace(/^schematics\//, '')
|
||||
: cat.replace(/^items\//, '')
|
||||
const parts = stripped.split('/')
|
||||
const root = isSchematic ? schematicRoot : itemRoot
|
||||
|
||||
let current = root
|
||||
let displayPath = ''
|
||||
let filterPath = ''
|
||||
for (const part of parts) {
|
||||
displayPath = displayPath ? `${displayPath}/${part}` : part
|
||||
filterPath = isSchematic
|
||||
? (filterPath ? `${filterPath}/${part}` : `schematics/${part}`)
|
||||
: (filterPath ? `${filterPath}/${part}` : `items/${part}`)
|
||||
|
||||
let node = current.find((n) => n.label === part)
|
||||
if (!node) {
|
||||
node = { label: part, path: filterPath, displayPath, children: [] }
|
||||
current.push(node)
|
||||
}
|
||||
current = node.children
|
||||
}
|
||||
}
|
||||
|
||||
return { items: itemRoot, schematics: schematicRoot }
|
||||
}
|
||||
|
||||
function formatLabel(label: string): string {
|
||||
return label
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function collectAncestorPaths(categories: string[], selected: string): Set<string> {
|
||||
const ancestors = new Set<string>()
|
||||
for (const cat of categories) {
|
||||
if (cat === selected || cat.startsWith(selected + '/') || selected.startsWith(cat + '/')) {
|
||||
const parts = cat.replace(/^items\//, '').split('/')
|
||||
let cur = ''
|
||||
for (const p of parts) {
|
||||
cur = cur ? `${cur}/${p}` : p
|
||||
ancestors.add(cur)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
type TreeNodeProps = {
|
||||
node: Node
|
||||
selected: string
|
||||
depth: number
|
||||
expanded: Set<string>
|
||||
onToggle: (displayPath: string) => void
|
||||
onSelect: (path: string) => void
|
||||
}
|
||||
|
||||
function TreeNode({ node, selected, depth, expanded, onToggle, onSelect }: TreeNodeProps) {
|
||||
const isExact = selected === node.path
|
||||
const isAncestor = !isExact && selected.startsWith(node.path + '/')
|
||||
const hasChildren = node.children.length > 0
|
||||
const isOpen = expanded.has(node.displayPath)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={[
|
||||
'group flex items-center rounded transition-colors',
|
||||
isExact ? 'bg-accent/15' : 'hover:bg-surface',
|
||||
].join(' ')}
|
||||
style={{ paddingLeft: `${depth * 12}px` }}
|
||||
>
|
||||
{/* Expand/collapse toggle — only shown for nodes with children */}
|
||||
{hasChildren
|
||||
? (
|
||||
<button
|
||||
className="flex items-center justify-center w-5 h-5 shrink-0 text-muted hover:text-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(node.displayPath)
|
||||
}}
|
||||
aria-label={isOpen ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span className="w-5 shrink-0 flex items-center justify-center">
|
||||
<span className="w-1 h-1 rounded-full bg-border/60" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Label button */}
|
||||
<button
|
||||
className={[
|
||||
'flex-1 text-left py-1 pr-2 text-sm truncate',
|
||||
isExact ? 'text-accent font-medium' : isAncestor ? 'text-foreground/80' : 'text-muted',
|
||||
].join(' ')}
|
||||
onClick={() => {
|
||||
onSelect(node.path)
|
||||
if (hasChildren && !isOpen) onToggle(node.displayPath)
|
||||
}}
|
||||
>
|
||||
{formatLabel(node.label)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Children with left border guide */}
|
||||
{hasChildren && isOpen && (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute top-0 bottom-0 border-l border-border/30"
|
||||
style={{ left: `${depth * 12 + 10}px` }}
|
||||
/>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.displayPath}
|
||||
node={child}
|
||||
selected={selected}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MarketSidebar: React.FC<MarketSidebarProps> = ({ categories, selected, onSelect }: MarketSidebarProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { items, schematics } = useMemo(() => buildTree(categories), [categories])
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
// Default: top-level nodes open. Auto-expand ancestors of selected node.
|
||||
const defaultExpanded = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const node of [...items, ...schematics]) set.add(node.displayPath)
|
||||
for (const p of collectAncestorPaths(categories, selected)) set.add(p)
|
||||
return set
|
||||
}, [items, schematics, categories, selected])
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(defaultExpanded)
|
||||
|
||||
const toggle = (displayPath: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(displayPath)) next.delete(displayPath)
|
||||
else next.add(displayPath)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||
<Button size="sm" variant="ghost" isIconOnly aria-label={t('market.sidebar.expandAriaLabel')} onPress={() => setCollapsed(false)}>
|
||||
<Icon name="chevron-right" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-48 shrink-0 flex flex-col gap-0.5 overflow-y-auto pr-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold text-muted uppercase tracking-wider">{t('market.sidebar.categories')}</span>
|
||||
<Button size="sm" variant="ghost" isIconOnly aria-label={t('market.sidebar.collapseAriaLabel')} onPress={() => setCollapsed(true)}>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selected === '' ? 'primary' : 'ghost'}
|
||||
className="w-full justify-start text-sm mb-1"
|
||||
onPress={() => onSelect('')}
|
||||
>
|
||||
{t('market.sidebar.allItems')}
|
||||
</Button>
|
||||
|
||||
{items.map((node) => (
|
||||
<TreeNode
|
||||
key={node.displayPath}
|
||||
node={node}
|
||||
selected={selected}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
onToggle={toggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
{schematics.length > 0 && (
|
||||
<>
|
||||
<div className="my-2 border-t border-border/40" />
|
||||
<span className="text-[10px] font-semibold text-muted/60 uppercase tracking-wider px-1 mb-0.5">
|
||||
{t('market.sidebar.schematics')}
|
||||
</span>
|
||||
{schematics.map((node) => (
|
||||
<TreeNode
|
||||
key={node.displayPath}
|
||||
node={node}
|
||||
selected={selected}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
onToggle={toggle}
|
||||
onSelect={node.path.startsWith('schematics/') ? onSelect : onSelect}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type React from 'react'
|
||||
import { DataTable, type Column } from '../../dune-ui'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MarketItem } from '../../api/client'
|
||||
import { qualityLabel } from '../../utils/icons'
|
||||
|
||||
type Key = 'display_name' | 'quality' | 'category' | 'tier' | 'rarity' | 'lowest_price' | 'total_stock' | 'bot_stock' | 'listing_count'
|
||||
|
||||
const RARITY_COLORS: Record<string, string> = {
|
||||
common: 'text-foreground',
|
||||
uncommon: 'text-rarity-uncommon',
|
||||
rare: 'text-rarity-rare',
|
||||
epic: 'text-rarity-epic',
|
||||
legendary: 'text-rarity-legendary',
|
||||
unique: 'text-rarity-unique',
|
||||
memento: 'text-rarity-memento',
|
||||
}
|
||||
|
||||
type MarketTableProps = {
|
||||
items: MarketItem[]
|
||||
onSelect: (item: MarketItem) => void
|
||||
}
|
||||
|
||||
export const MarketTable: React.FC<MarketTableProps> = ({ items, onSelect }: MarketTableProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const COLUMNS: Column<Key>[] = [
|
||||
{ key: 'display_name', label: t('market.table.item'), minWidth: 200 },
|
||||
{ key: 'quality', label: t('market.table.grade'), width: 100 },
|
||||
{ key: 'category', label: t('market.table.category'), minWidth: 140 },
|
||||
{ key: 'tier', label: t('market.table.tier'), width: 60 },
|
||||
{ key: 'rarity', label: t('market.table.rarity'), width: 100 },
|
||||
{ key: 'lowest_price', label: t('market.table.lowestPrice'), width: 120 },
|
||||
{ key: 'total_stock', label: t('market.table.stock'), width: 80 },
|
||||
{ key: 'bot_stock', label: t('market.table.botStock'), width: 90 },
|
||||
{ key: 'listing_count', label: t('market.table.listings'), width: 80 },
|
||||
]
|
||||
|
||||
return (
|
||||
<DataTable<MarketItem, Key>
|
||||
aria-label={t('market.table.ariaLabel')}
|
||||
className="min-h-0 max-h-full"
|
||||
columns={COLUMNS}
|
||||
rows={items}
|
||||
rowId={(it) => `${it.template_id}:${it.quality}`}
|
||||
initialSort={{ column: 'display_name', direction: 'ascending' }}
|
||||
sortValue={(it, k) => {
|
||||
switch (k) {
|
||||
case 'display_name': return it.display_name
|
||||
case 'quality': return it.quality
|
||||
case 'category': return it.category
|
||||
case 'rarity': return it.rarity
|
||||
case 'tier': return it.tier
|
||||
case 'lowest_price': return it.lowest_price
|
||||
case 'total_stock': return it.total_stock
|
||||
case 'bot_stock': return it.bot_stock
|
||||
case 'listing_count': return it.listing_count
|
||||
}
|
||||
}}
|
||||
onRowAction={onSelect}
|
||||
emptyState={<div className="py-8 text-center text-muted">{t('market.table.noItemsFound')}</div>}
|
||||
renderCell={(it, key) => {
|
||||
switch (key) {
|
||||
case 'display_name':
|
||||
return <span className="font-medium">{it.display_name || it.template_id}</span>
|
||||
case 'quality':
|
||||
return it.quality > 0
|
||||
? <span className="text-xs text-muted">{qualityLabel(it.quality)}</span>
|
||||
: <span className="text-xs text-muted/50">Standard</span>
|
||||
case 'category':
|
||||
return <span className="text-muted text-xs">{it.category || '—'}</span>
|
||||
case 'tier':
|
||||
return it.tier > 0 ? <span className="text-muted">{it.tier}</span> : <span className="text-muted">—</span>
|
||||
case 'rarity':
|
||||
return (
|
||||
<span className={`text-xs font-medium capitalize ${RARITY_COLORS[it.rarity?.toLowerCase()] ?? 'text-foreground'}`}>
|
||||
{it.rarity || '—'}
|
||||
</span>
|
||||
)
|
||||
case 'lowest_price':
|
||||
return <span className="font-mono text-accent">{it.lowest_price.toLocaleString()}</span>
|
||||
case 'total_stock':
|
||||
return <span className="text-muted">{it.total_stock.toLocaleString()}</span>
|
||||
case 'bot_stock':
|
||||
return <span className="text-muted">{it.bot_stock.toLocaleString()}</span>
|
||||
case 'listing_count':
|
||||
return <span className="text-muted">{it.listing_count}</span>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type React from 'react'
|
||||
import { ToggleButtonGroup, ToggleButton } from '@heroui/react'
|
||||
import { Icon } from '../../dune-ui'
|
||||
|
||||
export type MarketView = 'grid' | 'table'
|
||||
|
||||
type ViewToggleProps = {
|
||||
view: MarketView
|
||||
onChange: (v: MarketView) => void
|
||||
}
|
||||
|
||||
export const ViewToggle: React.FC<ViewToggleProps> = ({ view, onChange }: ViewToggleProps) => {
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
selectedKeys={[view]}
|
||||
onSelectionChange={(keys) => {
|
||||
const next = [...keys][0]
|
||||
if (next === 'grid' || next === 'table') onChange(next)
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ToggleButton id="grid" isIconOnly aria-label="Grid view">
|
||||
<Icon name="layout-grid" />
|
||||
</ToggleButton>
|
||||
<ToggleButton id="table" isIconOnly aria-label="Table view">
|
||||
<Icon name="list" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Spinner, toast } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../../api/client'
|
||||
import type { BotStatus } from '../../../api/client'
|
||||
import { Icon, ConfirmDialog } from '../../../dune-ui'
|
||||
|
||||
type BotActionsProps = {
|
||||
status: BotStatus
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
type BusyOp = 'start' | 'stop' | 'restart' | 'cleanup'
|
||||
|
||||
export const BotActions: React.FC<BotActionsProps> = ({ status, onRefresh }: BotActionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [busy, setBusy] = useState<BusyOp | null>(null)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const run = async (cmd: 'start' | 'stop' | 'restart') => {
|
||||
setBusy(cmd)
|
||||
try {
|
||||
const res = await api.marketBot.lifecycle(cmd)
|
||||
const successKey = cmd === 'start'
|
||||
? 'market.bot.actions.resumeSuccess'
|
||||
: cmd === 'stop'
|
||||
? 'market.bot.actions.pauseSuccess'
|
||||
: 'market.bot.actions.reinitializeSuccess'
|
||||
toast.success(t(successKey, { output: res.output || 'ok' }))
|
||||
setTimeout(onRefresh, 1500)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
const failKey = cmd === 'start'
|
||||
? 'market.bot.actions.resumeFailed'
|
||||
: cmd === 'stop'
|
||||
? 'market.bot.actions.pauseFailed'
|
||||
: 'market.bot.actions.reinitializeFailed'
|
||||
toast.danger(t(failKey, { message: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runCleanup = async () => {
|
||||
setConfirmOpen(false)
|
||||
setBusy('cleanup')
|
||||
try {
|
||||
const res = await api.marketBot.cleanup()
|
||||
toast.success(t('market.bot.actions.wipedListings', { orders: res.orders_deleted, items: res.items_deleted }))
|
||||
setTimeout(onRefresh, 1500)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(t('market.bot.actions.cleanupFailed', { message: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const running = status?.running ?? false
|
||||
const dormant = status?.mode === 'none'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{dormant
|
||||
? (
|
||||
<span className="text-xs text-muted">
|
||||
{t('market.bot.actions.dormantHint')}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={running || busy !== null}
|
||||
onPress={() => run('start')}
|
||||
>
|
||||
{busy === 'start' ? <Spinner size="sm" color="current" /> : <Icon name="play" />}
|
||||
{t('market.bot.actions.resume')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
isDisabled={!running || busy !== null}
|
||||
onPress={() => run('stop')}
|
||||
>
|
||||
{busy === 'stop' ? <Spinner size="sm" color="current" /> : <Icon name="square" />}
|
||||
{t('market.bot.actions.pause')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={busy !== null}
|
||||
onPress={() => run('restart')}
|
||||
>
|
||||
{busy === 'restart' ? <Spinner size="sm" color="current" /> : <Icon name="refresh-cw" />}
|
||||
{t('market.bot.actions.reinitialize')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
isDisabled={busy !== null}
|
||||
onPress={() => setConfirmOpen(true)}
|
||||
>
|
||||
{busy === 'cleanup' ? <Spinner size="sm" color="current" /> : <Icon name="trash-2" />}
|
||||
{t('market.bot.actions.wipeListings')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
title={t('market.bot.actions.wipeListingsTitle')}
|
||||
description={t('market.bot.actions.wipeListingsDesc')}
|
||||
confirmLabel={t('market.bot.actions.wipeListingsConfirm')}
|
||||
onConfirm={runCleanup}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type React from 'react'
|
||||
import { useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { toast } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../../api/client'
|
||||
import type { BotConfig } from '../../../api/client'
|
||||
import { NumberInput, Panel, SectionLabel } from '../../../dune-ui'
|
||||
|
||||
export type ConfigEditorHandle = {
|
||||
save: () => Promise<void>
|
||||
reset: () => void
|
||||
getEnabled: () => boolean
|
||||
setEnabled: (v: boolean) => void
|
||||
}
|
||||
|
||||
type BotConfigEditorProps = {
|
||||
config: BotConfig
|
||||
onSaved: (cfg: BotConfig) => void
|
||||
}
|
||||
|
||||
function thresholdToPercent(t: number): number {
|
||||
return Math.round(t * 100)
|
||||
}
|
||||
|
||||
function percentToThreshold(p: number): number {
|
||||
return Math.round(p) / 100
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
const BotConfigEditorComponent = forwardRef<
|
||||
ConfigEditorHandle,
|
||||
BotConfigEditorProps
|
||||
>(function BotConfigEditor({ config, onSaved }, ref) {
|
||||
const { t } = useTranslation()
|
||||
const [draft, setDraft] = useState<BotConfig>(config)
|
||||
const [buyPct, setBuyPct] = useState<number>(thresholdToPercent(config.buy_threshold))
|
||||
|
||||
const set = <K extends keyof BotConfig>(key: K, val: BotConfig[K]) => {
|
||||
setDraft((d) => ({ ...d, [key]: val }))
|
||||
}
|
||||
|
||||
const setRarity = (key: string, val: number) => {
|
||||
setDraft((d) => ({ ...d, rarity_multipliers: { ...d.rarity_multipliers, [key]: val } }))
|
||||
}
|
||||
|
||||
const setVendor = (key: string, val: number) => {
|
||||
setDraft((d) => ({ ...d, vendor_multipliers: { ...d.vendor_multipliers, [key]: val } }))
|
||||
}
|
||||
|
||||
const setGrade = (idx: number, val: number) => {
|
||||
setDraft((d) => {
|
||||
const arr = [...d.grade_multipliers]
|
||||
arr[idx] = val
|
||||
return { ...d, grade_multipliers: arr }
|
||||
})
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: async () => {
|
||||
const payload: BotConfig = { ...draft, buy_threshold: percentToThreshold(buyPct) }
|
||||
const saved = await api.marketBot.saveConfig(payload)
|
||||
setBuyPct(thresholdToPercent(saved.buy_threshold))
|
||||
onSaved(saved)
|
||||
toast.success(t('market.bot.configEditor.configSaved'))
|
||||
},
|
||||
reset: () => {
|
||||
setDraft(config)
|
||||
setBuyPct(thresholdToPercent(config.buy_threshold))
|
||||
},
|
||||
getEnabled: () => draft.enabled,
|
||||
setEnabled: (v: boolean) => set('enabled', v),
|
||||
}), [draft, buyPct, config, onSaved, t])
|
||||
|
||||
const GRADE_LABELS = [
|
||||
t('market.bot.configEditor.gradeStandard'),
|
||||
t('market.bot.configEditor.gradeRefined'),
|
||||
t('market.bot.configEditor.gradeSuperior'),
|
||||
t('market.bot.configEditor.gradeMasterwork'),
|
||||
t('market.bot.configEditor.gradePristine'),
|
||||
t('market.bot.configEditor.gradeFlawless'),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pr-1">
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.bot.configEditor.tickIntervals')}</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-3 mt-1">
|
||||
<Field label={t('market.bot.configEditor.listTickInterval')} hint={t('market.bot.configEditor.listTickHint')}>
|
||||
<input
|
||||
className="bg-surface border border-border rounded px-2 py-1.5 text-sm text-foreground w-full"
|
||||
value={draft.list_interval}
|
||||
onChange={(e) => set('list_interval', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('market.bot.configEditor.buyTickInterval')} hint={t('market.bot.configEditor.buyTickHint')}>
|
||||
<input
|
||||
className="bg-surface border border-border rounded px-2 py-1.5 text-sm text-foreground w-full"
|
||||
value={draft.buy_interval}
|
||||
onChange={(e) => set('buy_interval', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.bot.configEditor.limits')}</SectionLabel>
|
||||
<p className="text-xs text-muted -mt-1">{t('market.bot.configEditor.limitsDesc')}</p>
|
||||
<div className="grid grid-cols-3 gap-3 mt-1">
|
||||
<Field label={t('market.bot.configEditor.maxBuysPerTick')}>
|
||||
<NumberInput
|
||||
ariaLabel={t('market.bot.configEditor.maxBuysPerTick')}
|
||||
value={draft.max_buys}
|
||||
onChange={(v) => set('max_buys', v)}
|
||||
showButtons={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('market.bot.configEditor.listingsPerGrade')} hint={t('market.bot.configEditor.listingsPerGradeHint')}>
|
||||
<NumberInput
|
||||
ariaLabel={t('market.bot.configEditor.listingsPerGrade')}
|
||||
value={draft.listings_per_grade}
|
||||
onChange={(v) => set('listings_per_grade', v)}
|
||||
showButtons={false}
|
||||
className="w-full"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('market.bot.configEditor.buyThreshold')} hint={t('market.bot.configEditor.buyThresholdHint', { pct: buyPct })}>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
ariaLabel={t('market.bot.configEditor.buyThreshold')}
|
||||
min={1}
|
||||
max={200}
|
||||
value={buyPct}
|
||||
onChange={setBuyPct}
|
||||
showButtons={false}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-sm text-muted">%</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 mt-1">
|
||||
<p className="text-xs text-muted">
|
||||
<strong>
|
||||
{t('market.bot.configEditor.buyThreshold')}
|
||||
:
|
||||
</strong>
|
||||
{' '}
|
||||
{t('market.bot.configEditor.buyThresholdDesc')}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
<strong>
|
||||
{t('market.bot.configEditor.listingsPerGrade')}
|
||||
:
|
||||
</strong>
|
||||
{' '}
|
||||
{t('market.bot.configEditor.listingsPerGradeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.bot.configEditor.gradeMultipliers')}</SectionLabel>
|
||||
<p className="text-xs text-muted -mt-1">{t('market.bot.configEditor.gradeMultipliersDesc')}</p>
|
||||
<div className="flex flex-wrap gap-3 mt-1">
|
||||
{(draft.grade_multipliers ?? []).map((mult, i) => (
|
||||
<Field key={i} label={GRADE_LABELS[i] ?? `Grade ${i}`} hint={`×${mult.toFixed(2)}`}>
|
||||
<NumberInput
|
||||
ariaLabel={GRADE_LABELS[i] ?? `Grade ${i}`}
|
||||
step={0.01}
|
||||
min={0}
|
||||
value={mult}
|
||||
onChange={(v) => setGrade(i, v)}
|
||||
showButtons={false}
|
||||
className="w-28"
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.bot.configEditor.rarityMultipliers')}</SectionLabel>
|
||||
<p className="text-xs text-muted -mt-1">{t('market.bot.configEditor.rarityMultipliersDesc')}</p>
|
||||
<div className="flex flex-wrap gap-3 mt-1">
|
||||
{Object.entries(draft.rarity_multipliers ?? {}).map(([rarity, mult]) => (
|
||||
<Field key={rarity} label={capitalize(rarity)} hint={`×${(mult as number).toFixed(2)}`}>
|
||||
<NumberInput
|
||||
ariaLabel={capitalize(rarity)}
|
||||
step={0.01}
|
||||
min={0}
|
||||
value={mult as number}
|
||||
onChange={(v) => setRarity(rarity, v)}
|
||||
showButtons={false}
|
||||
className="w-28"
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{draft.vendor_multipliers && Object.keys(draft.vendor_multipliers ?? {}).length > 0 && (
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.bot.configEditor.vendorMultipliers')}</SectionLabel>
|
||||
<p className="text-xs text-muted -mt-1">{t('market.bot.configEditor.vendorMultipliersDesc')}</p>
|
||||
<div className="flex flex-wrap gap-3 mt-1">
|
||||
{Object.entries(draft.vendor_multipliers ?? {}).map(([rarity, mult]) => (
|
||||
<Field key={rarity} label={capitalize(rarity)} hint={`×${(mult as number).toFixed(2)}`}>
|
||||
<NumberInput
|
||||
ariaLabel={capitalize(rarity)}
|
||||
step={0.01}
|
||||
min={0}
|
||||
value={mult as number}
|
||||
onChange={(v) => setVendor(rarity, v)}
|
||||
showButtons={false}
|
||||
className="w-28"
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const BotConfigEditor = BotConfigEditorComponent
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: FieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-xs text-muted">
|
||||
{label}
|
||||
{hint && (
|
||||
<span className="text-muted/60 ml-1">
|
||||
(
|
||||
{hint}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type React from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Button, Modal, Spinner, Switch, Tabs } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../../api/client'
|
||||
import type { BotStatus, BotConfig } from '../../../api/client'
|
||||
import { Icon } from '../../../dune-ui'
|
||||
import { BotStatusCard } from './BotStatusCard'
|
||||
import { BotActions } from './BotActions'
|
||||
import { BotLogViewer } from './BotLogViewer'
|
||||
import { BotConfigEditor, type ConfigEditorHandle } from './BotConfigEditor'
|
||||
import { DisabledItemsManager } from './DisabledItemsManager'
|
||||
import { BotServerConfig } from './BotServerConfig'
|
||||
|
||||
type BotControlPanelProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const BotControlPanel: React.FC<BotControlPanelProps> = ({ open, onClose }: BotControlPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [status, setStatus] = useState<BotStatus | null>(null)
|
||||
const [config, setConfig] = useState<BotConfig | null>(null)
|
||||
const [statusLoading, setStatusLoading] = useState(false)
|
||||
const [configLoading, setConfigLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('config')
|
||||
const editorRef = useRef<ConfigEditorHandle>(null)
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setStatusLoading(true))
|
||||
.then(() => api.marketBot.status())
|
||||
.then((s) => {
|
||||
setStatus(s)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setStatusLoading(false))
|
||||
}, [])
|
||||
|
||||
const loadConfig = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setConfigLoading(true))
|
||||
.then(() => api.marketBot.config())
|
||||
.then(setConfig)
|
||||
.catch(() => { /* config load failure is non-fatal */ })
|
||||
.finally(() => setConfigLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadStatus()
|
||||
loadConfig()
|
||||
}
|
||||
}, [open, loadStatus, loadConfig])
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<Modal.Backdrop isOpen={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<Modal.Container size="cover" scroll="outside">
|
||||
<Modal.Dialog className="h-[92vh] flex flex-col dialog-surface-alt">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t('market.bot.panelTitle')}</Modal.Heading>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body className="flex flex-col gap-4 overflow-y-auto flex-1 pr-1 min-h-0">
|
||||
{/* Status + actions */}
|
||||
{error
|
||||
? (
|
||||
<p className="text-xs text-danger">{error}</p>
|
||||
)
|
||||
: status
|
||||
? (
|
||||
<div className="flex flex-wrap items-start gap-4 justify-between pb-2 border-b border-border shrink-0">
|
||||
<BotStatusCard status={status} />
|
||||
<BotActions status={status} onRefresh={loadStatus} />
|
||||
</div>
|
||||
)
|
||||
: statusLoading
|
||||
? (
|
||||
<div className="flex justify-center py-4 shrink-0"><Spinner size="sm" /></div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{/* Tabs — flex-1 so logs panel can fill the remaining height */}
|
||||
<Tabs selectedKey={activeTab} onSelectionChange={(k) => setActiveTab(String(k))} className="flex flex-col flex-1 min-h-0">
|
||||
<Tabs.ListContainer className="shrink-0">
|
||||
<Tabs.List aria-label={t('market.bot.botSectionsLabel')}>
|
||||
<Tabs.Tab id="config">
|
||||
{t('market.bot.config')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="disabled">
|
||||
{t('market.bot.disabledItemsTab')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="server">
|
||||
{t('market.bot.server')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="logs">
|
||||
{t('market.bot.logs')}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
|
||||
<Tabs.Panel id="config" className="pt-4 overflow-y-auto flex-1 pr-1">
|
||||
{configLoading
|
||||
? (
|
||||
<div className="flex justify-center py-6"><Spinner size="sm" /></div>
|
||||
)
|
||||
: config
|
||||
? (
|
||||
<BotConfigEditor ref={editorRef} config={config} onSaved={setConfig} />
|
||||
)
|
||||
: (
|
||||
<p className="text-xs text-muted">{t('market.bot.configUnavailable')}</p>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel id="disabled" className="pt-4 overflow-y-auto flex-1 pr-1">
|
||||
{configLoading
|
||||
? (
|
||||
<div className="flex justify-center py-6"><Spinner size="sm" /></div>
|
||||
)
|
||||
: config
|
||||
? (
|
||||
<DisabledItemsManager config={config} onSaved={setConfig} />
|
||||
)
|
||||
: (
|
||||
<p className="text-xs text-muted">{t('market.bot.configUnavailable')}</p>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel id="server" className="pt-4 overflow-y-auto flex-1 pr-1">
|
||||
<BotServerConfig />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel id="logs" className="pt-4 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BotLogViewer active={activeTab === 'logs'} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal.Body>
|
||||
|
||||
{/* Static config footer — only shown on the config tab */}
|
||||
{activeTab === 'config' && config && !configLoading && (
|
||||
<ConfigFooter editorRef={editorRef} initialEnabled={config.enabled} onReload={loadConfig} />
|
||||
)}
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfigFooterProps {
|
||||
editorRef: React.RefObject<ConfigEditorHandle | null>
|
||||
initialEnabled: boolean
|
||||
onReload: () => void
|
||||
}
|
||||
|
||||
function ConfigFooter({ editorRef, initialEnabled, onReload }: ConfigFooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [enabled, setEnabledLocal] = useState(initialEnabled)
|
||||
|
||||
return (
|
||||
<div className="shrink-0 flex items-center gap-3 px-4 py-3 border-t border-border">
|
||||
<Switch
|
||||
isSelected={enabled}
|
||||
onChange={(v) => {
|
||||
setEnabledLocal(v)
|
||||
editorRef.current?.setEnabled(v)
|
||||
}}
|
||||
size="sm"
|
||||
className="mr-auto"
|
||||
>
|
||||
<Switch.Control><Switch.Thumb /></Switch.Control>
|
||||
<Switch.Content>{t('market.bot.tickingEnabled')}</Switch.Content>
|
||||
</Switch>
|
||||
<Button size="sm" variant="ghost" onPress={() => editorRef.current?.reset()}>
|
||||
{t('market.bot.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={reloading}
|
||||
onPress={() => {
|
||||
setReloading(true)
|
||||
Promise.resolve().then(onReload).finally(() => setReloading(false))
|
||||
}}
|
||||
>
|
||||
{reloading ? <Spinner size="sm" color="current" /> : <Icon name="refresh-cw" />}
|
||||
{t('market.bot.reloadConfig')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isDisabled={saving}
|
||||
onPress={() => {
|
||||
setSaving(true)
|
||||
editorRef.current?.save()
|
||||
.catch(() => { /* toast shown inside save */ })
|
||||
.finally(() => setSaving(false))
|
||||
}}
|
||||
>
|
||||
{saving ? <Spinner size="sm" color="current" /> : null}
|
||||
{t('market.bot.saveConfig')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type React from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Button, Switch } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getWsBase, api } from '../../../api/client'
|
||||
import { Icon } from '../../../dune-ui'
|
||||
|
||||
type BotLogViewerProps = {
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
type ConnState = 'idle' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
export const BotLogViewer: React.FC<BotLogViewerProps> = ({ active = false }: BotLogViewerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [connState, setConnState] = useState<ConnState>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const bufRef = useRef<string[]>([])
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const containerRef = useRef<HTMLPreElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
}
|
||||
}, [lines, autoScroll])
|
||||
|
||||
const startFlush = useCallback(() => {
|
||||
if (timerRef.current) return
|
||||
timerRef.current = setInterval(() => {
|
||||
if (bufRef.current.length > 0) {
|
||||
setLines((prev) => {
|
||||
const combined = [...prev, ...bufRef.current]
|
||||
bufRef.current = []
|
||||
return combined.length > 5000 ? combined.slice(-5000) : combined
|
||||
})
|
||||
}
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
const stopFlush = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
stopFlush()
|
||||
bufRef.current = []
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
setLines([])
|
||||
setError(null)
|
||||
setConnState('connecting')
|
||||
})
|
||||
.then(() => api.marketBot.logsReady())
|
||||
.then((check) => {
|
||||
if (!check.ready) {
|
||||
setError(check.reason ?? t('market.bot.log.notAvailable'))
|
||||
setConnState('error')
|
||||
return
|
||||
}
|
||||
const ws = new WebSocket(`${getWsBase()}/market-bot/logs`)
|
||||
wsRef.current = ws
|
||||
ws.onopen = () => {
|
||||
setConnState('connected')
|
||||
startFlush()
|
||||
}
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
bufRef.current.push(e.data as string)
|
||||
}
|
||||
ws.onerror = () => {
|
||||
setError(t('market.bot.log.wsError'))
|
||||
setConnState('error')
|
||||
}
|
||||
ws.onclose = (e) => {
|
||||
stopFlush()
|
||||
if (bufRef.current.length > 0) {
|
||||
setLines((prev) => [...prev, ...bufRef.current])
|
||||
bufRef.current = []
|
||||
}
|
||||
if (e.code !== 1000 && e.code !== 1001) {
|
||||
setError(e.reason
|
||||
? t('market.bot.log.connClosedReason', { code: e.code, reason: e.reason })
|
||||
: t('market.bot.log.connClosed', { code: e.code }))
|
||||
setConnState('error')
|
||||
}
|
||||
else {
|
||||
setConnState('idle')
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t('market.bot.log.backendUnreachable'))
|
||||
setConnState('error')
|
||||
})
|
||||
}, [startFlush, stopFlush, t])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000)
|
||||
wsRef.current = null
|
||||
}
|
||||
stopFlush()
|
||||
Promise.resolve().then(() => {
|
||||
setConnState('idle')
|
||||
setError(null)
|
||||
})
|
||||
}, [stopFlush])
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void connect()
|
||||
else disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active])
|
||||
|
||||
useEffect(() => () => {
|
||||
disconnect()
|
||||
}, [disconnect])
|
||||
|
||||
const stateLabel = {
|
||||
idle: t('market.bot.log.idle'),
|
||||
connecting: t('market.bot.log.connecting'),
|
||||
connected: t('market.bot.log.connected'),
|
||||
error: t('market.bot.log.error'),
|
||||
}[connState]
|
||||
|
||||
const stateColor = {
|
||||
idle: 'text-muted',
|
||||
connecting: 'text-muted animate-pulse',
|
||||
connected: 'text-success',
|
||||
error: 'text-danger',
|
||||
}[connState]
|
||||
|
||||
const clearLog = () => {
|
||||
setLines([])
|
||||
bufRef.current = []
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full min-h-0">
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<span className={`text-xs font-mono ${stateColor}`}>{stateLabel}</span>
|
||||
<div className="flex-1" />
|
||||
<Switch isSelected={autoScroll} onChange={setAutoScroll} size="sm">
|
||||
<Switch.Control><Switch.Thumb /></Switch.Control>
|
||||
<Switch.Content>{t('market.bot.log.autoScroll')}</Switch.Content>
|
||||
</Switch>
|
||||
{connState !== 'connected'
|
||||
? (
|
||||
<Button size="sm" variant="outline" onPress={connect} isDisabled={connState === 'connecting'}>
|
||||
<Icon name="play" />
|
||||
{' '}
|
||||
{t('market.bot.log.connect')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button size="sm" variant="danger-soft" onPress={disconnect}>
|
||||
<Icon name="square" />
|
||||
{' '}
|
||||
{t('market.bot.log.stop')}
|
||||
</Button>
|
||||
)}
|
||||
{lines.length > 0 && (
|
||||
<Button size="sm" variant="ghost" onPress={clearLog}>
|
||||
<Icon name="trash-2" />
|
||||
{' '}
|
||||
{t('market.bot.log.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-danger bg-danger/10 border border-danger/20 rounded px-2 py-1.5 shrink-0">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<pre
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto p-3 text-xs font-mono m-0 whitespace-pre-wrap break-all rounded-[var(--radius)] border border-border/60 bg-background text-success"
|
||||
>
|
||||
{lines.length === 0
|
||||
? (connState === 'connected' ? t('market.bot.log.waitingForLines') : connState === 'connecting' ? t('market.bot.log.connectingState') : '')
|
||||
: lines.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type React from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button, Input, Spinner, Switch, toast } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api, MASKED } from '../../../api/client'
|
||||
import type { AppConfig } from '../../../api/client'
|
||||
import { Panel, SectionLabel } from '../../../dune-ui'
|
||||
|
||||
// Restrict the set() helper to string-typed fields so it can't accidentally coerce
|
||||
// numeric/boolean AppConfig keys to strings (which the backend would reject or misparse).
|
||||
type StringAppConfigKey = { [K in keyof AppConfig]: AppConfig[K] extends string ? K : never }[keyof AppConfig]
|
||||
|
||||
export const BotServerConfig: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [cfg, setCfg] = useState<AppConfig | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => api.config.get())
|
||||
.then(setCfg)
|
||||
.catch(() => toast.danger(t('market.bot.serverConfig.loadFailed')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [t])
|
||||
|
||||
const set = (key: StringAppConfigKey) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCfg((prev) => prev ? { ...prev, [key]: e.target.value } : prev)
|
||||
|
||||
const setBool = (key: keyof AppConfig) => (checked: boolean) =>
|
||||
setCfg((prev) => prev ? { ...prev, [key]: checked } : prev)
|
||||
|
||||
const save = async () => {
|
||||
if (!cfg) return
|
||||
setSaving(true)
|
||||
try {
|
||||
// Sends the full AppConfig. The backend treats MASKED sentinel values as
|
||||
// "unchanged" for credential fields so they are never overwritten on save.
|
||||
await api.config.save(cfg)
|
||||
toast.success(t('market.bot.serverConfig.savedConfig'))
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(t('market.bot.serverConfig.saveFailed', { message: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-8"><Spinner size="sm" /></div>
|
||||
}
|
||||
if (!cfg) {
|
||||
return <p className="text-xs text-muted">{t('market.bot.configUnavailable')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.bot.serverConfig.embeddedBot')}</SectionLabel>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
isSelected={cfg.market_bot_enabled}
|
||||
onChange={setBool('market_bot_enabled')}
|
||||
size="sm"
|
||||
>
|
||||
<Switch.Control><Switch.Thumb /></Switch.Control>
|
||||
<Switch.Content>{t('market.bot.serverConfig.enableEmbedded')}</Switch.Content>
|
||||
</Switch>
|
||||
<span className="text-xs text-muted">{t('market.bot.serverConfig.restartRequired')}</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">{t('market.bot.serverConfig.cacheDb')}</span>
|
||||
<Input className="font-mono" value={cfg.market_bot_cache_db} onChange={set('market_bot_cache_db')} placeholder="~/.dune-admin/market-bot-cache.db" aria-label={t('market.bot.serverConfig.cacheDb')} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">{t('market.bot.serverConfig.itemData')}</span>
|
||||
<Input className="font-mono" value={cfg.market_bot_item_data} onChange={set('market_bot_item_data')} placeholder="item-data.json" aria-label={t('market.bot.serverConfig.itemData')} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 sm:col-span-2">
|
||||
<span className="text-xs font-medium text-muted">{t('market.bot.serverConfig.statePath')}</span>
|
||||
<Input className="font-mono" value={cfg.market_bot_state} onChange={set('market_bot_state')} placeholder="~/.dune-admin/market-bot-state.json" aria-label={t('market.bot.serverConfig.statePath')} />
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('market.bot.serverConfig.remoteBot')}</SectionLabel>
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">{t('market.bot.serverConfig.remoteUrl')}</span>
|
||||
<Input className="font-mono" value={cfg.market_bot_remote_url} onChange={set('market_bot_remote_url')} placeholder="http://host:9191" aria-label={t('market.bot.serverConfig.remoteUrl')} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">{t('market.bot.serverConfig.remoteToken')}</span>
|
||||
<Input className="font-mono" type="password" value={cfg.market_bot_remote_token} onChange={set('market_bot_remote_token')} placeholder={MASKED} aria-label={t('market.bot.serverConfig.remoteToken')} />
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-muted">{t('market.bot.serverConfig.changesNote')}</p>
|
||||
<Button size="sm" onPress={save} isDisabled={saving}>
|
||||
{saving ? <Spinner size="sm" color="current" /> : null}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type React from 'react'
|
||||
import { Chip } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BotStatus } from '../../../api/client'
|
||||
|
||||
function fmt(ts: string | null | undefined): string {
|
||||
if (!ts) return '—'
|
||||
try {
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
}
|
||||
catch {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBalance(n: number | undefined): string {
|
||||
if (n == null) return '—'
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
interface BotStatusCardProps {
|
||||
status: BotStatus
|
||||
}
|
||||
|
||||
export const BotStatusCard: React.FC<BotStatusCardProps> = ({ status }) => {
|
||||
const { t } = useTranslation()
|
||||
const statusLabel = status.running ? t('market.bot.status.running') : t('market.bot.status.paused')
|
||||
const statusColor = status.running ? 'success' : 'warning'
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4 items-start">
|
||||
<div className="flex flex-col gap-1 min-w-[120px]">
|
||||
<span className="text-xs text-muted uppercase tracking-wider">{t('market.bot.status.label')}</span>
|
||||
<Chip
|
||||
size="sm"
|
||||
color={statusColor}
|
||||
variant="soft"
|
||||
>
|
||||
{statusLabel}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
<Stat label={t('market.bot.status.uptime')} value={status.uptime || '—'} />
|
||||
<Stat label={t('market.bot.status.listings')} value={status.listing_count?.toLocaleString() ?? '—'} />
|
||||
<Stat label={t('market.bot.status.balance')} value={fmtBalance(status.balance)} />
|
||||
<Stat label={t('market.bot.status.errors')} value={String(status.error_count ?? 0)} accent={status.error_count > 0 ? 'danger' : undefined} />
|
||||
<Stat label={t('market.bot.status.lastListTick')} value={fmt(status.last_list_tick)} />
|
||||
<Stat label={t('market.bot.status.lastBuyTick')} value={fmt(status.last_buy_tick)} />
|
||||
{status.next_list_tick != null && <Stat label={t('market.bot.status.nextListTick')} value={fmt(status.next_list_tick)} />}
|
||||
{status.next_buy_tick != null && <Stat label={t('market.bot.status.nextBuyTick')} value={fmt(status.next_buy_tick)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatProps {
|
||||
label: string
|
||||
value: string
|
||||
accent?: 'danger'
|
||||
}
|
||||
|
||||
function Stat({ label, value, accent }: StatProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 min-w-[100px]">
|
||||
<span className="text-xs text-muted uppercase tracking-wider">{label}</span>
|
||||
<span className={`text-sm font-mono ${accent === 'danger' ? 'text-danger' : 'text-foreground'}`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type React from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Button, SearchField, Spinner, toast } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../../api/client'
|
||||
import type { BotConfig, CatalogItem } from '../../../api/client'
|
||||
import { DataTable, type Column, Icon } from '../../../dune-ui'
|
||||
|
||||
type DisabledItemsManagerProps = {
|
||||
config: BotConfig
|
||||
onSaved: (cfg: BotConfig) => void
|
||||
}
|
||||
|
||||
type DisabledRow = { template_id: string, display_name: string }
|
||||
type RowKey = 'name' | 'template_id' | 'actions'
|
||||
|
||||
export const DisabledItemsManager: React.FC<DisabledItemsManagerProps> = (
|
||||
{ config, onSaved }: DisabledItemsManagerProps,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [catalog, setCatalog] = useState<CatalogItem[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const COLUMNS: Column<RowKey>[] = [
|
||||
{ key: 'name', label: t('market.bot.disabledItems.columns.name') },
|
||||
{ key: 'template_id', label: t('market.bot.disabledItems.columns.templateId') },
|
||||
{ key: 'actions', label: ' ', sortable: false },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
api.market.catalog().then(setCatalog).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const safeItems = useMemo(() => config.disabled_items ?? [], [config.disabled_items])
|
||||
|
||||
const results = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return []
|
||||
return catalog
|
||||
.filter((c) =>
|
||||
!safeItems.includes(c.template_id)
|
||||
&& (c.display_name.toLowerCase().includes(q) || c.template_id.toLowerCase().includes(q)),
|
||||
)
|
||||
.slice(0, 8)
|
||||
}, [search, catalog, safeItems])
|
||||
|
||||
const disabledRows: DisabledRow[] = useMemo(() =>
|
||||
safeItems.map((tmpl) => ({
|
||||
template_id: tmpl,
|
||||
display_name: catalog.find((c) => c.template_id === tmpl)?.display_name ?? tmpl,
|
||||
})),
|
||||
[safeItems, catalog],
|
||||
)
|
||||
|
||||
const saveList = async (next: string[]) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await api.marketBot.saveConfig({ ...config, disabled_items: next })
|
||||
onSaved(saved)
|
||||
}
|
||||
catch (e: unknown) {
|
||||
toast.danger(t('common.failed', { message: e instanceof Error ? e.message : String(e) }))
|
||||
}
|
||||
finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const add = (templateId: string) => {
|
||||
if (safeItems.includes(templateId)) return
|
||||
saveList([...safeItems, templateId])
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
const remove = (templateId: string) => {
|
||||
saveList(safeItems.filter((i) => i !== templateId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search + add row */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<label className="text-xs text-muted">{t('market.bot.disabledItems.searchLabel')}</label>
|
||||
<SearchField
|
||||
aria-label={t('market.bot.disabledItems.searchAriaLabel')}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
className="w-full"
|
||||
>
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input placeholder={t('market.bot.disabledItems.searchPlaceholder')} />
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
</div>
|
||||
{saving && <Spinner size="sm" color="current" className="mb-2" />}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{results.length > 0 && (
|
||||
<div className="flex flex-col border border-border rounded overflow-hidden">
|
||||
{results.map((item) => (
|
||||
<div
|
||||
key={item.template_id}
|
||||
className="flex items-center gap-3 px-3 py-2 bg-surface hover:bg-surface/70 border-b border-border/40 last:border-0 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-sm text-foreground truncate">{item.display_name}</span>
|
||||
<span className="text-xs text-muted font-mono truncate">{item.template_id}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onPress={() => add(item.template_id)}>
|
||||
<Icon name="plus" />
|
||||
{' '}
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{search.trim() && results.length === 0 && (
|
||||
<p className="text-xs text-muted">{t('market.bot.disabledItems.noMatchingItems')}</p>
|
||||
)}
|
||||
|
||||
{/* Disabled list */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-muted uppercase tracking-wider">
|
||||
{safeItems.length > 0
|
||||
? t('market.bot.disabledItems.disabledItemsCount', { count: safeItems.length })
|
||||
: t('market.bot.disabledItems.disabledItemsHeading')}
|
||||
</span>
|
||||
{disabledRows.length === 0
|
||||
? (
|
||||
<p className="text-xs text-muted">{t('market.bot.disabledItems.noDisabledItems')}</p>
|
||||
)
|
||||
: (
|
||||
<DataTable<DisabledRow, RowKey>
|
||||
aria-label={t('market.bot.disabledItems.ariaLabel')}
|
||||
className="flex-1 min-h-0"
|
||||
columns={COLUMNS}
|
||||
rows={disabledRows}
|
||||
rowId={(r) => r.template_id}
|
||||
initialSort={{ column: 'name', direction: 'ascending' }}
|
||||
sortValue={(r, k) => k === 'name' ? r.display_name : r.template_id}
|
||||
renderCell={(r, key) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return <span className="font-medium text-foreground">{r.display_name}</span>
|
||||
case 'template_id':
|
||||
return <span className="font-mono text-xs text-muted">{r.template_id}</span>
|
||||
case 'actions':
|
||||
return (
|
||||
<Button size="sm" variant="danger-soft" onPress={() => remove(r.template_id)}>
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
docs/reference-repos/icehunter/web/src/tabs/MarketTab/index.tsx
Normal file
145
docs/reference-repos/icehunter/web/src/tabs/MarketTab/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import type React from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Button, Spinner } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import type { MarketItem } from '../../api/client'
|
||||
import { Icon, LoadingState, PageHeader } from '../../dune-ui'
|
||||
import { MarketSidebar } from './MarketSidebar'
|
||||
import { MarketSearch, type MarketFilters } from './MarketSearch'
|
||||
import { MarketTable } from './MarketTable'
|
||||
import { MarketGrid } from './MarketGrid'
|
||||
import { ViewToggle, type MarketView } from './ViewToggle'
|
||||
import { ItemDetail } from './ItemDetail'
|
||||
import { BotControlPanel } from './bot/BotControlPanel'
|
||||
|
||||
const DEFAULT_FILTERS: MarketFilters = { search: '', category: '', owner: '' }
|
||||
|
||||
export const MarketTab: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [items, setItems] = useState<MarketItem[]>([])
|
||||
const [categories, setCategories] = useState<string[]>([])
|
||||
const categoriesRef = useRef<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState<MarketFilters>(DEFAULT_FILTERS)
|
||||
const [selected, setSelected] = useState<MarketItem | null>(null)
|
||||
const [view, setView] = useState<MarketView>('table')
|
||||
const [botOpen, setBotOpen] = useState(false)
|
||||
// Show Bot Control whenever the bot is configured (embedded or remote),
|
||||
// even if currently disabled/not running.
|
||||
const [botConfigured, setBotConfigured] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.marketBot
|
||||
.status()
|
||||
// configured field from newer backends; fall back to mode check for older ones.
|
||||
// Treat absent mode (pre-mode backend) as not-configured rather than configured.
|
||||
.then((s) => setBotConfigured(s.configured ?? (s.mode !== undefined && s.mode !== 'none')))
|
||||
.catch(() => setBotConfigured(false))
|
||||
}, [])
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() =>
|
||||
Promise.all([
|
||||
api.market.items({
|
||||
search: filters.search || undefined,
|
||||
category: filters.category || undefined,
|
||||
owner: filters.owner || undefined,
|
||||
}),
|
||||
categoriesRef.current.length === 0 ? api.market.categories() : Promise.resolve(categoriesRef.current),
|
||||
]),
|
||||
)
|
||||
.then(([res, cats]) => {
|
||||
setItems(res.items)
|
||||
if (categoriesRef.current.length === 0) {
|
||||
categoriesRef.current = cats
|
||||
setCategories(cats)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* errors surface via empty state */
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const handleFiltersChange = (f: MarketFilters) => {
|
||||
setFilters(f)
|
||||
if (selected && f.category !== filters.category) setSelected(null)
|
||||
}
|
||||
|
||||
const handleCategorySelect = (cat: string) => {
|
||||
setFilters((f) => ({ ...f, category: cat }))
|
||||
setSelected(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 min-h-0">
|
||||
<PageHeader title={t('market.title')} subtitle={t('market.subtitle')}>
|
||||
{botConfigured
|
||||
? (
|
||||
<Button size="sm" variant="ghost" onPress={() => setBotOpen(true)}>
|
||||
<Icon name="bot" />
|
||||
{' '}
|
||||
{t('market.botControl')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<span className="hidden text-xs text-muted sm:inline">
|
||||
{t('market.noBotConnected')}
|
||||
</span>
|
||||
)}
|
||||
<ViewToggle view={view} onChange={setView} />
|
||||
<Button size="sm" variant="ghost" onPress={load} isDisabled={loading}>
|
||||
{loading
|
||||
? (
|
||||
<Spinner size="sm" color="current" />
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Icon name="refresh-cw" />
|
||||
{' '}
|
||||
{t('common.refresh')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<MarketSearch
|
||||
filters={filters}
|
||||
onChange={handleFiltersChange}
|
||||
onReset={() => {
|
||||
setFilters(DEFAULT_FILTERS)
|
||||
setSelected(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 gap-3 min-h-0 overflow-hidden">
|
||||
<MarketSidebar categories={categories} selected={filters.category} onSelect={handleCategorySelect} />
|
||||
|
||||
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{loading && items.length === 0
|
||||
? (
|
||||
<LoadingState fill />
|
||||
)
|
||||
: view === 'grid'
|
||||
? (
|
||||
<MarketGrid items={items} onSelect={setSelected} />
|
||||
)
|
||||
: (
|
||||
<MarketTable items={items} onSelect={setSelected} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<ItemDetail item={selected} onClose={() => setSelected(null)} />
|
||||
<BotControlPanel open={botOpen} onClose={() => setBotOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type React from 'react'
|
||||
import type { Player } from '../../../api/client'
|
||||
import { StatusDot } from './StatusDot'
|
||||
|
||||
interface PlayerCardProps {
|
||||
player: Player
|
||||
selected: boolean
|
||||
onSelect: (player: Player) => void
|
||||
}
|
||||
|
||||
export const PlayerCard: React.FC<PlayerCardProps> = ({ player, selected, onSelect }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(player)}
|
||||
className={[
|
||||
'w-full text-left px-3 py-2 rounded-[var(--radius)] flex items-center gap-3',
|
||||
'text-sm transition-colors cursor-pointer',
|
||||
selected
|
||||
? 'bg-accent text-accent-foreground font-semibold'
|
||||
: 'text-foreground hover:bg-surface-hover',
|
||||
].join(' ')}
|
||||
>
|
||||
<StatusDot status={player.online_status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm truncate">{player.name}</div>
|
||||
<div className={`text-xs truncate ${selected ? 'opacity-80' : 'text-muted'}`}>
|
||||
{player.class}
|
||||
{' · '}
|
||||
{player.map}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../../api/client'
|
||||
import type { Player, PlayerStats, SessionRecord, StatSnapshot } from '../../../api/client'
|
||||
import { LoadingState, Panel, SectionLabel } from '../../../dune-ui'
|
||||
import { SolarisChart } from './SolarisChart'
|
||||
import { SessionChart } from './SessionChart'
|
||||
import { XPChart } from './XPChart'
|
||||
|
||||
interface PlayerDetailPanelProps {
|
||||
player: Player
|
||||
}
|
||||
|
||||
function fmtSolaris(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function fmtDuration(s: number): string {
|
||||
if (s <= 0) return '—'
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
interface StatRowProps {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: StatRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 border-b border-border/30 last:border-0">
|
||||
<span className="text-sm text-muted">{label}</span>
|
||||
<span className="text-sm font-semibold">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PlayerDetailPanel: React.FC<PlayerDetailPanelProps> = ({ player }) => {
|
||||
const { t } = useTranslation()
|
||||
const [stats, setStats] = useState<PlayerStats | null>(null)
|
||||
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||
const [snapshots, setSnapshots] = useState<StatSnapshot[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.resolve()
|
||||
.then(() => setLoading(true))
|
||||
.then(() => Promise.all([
|
||||
api.players.stats(player.account_id),
|
||||
api.players.sessionHistory(player.account_id),
|
||||
api.players.statSnapshots(player.account_id),
|
||||
]))
|
||||
.then(([s, sess, snaps]) => {
|
||||
setStats(s)
|
||||
setSessions(sess)
|
||||
setSnapshots(snaps)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// stats fetch failed — panel shows "Failed to load stats." fallback
|
||||
console.error('PlayerDetailPanel load error:', e)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [player.account_id])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState />
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <p className="text-muted text-sm py-4 text-center">{t('players.detail.failedToLoad')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Panel>
|
||||
<SectionLabel>{t('players.detail.economy')}</SectionLabel>
|
||||
<div className="mt-2">
|
||||
<StatRow label={t('players.detail.solaris')} value={fmtSolaris(stats.solaris_balance)} />
|
||||
<StatRow label={t('players.detail.scrip')} value={fmtSolaris(stats.scrip_balance)} />
|
||||
<StatRow label={t('players.detail.earned')} value={stats.solaris_earned > 0 ? fmtSolaris(stats.solaris_earned) : '—'} />
|
||||
<StatRow label={t('players.detail.spent')} value={stats.solaris_spent > 0 ? fmtSolaris(stats.solaris_spent) : '—'} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('players.detail.progression')}</SectionLabel>
|
||||
<div className="mt-2">
|
||||
<StatRow label={t('players.detail.charXP')} value={stats.char_xp > 0 ? stats.char_xp.toLocaleString() : '—'} />
|
||||
<StatRow label={t('players.detail.skillPts')} value={stats.skill_points > 0 ? stats.skill_points : '—'} />
|
||||
<StatRow label={t('players.detail.pois')} value={stats.pois_discovered > 0 ? stats.pois_discovered : '—'} />
|
||||
<StatRow label={t('players.detail.milestones')} value={stats.story_milestones > 0 ? stats.story_milestones : '—'} />
|
||||
<StatRow
|
||||
label={t('players.detail.factionAlignment')}
|
||||
value={stats.faction || t('players.detail.unaligned')}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('players.detail.factionTier')}
|
||||
value={stats.max_faction_tier > 0 ? t('players.detail.tier', { tier: stats.max_faction_tier }) : '—'}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SectionLabel>{t('players.detail.sessions')}</SectionLabel>
|
||||
<div className="mt-2">
|
||||
<StatRow label={t('players.detail.playtime')} value={fmtDuration(stats.total_playtime_secs)} />
|
||||
<StatRow label={t('players.detail.sessionCount')} value={stats.session_count > 0 ? stats.session_count : '—'} />
|
||||
<StatRow label={t('players.detail.avgSession')} value={fmtDuration(stats.avg_session_secs)} />
|
||||
<StatRow
|
||||
label={t('players.detail.lastSeen')}
|
||||
value={stats.last_seen ? new Date(stats.last_seen as string).toLocaleDateString() : '—'}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<SolarisChart data={snapshots} />
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<SessionChart data={sessions} />
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<XPChart data={snapshots} />
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user