This commit is contained in:
+116
@@ -0,0 +1,116 @@
|
||||
export const bankPageSize = 25;
|
||||
export const bankAttentionAgeMs = 5 * 60_000;
|
||||
|
||||
export interface BankQuery {
|
||||
view: 'accounts' | 'transactions' | 'pending';
|
||||
q: string;
|
||||
balance: '' | 'funded' | 'limit' | 'pending';
|
||||
sort: 'balance' | 'recent';
|
||||
game: '' | 't5' | 't6';
|
||||
server: string;
|
||||
operation: '' | 'deposit' | 'withdraw';
|
||||
from: string;
|
||||
to: string;
|
||||
offset: number;
|
||||
player: string;
|
||||
historyOffset: number;
|
||||
}
|
||||
|
||||
export function readBankQuery(params: URLSearchParams): BankQuery {
|
||||
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
|
||||
const value = params.get(key) || fallback;
|
||||
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
|
||||
return value as T;
|
||||
}
|
||||
function text(key: string, max: number) {
|
||||
const value = (params.get(key) ?? '').trim();
|
||||
if (value.length > max) throw new Error(`${key} is too long.`);
|
||||
return value;
|
||||
}
|
||||
function offset(key: string) {
|
||||
const value = Number(params.get(key) || 0);
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid page offset.');
|
||||
return Math.floor(value / bankPageSize) * bankPageSize;
|
||||
}
|
||||
function date(key: string) {
|
||||
const value = text(key, 10);
|
||||
if (
|
||||
value &&
|
||||
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
||||
!Number.isFinite(Date.parse(value)) ||
|
||||
new Date(value).toISOString().slice(0, 10) !== value)
|
||||
)
|
||||
throw new Error('Use a valid date in YYYY-MM-DD format.');
|
||||
return value;
|
||||
}
|
||||
const from = date('from');
|
||||
const to = date('to');
|
||||
if (from && to && from > to) throw new Error('The end date must follow the start date.');
|
||||
return {
|
||||
view: choice('view', ['accounts', 'transactions', 'pending'], 'accounts'),
|
||||
q: text('q', 100),
|
||||
balance: choice('balance', ['', 'funded', 'limit', 'pending'], ''),
|
||||
sort: choice('sort', ['balance', 'recent'], 'balance'),
|
||||
game: choice('game', ['', 't5', 't6'], ''),
|
||||
server: text('server', 256),
|
||||
operation: choice('operation', ['', 'deposit', 'withdraw'], ''),
|
||||
from,
|
||||
to,
|
||||
offset: offset('offset'),
|
||||
player: text('player', 128),
|
||||
historyOffset: offset('historyOffset')
|
||||
};
|
||||
}
|
||||
|
||||
export interface BankIdentity {
|
||||
guid: string;
|
||||
name: string | null;
|
||||
}
|
||||
export interface BankAccount extends BankIdentity {
|
||||
balance: number;
|
||||
available: number;
|
||||
pendingOperation: 'deposit' | 'withdraw' | null;
|
||||
pendingAmount: number | null;
|
||||
lastActivity: string | null;
|
||||
lastAmount: number | null;
|
||||
}
|
||||
export interface BankTransfer extends BankIdentity {
|
||||
id: number;
|
||||
server: string;
|
||||
game: string | null;
|
||||
matchId: number | null;
|
||||
matchAvailable: number;
|
||||
amount: number;
|
||||
balance: number;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface BankPending extends BankIdentity {
|
||||
requestId: string;
|
||||
server: string;
|
||||
game: string | null;
|
||||
matchId: number | null;
|
||||
matchAvailable: number;
|
||||
operation: 'deposit' | 'withdraw';
|
||||
amount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface BankPage<T> {
|
||||
entries: T[];
|
||||
total: number;
|
||||
offset: number;
|
||||
}
|
||||
export interface BankOverview {
|
||||
query: BankQuery;
|
||||
maximum: number | null;
|
||||
checkedAt: string;
|
||||
summary: { totalBanked: number; fundedAccounts: number; transfers24h: number; pending: number };
|
||||
servers: string[];
|
||||
accounts: BankPage<BankAccount>;
|
||||
transactions: BankPage<BankTransfer>;
|
||||
pending: BankPage<BankPending>;
|
||||
detail: {
|
||||
account: BankAccount;
|
||||
pending: BankPending | null;
|
||||
history: BankPage<BankTransfer>;
|
||||
} | null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const origin = 'https://electric-cherry.invalid';
|
||||
const pagePath =
|
||||
/^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||
/^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||
|
||||
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
||||
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
||||
@@ -60,6 +60,7 @@ export function returnLabel(href: string): string {
|
||||
maintenance: 'Maintenance',
|
||||
provisioning: 'Server queues',
|
||||
leaderboard: 'Leaderboard',
|
||||
bank: 'Bank',
|
||||
matches: 'Match History',
|
||||
metrics: 'Metrics'
|
||||
};
|
||||
|
||||
@@ -78,6 +78,7 @@ export const navigationGroups: NavigationGroup[] = [
|
||||
items: [
|
||||
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
||||
{ label: 'Matches', href: '/matches' },
|
||||
{ label: 'Bank', href: '/bank' },
|
||||
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { bankMaximum } from './bank.js';
|
||||
import {
|
||||
bankPageSize,
|
||||
type BankAccount,
|
||||
type BankOverview,
|
||||
type BankPage,
|
||||
type BankPending,
|
||||
type BankQuery,
|
||||
type BankTransfer
|
||||
} from '../../bank.js';
|
||||
|
||||
// A GUID owns one account even when telemetry has identities in both games.
|
||||
const identities = `WITH identities AS (
|
||||
SELECT guid, name FROM (
|
||||
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS rank
|
||||
FROM leaderboard_players WHERE game IN ('t5', 't6')
|
||||
) WHERE rank = 1
|
||||
)`;
|
||||
const accounts = `${identities}, account_guids AS (
|
||||
SELECT guid FROM bank_accounts UNION SELECT guid FROM bank_requests WHERE state = 'prepared'
|
||||
), accounts AS (
|
||||
SELECT g.guid, p.name, COALESCE(a.balance, 0) AS balance,
|
||||
COALESCE(a.balance, 0) - CASE WHEN r.operation = 'withdraw' THEN r.amount ELSE 0 END AS available,
|
||||
r.operation AS pendingOperation, r.amount AS pendingAmount,
|
||||
l.created_at AS lastActivity, l.amount AS lastAmount
|
||||
FROM account_guids g
|
||||
LEFT JOIN bank_accounts a ON a.guid = g.guid
|
||||
LEFT JOIN identities p ON p.guid = g.guid
|
||||
LEFT JOIN bank_requests r ON r.guid = g.guid AND r.state = 'prepared'
|
||||
LEFT JOIN bank_ledger l ON l.id = (SELECT id FROM bank_ledger WHERE guid = g.guid ORDER BY id DESC LIMIT 1)
|
||||
)`;
|
||||
// Canonical server names retain the game after match retention or server removal.
|
||||
const game = (alias: string) => `COALESCE(m.game, CASE
|
||||
WHEN substr(${alias}.server, 1, 3) = 't5_' THEN 't5'
|
||||
WHEN substr(${alias}.server, 1, 3) = 't6_' THEN 't6' END)`;
|
||||
const transfers = `${identities}, transfers AS (
|
||||
SELECT l.id, l.guid, p.name, l.server, ${game('l')} AS game,
|
||||
l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable,
|
||||
l.amount, l.balance, l.created_at AS createdAt
|
||||
FROM bank_ledger l LEFT JOIN identities p ON p.guid = l.guid
|
||||
LEFT JOIN leaderboard_matches m ON m.id = l.match_id
|
||||
)`;
|
||||
const pending = `${identities}, pending AS (
|
||||
SELECT r.guid, p.name, r.request_id AS requestId, r.server, ${game('r')} AS game,
|
||||
r.match_id AS matchId, m.id IS NOT NULL AS matchAvailable,
|
||||
r.operation, r.amount, r.created_at AS createdAt
|
||||
FROM bank_requests r LEFT JOIN identities p ON p.guid = r.guid
|
||||
LEFT JOIN leaderboard_matches m ON m.id = r.match_id WHERE r.state = 'prepared'
|
||||
)`;
|
||||
const emptyPage = <T>(): BankPage<T> => ({ entries: [], total: 0, offset: 0 });
|
||||
|
||||
export function bankOverview(query: BankQuery, now = new Date()): BankOverview {
|
||||
const handle = db();
|
||||
return handle.transaction(() => {
|
||||
let maximum: number | null;
|
||||
try {
|
||||
maximum = bankMaximum();
|
||||
} catch {
|
||||
// Invalid deposit configuration must not hide balances or recovery information.
|
||||
maximum = null;
|
||||
}
|
||||
const checkedAt = now.toISOString();
|
||||
const summary = handle
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COALESCE(SUM(balance), 0) FROM bank_accounts) AS totalBanked,
|
||||
(SELECT COUNT(*) FROM bank_accounts WHERE balance > 0) AS fundedAccounts,
|
||||
(SELECT COUNT(*) FROM bank_ledger WHERE created_at >= ? AND created_at <= ?) AS transfers24h,
|
||||
(SELECT COUNT(*) FROM bank_requests WHERE state = 'prepared') AS pending
|
||||
`
|
||||
)
|
||||
.get(
|
||||
new Date(now.getTime() - 86_400_000).toISOString(),
|
||||
checkedAt
|
||||
) as BankOverview['summary'];
|
||||
const servers = (
|
||||
handle
|
||||
.prepare(
|
||||
`SELECT server FROM bank_ledger
|
||||
UNION SELECT server FROM bank_requests WHERE state = 'prepared' ORDER BY server`
|
||||
)
|
||||
.all() as { server: string }[]
|
||||
).map((row) => row.server);
|
||||
|
||||
function page<T>(
|
||||
cte: string,
|
||||
table: string,
|
||||
where: string[],
|
||||
values: (string | number)[],
|
||||
order: string,
|
||||
requested: number
|
||||
): BankPage<T> {
|
||||
const scope = `FROM ${table}${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`;
|
||||
const { total } = handle
|
||||
.prepare(`${cte} SELECT COUNT(*) AS total ${scope}`)
|
||||
.get(...values) as { total: number };
|
||||
const offset = Math.min(
|
||||
requested,
|
||||
Math.max(0, Math.floor((total - 1) / bankPageSize) * bankPageSize)
|
||||
);
|
||||
const entries = handle
|
||||
.prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`)
|
||||
.all(...values, bankPageSize, offset) as T[];
|
||||
return { entries, total, offset };
|
||||
}
|
||||
const where: string[] = [];
|
||||
const values: (string | number)[] = [];
|
||||
if (query.q) {
|
||||
where.push(
|
||||
"(instr(lower(COALESCE(name, '')), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0)"
|
||||
);
|
||||
values.push(query.q, query.q);
|
||||
}
|
||||
let accountPage = emptyPage<BankAccount>();
|
||||
let transactionPage = emptyPage<BankTransfer>();
|
||||
let pendingPage = emptyPage<BankPending>();
|
||||
if (query.view === 'accounts') {
|
||||
if (query.balance === 'funded') where.push('balance > 0');
|
||||
if (query.balance === 'pending') where.push('pendingOperation IS NOT NULL');
|
||||
if (query.balance === 'limit') {
|
||||
where.push('balance >= ?');
|
||||
values.push(maximum ?? 1_000_001);
|
||||
}
|
||||
accountPage = page(
|
||||
accounts,
|
||||
'accounts',
|
||||
where,
|
||||
values,
|
||||
query.sort === 'recent' ? 'lastActivity DESC, guid' : 'balance DESC, guid',
|
||||
query.offset
|
||||
);
|
||||
} else {
|
||||
if (query.game) {
|
||||
where.push('game = ?');
|
||||
values.push(query.game);
|
||||
}
|
||||
if (query.server) {
|
||||
where.push('server = ?');
|
||||
values.push(query.server);
|
||||
}
|
||||
if (query.operation) {
|
||||
where.push(
|
||||
query.view === 'pending'
|
||||
? 'operation = ?'
|
||||
: query.operation === 'deposit'
|
||||
? 'amount > 0'
|
||||
: 'amount < 0'
|
||||
);
|
||||
if (query.view === 'pending') values.push(query.operation);
|
||||
}
|
||||
if (query.from) {
|
||||
where.push('createdAt >= ?');
|
||||
values.push(`${query.from}T00:00:00.000Z`);
|
||||
}
|
||||
if (query.to) {
|
||||
where.push('createdAt <= ?');
|
||||
values.push(`${query.to}T23:59:59.999Z`);
|
||||
}
|
||||
if (query.view === 'transactions')
|
||||
transactionPage = page(
|
||||
transfers,
|
||||
'transfers',
|
||||
where,
|
||||
values,
|
||||
'createdAt DESC, id DESC',
|
||||
query.offset
|
||||
);
|
||||
else
|
||||
pendingPage = page(
|
||||
pending,
|
||||
'pending',
|
||||
where,
|
||||
values,
|
||||
'createdAt, server, requestId',
|
||||
query.offset
|
||||
);
|
||||
}
|
||||
let detail: BankOverview['detail'] = null;
|
||||
if (query.player) {
|
||||
let account = handle
|
||||
.prepare(`${accounts} SELECT * FROM accounts WHERE guid = ?`)
|
||||
.get(query.player) as BankAccount | undefined;
|
||||
if (!account) {
|
||||
const identity = handle
|
||||
.prepare(`${identities} SELECT * FROM identities WHERE guid = ?`)
|
||||
.get(query.player) as { guid: string; name: string } | undefined;
|
||||
if (identity)
|
||||
account = {
|
||||
...identity,
|
||||
balance: 0,
|
||||
available: 0,
|
||||
pendingOperation: null,
|
||||
pendingAmount: null,
|
||||
lastActivity: null,
|
||||
lastAmount: null
|
||||
};
|
||||
}
|
||||
if (account)
|
||||
detail = {
|
||||
account,
|
||||
pending:
|
||||
(handle
|
||||
.prepare(`${pending} SELECT * FROM pending WHERE guid = ?`)
|
||||
.get(query.player) as BankPending | undefined) ?? null,
|
||||
history: page(
|
||||
transfers,
|
||||
'transfers',
|
||||
['guid = ?'],
|
||||
[query.player],
|
||||
'createdAt DESC, id DESC',
|
||||
query.historyOffset
|
||||
)
|
||||
};
|
||||
}
|
||||
return {
|
||||
query,
|
||||
maximum,
|
||||
checkedAt,
|
||||
summary,
|
||||
servers,
|
||||
accounts: accountPage,
|
||||
transactions: transactionPage,
|
||||
pending: pendingPage,
|
||||
detail
|
||||
};
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { bankPageSize } from '$lib/bank.js';
|
||||
import Button from '../primitives/Button.svelte';
|
||||
let {
|
||||
total,
|
||||
offset,
|
||||
disabled = false,
|
||||
onchange,
|
||||
label = 'results'
|
||||
}: {
|
||||
total: number;
|
||||
offset: number;
|
||||
disabled?: boolean;
|
||||
onchange: (offset: number) => void;
|
||||
label?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<nav class="pagination" aria-label={`${label} pagination`}>
|
||||
<span role="status"
|
||||
>{total ? offset + 1 : 0}–{Math.min(offset + bankPageSize, total)} of {total.toLocaleString()}
|
||||
{label}</span
|
||||
>
|
||||
{#if total > bankPageSize}
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled || offset === 0}
|
||||
onclick={() => onchange(offset - bankPageSize)}>Previous</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled || offset + bankPageSize >= total}
|
||||
onclick={() => onchange(offset + bankPageSize)}>Next</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.pagination div {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { BankPending } from '$lib/bank.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import CopyValue from '../CopyValue.svelte';
|
||||
import RelativeTime from '../RelativeTime.svelte';
|
||||
import ServerIdentity from '../ServerIdentity.svelte';
|
||||
let { transfer, source }: { transfer: BankPending; source: URL } = $props();
|
||||
</script>
|
||||
|
||||
<div class="pending-details">
|
||||
<p>
|
||||
Waiting for the game server to confirm or cancel this {transfer.operation === 'deposit'
|
||||
? 'deposit'
|
||||
: 'withdrawal'}.
|
||||
</p>
|
||||
<p>
|
||||
{transfer.operation === 'deposit'
|
||||
? 'These points have not been added to the bank balance. Deposit capacity is reserved until the transfer settles.'
|
||||
: 'These points are reserved and cannot be withdrawn again until the transfer settles.'}
|
||||
</p>
|
||||
<p>
|
||||
Restore the server’s connection and allow it to retry. Pending transfers do not expire
|
||||
automatically.
|
||||
</p>
|
||||
<div>
|
||||
<ServerIdentity name={transfer.server} /> · <RelativeTime value={transfer.createdAt} />
|
||||
</div>
|
||||
{#if transfer.matchAvailable}<a href={contextHref(`/matches/${transfer.matchId}`, source)}
|
||||
>View match #{transfer.matchId}</a
|
||||
>{:else if transfer.matchId}<span>Match #{transfer.matchId} · History expired</span>{/if}
|
||||
<details>
|
||||
<summary>Transfer details</summary><CopyValue
|
||||
value={transfer.requestId}
|
||||
label="Request ID"
|
||||
/><CopyValue value={transfer.server} label="Server ID" />
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pending-details {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
summary {
|
||||
padding-block: var(--space-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { BankTransfer } from '$lib/bank.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import RelativeTime from '../RelativeTime.svelte';
|
||||
import ServerIdentity from '../ServerIdentity.svelte';
|
||||
import EmptyState from '../primitives/EmptyState.svelte';
|
||||
|
||||
let {
|
||||
entries,
|
||||
source,
|
||||
onPlayer,
|
||||
compact = false
|
||||
}: {
|
||||
entries: BankTransfer[];
|
||||
source: URL;
|
||||
onPlayer?: (guid: string) => void;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
</script>
|
||||
|
||||
{#if entries.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||
<div class="bank-table-scroll" role="region" aria-label="Bank transactions" tabindex="0">
|
||||
<table class="bank-table" class:compact>
|
||||
<caption class="sr-only">Completed bank transfers</caption>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Time</th>{#if onPlayer}<th scope="col">Player</th>{/if}<th
|
||||
scope="col">Transfer</th
|
||||
><th scope="col">Balance after</th><th scope="col">Server / match</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each entries as entry (entry.id)}
|
||||
<tr>
|
||||
<td><RelativeTime value={entry.createdAt} /></td>
|
||||
{#if onPlayer}<td
|
||||
><button class="bank-player" onclick={() => onPlayer?.(entry.guid)}
|
||||
>{entry.name ?? entry.guid}</button
|
||||
><small class="bank-id">{entry.guid}</small></td
|
||||
>{/if}
|
||||
<td class="bank-number"
|
||||
><strong
|
||||
>{entry.amount > 0 ? '+' : '−'}{number.format(
|
||||
Math.abs(entry.amount)
|
||||
)}</strong
|
||||
><small>{entry.amount > 0 ? 'Deposit' : 'Withdrawal'}</small></td
|
||||
>
|
||||
<td class="bank-number">{number.format(entry.balance)}</td>
|
||||
<td
|
||||
><ServerIdentity name={entry.server} /><small
|
||||
>{#if entry.matchAvailable}<a
|
||||
href={contextHref(`/matches/${entry.matchId}`, source)}
|
||||
>Match #{entry.matchId}</a
|
||||
>{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}Match
|
||||
unavailable{/if}</small
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
title="No transactions found"
|
||||
description="Completed deposits and withdrawals appear here. Try changing your filters if you expected to see more."
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,68 @@
|
||||
.bank-table-scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.bank-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.bank-table th {
|
||||
color: var(--text-tertiary);
|
||||
background: var(--surface-sunken);
|
||||
font-weight: var(--weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bank-table th,
|
||||
.bank-table td {
|
||||
padding: var(--space-4);
|
||||
vertical-align: top;
|
||||
}
|
||||
.bank-table tbody tr + tr {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.bank-table td {
|
||||
min-width: 130px;
|
||||
}
|
||||
.bank-table small {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.bank-table .bank-id {
|
||||
font-family: var(--font-mono);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bank-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bank-player {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--accent-primary-hover);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bank-player:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bank-table.compact th,
|
||||
.bank-table.compact td {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.bank-table.compact td {
|
||||
min-width: 100px;
|
||||
}
|
||||
.bank-table .bank-warning {
|
||||
color: var(--state-warning);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { readBankQuery } from '$lib/bank.js';
|
||||
import { bankOverview } from '$lib/server/fleet/bank-overview.js';
|
||||
|
||||
export function GET({ url }: { url: URL }) {
|
||||
let query;
|
||||
try {
|
||||
query = readBankQuery(url.searchParams);
|
||||
} catch (cause) {
|
||||
error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.');
|
||||
}
|
||||
return json(bankOverview(query), { headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { readBankQuery } from '$lib/bank.js';
|
||||
import { bankOverview } from '$lib/server/fleet/bank-overview.js';
|
||||
|
||||
export function load({ url }: { url: URL }) {
|
||||
let query;
|
||||
try {
|
||||
query = readBankQuery(url.searchParams);
|
||||
} catch (cause) {
|
||||
error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.');
|
||||
}
|
||||
return { report: bankOverview(query) };
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { page, navigating } from '$app/stores';
|
||||
import { bankAttentionAgeMs, type BankOverview, type BankQuery } from '$lib/bank.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import BankPagination from '$ui/components/bank/BankPagination.svelte';
|
||||
import BankTransfers from '$ui/components/bank/BankTransfers.svelte';
|
||||
import BankPendingDetails from '$ui/components/bank/BankPendingDetails.svelte';
|
||||
import '$ui/styles/bank.css';
|
||||
|
||||
let { data }: { data: import('./$types').PageData } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
let snapshot = $state<BankOverview | null>(null);
|
||||
const report = $derived(snapshot ?? data.report);
|
||||
let filters = $state<BankQuery>(untrack(() => ({ ...data.report.query })));
|
||||
let refreshError = $state('');
|
||||
let navigationError = $state('');
|
||||
let updatedAt = $state<number | null>(null);
|
||||
let refreshing = $state(false);
|
||||
let controller: AbortController | undefined;
|
||||
let generation = 0;
|
||||
const busy = $derived(!!$navigating);
|
||||
const results = $derived(report[report.query.view]);
|
||||
const tabs = $derived([
|
||||
{ id: 'accounts', label: 'Accounts' },
|
||||
{ id: 'transactions', label: 'Transactions' },
|
||||
{ id: 'pending', label: 'Pending', badge: report.summary.pending || null }
|
||||
]);
|
||||
const serverOptions = $derived([
|
||||
{ value: '', label: 'All servers' },
|
||||
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
|
||||
(server) => ({ value: server, label: serverTitle(server) })
|
||||
)
|
||||
]);
|
||||
$effect(() => {
|
||||
const initial = data.report;
|
||||
untrack(() => {
|
||||
snapshot = null;
|
||||
filters = { ...initial.query };
|
||||
refreshError = '';
|
||||
});
|
||||
});
|
||||
function cancelRefresh() {
|
||||
generation++;
|
||||
controller?.abort();
|
||||
refreshing = false;
|
||||
}
|
||||
beforeNavigate(cancelRefresh);
|
||||
afterNavigate(() => {
|
||||
updatedAt = Date.now();
|
||||
navigationError = '';
|
||||
});
|
||||
async function refresh(signal?: AbortSignal) {
|
||||
if (busy || refreshing) return;
|
||||
const current = ++generation;
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
const abort = () => request.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) request.abort();
|
||||
const timeout = setTimeout(abort, 30_000);
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch(`/api/bank${$page.url.search}`, {
|
||||
signal: request.signal
|
||||
});
|
||||
if (!response.ok) throw new Error('Bank refresh failed.');
|
||||
const next: BankOverview = await response.json();
|
||||
if (current !== generation || request.signal.aborted) return;
|
||||
snapshot = next;
|
||||
updatedAt = Date.now();
|
||||
refreshError = '';
|
||||
} catch {
|
||||
if (current === generation) refreshError = 'Bank updates paused.';
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (current === generation) refreshing = false;
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
updatedAt = Date.now();
|
||||
const stop = pollVisible(refresh, 30_000, false);
|
||||
return () => {
|
||||
stop();
|
||||
cancelRefresh();
|
||||
};
|
||||
});
|
||||
async function navigate(changes: Record<string, string | number | null>) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, String(value));
|
||||
}
|
||||
navigationError = '';
|
||||
try {
|
||||
await goto(`/bank?${params}`, { keepFocus: true, noScroll: true });
|
||||
} catch {
|
||||
navigationError = 'Could not load this bank view. Please try again.';
|
||||
}
|
||||
}
|
||||
function changeView(view: string) {
|
||||
void navigate({
|
||||
view,
|
||||
offset: null,
|
||||
balance: null,
|
||||
sort: null,
|
||||
game: null,
|
||||
server: null,
|
||||
operation: null,
|
||||
from: null,
|
||||
to: null
|
||||
});
|
||||
}
|
||||
function applyFilters(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
q: filters.q,
|
||||
balance: filters.balance,
|
||||
sort: filters.sort,
|
||||
game: filters.game,
|
||||
server: filters.server,
|
||||
operation: filters.operation,
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
offset: null
|
||||
});
|
||||
}
|
||||
const openPlayer = (guid: string) => {
|
||||
void navigate({ player: guid, historyOffset: null });
|
||||
};
|
||||
function setDrawerOpen(open: boolean) {
|
||||
if (!open && report.query.player && !busy)
|
||||
void navigate({ player: null, historyOffset: null });
|
||||
}
|
||||
const isOlder = (createdAt: string) =>
|
||||
Date.parse(report.checkedAt) - Date.parse(createdAt) >= bankAttentionAgeMs;
|
||||
const capacity = (balance: number) =>
|
||||
report.maximum ? Math.round((balance / report.maximum) * 100) : 0;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Bank · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="bank-page">
|
||||
<SectionHeader
|
||||
title="Bank"
|
||||
eyebrow="Players"
|
||||
subtitle="Shared player balances across Black Ops I & II."
|
||||
>
|
||||
{#snippet actions()}<Freshness
|
||||
{updatedAt}
|
||||
error={refreshError}
|
||||
intervalLabel="every 30 s"
|
||||
retryDisabled={refreshing || busy}
|
||||
onRetry={() => void refresh()}
|
||||
/><Button size="sm" loading={refreshing} disabled={busy} onclick={() => void refresh()}
|
||||
>Refresh</Button
|
||||
>{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<dl class="summary">
|
||||
<div>
|
||||
<dt>Total banked</dt>
|
||||
<dd>{number.format(report.summary.totalBanked)} <small>pts</small></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Funded accounts</dt>
|
||||
<dd>{number.format(report.summary.fundedAccounts)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Transfers · last 24 hours</dt>
|
||||
<dd>{number.format(report.summary.transfers24h)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pending transfers</dt>
|
||||
<dd>
|
||||
<button class="summary-link" onclick={() => changeView('pending')}
|
||||
>{number.format(report.summary.pending)}</button
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if report.maximum === null}<p class="notice">
|
||||
The bank deposit limit is unavailable. Balances and transfer history remain visible; new
|
||||
deposits are disabled until the configuration is corrected.
|
||||
</p>{/if}
|
||||
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
|
||||
<Tabs
|
||||
{tabs}
|
||||
value={report.query.view}
|
||||
onchange={changeView}
|
||||
ariaLabel="Bank views"
|
||||
panelId="bank-results"
|
||||
idPrefix="bank-tab"
|
||||
/>
|
||||
|
||||
<div
|
||||
id="bank-results"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`bank-tab-${report.query.view}`}
|
||||
aria-busy={busy}
|
||||
class="results"
|
||||
>
|
||||
<form class="filters" method="GET" action="/bank" onsubmit={applyFilters}>
|
||||
<input type="hidden" name="view" value={report.query.view} />
|
||||
<div class="search">
|
||||
<Field label="Player name or GUID"
|
||||
><Input
|
||||
name="q"
|
||||
type="search"
|
||||
bind:value={filters.q}
|
||||
maxlength={100}
|
||||
placeholder="Search players…"
|
||||
/></Field
|
||||
>
|
||||
</div>
|
||||
{#if report.query.view === 'accounts'}
|
||||
<Field label="Accounts"
|
||||
><Select
|
||||
name="balance"
|
||||
bind:value={filters.balance}
|
||||
options={[
|
||||
{ value: '', label: 'All accounts' },
|
||||
{ value: 'funded', label: 'Funded accounts' },
|
||||
{
|
||||
value: 'limit',
|
||||
label: 'At or above limit',
|
||||
disabled: report.maximum === null
|
||||
},
|
||||
{ value: 'pending', label: 'Pending transfers' }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
<Field label="Sort by"
|
||||
><Select
|
||||
name="sort"
|
||||
bind:value={filters.sort}
|
||||
options={[
|
||||
{ value: 'balance', label: 'Highest balance' },
|
||||
{ value: 'recent', label: 'Recent activity' }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
{:else}
|
||||
<Field label="Game"
|
||||
><Select
|
||||
name="game"
|
||||
bind:value={filters.game}
|
||||
options={[
|
||||
{ value: '', label: 'All games' },
|
||||
{ value: 't5', label: 'Black Ops I' },
|
||||
{ value: 't6', label: 'Black Ops II' }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
<Field label="Server"
|
||||
><Select
|
||||
name="server"
|
||||
bind:value={filters.server}
|
||||
options={serverOptions}
|
||||
/></Field
|
||||
>
|
||||
<Field label="Transfer"
|
||||
><Select
|
||||
name="operation"
|
||||
bind:value={filters.operation}
|
||||
options={[
|
||||
{ value: '', label: 'All transfers' },
|
||||
{ value: 'deposit', label: 'Deposits' },
|
||||
{ value: 'withdraw', label: 'Withdrawals' }
|
||||
]}
|
||||
/></Field
|
||||
>
|
||||
<Field label="From (UTC)"
|
||||
><Input
|
||||
name="from"
|
||||
type="date"
|
||||
bind:value={filters.from}
|
||||
max={filters.to || undefined}
|
||||
/></Field
|
||||
>
|
||||
<Field label="Through (UTC)"
|
||||
><Input
|
||||
name="to"
|
||||
type="date"
|
||||
bind:value={filters.to}
|
||||
min={filters.from || undefined}
|
||||
/></Field
|
||||
>
|
||||
{/if}
|
||||
<div class="filter-actions">
|
||||
<Button type="submit" disabled={busy}>Apply</Button><Button
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onclick={() => {
|
||||
void navigate({
|
||||
q: null,
|
||||
balance: null,
|
||||
sort: null,
|
||||
game: null,
|
||||
server: null,
|
||||
operation: null,
|
||||
from: null,
|
||||
to: null,
|
||||
offset: null
|
||||
});
|
||||
}}>Reset</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if report.query.view === 'accounts'}
|
||||
<p class="hint">
|
||||
Balances are shared across both games.{report.maximum !== null
|
||||
? ` Account limit: ${number.format(report.maximum)} points.`
|
||||
: ''}
|
||||
</p>
|
||||
{#if report.accounts.entries.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||
<div
|
||||
class="bank-table-scroll"
|
||||
role="region"
|
||||
aria-label="Bank accounts"
|
||||
tabindex="0"
|
||||
>
|
||||
<table class="bank-table">
|
||||
<caption class="sr-only">Player bank accounts</caption>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Player</th><th scope="col">Balance</th><th
|
||||
scope="col">Bank capacity</th
|
||||
><th scope="col">Last activity</th><th scope="col">Status</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each report.accounts.entries as account (account.guid)}
|
||||
<tr>
|
||||
<td
|
||||
><button
|
||||
class="bank-player"
|
||||
onclick={() => openPlayer(account.guid)}
|
||||
>{account.name ?? account.guid}</button
|
||||
><small class="bank-id">{account.guid}</small></td
|
||||
>
|
||||
<td class="bank-number"
|
||||
><strong>{number.format(account.balance)} pts</strong
|
||||
>{#if account.available !== account.balance}<small
|
||||
>{number.format(account.available)} available</small
|
||||
>{/if}</td
|
||||
>
|
||||
<td
|
||||
>{#if report.maximum !== null}<div class="capacity">
|
||||
<meter
|
||||
min="0"
|
||||
max={report.maximum}
|
||||
value={Math.min(
|
||||
account.balance,
|
||||
report.maximum
|
||||
)}
|
||||
aria-label={`${account.name ?? account.guid} bank capacity`}
|
||||
></meter><span>{capacity(account.balance)}%</span>
|
||||
</div>{:else}Unavailable{/if}</td
|
||||
>
|
||||
<td
|
||||
>{#if account.lastActivity}<span
|
||||
>{(account.lastAmount ?? 0) > 0
|
||||
? 'Deposit'
|
||||
: 'Withdrawal'}</span
|
||||
><small
|
||||
><RelativeTime
|
||||
value={account.lastActivity}
|
||||
/></small
|
||||
>{:else}No completed transfers{/if}</td
|
||||
>
|
||||
<td
|
||||
>{#if account.pendingOperation}<span
|
||||
>Pending {account.pendingOperation === 'deposit'
|
||||
? 'deposit'
|
||||
: 'withdrawal'}</span
|
||||
><small
|
||||
>{number.format(account.pendingAmount ?? 0)} pts</small
|
||||
>{:else if report.maximum !== null && account.balance >= report.maximum}At
|
||||
limit{:else}Available{/if}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{:else}<EmptyState
|
||||
title="No bank accounts found"
|
||||
description="Accounts appear when players deposit points in game. Try changing your filters if you expected to see more."
|
||||
/>{/if}
|
||||
{:else if report.query.view === 'transactions'}
|
||||
<p class="hint">
|
||||
Completed deposits and withdrawals across your fleet. Game filters apply to
|
||||
activity; account balances remain shared.
|
||||
</p>
|
||||
<BankTransfers
|
||||
entries={report.transactions.entries}
|
||||
source={$page.url}
|
||||
onPlayer={openPlayer}
|
||||
/>
|
||||
{:else}
|
||||
<p class="hint">
|
||||
Oldest transfers first. Transfers waiting at least five minutes are highlighted for
|
||||
attention.
|
||||
</p>
|
||||
{#if report.pending.entries.length}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
|
||||
<div
|
||||
class="bank-table-scroll"
|
||||
role="region"
|
||||
aria-label="Pending bank transfers"
|
||||
tabindex="0"
|
||||
>
|
||||
<table class="bank-table">
|
||||
<caption class="sr-only">Transfers awaiting settlement</caption>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Player</th><th scope="col">Transfer</th><th
|
||||
scope="col">Server</th
|
||||
><th scope="col">Waiting since</th><th scope="col">Details</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each report.pending.entries as transfer (transfer.guid)}
|
||||
<tr>
|
||||
<td
|
||||
><button
|
||||
class="bank-player"
|
||||
onclick={() => openPlayer(transfer.guid)}
|
||||
>{transfer.name ?? transfer.guid}</button
|
||||
><small class="bank-id">{transfer.guid}</small></td
|
||||
>
|
||||
<td class="bank-number"
|
||||
><strong>{number.format(transfer.amount)} pts</strong><small
|
||||
>{transfer.operation === 'deposit'
|
||||
? 'Deposit'
|
||||
: 'Withdrawal'}</small
|
||||
></td
|
||||
>
|
||||
<td><ServerIdentity name={transfer.server} /></td>
|
||||
<td
|
||||
><RelativeTime
|
||||
value={transfer.createdAt}
|
||||
/>{#if isOlder(transfer.createdAt)}<small
|
||||
class="bank-warning">Waiting over 5 minutes</small
|
||||
>{/if}</td
|
||||
>
|
||||
<td
|
||||
><details>
|
||||
<summary>Why pending?</summary>
|
||||
<div class="pending-expanded">
|
||||
<BankPendingDetails {transfer} source={$page.url} />
|
||||
</div>
|
||||
</details></td
|
||||
>
|
||||
</tr>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{:else}<EmptyState
|
||||
title="No pending transfers"
|
||||
description="No transfers are waiting for settlement in this view."
|
||||
/>{/if}
|
||||
{/if}
|
||||
<BankPagination
|
||||
total={results.total}
|
||||
offset={results.offset}
|
||||
disabled={busy}
|
||||
onchange={(offset) => {
|
||||
void navigate({ offset });
|
||||
}}
|
||||
label={report.query.view === 'accounts' ? 'accounts' : 'transfers'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
drawer
|
||||
bind:open={() => !!report.query.player, setDrawerOpen}
|
||||
title={report.detail?.account.name ?? (report.query.player || 'Player bank')}
|
||||
description="Shared bank account across Black Ops I & II."
|
||||
>
|
||||
{#if report.detail}
|
||||
{@const detail = report.detail}
|
||||
<div class="account-detail">
|
||||
<Freshness
|
||||
{updatedAt}
|
||||
error={refreshError}
|
||||
intervalLabel="every 30 s"
|
||||
retryDisabled={refreshing || busy}
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
|
||||
<CopyValue value={detail.account.guid} label="Player GUID" />
|
||||
<dl class="detail-summary">
|
||||
<div>
|
||||
<dt>Balance</dt>
|
||||
<dd>{number.format(detail.account.balance)} pts</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Available</dt>
|
||||
<dd>{number.format(detail.account.available)} pts</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if detail.account.name}<Button
|
||||
size="sm"
|
||||
href={contextHref(
|
||||
`/leaderboard/players/${encodeURIComponent(detail.account.guid)}`,
|
||||
$page.url
|
||||
)}>View player profile</Button
|
||||
>{/if}
|
||||
{#if detail.pending}<section class="pending-card">
|
||||
<h3>
|
||||
Pending {detail.pending.operation === 'deposit' ? 'deposit' : 'withdrawal'} ·
|
||||
{number.format(detail.pending.amount)} pts
|
||||
</h3>
|
||||
<BankPendingDetails transfer={detail.pending} source={$page.url} />
|
||||
</section>{/if}
|
||||
<h3>Transaction history</h3>
|
||||
<BankTransfers entries={detail.history.entries} source={$page.url} compact />
|
||||
<BankPagination
|
||||
total={detail.history.total}
|
||||
offset={detail.history.offset}
|
||||
disabled={busy}
|
||||
onchange={(historyOffset) => {
|
||||
void navigate({ historyOffset });
|
||||
}}
|
||||
label="player transfers"
|
||||
/>
|
||||
</div>
|
||||
{:else}<EmptyState
|
||||
title="Player bank not found"
|
||||
description="There is no bank or player record for this GUID."
|
||||
/>{/if}
|
||||
{#snippet actions()}<Button onclick={() => setDrawerOpen(false)}>Close</Button>{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.bank-page,
|
||||
.results,
|
||||
.account-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
.summary > div {
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
dd {
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
dd small {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-regular);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.summary-link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--accent-primary-hover);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.search {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.capacity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
meter {
|
||||
width: 100px;
|
||||
height: 12px;
|
||||
accent-color: var(--text-secondary);
|
||||
}
|
||||
meter::-webkit-meter-bar {
|
||||
background: var(--surface-raised);
|
||||
border: 0;
|
||||
}
|
||||
meter::-webkit-meter-optimum-value {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
meter::-moz-meter-bar {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
summary {
|
||||
color: var(--accent-primary-hover);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pending-expanded {
|
||||
min-width: 250px;
|
||||
max-width: 380px;
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
.detail-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-6);
|
||||
margin: 0;
|
||||
}
|
||||
.detail-summary dd {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.pending-card {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--state-warning);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.summary {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.summary > div {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
dd {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -164,8 +164,13 @@
|
||||
description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute."
|
||||
/>
|
||||
{/if}
|
||||
<section aria-labelledby="bank-title">
|
||||
<h2 id="bank-title">Bank</h2>
|
||||
<section class="bank-card" aria-labelledby="bank-title">
|
||||
<div class="bank-heading">
|
||||
<h2 id="bank-title">Bank</h2>
|
||||
<Button size="sm" href={`/bank?player=${encodeURIComponent(profile.guid)}`}
|
||||
>View bank history</Button
|
||||
>
|
||||
</div>
|
||||
<p>
|
||||
<strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II.
|
||||
</p>
|
||||
@@ -184,7 +189,7 @@
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each data.bank.entries as entry (entry.id)}
|
||||
>{#each data.bank.entries.slice(0, 3) as entry (entry.id)}
|
||||
<tr
|
||||
><td>{$timeFormat.dateTime(entry.createdAt)}</td><td
|
||||
>{serverTitle(entry.server)}</td
|
||||
@@ -499,6 +504,22 @@
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.bank-card {
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.bank-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.bank-heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user