feat: Refactor admin UI pages to support embedded mode and improve navigation

- Updated PlayerLogs, Players, Reports, Rooms, Settings, SignupCodes, and other pages to accept an optional `embedded` prop for better integration in various contexts.
- Introduced a new Activity page consolidating audit logs and player request logs with tab navigation.
- Added Content page for community board and loading tips with tab navigation.
- Created Moderation page to unify player directory, bans, and reports under a single interface.
- Implemented SettingsHome page to manage server settings and signup codes with tab navigation.
- Enhanced the Join page to include password creation for new accounts.
- Added global friends feature in Settings to allow all accounts to be friends without database entries.
- Introduced RelationshipQueries service to manage effective friend resolution based on global friends toggle.
- Added WireDates for consistent date handling in serialized data.
- Created reusable Tabs component for consistent tab navigation across various pages.
This commit is contained in:
Alexa
2026-06-06 16:39:19 +03:00
parent 34506e8294
commit 955246c6e4
41 changed files with 4129 additions and 2057 deletions
@@ -0,0 +1,25 @@
import { Audit } from './Audit';
import { PlayerLogs } from './PlayerLogs';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// Both log views in one place: the admin audit trail and the per-player
// HTTP request ring buffer. Old /audit and /logs routes redirect here.
export function Activity() {
const [tab, setTab] = useTabParam<'audit' | 'logs'>('audit', ['audit', 'logs']);
return (
<div>
<PageHeader title="Activity" blurb="Admin audit trail and per-player HTTP request logs." />
<Tabs
tabs={[
{ key: 'audit', label: 'Audit log' },
{ key: 'logs', label: 'Player requests' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'audit' && <Audit embedded />}
{tab === 'logs' && <PlayerLogs embedded />}
</div>
);
}
+23 -15
View File
@@ -6,26 +6,34 @@ import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function Audit() {
export function Audit({ embedded }: { embedded?: boolean } = {}) {
const [take, setTake] = useState(200);
const { data, loading, error, refresh } = useApi<AuditEntry[]>(`/audit?take=${take}`);
const controls = (
<>
<select value={take} onChange={e => setTake(parseInt(e.target.value))} className="input w-24 text-xs !py-1.5">
<option value={100}>last 100</option>
<option value={200}>last 200</option>
<option value={500}>last 500</option>
</select>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>
);
return (
<div>
<PageHeader
title="Audit log"
blurb="Every admin action with actor, target, and reason. Newest first."
actions={<>
<select value={take} onChange={e => setTake(parseInt(e.target.value))} className="input w-24 text-xs !py-1.5">
<option value={100}>last 100</option>
<option value={200}>last 200</option>
<option value={500}>last 500</option>
</select>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>}
/>
{embedded ? (
<div className="flex justify-end gap-2 mb-3">{controls}</div>
) : (
<PageHeader
title="Audit log"
blurb="Every admin action with actor, target, and reason. Newest first."
actions={controls}
/>
)}
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
+7 -5
View File
@@ -8,14 +8,16 @@ import { Empty } from '../components/Empty';
import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw, Trash } from '../components/Icons';
export function Bans() {
export function Bans({ embedded }: { embedded?: boolean } = {}) {
const [tab, setTab] = useState<'players' | 'ip'>('players');
return (
<div>
<PageHeader
title="Bans"
blurb="Player bans and IP-level bans, all in one place."
/>
{!embedded && (
<PageHeader
title="Bans"
blurb="Player bans and IP-level bans, all in one place."
/>
)}
<div className="flex border-b border-ink-800 text-sm mb-4">
{(['players', 'ip'] as const).map(t => (
<button
@@ -6,7 +6,7 @@ import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Plus, Trash } from '../components/Icons';
export function CommunityBoard() {
export function CommunityBoard({ embedded }: { embedded?: boolean } = {}) {
const { data: roomList } = useApi<Room[]>('/rooms');
const { data: players } = useApi<Player[]>('/players?take=500');
const [state, setState] = useState<CommunityBoardState | null>(null);
@@ -37,13 +37,19 @@ export function CommunityBoard() {
}
};
const saveBtn = <button onClick={save} disabled={busy} className="btn-primary text-xs">{busy ? 'Saving…' : 'Save all'}</button>;
return (
<div>
<PageHeader
title="Community board"
blurb="The dorm community panel the watch fetches from /api/communityboard/v1/current. Edits go live instantly."
actions={<button onClick={save} disabled={busy} className="btn-primary text-xs">{busy ? 'Saving…' : 'Save all'}</button>}
/>
{embedded ? (
<div className="flex justify-end mb-3">{saveBtn}</div>
) : (
<PageHeader
title="Community board"
blurb="The dorm community panel the watch fetches from /api/communityboard/v1/current. Edits go live instantly."
actions={saveBtn}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Announcement state={state} setState={setState} />
@@ -0,0 +1,26 @@
import { CommunityBoard } from './CommunityBoard';
import { LoadingTips } from './LoadingTips';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// The two player-facing content surfaces the watch pulls on dorm load —
// the community board and the loading-screen tips — share one page.
// Old /community and /loading-tips routes redirect here.
export function Content() {
const [tab, setTab] = useTabParam<'community' | 'tips'>('community', ['community', 'tips']);
return (
<div>
<PageHeader title="Content" blurb="Player-facing content shown on dorm load: the community board and loading-screen tips." />
<Tabs
tabs={[
{ key: 'community', label: 'Community board' },
{ key: 'tips', label: 'Loading tips' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'community' && <CommunityBoard embedded />}
{tab === 'tips' && <LoadingTips embedded />}
</div>
);
}
+36 -36
View File
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { api, get } from '../lib/api';
import type { Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm';
import { RefreshCw, Search, Trash } from '../components/Icons';
@@ -27,12 +25,14 @@ const RARITY_OPTIONS: Array<{ value: number; label: string }> = [
];
interface AvatarItem {
// The exact RecNet desc to gift: a bare base GUID (default colour) or a
// "{guid},{swatch},{mask}," string for a colour variant.
desc: string;
guid: string;
slot: number;
friendlyName: string;
tooltip: string;
rarity: number;
safe: boolean;
color: string | null;
isVariant: boolean;
}
// Gifting fires the in-game gift-box popup by inserting a GiftPackageEntity
@@ -41,10 +41,11 @@ interface AvatarItem {
// and the consume endpoint writes the avatar item into their inventory.
// This is the only flow that triggers the popup — directly poking
// InventoryJson skips it entirely.
export function Gift() {
const { data: players } = useApi<Player[]>('/players?take=500');
const [playerId, setPlayerId] = useState<number | null>(null);
// Per-player gift composer, rendered as the "Gift" tab of the player
// detail modal. The recipient is fixed to the open player, so there's
// no picker and no page chrome — just the reward builder, presentation
// options, and that player's pending-gift inbox.
export function GiftPanel({ playerId }: { playerId: number }) {
const [includeItem, setIncludeItem] = useState(true);
const [includeCurrency, setIncludeCurrency] = useState(false);
const [includeXp, setIncludeXp] = useState(false);
@@ -66,12 +67,14 @@ export function Gift() {
const toast = useToast();
const send = async () => {
if (!playerId) return toast.push('Pick a recipient first', 'error');
if (!includeItem && !includeCurrency && !includeXp) {
return toast.push('Pick at least one reward (item / currency / xp)', 'error');
}
const guid = includeItem ? (manualGuid.trim() || item?.guid) : null;
if (includeItem && !guid) {
// The picked item carries the full desc (colour variants included);
// a manually pasted value may be a bare GUID or a full desc — both are
// normalised server-side.
const desc = includeItem ? (manualGuid.trim() || item?.desc) : null;
if (includeItem && !desc) {
return toast.push('Pick an avatar item from the list, or paste a GUID', 'error');
}
setBusy(true);
@@ -79,7 +82,7 @@ export function Gift() {
const res = await api<{ id: number }>(`/players/${playerId}/gift`, {
method: 'POST',
body: {
AvatarItemGuid: includeItem ? guid : null,
AvatarItemGuid: includeItem ? desc : null,
AvatarItemType: includeItem ? avatarItemType : null,
CurrencyType: includeCurrency ? currencyType : null,
Currency: includeCurrency ? currencyAmount : null,
@@ -101,23 +104,14 @@ export function Gift() {
return (
<div>
<PageHeader
title="Send a gift"
blurb="Drops a wrapped gift box on the recipient's HUD — they tap to open it and the rewards land in their inventory / wallet."
/>
<p className="text-xs text-ink-400 mb-4">
Drops a wrapped gift box on this player's HUD — they tap to open it and the rewards land in their inventory / wallet.
</p>
{playerId !== null && <PendingGifts playerId={playerId} refreshKey={pendingRefreshKey} />}
<PendingGifts playerId={playerId} refreshKey={pendingRefreshKey} />
<div className="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-4 max-w-5xl">
<div className="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-4">
<div className="card !p-5 space-y-4">
<label className="flex flex-col gap-1">
<span className="label">Recipient</span>
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} className="input">
<option value="">— pick a player —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
<input type="checkbox" checked={includeItem} onChange={e => setIncludeItem(e.target.checked)} className="size-4 accent-brand-500" />
@@ -198,7 +192,7 @@ export function Gift() {
<span className="label">Message (shown on the open card)</span>
<textarea value={message} onChange={e => setMessage(e.target.value)} rows={3} className="input" />
</label>
<button onClick={send} disabled={busy || !playerId} className="btn-primary w-full text-xs">
<button onClick={send} disabled={busy} className="btn-primary w-full text-xs">
{busy ? 'Sending…' : 'Send gift'}
</button>
<div className="text-[11px] text-ink-500">
@@ -219,7 +213,10 @@ function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null;
const term = search.trim().toLowerCase();
return (catalog ?? []).filter(it => {
if (slotFilter !== 'all' && it.slot !== slotFilter) return false;
if (term && !it.friendlyName.toLowerCase().includes(term) && !it.guid.toLowerCase().includes(term)) return false;
if (term
&& !it.friendlyName.toLowerCase().includes(term)
&& !(it.color ?? '').toLowerCase().includes(term)
&& !it.guid.toLowerCase().includes(term)) return false;
return true;
});
}, [catalog, search, slotFilter]);
@@ -244,21 +241,24 @@ function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null;
{filtered.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[560px]">
<tbody className="divide-y divide-ink-800">
{filtered.slice(0, 200).map(it => (
{filtered.slice(0, 500).map(it => (
<tr
key={it.guid}
key={it.desc}
onClick={() => onSelect(it)}
className={`cursor-pointer table-row-hover ${selected?.guid === it.guid ? 'bg-brand-500/10' : ''}`}
className={`cursor-pointer table-row-hover ${selected?.desc === it.desc ? 'bg-brand-500/10' : ''}`}
>
<td className="px-3 py-1.5">
<div className="text-ink-100 text-sm">{it.friendlyName || <span className="text-ink-500">unnamed</span>}</div>
<div className="font-mono text-[10px] text-ink-500">{it.guid}</div>
<div className="text-ink-100 text-sm flex items-center gap-1.5">
{it.friendlyName || <span className="text-ink-500">unnamed</span>}
{it.isVariant && <span className="badge-neutral text-[10px]">{it.color}</span>}
</div>
<div className="font-mono text-[10px] text-ink-500">{it.desc}</div>
</td>
<td className="px-3 py-1.5 text-xs text-ink-300 whitespace-nowrap">{SLOT_NAMES[it.slot] ?? `slot ${it.slot}`}</td>
</tr>
))}
{filtered.length > 200 && (
<tr><td colSpan={2} className="p-3 text-center text-xs text-ink-500">+ {filtered.length - 200} more — refine the search.</td></tr>
{filtered.length > 500 && (
<tr><td colSpan={2} className="p-3 text-center text-xs text-ink-500">+ {filtered.length - 500} more — refine the search.</td></tr>
)}
</tbody>
</table></div>
@@ -1,36 +0,0 @@
import { PageHeader } from '../components/PageHeader';
// Maker-pen-anywhere needs server-side integration with CdnController
// (substitute the room blob for flagged players so they get the
// all-permissions PersistedRoomData) AND a PlayerEntity.HasGlobalMakerPen
// column with an EF migration. Wiring is non-trivial enough to keep this
// page as a clearly-labeled placeholder until that lands — saves shipping
// a button that does nothing.
export function Grants() {
return (
<div>
<PageHeader
title="Grant maker pen"
blurb="Flag players who should receive maker-pen permissions in every room they load."
/>
<div className="card !p-6 max-w-2xl space-y-3 text-sm text-ink-300">
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
<strong className="text-warn">Not yet wired up.</strong> The endpoint and CDN integration land in a follow-up commit.
</div>
<p>
The plan: add <span className="font-mono text-xs text-ink-100">HasGlobalMakerPen</span> to
<span className="font-mono text-xs text-ink-100"> PlayerEntity</span>, intercept room blob
fetches in <span className="font-mono text-xs text-ink-100">CdnController</span> for flagged players,
and substitute the all-permissions default blob that
<span className="font-mono text-xs text-ink-100"> RoomDataBlobService</span> already produces.
Result: the player loads any room with full Maker Pen / invention spawn permissions, regardless of the
room's owner-defined roles.
</p>
<p className="text-xs text-ink-400">
Until then, use the Player → Grants tab to bestow per-item inventory grants (Maker Pen is granted via
inventory in some flows).
</p>
</div>
</div>
);
}
@@ -29,7 +29,7 @@ function blankTip(): LoadingScreenTip {
};
}
export function LoadingTips() {
export function LoadingTips({ embedded }: { embedded?: boolean } = {}) {
const [tips, setTips] = useState<LoadingScreenTip[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const toast = useToast();
@@ -100,13 +100,19 @@ export function LoadingTips() {
}
};
const addBtn = <button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>;
return (
<div>
<PageHeader
title="Loading screen tips"
blurb="The list the watch fetches from cdn.localhost/config/LoadingScreenTipData on every dorm load. Edit, upload an image, or scope a tip to specific rooms — saves are live immediately."
actions={<button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>}
/>
{embedded ? (
<div className="flex justify-end mb-3">{addBtn}</div>
) : (
<PageHeader
title="Loading screen tips"
blurb="The list the watch fetches from cdn.localhost/config/LoadingScreenTipData on every dorm load. Edit, upload an image, or scope a tip to specific rooms — saves are live immediately."
actions={addBtn}
/>
)}
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
@@ -0,0 +1,31 @@
import { Players } from './Players';
import { Bans } from './Bans';
import { Reports } from './Reports';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// Unified moderation hub. The account directory (with its per-player
// detail / ban / grant / gift / password modal), the active-ban list,
// and the open-report queue were three separate sidebar entries — they
// describe one workflow, so they're tabs of one page now. The old
// /bans and /reports routes redirect here with ?tab= preselected.
export function Moderation() {
const [tab, setTab] = useTabParam<'directory' | 'bans' | 'reports'>('directory', ['directory', 'bans', 'reports']);
return (
<div>
<PageHeader title="Players" blurb="Search and moderate accounts, review active bans, and triage open reports." />
<Tabs
tabs={[
{ key: 'directory', label: 'Directory' },
{ key: 'bans', label: 'Bans' },
{ key: 'reports', label: 'Reports' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'directory' && <Players embedded />}
{tab === 'bans' && <Bans embedded />}
{tab === 'reports' && <Reports embedded />}
</div>
);
}
+42 -58
View File
@@ -1,13 +1,12 @@
import { useState } from 'react';
import { api } from '../lib/api';
import type { Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
export function Passwords() {
const { data: players } = useApi<Player[]>('/players?take=500');
const [playerId, setPlayerId] = useState<number | null>(null);
// Admin password reset, rendered as a card inside the player detail
// modal's Profile tab. The target account is the open player, so there's
// no picker — just set-and-confirm. Issues a Logout push so any active
// sessions on the old password get dropped.
export function PasswordResetCard({ playerId, username }: { playerId: number; username: string }) {
const [pw, setPw] = useState('');
const [pw2, setPw2] = useState('');
const [show, setShow] = useState(false);
@@ -27,11 +26,9 @@ export function Passwords() {
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!playerId) return;
if (pw !== pw2) return toast.push("Passwords don't match", 'error');
if (pw.length < 8) return toast.push('Password must be at least 8 characters', 'error');
const player = players?.find(p => p.id === playerId);
if (!confirm(`Reset password for @${player?.username ?? `#${playerId}`}? This forces a logout on every active session.`)) return;
if (!confirm(`Reset password for @${username}? This forces a logout on every active session.`)) return;
setBusy(true);
try {
await api(`/players/${playerId}/password`, {
@@ -48,62 +45,49 @@ export function Passwords() {
};
return (
<div>
<PageHeader
title="Reset passwords"
blurb="Admin-set a new password for any account. Issues a Logout push so any active sessions on the old password get dropped."
/>
<form onSubmit={submit} className="card !p-4 md:col-span-2 space-y-3">
<div>
<h3 className="text-sm font-semibold text-ink-50 mb-1">Reset password</h3>
<p className="text-xs text-ink-400">
Admin-set a new password. The plaintext is hashed with BCrypt before write — we never store it, and the audit log
records the action but not the password. Issues a Logout push that drops any session still on the old password.
</p>
</div>
<form onSubmit={submit} className="card !p-5 max-w-xl space-y-4">
<label className="flex flex-col gap-1">
<span className="label">Account</span>
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} required className="input">
<option value="">— pick an account —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="label">New password</span>
<div className="flex gap-2">
<input
type={show ? 'text' : 'password'}
value={pw}
onChange={e => setPw(e.target.value)}
minLength={8}
required
autoComplete="new-password"
className="input flex-1 font-mono"
/>
<button type="button" onClick={() => setShow(s => !s)} className="btn-ghost text-xs">{show ? 'Hide' : 'Show'}</button>
<button type="button" onClick={random} className="btn-secondary text-xs">Random</button>
</div>
</label>
<label className="flex flex-col gap-1">
<span className="label">Confirm new password</span>
<label className="flex flex-col gap-1">
<span className="label">New password</span>
<div className="flex gap-2">
<input
type={show ? 'text' : 'password'}
value={pw2}
onChange={e => setPw2(e.target.value)}
value={pw}
onChange={e => setPw(e.target.value)}
minLength={8}
required
autoComplete="new-password"
className="input font-mono"
className="input flex-1 font-mono"
/>
</label>
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
The plaintext is hashed with BCrypt before write — we never store it. The audit log records the action but not the password.
<button type="button" onClick={() => setShow(s => !s)} className="btn-ghost text-xs">{show ? 'Hide' : 'Show'}</button>
<button type="button" onClick={random} className="btn-secondary text-xs">Random</button>
</div>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => { setPw(''); setPw2(''); setShow(false); }} className="btn-ghost text-xs" disabled={busy}>Clear</button>
<button className="btn-primary text-xs" disabled={busy || !playerId || pw.length < 8 || pw !== pw2}>
{busy ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
</div>
<label className="flex flex-col gap-1">
<span className="label">Confirm new password</span>
<input
type={show ? 'text' : 'password'}
value={pw2}
onChange={e => setPw2(e.target.value)}
minLength={8}
autoComplete="new-password"
className="input font-mono"
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => { setPw(''); setPw2(''); setShow(false); }} className="btn-ghost text-xs" disabled={busy}>Clear</button>
<button className="btn-primary text-xs" disabled={busy || pw.length < 8 || pw !== pw2}>
{busy ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
);
}
@@ -7,7 +7,7 @@ import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function PlayerLogs() {
export function PlayerLogs({ embedded }: { embedded?: boolean } = {}) {
const { data: players } = useApi<Player[]>('/players?take=500');
const [pid, setPid] = useState<number | null>(null);
const [filter, setFilter] = useState('');
@@ -49,10 +49,12 @@ export function PlayerLogs() {
return (
<div>
<PageHeader
title="Player request logs"
blurb="Last few hundred HTTP calls per player, pulled from the Redis ring buffer. Useful when a player reports something weird."
/>
{!embedded && (
<PageHeader
title="Player request logs"
blurb="Last few hundred HTTP calls per player, pulled from the Redis ring buffer. Useful when a player reports something weird."
/>
)}
<div className="card !p-4 mb-4 grid grid-cols-1 md:grid-cols-[2fr,1fr,120px,auto,auto] gap-2 items-end">
<label className="flex flex-col gap-1">
+24 -13
View File
@@ -9,8 +9,10 @@ import { useToast } from '../components/Toast';
import { Empty } from '../components/Empty';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { RefreshCw, Search } from '../components/Icons';
import { GiftPanel } from './Gift';
import { PasswordResetCard } from './Passwords';
export function Players() {
export function Players({ embedded }: { embedded?: boolean } = {}) {
const [query, setQuery] = useState('');
const [rows, setRows] = useState<Player[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -38,18 +40,24 @@ export function Players() {
useEffect(() => { search(query); }, [query, search]);
const refreshBtn = (
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
);
return (
<div>
<PageHeader
title="Players"
blurb="Search and moderate every account in the database."
actions={
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
}
/>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Players"
blurb="Search and moderate every account in the database."
actions={refreshBtn}
/>
)}
<div className="card overflow-hidden">
<div className="border-b border-ink-800 p-3 flex items-center gap-2">
@@ -146,7 +154,7 @@ export function Players() {
function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => void; onChanged: () => void }) {
const [data, setData] = useState<PlayerDetail | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'profile'>('overview');
const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'gift' | 'profile'>('overview');
const reload = () => {
setErr(null);
@@ -189,7 +197,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
</div>
<div className="flex border-b border-ink-800 text-sm">
{(['overview', 'mod', 'grants', 'profile'] as const).map(t => (
{(['overview', 'mod', 'grants', 'gift', 'profile'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
@@ -203,6 +211,7 @@ function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => v
{tab === 'overview' && <OverviewTab data={data} />}
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
{tab === 'gift' && <GiftPanel playerId={data.id} />}
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} />}
</div>
)}
@@ -537,6 +546,8 @@ function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () =>
className="btn-danger text-xs"
>Reset to starter</button>
</div>
<PasswordResetCard playerId={data.id} username={data.username} />
</div>
);
}
+16 -10
View File
@@ -9,21 +9,27 @@ import { useToast } from '../components/Toast';
import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function Reports() {
export function Reports({ embedded }: { embedded?: boolean } = {}) {
const { data, loading, error, refresh } = useApi<Report[]>('/reports?take=100');
const [resolving, setResolving] = useState<Report | null>(null);
const refreshBtn = (
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
);
return (
<div>
<PageHeader
title="Open reports"
blurb="Oldest first. Resolving a report only closes the ticket — issue the actual moderation action separately."
actions={
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
}
/>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Open reports"
blurb="Oldest first. Resolving a report only closes the ticket — issue the actual moderation action separately."
actions={refreshBtn}
/>
)}
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
+4 -1
View File
@@ -8,7 +8,7 @@ import { Empty } from '../components/Empty';
import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm';
import { RefreshCw, Trash } from '../components/Icons';
import { RefreshCw, Trash, Upload } from '../components/Icons';
export function Rooms() {
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
@@ -55,6 +55,9 @@ export function Rooms() {
title="Rooms"
blurb="Every room in the DB. Archive (trash icon) is soft and reversible. Purge is a hard wipe — drops scene rows + per-room blobs + thumbnail and requires typing the room name twice."
actions={<>
<Link to="/import-room" className="btn-primary text-xs">
<Upload /> Import room
</Link>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
+74 -11
View File
@@ -6,6 +6,7 @@ import { Plus, RefreshCw, Trash } from '../components/Icons';
interface ServerSettings {
signupsDisabled: boolean;
globalFriendsEnabled: boolean;
weeklyChallengesCompletedRequired: boolean;
updatedAt: string;
}
@@ -58,7 +59,7 @@ interface RewardOptions {
consumables: RewardOption[];
}
export function Settings() {
export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
@@ -108,6 +109,30 @@ export function Settings() {
}
};
const toggleGlobalFriends = async () => {
if (!settings) return;
const next = !settings.globalFriendsEnabled;
const verb = next ? 'make everyone friends' : 'turn off all-friends';
if (!confirm(
next
? 'Make every account friends with every other account? No friend rows are written — it\'s synthesized live, and connected players refresh instantly. Toggle off to revert.'
: `Really ${verb}? Players will revert to their real friends list.`,
)) return;
setBusy(true);
try {
const updated = await api<ServerSettings>('/settings/global-friends', {
method: 'POST',
body: { Enabled: next },
});
setSettings(updated);
toast.push(next ? 'Everyone is now friends — players refreshing live.' : 'All-friends turned off.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const updateWeeklyChallenge = (index: number, patch: Partial<WeeklyChallenge>) => {
setWeekly((current) => {
if (!current) return current;
@@ -253,18 +278,24 @@ export function Settings() {
}
};
const refreshBtn = (
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
);
return (
<div>
<PageHeader
title="Server settings"
blurb="Runtime toggles applied across every replica without a redeploy."
actions={
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
}
/>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Server settings"
blurb="Runtime toggles applied across every replica without a redeploy."
actions={refreshBtn}
/>
)}
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
@@ -303,6 +334,38 @@ export function Settings() {
</div>
)}
{settings && (
<div className="card !p-5 max-w-2xl mt-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">Everyone is friends</h2>
<p className="mt-1 text-xs text-ink-400">
When on, every account is treated as a friend of every other account, so players
don't have to search and friend-request each other on a small server. Nothing is
written to the database — it's synthesized live, and connected players get a refresh
signal so their friends list updates without relogging. Turn it off to instantly
revert to real friend lists. Blocks are still respected.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<button
onClick={toggleGlobalFriends}
disabled={busy}
className={(settings.globalFriendsEnabled ? 'btn-danger' : 'btn-primary') + ' text-xs shrink-0'}
>
{busy ? 'Working…' : settings.globalFriendsEnabled ? 'Turn off' : 'Make everyone friends'}
</button>
</div>
<div className="mt-4 text-xs">
{settings.globalFriendsEnabled
? <span className="badge-online">Everyone is friends</span>
: <span className="badge-neutral">Normal friend lists</span>}
</div>
</div>
)}
{weekly && (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -0,0 +1,26 @@
import { Settings } from './Settings';
import { SignupCodes } from './SignupCodes';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// Server-wide configuration: runtime toggles plus the single-use signup
// invite codes (an access-control setting in its own right). Old
// /signup-codes route redirects here with ?tab=signup.
export function SettingsHome() {
const [tab, setTab] = useTabParam<'server' | 'signup'>('server', ['server', 'signup']);
return (
<div>
<PageHeader title="Settings" blurb="Runtime server toggles and single-use signup invite codes." />
<Tabs
tabs={[
{ key: 'server', label: 'Server' },
{ key: 'signup', label: 'Signup codes' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'server' && <Settings embedded />}
{tab === 'signup' && <SignupCodes embedded />}
</div>
);
}
@@ -24,7 +24,7 @@ const STATUS_BADGE: Record<SignupCode['status'], string> = {
expired: 'badge-neutral',
};
export function SignupCodes() {
export function SignupCodes({ embedded }: { embedded?: boolean } = {}) {
const [codes, setCodes] = useState<SignupCode[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [descriptor, setDescriptor] = useState('');
@@ -84,18 +84,24 @@ export function SignupCodes() {
}
};
const refreshBtn = (
<button onClick={load} className="btn-secondary text-xs">
<RefreshCw />
Refresh
</button>
);
return (
<div>
<PageHeader
title="Signup codes"
blurb="Single-use invite codes. Hand one to a player; they redeem it on the site's /join page to create an account while signups are disabled."
actions={
<button onClick={load} className="btn-secondary text-xs">
<RefreshCw />
Refresh
</button>
}
/>
{embedded ? (
<div className="flex justify-end mb-3">{refreshBtn}</div>
) : (
<PageHeader
title="Signup codes"
blurb="Single-use invite codes. Hand one to a player; they redeem it on the site's /join page to create an account while signups are disabled."
actions={refreshBtn}
/>
)}
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>