feat(dashboard): logout button + admin user-management window
Logout: new sidebar link 'Log out (username)' that POSTs /api/logout (clears session cookie) and navigates to /login. Visible to everyone. Replaces 'no logout functionality' state where users could only get out by deleting cookies manually. Admin window: new 'Admin · Users' window (only shown when current user.is_admin) lists all users in a table with: - Add user (username + password + admin checkbox) - Reset password inline per row - Toggle admin per row - Delete user per row (blocked for self) Wraps the existing /api-admin/users CRUD endpoints in main.py. Plumbing: useCurrentUser hook fetches /me on mount; apiPatch+apiDelete helpers added to api/client.ts; new endpoint wrappers exported from api/endpoints.ts; AdminUsersWindow.tsx registered in WindowRenderer under id prefix 'adminusers'; CSS for admin table/form/buttons and the muted-red logout link. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
88e9e88f46
commit
1c1c43d28b
23 changed files with 521 additions and 50 deletions
|
|
@ -29,6 +29,40 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH JSON to an authenticated API endpoint. Same shape as apiPost.
|
||||||
|
*/
|
||||||
|
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
|
||||||
|
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE an authenticated API endpoint. No body. Returns parsed JSON.
|
||||||
|
*/
|
||||||
|
export async function apiDelete<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
|
||||||
|
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export function wsUrl(): string {
|
export function wsUrl(): string {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
return `${proto}//${location.host}/api/ws/live`;
|
return `${proto}//${location.host}/api/ws/live`;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiFetch, apiPost } from './client';
|
import { apiFetch, apiPost, apiPatch, apiDelete } from './client';
|
||||||
import type { TelemetrySnapshot, CombatStatsMessage, ServerHealth } from '../types';
|
import type { TelemetrySnapshot, CombatStatsMessage, ServerHealth } from '../types';
|
||||||
|
|
||||||
interface LiveResponse {
|
interface LiveResponse {
|
||||||
|
|
@ -46,3 +46,51 @@ export const agentSessionHistory = (sessionId: string) =>
|
||||||
apiFetch<{ messages: AgentHistoryMessage[] }>(
|
apiFetch<{ messages: AgentHistoryMessage[] }>(
|
||||||
`/agent/sessions/${encodeURIComponent(sessionId)}/history`,
|
`/agent/sessions/${encodeURIComponent(sessionId)}/history`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Auth / current user ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCurrentUser = () => apiFetch<CurrentUser>('/me');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out by hitting /logout (which clears the cookie server-side and 302s
|
||||||
|
* to /login). We follow the redirect explicitly so the browser ends up on
|
||||||
|
* the login page with a fresh state.
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
// /logout is a GET that returns a redirect. apiFetch would throw because
|
||||||
|
// the redirect target /login returns HTML, not JSON. Use a bare fetch.
|
||||||
|
await fetch('/api/logout', { credentials: 'include', redirect: 'manual' });
|
||||||
|
// Force navigation regardless — the cookie is gone either way.
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin user CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listAdminUsers = () =>
|
||||||
|
apiFetch<{ users: AdminUser[] }>('/api-admin/users');
|
||||||
|
|
||||||
|
export const createAdminUser = (username: string, password: string, isAdmin: boolean) =>
|
||||||
|
apiPost<{ ok: boolean; username: string }>('/api-admin/users', {
|
||||||
|
username, password, is_admin: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateAdminUser = (
|
||||||
|
id: number,
|
||||||
|
body: { password?: string; is_admin?: boolean },
|
||||||
|
) =>
|
||||||
|
apiPatch<{ ok: boolean }>(`/api-admin/users/${id}`, body);
|
||||||
|
|
||||||
|
export const deleteAdminUser = (id: number) =>
|
||||||
|
apiDelete<{ ok: boolean }>(`/api-admin/users/${id}`);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||||
|
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||||
|
import { logout } from '../../api/endpoints';
|
||||||
|
|
||||||
export const SidebarWindowButtons: React.FC = () => {
|
export const SidebarWindowButtons: React.FC = () => {
|
||||||
const { openWindow } = useWindowManager();
|
const { openWindow } = useWindowManager();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const isAdmin = !!user?.is_admin;
|
||||||
|
|
||||||
|
const onLogout = useCallback(async () => {
|
||||||
|
if (!confirm('Log out?')) return;
|
||||||
|
try { await logout(); } catch { window.location.href = '/login'; }
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ml-tool-links">
|
<div className="ml-tool-links">
|
||||||
|
|
@ -18,6 +27,18 @@ export const SidebarWindowButtons: React.FC = () => {
|
||||||
onClick={() => openWindow('vitalsharing', 'Vital Sharing')}>🤝 Vitals</span>
|
onClick={() => openWindow('vitalsharing', 'Vital Sharing')}>🤝 Vitals</span>
|
||||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||||
onClick={() => openWindow('combatpicker', 'Combat Stats')}>⚔️ Combat</span>
|
onClick={() => openWindow('combatpicker', 'Combat Stats')}>⚔️ Combat</span>
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => openWindow('adminusers', 'Admin · Users')}>🛡️ Admin</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="ml-tool-link ml-tool-link-logout"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={onLogout}
|
||||||
|
title={user ? `Logged in as ${user.username}` : 'Log out'}
|
||||||
|
>
|
||||||
|
🚪 Log out{user ? ` (${user.username})` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
223
frontend/src/components/windows/AdminUsersWindow.tsx
Normal file
223
frontend/src/components/windows/AdminUsersWindow.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { DraggableWindow } from './DraggableWindow';
|
||||||
|
import {
|
||||||
|
listAdminUsers,
|
||||||
|
createAdminUser,
|
||||||
|
updateAdminUser,
|
||||||
|
deleteAdminUser,
|
||||||
|
type AdminUser,
|
||||||
|
} from '../../api/endpoints';
|
||||||
|
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
zIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCreated(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminUsersWindow: React.FC<Props> = ({ id, zIndex }) => {
|
||||||
|
const { user: me } = useCurrentUser();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Add-user form state
|
||||||
|
const [newUsername, setNewUsername] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [newIsAdmin, setNewIsAdmin] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
// Per-row "reset password" state
|
||||||
|
const [pwEditingId, setPwEditingId] = useState<number | null>(null);
|
||||||
|
const [pwValue, setPwValue] = useState('');
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await listAdminUsers();
|
||||||
|
setUsers(res.users ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const onCreate = useCallback(async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newUsername.trim() || newPassword.length < 4) {
|
||||||
|
setError('Username required and password must be at least 4 chars');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createAdminUser(newUsername.trim(), newPassword, newIsAdmin);
|
||||||
|
setNewUsername(''); setNewPassword(''); setNewIsAdmin(false);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}, [newUsername, newPassword, newIsAdmin, refresh]);
|
||||||
|
|
||||||
|
const onToggleAdmin = useCallback(async (u: AdminUser) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateAdminUser(u.id, { is_admin: !u.is_admin });
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const onSavePassword = useCallback(async (id: number) => {
|
||||||
|
if (pwValue.length < 4) {
|
||||||
|
setError('Password must be at least 4 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateAdminUser(id, { password: pwValue });
|
||||||
|
setPwEditingId(null);
|
||||||
|
setPwValue('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}, [pwValue]);
|
||||||
|
|
||||||
|
const onDelete = useCallback(async (u: AdminUser) => {
|
||||||
|
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteAdminUser(u.id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableWindow id={id} title="🛡️ Admin · Users" zIndex={zIndex} width={620} height={540}>
|
||||||
|
<div className="ml-admin">
|
||||||
|
{error && <div className="ml-admin-error">{error}</div>}
|
||||||
|
|
||||||
|
<section className="ml-admin-section">
|
||||||
|
<h3>Add user</h3>
|
||||||
|
<form onSubmit={onCreate} className="ml-admin-create">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={e => setNewUsername(e.target.value)}
|
||||||
|
disabled={creating}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password (min 4)"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
disabled={creating}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newIsAdmin}
|
||||||
|
onChange={e => setNewIsAdmin(e.target.checked)}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
admin
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={creating || !newUsername.trim() || newPassword.length < 4}>
|
||||||
|
{creating ? 'Adding…' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ml-admin-section">
|
||||||
|
<h3>Users {loading && <span className="ml-admin-muted">(loading…)</span>}</h3>
|
||||||
|
<table className="ml-admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(u => {
|
||||||
|
const isMe = me != null && me.username.toLowerCase() === u.username.toLowerCase();
|
||||||
|
return (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.id}</td>
|
||||||
|
<td>
|
||||||
|
{u.username}
|
||||||
|
{isMe && <span className="ml-admin-muted"> (you)</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="ml-admin-toggle"
|
||||||
|
onClick={() => onToggleAdmin(u)}
|
||||||
|
title="Click to toggle admin"
|
||||||
|
>
|
||||||
|
{u.is_admin ? '✓' : '–'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{fmtCreated(u.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
{pwEditingId === u.id ? (
|
||||||
|
<span className="ml-admin-pw-edit">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New password"
|
||||||
|
value={pwValue}
|
||||||
|
onChange={e => setPwValue(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => onSavePassword(u.id)}>Save</button>
|
||||||
|
<button onClick={() => { setPwEditingId(null); setPwValue(''); }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => { setPwEditingId(u.id); setPwValue(''); }}>
|
||||||
|
Reset PW
|
||||||
|
</button>
|
||||||
|
{!isMe && (
|
||||||
|
<button className="ml-admin-danger" onClick={() => onDelete(u)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{users.length === 0 && !loading && (
|
||||||
|
<tr><td colSpan={5} className="ml-admin-muted">No users.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DraggableWindow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -12,6 +12,7 @@ const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => (
|
||||||
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
|
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
|
||||||
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
|
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
|
||||||
const AgentWindow = lazy(() => import('./AgentWindow').then(m => ({ default: m.AgentWindow })));
|
const AgentWindow = lazy(() => import('./AgentWindow').then(m => ({ default: m.AgentWindow })));
|
||||||
|
const AdminUsersWindow = lazy(() => import('./AdminUsersWindow').then(m => ({ default: m.AdminUsersWindow })));
|
||||||
import type { CharacterState } from '../../types';
|
import type { CharacterState } from '../../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -65,6 +66,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
|
||||||
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
|
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return <AgentWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
return <AgentWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||||
|
case 'adminusers':
|
||||||
|
return <AdminUsersWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
frontend/src/hooks/useCurrentUser.ts
Normal file
25
frontend/src/hooks/useCurrentUser.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getCurrentUser, type CurrentUser } from '../api/endpoints';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently-logged-in dashboard user, or null if not logged in /
|
||||||
|
* not yet loaded. Useful for conditionally showing admin-only UI bits.
|
||||||
|
*
|
||||||
|
* Fetches `/me` once on mount. Cheap — the endpoint just decodes the
|
||||||
|
* session cookie and returns {username, is_admin}.
|
||||||
|
*/
|
||||||
|
export function useCurrentUser(): { user: CurrentUser | null; loading: boolean } {
|
||||||
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getCurrentUser()
|
||||||
|
.then(u => { if (!cancelled) setUser(u); })
|
||||||
|
.catch(() => { if (!cancelled) setUser(null); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { user, loading };
|
||||||
|
}
|
||||||
|
|
@ -810,6 +810,122 @@
|
||||||
100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
|
100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar logout link — visually distinct from window-opener links ── */
|
||||||
|
.ml-tool-link-logout {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #d88;
|
||||||
|
border-top: 1px dashed #444;
|
||||||
|
padding-top: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.ml-tool-link-logout:hover { color: #f88; background: rgba(150, 60, 60, 0.15); }
|
||||||
|
|
||||||
|
/* ── Admin · Users window ──────────────────────────────── */
|
||||||
|
.ml-admin {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ml-admin-section {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.ml-admin-section h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #cfcfff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ml-admin-error {
|
||||||
|
background: #3a1c1c;
|
||||||
|
border: 1px solid #803333;
|
||||||
|
color: #ffaaaa;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.ml-admin-muted { color: #888; font-style: italic; }
|
||||||
|
.ml-admin-create {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ml-admin-create input[type=text],
|
||||||
|
.ml-admin-create input[type=password] {
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
flex: 1 1 140px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.ml-admin-create label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.ml-admin button {
|
||||||
|
background: #2a2a3a;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.ml-admin button:hover:not(:disabled) { background: #353550; border-color: #88f; }
|
||||||
|
.ml-admin button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.ml-admin-danger { color: #ffaaaa; border-color: #803333 !important; }
|
||||||
|
.ml-admin-danger:hover:not(:disabled) { background: #3a1c1c !important; }
|
||||||
|
.ml-admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.ml-admin-table th, .ml-admin-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.ml-admin-table th { color: #aaa; font-weight: 600; text-transform: uppercase; font-size: 0.68rem; letter-spacing: 0.04em; }
|
||||||
|
.ml-admin-table tbody tr:hover { background: rgba(255, 255, 255, 0.025); }
|
||||||
|
.ml-admin-toggle {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ml-admin-pw-edit {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ml-admin-pw-edit input {
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
border: 1px solid #88f;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile ───────────────────────────────────────────── */
|
/* ── Mobile ───────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ml-layout { flex-direction: column; }
|
.ml-layout { flex-direction: column; }
|
||||||
|
|
|
||||||
1
static/assets/AdminUsersWindow-C8y7Dpy1.js
Normal file
1
static/assets/AdminUsersWindow-C8y7Dpy1.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-3SHiQu4l.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":"–"})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-Dcc3Au5i.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};
|
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-3SHiQu4l.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{u as c,j as r,D as d}from"./index-Dcc3Au5i.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};
|
import{u as c,j as r,D as d}from"./index-3SHiQu4l.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{r as c,j as t,D as u,a as f}from"./index-Dcc3Au5i.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};
|
import{r as c,j as t,D as u,a as f}from"./index-3SHiQu4l.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{r as o,j as t,D as d}from"./index-Dcc3Au5i.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};
|
import{r as o,j as t,D as d}from"./index-3SHiQu4l.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};
|
||||||
|
|
@ -1 +1 @@
|
||||||
import{r as n,j as t,D as x,a as m}from"./index-Dcc3Au5i.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};
|
import{r as n,j as t,D as x,a as m}from"./index-3SHiQu4l.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};
|
||||||
34
static/assets/index-3SHiQu4l.js
Normal file
34
static/assets/index-3SHiQu4l.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,9 +8,9 @@
|
||||||
<link rel="preload" as="image" href="/dereth.png" />
|
<link rel="preload" as="image" href="/dereth.png" />
|
||||||
<link rel="preload" as="image" href="/icons/0600127E.png" />
|
<link rel="preload" as="image" href="/icons/0600127E.png" />
|
||||||
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
|
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
|
||||||
<script type="module" crossorigin src="/assets/index-Dcc3Au5i.js"></script>
|
<script type="module" crossorigin src="/assets/index-3SHiQu4l.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js">
|
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CQflOExa.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Hl9Lf_CI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue