mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -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,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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user