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,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"
/>
);
}