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
+46
View File
@@ -0,0 +1,46 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { Header } from './components/Header';
import { Home } from './pages/Home';
import { Feed } from './pages/Feed';
import { PhotoDetail } from './pages/PhotoDetail';
import { Players } from './pages/Players';
import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms';
import { NotFound } from './pages/NotFound';
export function App() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 mx-auto w-full max-w-6xl px-4 sm:px-6 py-6">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/feed" element={<Feed />} />
<Route path="/photo/:id" element={<PhotoDetail />} />
<Route path="/players" element={<Players />} />
<Route path="/players/:id" element={<PlayerProfile />} />
<Route path="/rooms" element={<Rooms />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<footer className="border-t border-ink-800 text-xs text-ink-400">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-2">
<span>DorkNet — a private Rec Room server.</span>
<span>
<a className="hover:text-ink-200" href="https://github.com/Alexa-RR/Dorknet">GitHub</a>
<span className="mx-2">·</span>
<a className="hover:text-ink-200" href="/admin">Admin</a>
</span>
</div>
</footer>
</div>
);
}
// Small redirect shims so old #/p/123 / #/u/45 links keep working.
import { useParams } from 'react-router-dom';
function RedirectPhoto() { const { id } = useParams(); return <Navigate to={`/photo/${id}`} replace />; }
function RedirectPlayer() { const { id } = useParams(); return <Navigate to={`/players/${id}`} replace />; }
@@ -0,0 +1,8 @@
export function Empty({ title, blurb }: { title: string; blurb?: string }) {
return (
<div className="py-12 text-center">
<div className="text-sm font-medium text-ink-200">{title}</div>
{blurb && <div className="mt-1 text-xs text-ink-400">{blurb}</div>}
</div>
);
}
@@ -0,0 +1,103 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';
const NAV = [
{ to: '/', label: 'Home', exact: true },
{ to: '/feed', label: 'Feed' },
{ to: '/players', label: 'Players' },
{ to: '/rooms', label: 'Rooms' },
];
export function Header() {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const nav = useNavigate();
const loc = useLocation();
const inputRef = useRef<HTMLInputElement>(null);
// Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page.
useEffect(() => { setOpen(false); }, [loc.pathname]);
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const v = q.trim();
if (v) nav(`/players?q=${encodeURIComponent(v)}`);
};
return (
<header className="sticky top-0 z-40 border-b border-ink-800 bg-ink-950/85 backdrop-blur">
<div className="mx-auto max-w-6xl flex items-center gap-4 px-4 sm:px-6 py-3">
<Link to="/" className="flex items-center gap-2 shrink-0">
<span className="inline-block size-6 rounded-md bg-gradient-to-br from-brand-400 to-brand-700 shadow-inner" />
<span className="font-semibold text-ink-50 tracking-tight">DorkNet</span>
</Link>
<nav className="hidden md:flex items-center gap-1 ml-2">
{NAV.map(n => (
<NavLink
key={n.to}
to={n.to}
end={n.exact}
className={({ isActive }) =>
`px-3 py-1.5 rounded-md text-sm transition-colors ${
isActive ? 'bg-ink-800 text-ink-50' : 'text-ink-300 hover:bg-ink-800/60 hover:text-ink-100'
}`
}
>
{n.label}
</NavLink>
))}
</nav>
<form onSubmit={submitSearch} className="ml-auto hidden sm:flex flex-1 max-w-xs">
<input
ref={inputRef}
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Search players…"
className="input"
/>
</form>
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-label="Toggle menu"
className="md:hidden btn-ghost text-sm ml-auto"
>
{open ? 'Close' : 'Menu'}
</button>
</div>
{open && (
<div className="md:hidden border-t border-ink-800 bg-ink-900">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-3 flex flex-col gap-2">
<form onSubmit={submitSearch} className="flex">
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Search players…"
className="input"
/>
</form>
<nav className="flex flex-col gap-1">
{NAV.map(n => (
<NavLink
key={n.to}
to={n.to}
end={n.exact}
className={({ isActive }) =>
`px-3 py-2 rounded-md text-sm ${isActive ? 'bg-ink-800 text-ink-50' : 'text-ink-200 hover:bg-ink-800/60'}`
}
>
{n.label}
</NavLink>
))}
</nav>
</div>
</div>
)}
</header>
);
}
@@ -0,0 +1,55 @@
import { Link } from 'react-router-dom';
import type { SitePhoto } from '../lib/types';
import { PlayerAvatar } from './PlayerAvatar';
import { num, relativeTime } from '../lib/format';
// Single-photo tile for the masonry feed. The outer block is a Link
// to the photo-detail route; the avatar inside is its OWN Link to the
// uploader profile — the inner Link wins click-precedence because
// React Router stops event propagation. event.stopPropagation on the
// inner anchor preserves that even on touch devices where iOS
// occasionally fires both handlers.
export function PhotoCard({ photo }: { photo: SitePhoto }) {
return (
<Link
to={`/photo/${photo.id}`}
className="card overflow-hidden block transition-transform hover:-translate-y-0.5 hover:shadow-lg"
>
<div className="relative">
<img
src={photo.imageUrl}
alt={photo.caption || `Photo by ${photo.uploaderDisplayName}`}
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
className="w-full h-auto bg-ink-800 object-cover"
/>
</div>
<div className="p-2.5">
<Link
to={`/players/${photo.uploaderPlayerId}`}
onClick={e => e.stopPropagation()}
className="flex items-center gap-2 group"
>
<PlayerAvatar
name={photo.uploaderProfileImageName}
displayName={photo.uploaderDisplayName}
size={24}
/>
<span className="text-xs text-ink-200 group-hover:text-ink-50 truncate">
{photo.uploaderDisplayName}
</span>
</Link>
{photo.caption && (
<p className="mt-2 text-xs text-ink-300 line-clamp-2">{photo.caption}</p>
)}
<div className="mt-2 flex items-center justify-between text-[11px] text-ink-400">
<span title="cheers">♥ {num(photo.cheerCount)}</span>
{photo.roomName
? <span className="font-mono truncate ml-2" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span>
: <span>{relativeTime(photo.createdAt)}</span>
}
</div>
</div>
</Link>
);
}
@@ -0,0 +1,45 @@
import { useState } from 'react';
import { profileImageUrl } from '../lib/types';
// Round avatar with onError fallback to a gradient circle stamped with
// the display-name initial. Mirrors the admin SPA's PlayerAvatar so the
// two surfaces feel consistent. img.* CDN signs the URL via the p1
// key-id baked into the patched client; browsers either render or 404,
// no extra client-side handling required.
export function PlayerAvatar({
name,
displayName,
size = 36,
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"
/>
);
}
+44
View File
@@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body, #root { height: 100%; }
body {
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
::selection { background-color: theme('colors.brand.500'); color: white; }
}
@layer components {
.card { @apply rounded-xl border border-ink-800 bg-ink-900 shadow-card; }
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium
transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500/40 focus:ring-offset-2 focus:ring-offset-ink-950
disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary { @apply btn bg-brand-500 text-white hover:bg-brand-400 active:bg-brand-600; }
.btn-secondary { @apply btn bg-ink-800 text-ink-100 hover:bg-ink-700 border border-ink-700; }
.btn-ghost { @apply btn bg-transparent text-ink-200 hover:bg-ink-800; }
.input {
@apply w-full rounded-lg border border-ink-700 bg-ink-900 px-3 py-2 text-sm text-ink-100
placeholder:text-ink-400 focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/30;
}
.label { @apply text-xs font-medium uppercase tracking-wide text-ink-300; }
.badge {
@apply inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider;
}
.badge-admin { @apply badge bg-brand-500/15 text-brand-200; }
.badge-junior { @apply badge bg-warn/15 text-warn; }
.badge-neutral { @apply badge bg-ink-800 text-ink-200; }
/* Photo grid — Pinterest-style masonry via CSS columns. column-count
stays responsive; column-gap matches the gap between cards. Each
card needs break-inside: avoid so a tall image doesn't get sliced. */
.photo-grid { @apply gap-3; column-count: 2; column-gap: 0.75rem; }
@screen sm { .photo-grid { column-count: 3; } }
@screen lg { .photo-grid { column-count: 4; } }
@screen xl { .photo-grid { column-count: 5; } }
.photo-grid > * { @apply mb-3; break-inside: avoid; display: inline-block; width: 100%; }
}
+28
View File
@@ -0,0 +1,28 @@
// Lightweight fetch wrapper for the public-facing site. Talks to the
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only —
// the public site has no login flow, so there's no Authorization
// header or token plumbing.
const BASE = '/api/site/v1';
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'ApiError';
}
}
export async function get<T = unknown>(path: string): Promise<T> {
const res = await fetch(BASE + path);
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try {
const body = await res.json();
if (body?.error) msg = body.error;
else if (body?.message) msg = body.message;
} catch { /* keep status text */ }
throw new ApiError(res.status, msg);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
+25
View File
@@ -0,0 +1,25 @@
// Friendly date formatting — mirrors the admin SPA's helper but kept
// local to avoid sharing source across two TS projects.
export function relativeTime(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(+d)) return '';
const s = (Date.now() - d.getTime()) / 1000;
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
if (s < 604800) return `${Math.floor(s / 86400)}d ago`;
return d.toLocaleDateString();
}
export function absoluteTime(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleString();
}
export function num(n: number | null | undefined): string {
if (n === null || n === undefined) return '0';
return n.toLocaleString();
}
+70
View File
@@ -0,0 +1,70 @@
// Wire shapes returned by PublicSiteController. All keys are
// camelCase (server uses default ASP.NET serializer for this
// controller; only the legacy game-facing endpoints stick with
// PascalCase to placate LitJson on the watch).
export interface SitePlayerCard {
id: number;
username: string;
displayName: string;
level: number;
isAdmin: boolean;
isDeveloper: boolean;
isVerified: boolean;
isJunior: boolean;
profileImageName: string | null;
}
export interface SitePlayerDetail extends SitePlayerCard {
bio: string;
xp: number;
createdAt: string;
photoCount: number;
}
export interface SitePhoto {
id: number;
uploaderPlayerId: number;
uploaderUsername: string;
uploaderDisplayName: string;
uploaderProfileImageName: string | null;
blobName: string;
imageUrl: string;
caption: string;
roomId: number;
roomName: string;
isPublic: boolean;
cheerCount: number;
viewCount: number;
createdAt: string;
}
export interface SiteRoom {
id: number;
name: string;
description: string;
creatorPlayerId: number;
isDormRoom: boolean;
isAGRoom: boolean;
visitCount: number;
visitorCount: number;
cheerCount: number;
imageName: string;
}
export interface SiteStats {
playerCount: number;
roomCount: number;
photoCount: number;
inventionCount: number;
}
// CDN URL helper for profile images. Mirrors the admin SPA's helper
// so the apex visit ends up calling img.localhost (signed via the
// hardcoded p1 key id baked into the patched client). On rec.net
// fallbacks to img.rec.net so the site works on either apex.
export function profileImageUrl(name: string | null | undefined, width = 96): string | null {
if (!name) return null;
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
return `https://img.${apex}/${encodeURIComponent(name)}?width=${width}&cropSquare=1&sig=p1`;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+69
View File
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from 'react';
import { get } from '../lib/api';
import type { SitePhoto } from '../lib/types';
import { PhotoCard } from '../components/PhotoCard';
import { Empty } from '../components/Empty';
const PAGE = 24;
export function Feed() {
const [items, setItems] = useState<SitePhoto[]>([]);
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [err, setErr] = useState<string | null>(null);
const loadMore = useCallback(async () => {
if (loading || done) return;
setLoading(true);
setErr(null);
try {
const next = await get<SitePhoto[]>(`/feed?take=${PAGE}&skip=${items.length}`);
setItems(prev => [...prev, ...next]);
if (next.length < PAGE) setDone(true);
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
}, [items.length, loading, done]);
// Initial load.
useEffect(() => { void loadMore(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Photo feed</h1>
<p className="text-sm text-ink-400">Pictures from across the server, newest first.</p>
</div>
</div>
{err && (
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">
{err}
</div>
)}
{items.length === 0 && !loading && !err && (
<Empty title="No photos yet" blurb="Pick up the in-game camera, snap a shot, and tap Share." />
)}
{items.length > 0 && (
<div className="photo-grid">
{items.map(p => <PhotoCard key={p.id} photo={p} />)}
</div>
)}
<div className="py-6 text-center">
{!done ? (
<button onClick={loadMore} disabled={loading} className="btn-secondary text-sm">
{loading ? 'Loading…' : 'Load more'}
</button>
) : items.length > 0 && (
<div className="text-xs text-ink-500">You've reached the end.</div>
)}
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { get } from '../lib/api';
import type { SitePhoto, SiteStats } from '../lib/types';
import { PhotoCard } from '../components/PhotoCard';
import { num } from '../lib/format';
export function Home() {
const [stats, setStats] = useState<SiteStats | null>(null);
const [photos, setPhotos] = useState<SitePhoto[] | null>(null);
useEffect(() => {
get<SiteStats>('/stats').then(setStats).catch(() => setStats(null));
get<SitePhoto[]>('/feed?take=8').then(setPhotos).catch(() => setPhotos([]));
}, []);
return (
<div className="space-y-10">
<section className="card !p-6 sm:!p-10 relative overflow-hidden">
<div className="absolute -top-20 -right-24 size-72 rounded-full bg-brand-500/20 blur-3xl pointer-events-none" />
<div className="absolute -bottom-20 -left-24 size-72 rounded-full bg-brand-700/20 blur-3xl pointer-events-none" />
<div className="relative max-w-2xl">
<h1 className="text-3xl sm:text-5xl font-bold text-ink-50 tracking-tight">
Rec Room, kept alive.
</h1>
<p className="mt-3 text-ink-300 text-base sm:text-lg">
DorkNet is a private server for the 2020.03.10 Rec Room client. Browse the
photo feed below, find friends, and explore rooms made by the community.
</p>
<div className="mt-5 flex flex-wrap gap-2">
<Link to="/feed" className="btn-primary text-sm">Browse the feed</Link>
<Link to="/players" className="btn-secondary text-sm">Find players</Link>
</div>
</div>
{stats && (
<div className="relative mt-8 grid grid-cols-2 sm:grid-cols-4 gap-3 max-w-2xl">
<Stat label="Players" value={num(stats.playerCount)} />
<Stat label="Rooms" value={num(stats.roomCount)} />
<Stat label="Photos" value={num(stats.photoCount)} />
<Stat label="Inventions" value={num(stats.inventionCount)} />
</div>
)}
</section>
<section>
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-lg font-semibold text-ink-50">Latest photos</h2>
<Link to="/feed" className="text-xs text-ink-300 hover:text-ink-100">View all →</Link>
</div>
{photos === null
? <div className="text-xs text-ink-400 py-6">Loading photos…</div>
: photos.length === 0
? <div className="card !p-6 text-center text-sm text-ink-400">
No photos yet — be the first to share one from the in-game camera.
</div>
: <div className="photo-grid">
{photos.map(p => <PhotoCard key={p.id} photo={p} />)}
</div>
}
</section>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-ink-800 bg-ink-900/60 backdrop-blur px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className="text-lg font-semibold text-ink-50 tabular-nums">{value}</div>
</div>
);
}
@@ -0,0 +1,11 @@
import { Link } from 'react-router-dom';
export function NotFound() {
return (
<div className="card !p-8 text-center">
<h2 className="text-xl font-semibold text-ink-50">Page not found</h2>
<p className="text-sm text-ink-400 mt-1">The page you were looking for doesn't exist on this server.</p>
<Link to="/" className="btn-primary text-sm mt-4 inline-flex">Go home</Link>
</div>
);
}
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { get } from '../lib/api';
import type { SitePhoto } from '../lib/types';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { absoluteTime, num, relativeTime } from '../lib/format';
export function PhotoDetail() {
const { id } = useParams<{ id: string }>();
const [photo, setPhoto] = useState<SitePhoto | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
setErr(null);
setPhoto(null);
get<SitePhoto>(`/photos/${id}`).then(setPhoto).catch(e => setErr((e as Error).message));
}, [id]);
if (err) return (
<div className="card !p-6">
<h2 className="text-lg font-semibold text-ink-50">Photo not found</h2>
<p className="text-sm text-ink-400 mt-1">{err}</p>
<Link to="/feed" className="btn-secondary text-sm mt-4">← Back to feed</Link>
</div>
);
if (!photo) return <div className="py-10 text-center text-xs text-ink-400">Loading…</div>;
return (
<div className="space-y-4">
<Link to="/feed" className="btn-ghost text-xs">← Back to feed</Link>
<div className="card overflow-hidden grid grid-cols-1 lg:grid-cols-[1fr,360px]">
<div className="bg-ink-950 flex items-center justify-center">
<img
src={photo.imageUrl}
alt={photo.caption || `Photo #${photo.id}`}
className="w-full h-auto max-h-[80vh] object-contain"
/>
</div>
<aside className="p-5 border-t lg:border-t-0 lg:border-l border-ink-800 flex flex-col gap-4">
<div>
<Link
to={`/players/${photo.uploaderPlayerId}`}
className="flex items-center gap-3 group"
>
<PlayerAvatar
name={photo.uploaderProfileImageName}
displayName={photo.uploaderDisplayName}
size={44}
/>
<div className="min-w-0">
<div className="font-semibold text-ink-50 group-hover:text-brand-200 truncate">
{photo.uploaderDisplayName}
</div>
<div className="text-xs text-ink-400 truncate">@{photo.uploaderUsername}</div>
</div>
</Link>
</div>
{photo.caption && (
<p className="text-sm text-ink-200 whitespace-pre-wrap">{photo.caption}</p>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-ink-800 bg-ink-900/60 px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">Cheers</div>
<div className="text-sm font-semibold text-ink-50 tabular-nums">{num(photo.cheerCount)}</div>
</div>
<div className="rounded-md border border-ink-800 bg-ink-900/60 px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">Views</div>
<div className="text-sm font-semibold text-ink-50 tabular-nums">{num(photo.viewCount)}</div>
</div>
</div>
<div className="text-xs text-ink-400 space-y-1">
{photo.roomName && (
<div>
<span className="text-ink-500">In room: </span>
<span className="font-mono text-ink-200">^{photo.roomName}</span>
</div>
)}
<div title={absoluteTime(photo.createdAt)}>
<span className="text-ink-500">Posted: </span>
<span className="text-ink-200">{relativeTime(photo.createdAt)}</span>
</div>
</div>
</aside>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { get } from '../lib/api';
import type { SitePhoto, SitePlayerDetail } from '../lib/types';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { PhotoCard } from '../components/PhotoCard';
import { Empty } from '../components/Empty';
import { absoluteTime, num, relativeTime } from '../lib/format';
export function PlayerProfile() {
const { id } = useParams<{ id: string }>();
const [player, setPlayer] = useState<SitePlayerDetail | null>(null);
const [photos, setPhotos] = useState<SitePhoto[] | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
setErr(null);
setPlayer(null);
setPhotos(null);
get<SitePlayerDetail>(`/players/${id}`)
.then(setPlayer)
.catch(e => setErr((e as Error).message));
get<SitePhoto[]>(`/players/${id}/photos?take=48`).then(setPhotos).catch(() => setPhotos([]));
}, [id]);
if (err) return (
<div className="card !p-6">
<h2 className="text-lg font-semibold text-ink-50">Player not found</h2>
<p className="text-sm text-ink-400 mt-1">{err}</p>
<Link to="/players" className="btn-secondary text-sm mt-4">← Back to players</Link>
</div>
);
if (!player) return <div className="py-10 text-center text-xs text-ink-400">Loading…</div>;
return (
<div className="space-y-6">
<Link to="/players" className="btn-ghost text-xs">← Back to players</Link>
<header className="card !p-5 flex flex-col sm:flex-row gap-4 sm:items-center">
<PlayerAvatar
name={player.profileImageName}
displayName={player.displayName || player.username}
size={88}
/>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold text-ink-50 truncate">{player.displayName || player.username}</h1>
<div className="text-sm text-ink-400 truncate">@{player.username} <span className="text-ink-600">·</span> #{player.id}</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{player.isAdmin && <span className="badge-admin">Admin</span>}
{player.isDeveloper && <span className="badge-neutral">Developer</span>}
{player.isVerified && <span className="badge-neutral">Verified</span>}
{player.isJunior && <span className="badge-junior">Junior</span>}
</div>
{player.bio && (
<p className="mt-3 text-sm text-ink-200 whitespace-pre-wrap">{player.bio}</p>
)}
</div>
<div className="grid grid-cols-3 sm:grid-cols-1 sm:w-32 gap-2 text-xs">
<Stat label="Level" value={num(player.level)} />
<Stat label="Photos" value={num(player.photoCount)} />
<Stat label="Joined" value={relativeTime(player.createdAt)} title={absoluteTime(player.createdAt)} />
</div>
</header>
<section>
<h2 className="text-lg font-semibold text-ink-50 mb-3">Photos</h2>
{photos === null
? <div className="text-xs text-ink-400 py-6">Loading…</div>
: photos.length === 0
? <Empty title="No photos yet" blurb={`${player.displayName || player.username} hasn't shared any photos.`} />
: <div className="photo-grid">
{photos.map(p => <PhotoCard key={p.id} photo={p} />)}
</div>
}
</section>
</div>
);
}
function Stat({ label, value, title }: { label: string; value: string; title?: string }) {
return (
<div className="rounded-md border border-ink-800 bg-ink-900/60 px-3 py-2 text-center" title={title}>
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className="text-sm font-semibold text-ink-50 truncate">{value}</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { get } from '../lib/api';
import type { SitePlayerCard } from '../lib/types';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { Empty } from '../components/Empty';
export function Players() {
const [params, setParams] = useSearchParams();
const initialQ = params.get('q') ?? '';
const [q, setQ] = useState(initialQ);
const [rows, setRows] = useState<SitePlayerCard[] | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Debounced search — fires 250 ms after the last keystroke. Reuses
// the URL ?q= param so search results are linkable / shareable and
// the back button restores the last query.
const debounce = useMemo(() => {
let t: number | undefined;
return (value: string) => {
window.clearTimeout(t);
t = window.setTimeout(() => {
setParams(value ? { q: value } : {}, { replace: true });
if (!value.trim()) { setRows([]); return; }
setLoading(true);
setErr(null);
get<SitePlayerCard[]>(`/players/search?q=${encodeURIComponent(value.trim())}&take=40`)
.then(setRows)
.catch(e => setErr((e as Error).message))
.finally(() => setLoading(false));
}, 250);
};
}, [setParams]);
useEffect(() => { debounce(q); }, [q, debounce]);
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Players</h1>
<p className="text-sm text-ink-400">Find an account by display name or username.</p>
</div>
<div className="card !p-3 flex items-center gap-2">
<input
autoFocus
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Search players by name…"
className="input"
/>
</div>
{err && (
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>
)}
{loading && <div className="py-6 text-center text-xs text-ink-400">Searching…</div>}
{!loading && rows && rows.length === 0 && q.trim().length > 0 && (
<Empty title="No matches" blurb="Try a different name." />
)}
{!loading && (!q.trim() || rows === null) && (
<Empty title="Type to search" blurb="Start typing a username or display name above." />
)}
{rows && rows.length > 0 && (
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{rows.map(p => (
<li key={p.id}>
<Link
to={`/players/${p.id}`}
className="card !p-3 flex items-center gap-3 transition-colors hover:bg-ink-800/60"
>
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={44} />
<div className="min-w-0 flex-1">
<div className="font-medium text-ink-50 truncate flex items-center gap-1.5">
{p.displayName || p.username}
{p.isAdmin && <span className="badge-admin">Admin</span>}
{p.isVerified && !p.isAdmin && <span className="badge-neutral">Verified</span>}
{p.isJunior && <span className="badge-junior">Junior</span>}
</div>
<div className="text-xs text-ink-400 truncate">
@{p.username} <span className="text-ink-600">·</span> #{p.id} <span className="text-ink-600">·</span> Lv {p.level}
</div>
</div>
<span className="text-xs text-ink-500">→</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useMemo, useState } from 'react';
import { get } from '../lib/api';
import type { SiteRoom } from '../lib/types';
import { Empty } from '../components/Empty';
import { num } from '../lib/format';
export function Rooms() {
const [q, setQ] = useState('');
const [rows, setRows] = useState<SiteRoom[] | null>(null);
const [loading, setLoading] = useState(false);
const debounce = useMemo(() => {
let t: number | undefined;
return (value: string) => {
window.clearTimeout(t);
t = window.setTimeout(() => {
setLoading(true);
get<SiteRoom[]>(`/rooms/search?q=${encodeURIComponent(value.trim())}&take=40`)
.then(setRows).catch(() => setRows([])).finally(() => setLoading(false));
}, 200);
};
}, []);
useEffect(() => { debounce(q); }, [q, debounce]);
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Rooms</h1>
<p className="text-sm text-ink-400">Browse the community-made rooms on the server.</p>
</div>
<div className="card !p-3">
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Filter rooms by name…"
className="input"
/>
</div>
{loading && <div className="py-6 text-center text-xs text-ink-400">Loading…</div>}
{!loading && rows && rows.length === 0 && (
<Empty title="No rooms match" blurb="Try a shorter or different query." />
)}
{rows && rows.length > 0 && (
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{rows.map(r => <li key={r.id}><RoomCard room={r} /></li>)}
</ul>
)}
</div>
);
}
function RoomCard({ room }: { room: SiteRoom }) {
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
const img = room.imageName ? `https://img.${apex}/${encodeURIComponent(room.imageName)}?width=320&sig=p1` : null;
return (
<div className="card overflow-hidden">
<div className="aspect-[16/9] bg-ink-800 flex items-center justify-center">
{img
? <img src={img} alt={room.name} className="size-full object-cover" loading="lazy"
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
: <span className="text-ink-500 font-mono text-sm">^{room.name}</span>
}
</div>
<div className="p-3 space-y-1">
<div className="flex items-center gap-1.5">
<span className="font-medium text-ink-50 truncate">^{room.name}</span>
{room.isDormRoom && <span className="badge-neutral">Dorm</span>}
{!room.isAGRoom && !room.isDormRoom && <span className="badge-neutral">Custom</span>}
</div>
{room.description && (
<p className="text-xs text-ink-400 line-clamp-2">{room.description}</p>
)}
<div className="flex gap-3 text-[11px] text-ink-500 pt-1">
<span>{num(room.visitCount)} visits</span>
<span>{num(room.visitorCount)} visitors</span>
<span>♥ {num(room.cheerCount)}</span>
</div>
</div>
</div>
);
}