mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 23:48:45 -07:00
Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).
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 dorknet-clientpatch-{key}.zip
for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
dorknet-server-march_2020_03_10-win-x64.zip and
dorknet-clientpatch-march_2020_03_10.zip as Release assets.
Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
This commit is contained in:
@@ -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