mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] implement share link for custom items
This commit is contained in:
@@ -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't have an account?{' '}
|
||||
<Link to="/signup" navigate={navigate}>
|
||||
<Link to={`/signup${search}`} navigate={navigate}>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -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'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's no item here — it hasn't been published, or it doesn'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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user