diff --git a/DorkNet.Server/admin-ui/src/App.tsx b/DorkNet.Server/admin-ui/src/App.tsx index 6a67d20..566cc3a 100644 --- a/DorkNet.Server/admin-ui/src/App.tsx +++ b/DorkNet.Server/admin-ui/src/App.tsx @@ -4,24 +4,16 @@ import { RequireAuth } from './components/RequireAuth'; import { ToastProvider } from './components/Toast'; import { Login } from './pages/Login'; import { Dashboard } from './pages/Dashboard'; -import { Players } from './pages/Players'; -import { Bans } from './pages/Bans'; -import { Reports } from './pages/Reports'; -import { Audit } from './pages/Audit'; -import { PlayerLogs } from './pages/PlayerLogs'; +import { Moderation } from './pages/Moderation'; +import { Activity } from './pages/Activity'; import { Rooms } from './pages/Rooms'; import { Room } from './pages/Room'; import { ImportRoom } from './pages/ImportRoom'; import { LegacyImportRoom } from './pages/LegacyImportRoom'; import { Store } from './pages/Store'; import { Broadcast } from './pages/Broadcast'; -import { CommunityBoard } from './pages/CommunityBoard'; -import { LoadingTips } from './pages/LoadingTips'; -import { Gift } from './pages/Gift'; -import { Passwords } from './pages/Passwords'; -import { Grants } from './pages/Grants'; -import { Settings } from './pages/Settings'; -import { SignupCodes } from './pages/SignupCodes'; +import { Content } from './pages/Content'; +import { SettingsHome } from './pages/SettingsHome'; export default function App() { return ( @@ -30,11 +22,23 @@ export default function App() { } /> }> } /> - } /> - } /> - } /> - } /> - } /> + + {/* Players hub — directory + bans + reports as tabs. The old + standalone routes redirect in with the matching sub-tab + preselected. Per-player ban / grant / gift / password + actions all live in the row's detail modal. */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Activity hub — audit trail + per-player request logs. */} + } /> + } /> + } /> + } /> } /> {/* Back-compat: the merge left RR-Originals / Instances / @@ -48,14 +52,17 @@ export default function App() { } /> } /> } /> - } /> - } /> + + {/* Content hub — community board + loading-screen tips. */} + } /> + } /> + } /> + } /> - } /> - } /> - } /> - } /> - } /> + + {/* Settings hub — server toggles + signup codes. */} + } /> + } /> } /> diff --git a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx index da6068e..7057bd6 100644 --- a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx +++ b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx @@ -1,10 +1,16 @@ import { useEffect } from 'react'; import { NavLink } from 'react-router-dom'; import { - LayoutDashboard, Users, Building2, Ban, Flag, ScrollText, Activity, - Megaphone, Upload, ShoppingBag, Gift, KeyRound, Wrench, MessageSquare, Settings, X, + LayoutDashboard, Users, Building2, Activity, + Megaphone, ShoppingBag, MessageSquare, Settings, X, } from './Icons'; +// Consolidated navigation. Per-player moderation (bans, grants, gifts, +// password resets) lives in the player detail modal; the old Bans / +// Reports / Audit / Player-logs / Community / Loading-tips / Gift / +// Passwords / Signup-codes pages are now tabs under Players, Activity, +// Content, and Settings. Import room is a header action on the Rooms +// page. Old routes still resolve via redirects in App.tsx. const groups: Array<{ title: string; items: Array<{ to: string; label: string; icon: React.ReactNode; new?: boolean }>}> = [ { title: 'Overview', @@ -13,38 +19,25 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i ], }, { - title: 'People', + title: 'Moderation', items: [ - { to: '/players', label: 'Players', icon: }, - { to: '/bans', label: 'Bans', icon: }, - { to: '/reports', label: 'Reports', icon: }, - { to: '/audit', label: 'Audit log', icon: }, - { to: '/logs', label: 'Player logs', icon: }, + { to: '/players', label: 'Players', icon: }, + { to: '/activity', label: 'Activity', icon: }, ], }, { title: 'Content', items: [ - // Rooms is the unified view — clicking a row opens /rooms/:id - // which carries the old RR-Originals edit modal, live Photon - // instance list, recent visitors, ownership management, and - // per-room leaderboard channels in one tabbed page. - { to: '/rooms', label: 'Rooms', icon: }, - { to: '/import-room', label: 'Import room', icon: }, - { to: '/store', label: 'Store catalog', icon: }, - { to: '/community', label: 'Community board', icon: }, - { to: '/loading-tips', label: 'Loading screen tips', icon: }, + { to: '/rooms', label: 'Rooms', icon: }, + { to: '/store', label: 'Store', icon: }, + { to: '/content', label: 'Content', icon: }, ], }, { - title: 'Tools', + title: 'Operations', items: [ - { to: '/broadcast', label: 'Broadcast', icon: }, - { to: '/gift', label: 'Gift items', icon: , new: true }, - { to: '/passwords', label: 'Reset passwords', icon: , new: true }, - { to: '/grants', label: 'Grant maker pen', icon: , new: true }, - { to: '/signup-codes', label: 'Signup codes', icon: , new: true }, - { to: '/settings', label: 'Server settings', icon: , new: true }, + { to: '/broadcast', label: 'Broadcast', icon: }, + { to: '/settings', label: 'Settings', icon: }, ], }, ]; diff --git a/DorkNet.Server/admin-ui/src/components/Tabs.tsx b/DorkNet.Server/admin-ui/src/components/Tabs.tsx new file mode 100644 index 0000000..0a3ce42 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Tabs.tsx @@ -0,0 +1,50 @@ +import { useSearchParams } from 'react-router-dom'; + +// Shared horizontal tab strip — the same underlined-button style the +// per-player modal and the old Bans page already used inline, pulled +// out so the consolidated hub pages (Players / Activity / Content / +// Settings) all render their sub-tabs identically. +export function Tabs({ + tabs, + active, + onChange, +}: { + tabs: ReadonlyArray<{ key: T; label: string }>; + active: T; + onChange: (key: T) => void; +}) { + return ( +
+ {tabs.map(t => ( + + ))} +
+ ); +} + +// Binds the active hub tab to a `?tab=` query param so the back-compat +// redirects (e.g. /bans → /players?tab=bans) land on the right sub-tab +// and the choice survives a refresh / shared link. The default tab +// drops the param entirely to keep URLs clean. +export function useTabParam(def: T, valid: readonly T[]): [T, (t: T) => void] { + const [params, setParams] = useSearchParams(); + const raw = params.get('tab'); + const active = (valid as readonly string[]).includes(raw ?? '') ? (raw as T) : def; + const set = (t: T) => { + const next = new URLSearchParams(params); + if (t === def) next.delete('tab'); + else next.set('tab', t); + setParams(next, { replace: true }); + }; + return [active, set]; +} diff --git a/DorkNet.Server/admin-ui/src/pages/Activity.tsx b/DorkNet.Server/admin-ui/src/pages/Activity.tsx new file mode 100644 index 0000000..9063dc9 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/Activity.tsx @@ -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 ( +
+ + + {tab === 'audit' && } + {tab === 'logs' && } +
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/pages/Audit.tsx b/DorkNet.Server/admin-ui/src/pages/Audit.tsx index 52083d9..6f3c53e 100644 --- a/DorkNet.Server/admin-ui/src/pages/Audit.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Audit.tsx @@ -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(`/audit?take=${take}`); + const controls = ( + <> + + + + ); + return (
- - - - } - /> + {embedded ? ( +
{controls}
+ ) : ( + + )} {error &&
{error}
} diff --git a/DorkNet.Server/admin-ui/src/pages/Bans.tsx b/DorkNet.Server/admin-ui/src/pages/Bans.tsx index 7705daa..01617dc 100644 --- a/DorkNet.Server/admin-ui/src/pages/Bans.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Bans.tsx @@ -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 (
- + {!embedded && ( + + )}
{(['players', 'ip'] as const).map(t => ( ; + return (
- {busy ? 'Saving…' : 'Save all'}} - /> + {embedded ? ( +
{saveBtn}
+ ) : ( + + )}
diff --git a/DorkNet.Server/admin-ui/src/pages/Content.tsx b/DorkNet.Server/admin-ui/src/pages/Content.tsx new file mode 100644 index 0000000..ed39b3b --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/Content.tsx @@ -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 ( +
+ + + {tab === 'community' && } + {tab === 'tips' && } +
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/pages/Gift.tsx b/DorkNet.Server/admin-ui/src/pages/Gift.tsx index 5ad11bd..a8b8e93 100644 --- a/DorkNet.Server/admin-ui/src/pages/Gift.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Gift.tsx @@ -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'; @@ -41,10 +39,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('/players?take=500'); - const [playerId, setPlayerId] = useState(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,7 +65,6 @@ 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'); } @@ -101,23 +99,14 @@ export function Gift() { return (
- +

+ Drops a wrapped gift box on this player's HUD — they tap to open it and the rewards land in their inventory / wallet. +

- {playerId !== null && } + -
+
- -