mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function Confirm({ title, body, confirmLabel = 'Confirm', destructive, open, onClose, onConfirm }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try { await onConfirm(); onClose(); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={run} className={(destructive ? 'btn-danger' : 'btn-primary') + ' text-xs'} disabled={busy}>
|
||||
{busy ? 'Working…' : confirmLabel}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
<div className="text-sm text-ink-200">{body}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Tiny stand-in for "nothing matches your filter / nothing exists yet"
|
||||
// table-empty states. Centered, muted, optional CTA.
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
blurb?: string;
|
||||
cta?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Empty({ title = 'Nothing here', blurb, cta, className }: Props) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center gap-1 py-10 text-center ${className ?? ''}`}>
|
||||
<div className="text-sm font-medium text-ink-200">{title}</div>
|
||||
{blurb && <div className="text-xs text-ink-400 max-w-xs">{blurb}</div>}
|
||||
{cta && <div className="mt-3">{cta}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Inline SVG icons. Keeping them inline avoids a lucide-react / heroicons
|
||||
// dependency for the dozen glyphs the sidebar needs. All icons take a
|
||||
// className override so the parent's text color cascades via currentColor.
|
||||
|
||||
type IconProps = { className?: string };
|
||||
const base = 'size-4 shrink-0 stroke-current';
|
||||
|
||||
const Svg = ({ children, className }: IconProps & { children: React.ReactNode }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={[base, className].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LayoutDashboard = (p: IconProps) => (
|
||||
<Svg {...p}><rect x="3" y="3" width="7" height="9" rx="1.5" /><rect x="14" y="3" width="7" height="5" rx="1.5" /><rect x="14" y="12" width="7" height="9" rx="1.5" /><rect x="3" y="16" width="7" height="5" rx="1.5" /></Svg>
|
||||
);
|
||||
export const Users = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></Svg>
|
||||
);
|
||||
export const Building2 = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z" /><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" /><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2" /><path d="M10 6h4" /><path d="M10 10h4" /><path d="M10 14h4" /><path d="M10 18h4" /></Svg>
|
||||
);
|
||||
export const Ban = (p: IconProps) => (
|
||||
<Svg {...p}><circle cx="12" cy="12" r="10" /><path d="m4.93 4.93 14.14 14.14" /></Svg>
|
||||
);
|
||||
export const Flag = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M4 22V4" /><path d="M4 4h13l-2 5 2 5H4" /></Svg>
|
||||
);
|
||||
export const ScrollText = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M15 12h-5" /><path d="M15 8h-5" /><path d="M19 17V5a2 2 0 0 0-2-2H4" /><path d="M8 21H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h3" /><path d="M8 21V7" /><path d="M14 21h6a2 2 0 0 0 2-2v-2H8" /></Svg>
|
||||
);
|
||||
export const Activity = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></Svg>
|
||||
);
|
||||
export const Megaphone = (p: IconProps) => (
|
||||
<Svg {...p}><path d="m3 11 18-5v12L3 14v-3Z" /><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" /></Svg>
|
||||
);
|
||||
export const Upload = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></Svg>
|
||||
);
|
||||
export const ShoppingBag = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" /><line x1="3" x2="21" y1="6" y2="6" /><path d="M16 10a4 4 0 0 1-8 0" /></Svg>
|
||||
);
|
||||
export const Server = (p: IconProps) => (
|
||||
<Svg {...p}><rect x="2" y="2" width="20" height="8" rx="2" /><rect x="2" y="14" width="20" height="8" rx="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" /></Svg>
|
||||
);
|
||||
export const Gift = (p: IconProps) => (
|
||||
<Svg {...p}><polyline points="20 12 20 22 4 22 4 12" /><rect x="2" y="7" width="20" height="5" /><line x1="12" x2="12" y1="22" y2="7" /><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7Z" /><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7Z" /></Svg>
|
||||
);
|
||||
export const KeyRound = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M2 18a4 4 0 0 1 4-4h2a4 4 0 0 1 4 4v3H2v-3Z" /><circle cx="16" cy="9" r="6" /></Svg>
|
||||
);
|
||||
export const Wrench = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76Z" /></Svg>
|
||||
);
|
||||
export const MessageSquare = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></Svg>
|
||||
);
|
||||
export const LogOut = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" x2="9" y1="12" y2="12" /></Svg>
|
||||
);
|
||||
export const Menu = (p: IconProps) => (
|
||||
<Svg {...p}><line x1="3" x2="21" y1="6" y2="6" /><line x1="3" x2="21" y1="12" y2="12" /><line x1="3" x2="21" y1="18" y2="18" /></Svg>
|
||||
);
|
||||
export const X = (p: IconProps) => (
|
||||
<Svg {...p}><line x1="18" x2="6" y1="6" y2="18" /><line x1="6" x2="18" y1="6" y2="18" /></Svg>
|
||||
);
|
||||
export const Search = (p: IconProps) => (
|
||||
<Svg {...p}><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></Svg>
|
||||
);
|
||||
export const Plus = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M5 12h14" /><path d="M12 5v14" /></Svg>
|
||||
);
|
||||
export const Trash = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></Svg>
|
||||
);
|
||||
export const RefreshCw = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><path d="M3 21v-5h5" /></Svg>
|
||||
);
|
||||
export const HardDrive = (p: IconProps) => (
|
||||
<Svg {...p}><line x1="22" x2="2" y1="12" y2="12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11Z" /><line x1="6" x2="6.01" y1="16" y2="16" /><line x1="10" x2="10.01" y1="16" y2="16" /></Svg>
|
||||
);
|
||||
export const Settings = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></Svg>
|
||||
);
|
||||
export const ArrowLeft = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M19 12H5" /><path d="m12 19-7-7 7-7" /></Svg>
|
||||
);
|
||||
export const Trophy = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" /><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path d="M4 22h16" /><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" /><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" /><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></Svg>
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { LogOut, Menu } from './Icons';
|
||||
import { clearSession, getMe, notifyAuthChange, subscribeAuth } from '../lib/auth';
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const me = useSyncExternalStore(subscribeAuth, getMe, () => null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Defensive: close the drawer on route change in case the user opens
|
||||
// it then navigates via something other than the sidebar links
|
||||
// (e.g. the legacy importer link in PageHeader actions). The drawer
|
||||
// also closes via NavLink onClick → onNavigate, so this is a belt.
|
||||
useEffect(() => { setDrawerOpen(false); }, [location.pathname]);
|
||||
|
||||
const logout = () => {
|
||||
clearSession();
|
||||
notifyAuthChange();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<Sidebar
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onNavigate={() => setDrawerOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<header className="flex h-14 items-center gap-2 border-b border-ink-800 bg-ink-900/60 px-3 sm:px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="md:hidden rounded-md p-1.5 text-ink-200 hover:bg-ink-800"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={drawerOpen}
|
||||
>
|
||||
<Menu />
|
||||
</button>
|
||||
<div className="flex-1 text-sm text-ink-300 truncate">
|
||||
{/* Tighter copy on small screens — the "Signed in as" prefix
|
||||
eats too much width on a phone. Tablet+ shows the full
|
||||
phrase. */}
|
||||
<span className="hidden sm:inline">Signed in as </span>
|
||||
<span className="font-medium text-ink-50">
|
||||
{me?.displayName ?? me?.username ?? `#${me?.id ?? '?'}`}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={logout} className="btn-ghost text-xs shrink-0" aria-label="Sign out">
|
||||
<LogOut />
|
||||
<span className="hidden sm:inline">Sign out</span>
|
||||
</button>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto px-3 sm:px-5 py-4 sm:py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
// Wider for dense edit forms (e.g. player detail with multiple sections).
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
// Sticky action bar at the bottom — pass buttons here so the form
|
||||
// body can scroll but the actions stay visible on small screens.
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ title, open, onClose, children, size = 'md', footer }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
const sizeClass = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-ink-950/70 backdrop-blur-sm px-4 py-6"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div
|
||||
className={`card flex max-h-full w-full flex-col ${sizeClass}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-ink-800 px-5 py-3">
|
||||
<h2 className="text-sm font-semibold tracking-tight text-ink-50">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-ink-400 hover:bg-ink-800 hover:text-ink-200"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-5 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-ink-800 px-5 py-3 bg-ink-900/40">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
blurb?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Consistent page header: title + optional one-line blurb + a slot
|
||||
// for primary actions (refresh, new, etc.) aligned to the right.
|
||||
export function PageHeader({ title, blurb, actions }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-4 sm:mb-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-ink-50">{title}</h1>
|
||||
{blurb && <p className="text-xs sm:text-sm text-ink-400 mt-0.5">{blurb}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { profileImageUrl } from '../lib/types';
|
||||
|
||||
// Round avatar tile that falls back to the player's display-name
|
||||
// initial if there's no ProfileImageName or the request 404s. The
|
||||
// img.* CDN signs every response, so we don't have to do anything
|
||||
// special on this side — just point at the URL and let the browser
|
||||
// render or fail to render. Size in pixels controls both the rendered
|
||||
// box and the `?width=N` hint we send so the CDN can resize.
|
||||
export function PlayerAvatar({
|
||||
name,
|
||||
displayName,
|
||||
size = 28,
|
||||
className = '',
|
||||
}: {
|
||||
name?: string | null;
|
||||
displayName?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
const url = errored ? null : profileImageUrl(name, Math.max(size * 2, 64));
|
||||
const initial = (displayName ?? '?').charAt(0).toUpperCase();
|
||||
const dim = { width: size, height: size };
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div
|
||||
style={dim}
|
||||
className={`shrink-0 rounded-full bg-gradient-to-br from-ink-700 to-ink-800 flex items-center justify-center font-semibold text-ink-200 ${className}`}
|
||||
>
|
||||
<span style={{ fontSize: size * 0.42 }}>{initial}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
style={dim}
|
||||
onError={() => setErrored(true)}
|
||||
alt={displayName ? `${displayName}'s avatar` : 'player avatar'}
|
||||
className={`shrink-0 rounded-full bg-ink-800 object-cover ${className}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getToken, subscribeAuth } from '../lib/auth';
|
||||
|
||||
// Gate every authenticated route. We re-render on any auth state
|
||||
// change so a 401 from `api()` (which clears the token and pings
|
||||
// listeners) bounces the user out of the dashboard mid-session.
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useSyncExternalStore(subscribeAuth, getToken, () => null);
|
||||
const location = useLocation();
|
||||
if (!token) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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,
|
||||
} from './Icons';
|
||||
|
||||
const groups: Array<{ title: string; items: Array<{ to: string; label: string; icon: React.ReactNode; new?: boolean }>}> = [
|
||||
{
|
||||
title: 'Overview',
|
||||
items: [
|
||||
{ to: '/', label: 'Dashboard', icon: <LayoutDashboard /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'People',
|
||||
items: [
|
||||
{ to: '/players', label: 'Players', icon: <Users /> },
|
||||
{ to: '/bans', label: 'Bans', icon: <Ban /> },
|
||||
{ to: '/reports', label: 'Reports', icon: <Flag /> },
|
||||
{ to: '/audit', label: 'Audit log', icon: <ScrollText /> },
|
||||
{ to: '/logs', label: 'Player logs', icon: <Activity /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: <Building2 /> },
|
||||
{ to: '/import-room', label: 'Import room', icon: <Upload /> },
|
||||
{ to: '/store', label: 'Store catalog', icon: <ShoppingBag /> },
|
||||
{ to: '/community', label: 'Community board', icon: <MessageSquare /> },
|
||||
{ to: '/loading-tips', label: 'Loading screen tips', icon: <MessageSquare /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
items: [
|
||||
{ to: '/broadcast', label: 'Broadcast', icon: <Megaphone /> },
|
||||
{ to: '/gift', label: 'Gift items', icon: <Gift />, new: true },
|
||||
{ to: '/passwords', label: 'Reset passwords', icon: <KeyRound />, new: true },
|
||||
{ to: '/grants', label: 'Grant maker pen', icon: <Wrench />, new: true },
|
||||
{ to: '/settings', label: 'Server settings', icon: <Settings />, new: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Two-mode rendering:
|
||||
// * Desktop (md+): always-visible static rail to the left of <main>.
|
||||
// * Mobile: hidden by default; opens as a slide-in drawer with a
|
||||
// backdrop scrim, controlled by `open` from <Layout>. Tapping any
|
||||
// nav link calls `onNavigate()` so the drawer closes automatically
|
||||
// after the user picks a destination. Escape also closes.
|
||||
export function Sidebar({ open, onClose, onNavigate }: { open: boolean; onClose: () => void; onNavigate: () => void }) {
|
||||
// Lock body scroll while the mobile drawer is up so the page behind
|
||||
// doesn't scroll through the backdrop. Skip on desktop where `open`
|
||||
// is irrelevant (the sidebar is always there).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="px-2 pb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-md bg-gradient-to-br from-brand-400 to-brand-600 flex items-center justify-center font-bold text-white">D</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-ink-50">DorkNet</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">admin</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="md:hidden rounded-md p-1 text-ink-400 hover:bg-ink-800 hover:text-ink-200"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-3 overflow-y-auto">
|
||||
{groups.map(group => (
|
||||
<div key={group.title}>
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-widest text-ink-400">{group.title}</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
`group flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-500/15 text-brand-100 ring-1 ring-inset ring-brand-500/30'
|
||||
: 'text-ink-200 hover:bg-ink-800 hover:text-ink-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-ink-300 group-[.active]:text-brand-200">{item.icon}</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.new && <span className="badge badge-neutral !bg-brand-500/20 !text-brand-200">new</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile drawer + backdrop */}
|
||||
<div
|
||||
className={`md:hidden fixed inset-0 z-40 transition-opacity ${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
|
||||
onClick={onClose}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ink-950/70 backdrop-blur-sm" />
|
||||
</div>
|
||||
<aside
|
||||
className={`md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] flex flex-col gap-1 border-r border-ink-800 bg-ink-900 px-3 py-4 transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`}
|
||||
aria-hidden={!open}
|
||||
role="dialog"
|
||||
aria-label="Navigation"
|
||||
>
|
||||
{inner}
|
||||
</aside>
|
||||
{/* Desktop static rail */}
|
||||
<aside className="hidden md:flex md:w-60 lg:w-64 flex-col gap-1 border-r border-ink-800 bg-ink-900/60 px-3 py-4">
|
||||
{inner}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Kind = 'info' | 'success' | 'error';
|
||||
interface ToastMsg { id: number; kind: Kind; text: string }
|
||||
|
||||
interface Ctx { push: (text: string, kind?: Kind) => void }
|
||||
const ToastCtx = createContext<Ctx | null>(null);
|
||||
|
||||
export function useToast(): Ctx {
|
||||
const ctx = useContext(ToastCtx);
|
||||
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<ToastMsg[]>([]);
|
||||
const push = useCallback((text: string, kind: Kind = 'info') => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems(prev => [...prev, { id, kind, text }]);
|
||||
setTimeout(() => setItems(prev => prev.filter(t => t.id !== id)), 4000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={{ push }}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
|
||||
{items.map(t => (
|
||||
<ToastItem key={t.id} msg={t} />
|
||||
))}
|
||||
</div>
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ msg }: { msg: ToastMsg }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => { requestAnimationFrame(() => setOpen(true)); }, []);
|
||||
const colors =
|
||||
msg.kind === 'success' ? 'bg-success/15 text-success border-success/30'
|
||||
: msg.kind === 'error' ? 'bg-danger/15 text-danger border-danger/30'
|
||||
: 'bg-ink-800 text-ink-100 border-ink-700';
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto min-w-[240px] max-w-sm rounded-lg border px-3.5 py-2 text-sm shadow-card transition-all duration-200 ${colors} ${open ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0'}`}
|
||||
role="status"
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user