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:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
@@ -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>
);
}