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,
|
usernamesFor,
|
||||||
where,
|
where,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
import { customAvatarItemIdFromPath, ItemPage } from './Item'
|
||||||
import { ModerationPage } from './Moderation'
|
import { ModerationPage } from './Moderation'
|
||||||
import { StatsPage } from './Stats'
|
import { StatsPage } from './Stats'
|
||||||
|
|
||||||
@@ -1506,6 +1507,7 @@ export function App() {
|
|||||||
const { path, search, navigate } = useRouter()
|
const { path, search, navigate } = useRouter()
|
||||||
const roomId = roomIdFromPath(path)
|
const roomId = roomIdFromPath(path)
|
||||||
const lookupUsername = usernameFromPath(path)
|
const lookupUsername = usernameFromPath(path)
|
||||||
|
const customAvatarItemId = customAvatarItemIdFromPath(path)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Config first, and everything else after it: it carries the hostnames every other
|
// Config first, and everything else after it: it carries the hostnames every other
|
||||||
@@ -1554,6 +1556,7 @@ export function App() {
|
|||||||
account={account}
|
account={account}
|
||||||
config={config}
|
config={config}
|
||||||
initialTab={path === '/signup' ? 'signup' : 'login'}
|
initialTab={path === '/signup' ? 'signup' : 'login'}
|
||||||
|
search={search}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
onAuthed={setAccount}
|
onAuthed={setAccount}
|
||||||
/>
|
/>
|
||||||
@@ -1580,6 +1583,16 @@ export function App() {
|
|||||||
// its data comes from `/api/stats/online`, which the existing `/api/*` allowlist
|
// its data comes from `/api/stats/online`, which the existing `/api/*` allowlist
|
||||||
// entry already sends to the Worker.
|
// entry already sends to the Worker.
|
||||||
<StatsPage search={search} navigate={navigate} />
|
<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 ? (
|
) : lookupUsername !== null ? (
|
||||||
<PlayerPage username={lookupUsername} config={config} navigate={navigate} />
|
<PlayerPage username={lookupUsername} config={config} navigate={navigate} />
|
||||||
) : roomId !== 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
|
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
|
||||||
* session exists, however it was obtained.
|
* 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({
|
function LoginPage({
|
||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
initialTab,
|
initialTab,
|
||||||
|
search,
|
||||||
navigate,
|
navigate,
|
||||||
onAuthed,
|
onAuthed,
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount | null | undefined
|
account: SelfAccount | null | undefined
|
||||||
config: SiteConfig | undefined
|
config: SiteConfig | undefined
|
||||||
initialTab: 'signup' | 'login'
|
initialTab: 'signup' | 'login'
|
||||||
|
/** The query string, for the `?next=` a page that needs a session sends people here with. */
|
||||||
|
search: string
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onAuthed: (a: SelfAccount) => void
|
onAuthed: (a: SelfAccount) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -1934,13 +1960,19 @@ function LoginPage({
|
|||||||
// never disagree — switching tabs pushes history, and back goes back to the other one.
|
// never disagree — switching tabs pushes history, and back goes back to the other one.
|
||||||
const tab = initialTab
|
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(() => {
|
useEffect(() => {
|
||||||
if (account) navigate('/account')
|
if (account) navigate(next)
|
||||||
}, [account, navigate])
|
}, [account, navigate, next])
|
||||||
|
|
||||||
const authed = (a: SelfAccount) => {
|
const authed = (a: SelfAccount) => {
|
||||||
onAuthed(a)
|
onAuthed(a)
|
||||||
navigate('/account')
|
navigate(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||||
@@ -1950,12 +1982,16 @@ function LoginPage({
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
{siteKey && (
|
{siteKey && (
|
||||||
<div className="tabs">
|
<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
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={tab === 'signup' ? 'active' : ''}
|
className={tab === 'signup' ? 'active' : ''}
|
||||||
onClick={() => navigate('/signup')}
|
onClick={() => navigate(`/signup${search}`)}
|
||||||
>
|
>
|
||||||
Create account
|
Create account
|
||||||
</button>
|
</button>
|
||||||
@@ -1984,7 +2020,7 @@ function LoginPage({
|
|||||||
{siteKey && (
|
{siteKey && (
|
||||||
<p className="muted swap">
|
<p className="muted swap">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link to="/signup" navigate={navigate}>
|
<Link to={`/signup${search}`} navigate={navigate}>
|
||||||
Create one
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</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));
|
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