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,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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user