[www] implement share link for custom items

This commit is contained in:
Devin Zuczek
2026-09-27 13:09:43 -04:00
parent fd5d0e6330
commit 53a46d91bc
3 changed files with 336 additions and 6 deletions
+42 -6
View File
@@ -27,6 +27,7 @@ import {
usernamesFor,
where,
} from './api'
import { customAvatarItemIdFromPath, ItemPage } from './Item'
import { ModerationPage } from './Moderation'
import { StatsPage } from './Stats'
@@ -1506,6 +1507,7 @@ export function App() {
const { path, search, navigate } = useRouter()
const roomId = roomIdFromPath(path)
const lookupUsername = usernameFromPath(path)
const customAvatarItemId = customAvatarItemIdFromPath(path)
useEffect(() => {
// Config first, and everything else after it: it carries the hostnames every other
@@ -1554,6 +1556,7 @@ export function App() {
account={account}
config={config}
initialTab={path === '/signup' ? 'signup' : 'login'}
search={search}
navigate={navigate}
onAuthed={setAccount}
/>
@@ -1580,6 +1583,16 @@ export function App() {
// its data comes from `/api/stats/online`, which the existing `/api/*` allowlist
// entry already sends to the Worker.
<StatsPage search={search} navigate={navigate} />
) : customAvatarItemId !== null ? (
// The game's share link for a store item (`/d/store/customavataritem/<uuid>`), so
// the path is the client's, not ours. Not in `run_worker_first` either: a cold
// load falls through to the SPA shell like every other page here. Gated on the
// session rather than the config because the lookup behind it needs a token.
<ItemPage
itemId={customAvatarItemId}
signedIn={account === undefined ? undefined : account !== null}
navigate={navigate}
/>
) : lookupUsername !== null ? (
<PlayerPage username={lookupUsername} config={config} navigate={navigate} />
) : roomId !== null ? (
@@ -1917,16 +1930,29 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
* session exists, however it was obtained.
*/
/**
* A `?next=` worth redirecting to after sign-in, or null. Only an absolute PATH on this
* site qualifies: it has to start with one slash and not two (`//host` is scheme-relative,
* i.e. another site), and carry no scheme. Anything else is dropped rather than repaired.
*/
function safeNextPath(raw: string | null): string | null {
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return null
return raw
}
function LoginPage({
account,
config,
initialTab,
search,
navigate,
onAuthed,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
initialTab: 'signup' | 'login'
/** The query string, for the `?next=` a page that needs a session sends people here with. */
search: string
navigate: Navigate
onAuthed: (a: SelfAccount) => void
}) {
@@ -1934,13 +1960,19 @@ function LoginPage({
// never disagree — switching tabs pushes history, and back goes back to the other one.
const tab = initialTab
// Where to go once signed in: the page that sent the visitor here, when one did (an
// item's share link needs a session to render), otherwise the account page. Only a
// same-site path is honoured — one leading slash, so `//evil.example` can't ride in
// through a link someone was handed — since this is a redirect after a sign-in.
const next = safeNextPath(new URLSearchParams(search).get('next')) ?? '/account'
useEffect(() => {
if (account) navigate('/account')
}, [account, navigate])
if (account) navigate(next)
}, [account, navigate, next])
const authed = (a: SelfAccount) => {
onAuthed(a)
navigate('/account')
navigate(next)
}
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
@@ -1950,12 +1982,16 @@ function LoginPage({
<section className="card">
{siteKey && (
<div className="tabs">
<button className={tab === 'login' ? 'active' : ''} onClick={() => navigate('/login')}>
{/* The query rides along, so switching doors keeps the `?next=`. */}
<button
className={tab === 'login' ? 'active' : ''}
onClick={() => navigate(`/login${search}`)}
>
Sign in
</button>
<button
className={tab === 'signup' ? 'active' : ''}
onClick={() => navigate('/signup')}
onClick={() => navigate(`/signup${search}`)}
>
Create account
</button>
@@ -1984,7 +2020,7 @@ function LoginPage({
{siteKey && (
<p className="muted swap">
Don&apos;t have an account?{' '}
<Link to="/signup" navigate={navigate}>
<Link to={`/signup${search}`} navigate={navigate}>
Create one
</Link>
</p>
+273
View File
@@ -0,0 +1,273 @@
import { useEffect, useState } from 'react'
import { call, usernamesFor, where } from './api'
import type { CustomAvatarItem } from '../../../api/src/custom-avatar-items-db'
import type { PublicAccount } from './api'
/**
* A custom avatar item's page, at `/d/store/customavataritem/<id>`.
*
* That path is what the game puts in the clipboard for "Share" on a store item — "Check
* out this item in RecRoom: https://<www>/d/store/customavataritem/<uuid>" — so it has to
* be exactly this, and it's the one URL on this site whose shape isn't ours to choose.
* The uuid is a `CustomAvatarItemId` on `api`'s `custom_avatar_item` table.
*
* Read through `api`'s bulk lookup (`POST /api/customAvatarItems/v1/bulk`), the same call
* the game makes to draw a shelf of items, with a batch of one — there is no single-item
* GET, and www reading the table itself would tie the site to a schema another worker
* owns (the room and player pages go to `rooms` and `accounts` for the same reason). That
* route wants a token, so a signed-out visitor is asked to sign in and brought back here
* afterwards (the `?next=` on the login link). Unpublished items miss for everyone but
* their creator, so a draft's link says "no item here" to anyone else, which is what the
* store says too.
*
* Its own file for the reason Stats.tsx and Moderation.tsx are: a whole surface rather
* than one more section of App.tsx.
*/
/** The `/d/store/customavataritem/<uuid>` path, or null for any other path. */
export function customAvatarItemIdFromPath(path: string): string | null {
// Case-insensitive on the fixed segments: the game writes them lower-case, but a link
// retyped by hand shouldn't 404 on `CustomAvatarItem`. The id is normalised to lower
// case to match how the table stores it.
const match =
/^\/d\/store\/customavataritem\/([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\/?$/i.exec(path)
return match ? match[1]!.toLowerCase() : null
}
/**
* The item, or null when the lookup came back without it: unknown, or unpublished and not
* the caller's. The bulk route answers a bare array of the ids that matched, in request
* order, and a miss is silence rather than an error.
*/
async function fetchCustomAvatarItem(itemId: string): Promise<CustomAvatarItem | null> {
const items = await call<CustomAvatarItem[]>(`${where().api}/api/customAvatarItems/v1/bulk`, {
form: { customAvatarItemIds: itemId },
authed: true,
})
if (!Array.isArray(items)) return null
return items.find((i) => i.CustomAvatarItemId?.toLowerCase() === itemId) ?? null
}
/**
* The image that stands for the item. A player-made shirt has a thumbnail of its own under
* `ThumbnailImageFilename`; a first-party item has none and is drawn from its saves, each of
* which carries the store thumbnail for one body type — the first is as good as any here.
* Both are keys on the `img` worker. Null when there's nothing to show, which shouldn't
* happen for a served item but must not break the page when it does.
*/
function thumbnailKey(item: CustomAvatarItem): string | null {
if (item.ThumbnailImageFilename) return item.ThumbnailImageFilename
const save = item.CurrentSaves?.find((s) => s.ThumbnailFileName)
return save?.ThumbnailFileName ?? null
}
/**
* The `OutfitType` slot's name — `RecRoom.Avatars.OutfitType`, banded by body region (see
* the enum note in api's custom-avatar-items-db.ts). Only the members seen on this table
* are named; anything else is shown by number rather than guessed at.
*/
const OUTFIT_TYPE_LABEL: Record<number, string> = {
0: 'Hat',
2: 'Hair',
3: 'Ears',
10: 'Eyes',
20: 'Beard',
100: 'Shoulders',
101: 'Shirt',
102: 'Waist',
103: 'Neck',
104: 'Team jersey',
105: 'Custom shirt',
200: 'Wrist',
203: 'Team wrist',
300: 'Legs',
301: 'Feet',
500: 'Roomie hat',
501: 'Roomie waist',
502: 'Roomie eyes',
}
function outfitTypeLabel(type: number): string {
return OUTFIT_TYPE_LABEL[type] ?? `Slot ${type}`
}
function formatDate(iso: string): string {
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleDateString()
}
export function ItemPage({
itemId,
signedIn,
navigate,
}: {
itemId: string
/**
* undefined while the stored token is still being checked, null when signed out — the
* lookup can't be made without a session, so the page waits on this rather than on
* the config alone.
*/
signedIn: boolean | undefined
navigate: (to: string) => void
}) {
// undefined = not fetched yet; null = nothing served for this id.
const [item, setItem] = useState<CustomAvatarItem | null | undefined>(undefined)
const [creator, setCreator] = useState<PublicAccount | null>(null)
const [error, setError] = useState('')
useEffect(() => {
setItem(undefined)
setCreator(null)
setError('')
if (!signedIn) return
void fetchCustomAvatarItem(itemId)
.then((found) => {
setItem(found)
if (found)
void usernamesFor([found.CreatorAccountId]).then((names) =>
setCreator(names.get(found.CreatorAccountId) ?? null)
)
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [itemId, signedIn])
const here = `/d/store/customavataritem/${itemId}`
if (signedIn === null || signedIn === false) {
return (
<main className="shell wide">
<section className="card">
<h1>Sign in to see this item</h1>
<p className="muted">
Items in the store are shown to signed-in players. Sign in and you&apos;ll be brought
straight back here.
</p>
<p>
<a
className="cta"
href={`/login?next=${encodeURIComponent(here)}`}
onClick={(e) => {
e.preventDefault()
navigate(`/login?next=${encodeURIComponent(here)}`)
}}
>
Sign in
</a>
</p>
</section>
</main>
)
}
if (error) {
return (
<main className="shell wide">
<p className="error">{error}</p>
</main>
)
}
if (item === undefined) {
return (
<main className="shell wide">
<p className="muted">Loading…</p>
</main>
)
}
if (item === null) {
return (
<main className="shell wide">
<p className="muted">
There&apos;s no item here — it hasn&apos;t been published, or it doesn&apos;t exist.
</p>
</main>
)
}
const thumb = thumbnailKey(item)
// A first-party item is built as Unity assetbundles rather than painted on a base
// shirt; that, not the creator id, is what tells the two kinds apart on the record.
const firstParty = item.CurrentSaves?.length > 0 && !item.DesignFilename
const published = item.Accessibility !== 0
return (
<main className="shell wide">
<section className="card room-hero">
{thumb ? (
<img
className="room-hero-img item-hero-img"
src={`${where().img}/${thumb}?width=512`}
alt=""
/>
) : (
<div className="room-hero-img item-hero-img" />
)}
<div className="room-hero-body">
<div className="room-head">
<h1 className="room-hero-name">{item.Name}</h1>
<span className={`badge ${published ? 'live' : ''}`}>
{published ? 'Published' : 'Unpublished'}
</span>
</div>
{creator && (
<p className="room-hero-by">
by{' '}
<a
href={`/u/${encodeURIComponent(creator.username)}`}
onClick={(e) => {
e.preventDefault()
navigate(`/u/${encodeURIComponent(creator.username)}`)
}}
>
{creator.displayName || creator.username}
</a>
</p>
)}
{item.Description ? (
<p className="muted room-hero-desc">{item.Description}</p>
) : (
<p className="muted room-hero-desc">No description.</p>
)}
<p className="room-stats">
{item.Price > 0 ? `${item.Price.toLocaleString()} tokens` : 'Free'}
{item.IsFeatured ? ' · Featured' : ''}
{item.IsRecRoomApproved ? ' · Approved' : ''}
</p>
</div>
</section>
<section className="card">
<h2>About this item</h2>
<dl className="facts">
<dt>Price</dt>
<dd>{item.Price > 0 ? `${item.Price.toLocaleString()} tokens` : 'Free'}</dd>
<dt>Type</dt>
<dd>
{outfitTypeLabel(item.OutfitType)}
{firstParty ? ' · official item' : ''}
</dd>
{item.BaseAvatarItemColor && (
<>
<dt>Base colour</dt>
<dd>
<span className="swatch" style={{ background: item.BaseAvatarItemColor }} />{' '}
{item.BaseAvatarItemColor}
</dd>
</>
)}
<dt>Tags</dt>
<dd>{item.Tags?.length ? item.Tags.map((t) => `#${t.Value}`).join(' ') : 'None'}</dd>
<dt>Created</dt>
<dd>{formatDate(item.CreatedAt)}</dd>
<dt>Updated</dt>
<dd>{formatDate(item.ModifiedAt)}</dd>
<dt>Item id</dt>
<dd>
<code>{item.CustomAvatarItemId}</code>
</dd>
</dl>
</section>
</main>
)
}
+21
View File
@@ -2024,3 +2024,24 @@ button[type='submit']:disabled {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ---- Custom avatar item page (/d/store/customavataritem/<id>) -------------- */
/* The item's thumbnail: a square PNG with transparent corners, so it's framed whole
rather than cropped to the room hero's 3:2 photo, on the raised surface so a dark
design stays visible on the dark theme. */
.item-hero-img {
aspect-ratio: 1 / 1;
object-fit: contain;
padding: 12px;
}
/* The base item's colour, beside its hex code. */
.swatch {
display: inline-block;
width: 0.9em;
height: 0.9em;
vertical-align: -0.1em;
border-radius: 3px;
border: 1px solid var(--line);
}