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
+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>
);
}