diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 3cbd353..e4f1aae 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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. + ) : customAvatarItemId !== null ? ( + // The game's share link for a store item (`/d/store/customavataritem/`), 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. + ) : lookupUsername !== null ? ( ) : 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({
{siteKey && (
- @@ -1984,7 +2020,7 @@ function LoginPage({ {siteKey && (

Don't have an account?{' '} - + Create one

diff --git a/apps/www/src/client/Item.tsx b/apps/www/src/client/Item.tsx new file mode 100644 index 0000000..d3a091c --- /dev/null +++ b/apps/www/src/client/Item.tsx @@ -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/`. + * + * That path is what the game puts in the clipboard for "Share" on a store item — "Check + * out this item in RecRoom: https:///d/store/customavataritem/" — 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/` 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 { + const items = await call(`${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 = { + 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(undefined) + const [creator, setCreator] = useState(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 ( +
+
+

Sign in to see this item

+

+ Items in the store are shown to signed-in players. Sign in and you'll be brought + straight back here. +

+

+ { + e.preventDefault() + navigate(`/login?next=${encodeURIComponent(here)}`) + }} + > + Sign in + +

+
+
+ ) + } + + if (error) { + return ( +
+

{error}

+
+ ) + } + if (item === undefined) { + return ( +
+

Loading…

+
+ ) + } + if (item === null) { + return ( +
+

+ There's no item here — it hasn't been published, or it doesn't exist. +

+
+ ) + } + + 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 ( +
+
+ {thumb ? ( + + ) : ( +
+ )} +
+
+

{item.Name}

+ + {published ? 'Published' : 'Unpublished'} + +
+ {creator && ( +

+ by{' '} + { + e.preventDefault() + navigate(`/u/${encodeURIComponent(creator.username)}`) + }} + > + {creator.displayName || creator.username} + +

+ )} + {item.Description ? ( +

{item.Description}

+ ) : ( +

No description.

+ )} +

+ {item.Price > 0 ? `${item.Price.toLocaleString()} tokens` : 'Free'} + {item.IsFeatured ? ' · Featured' : ''} + {item.IsRecRoomApproved ? ' · Approved' : ''} +

+
+
+ +
+

About this item

+
+
Price
+
{item.Price > 0 ? `${item.Price.toLocaleString()} tokens` : 'Free'}
+
Type
+
+ {outfitTypeLabel(item.OutfitType)} + {firstParty ? ' · official item' : ''} +
+ {item.BaseAvatarItemColor && ( + <> +
Base colour
+
+ {' '} + {item.BaseAvatarItemColor} +
+ + )} +
Tags
+
{item.Tags?.length ? item.Tags.map((t) => `#${t.Value}`).join(' ') : 'None'}
+
Created
+
{formatDate(item.CreatedAt)}
+
Updated
+
{formatDate(item.ModifiedAt)}
+
Item id
+
+ {item.CustomAvatarItemId} +
+
+
+
+ ) +} diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index b0b1834..0f301a4 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -2024,3 +2024,24 @@ button[type='submit']:disabled { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +/* ---- Custom avatar item page (/d/store/customavataritem/) -------------- */ + +/* 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); +}