[www] add share links from invention/image

This commit is contained in:
Devin Zuczek
2026-10-20 12:09:17 -04:00
parent f04b9bd66b
commit 6bf297ed98
3 changed files with 230 additions and 2 deletions
+67 -1
View File
@@ -28,7 +28,7 @@ import {
where,
} from './api'
import { DeviceLinkPage } from './DeviceLink'
import { customAvatarItemIdFromPath, ItemPage } from './Item'
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
import { ModerationPage, PlayerBanStatus } from './Moderation'
import { StatsPage } from './Stats'
import { StudioAccessPage } from './StudioAccess'
@@ -1227,6 +1227,62 @@ function PhotoGrid({ photos }: { photos: PublicPhoto[] }) {
)
}
/** The `/image/<id>` path's numeric image id, or null for any other path. */
function imageIdFromPath(path: string): number | null {
const match = /^\/image\/(\d+)\/?$/.exec(path)
return match ? Number(match[1]) : null
}
/**
* One photo on its own page (`/image/<id>`) — just the image.
*
* Read through `api`'s `GET /api/images/v6/:id` WITHOUT the session token, deliberately:
* that route serves a private image to the player who took it, and this page is a public
* link, so it must show only what anyone could see. Signed out or not, a private image is
* the same 404 as an unknown id.
*/
async function fetchPublicImage(imageId: number): Promise<{ ImageName: string } | null> {
const res = await fetch(`${where().api}/api/images/v6/${imageId}`)
if (res.status === 404) return null
if (!res.ok) throw new Error(`Couldn’t load the image (${res.status}).`)
const image = (await res.json()) as { ImageName: string; Accessibility: number }
// The route already refuses a private image to an anonymous caller; checked again so
// this page can't show one if that ever changes.
return image.Accessibility === Accessibility.Public ? image : null
}
function ImagePage({ imageId, config }: { imageId: number; config: SiteConfig | undefined }) {
// undefined = not fetched yet; null = no public image with this id.
const [image, setImage] = useState<{ ImageName: string } | null | undefined>(undefined)
const [error, setError] = useState('')
useEffect(() => {
// Waits for the config for the hostnames, as PlayerPage does.
if (config === undefined) return
setImage(undefined)
setError('')
void fetchPublicImage(imageId)
.then(setImage)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [imageId, config])
return (
<main className="shell wide image-page">
{error ? (
<p className="error">{error}</p>
) : image === undefined ? (
<p className="muted">Loading…</p>
) : image === null ? (
<p className="muted">
There&apos;s no image here — it&apos;s private, or it doesn&apos;t exist.
</p>
) : (
<img src={`${where().img}/${image.ImageName}`} alt="" />
)}
</main>
)
}
/**
* A player's public profile page (`/u/<username>`) — banner, avatar, bio-adjacent
* info, their public rooms, and their public photos. Read-only: this is the
@@ -2005,6 +2061,8 @@ export function App() {
const roomId = roomIdFromPath(path)
const lookupUsername = usernameFromPath(path)
const customAvatarItemId = customAvatarItemIdFromPath(path)
const imageId = imageIdFromPath(path)
const inventionId = inventionIdFromPath(path)
useEffect(() => {
// Config first, and everything else after it: it carries the hostnames every other
@@ -2100,6 +2158,14 @@ export function App() {
signedIn={account === undefined ? undefined : account !== null}
navigate={navigate}
/>
) : inventionId !== null ? (
// The store share link for an invention. Public, published inventions only (see
// InventionPage); a client-side route like the item page above.
<InventionPage inventionId={inventionId} ready={config !== undefined} navigate={navigate} />
) : imageId !== null ? (
// A photo's own link. Public images only, read without the session (see
// ImagePage); a client-side route, so a cold load falls through to the SPA shell.
<ImagePage imageId={imageId} config={config} />
) : lookupUsername !== null ? (
<PlayerPage username={lookupUsername} config={config} navigate={navigate} />
) : roomId !== null ? (
+154 -1
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { call, usernamesFor, where } from './api'
import type { CustomAvatarItem } from '../../../api/src/custom-avatar-items-db'
import type { SavedInvention } from '../../../api/src/inventions-db'
import type { PublicAccount } from './api'
/**
@@ -23,7 +24,8 @@ import type { PublicAccount } from './api'
* 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.
* than one more section of App.tsx. The invention page (`/d/store/invention/<id>`, the
* other store share link) lives here too, at the bottom.
*/
/** The `/d/store/customavataritem/<uuid>` path, or null for any other path. */
@@ -271,3 +273,154 @@ export function ItemPage({
</main>
)
}
/** The `/d/store/invention/<id>` path's numeric invention id, or null for any other path. */
export function inventionIdFromPath(path: string): number | null {
const match = /^\/d\/store\/invention\/(\d+)\/?$/i.exec(path)
return match ? Number(match[1]) : null
}
/**
* The invention, or null when there's no PUBLISHED invention with that id.
*
* Read through `api`'s `GET /api/inventions/v2/batch?id=<id>` WITHOUT the session token,
* deliberately: auth there only widens the answer to the caller's own unpublished
* inventions, and this page is a public link, so it shows what anyone could see. Not
* `GET /api/inventions/v1`, which serves an unpublished invention to anyone.
*/
async function fetchPublishedInvention(inventionId: number): Promise<SavedInvention | null> {
const found = await call<SavedInvention[]>(
`${where().api}/api/inventions/v2/batch?id=${inventionId}`
)
if (!Array.isArray(found)) return null
// The route already drops unpublished inventions for an anonymous caller; checked again
// so this page can't show one if that ever changes.
return found.find((i) => i.InventionId === inventionId && i.IsPublished) ?? null
}
/**
* A published invention's page, at `/d/store/invention/<id>` — the store share link's
* shape, like the custom avatar item page above. Public: no sign-in needed.
*/
export function InventionPage({
inventionId,
ready,
navigate,
}: {
inventionId: number
/** Whether `/api/config` has landed — `where()` has no hostnames before it. */
ready: boolean
navigate: (to: string) => void
}) {
// undefined = not fetched yet; null = no published invention with this id.
const [invention, setInvention] = useState<SavedInvention | null | undefined>(undefined)
const [creator, setCreator] = useState<PublicAccount | null>(null)
const [error, setError] = useState('')
useEffect(() => {
setInvention(undefined)
setCreator(null)
setError('')
if (!ready) return
void fetchPublishedInvention(inventionId)
.then((found) => {
setInvention(found)
if (found)
void usernamesFor([found.CreatorPlayerId]).then((names) =>
setCreator(names.get(found.CreatorPlayerId) ?? null)
)
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [inventionId, ready])
if (error) {
return (
<main className="shell wide">
<p className="error">{error}</p>
</main>
)
}
if (invention === undefined) {
return (
<main className="shell wide">
<p className="muted">Loading…</p>
</main>
)
}
if (invention === null) {
return (
<main className="shell wide">
<p className="muted">
There&apos;s no invention here — it hasn&apos;t been published, or it doesn&apos;t exist.
</p>
</main>
)
}
const price = invention.Price > 0 ? `${invention.Price.toLocaleString()} tokens` : 'Free'
return (
<main className="shell wide">
<section className="card room-hero">
{invention.ImageName ? (
<img
className="room-hero-img"
src={`${where().img}/${invention.ImageName}?width=512`}
alt=""
/>
) : (
<div className="room-hero-img" />
)}
<div className="room-hero-body">
<div className="room-head">
<h1 className="room-hero-name">{invention.Name}</h1>
</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>
)}
<p className="muted room-hero-desc">{invention.Description || 'No description.'}</p>
<p className="room-stats">
{price}
{invention.IsFeatured ? ' · Featured' : ''}
{invention.IsCertifiedInvention ? ' · Certified' : ''}
</p>
</div>
</section>
<section className="card">
<h2>About this invention</h2>
<dl className="facts">
<dt>Price</dt>
<dd>{price}</dd>
<dt>Downloads</dt>
<dd>{invention.NumDownloads.toLocaleString()}</dd>
<dt>Cheers</dt>
<dd>{invention.CheerCount.toLocaleString()}</dd>
<dt>Tags</dt>
<dd>
{invention.Tags?.length ? invention.Tags.map((t) => `#${t.Tag}`).join(' ') : 'None'}
</dd>
<dt>Published</dt>
<dd>{formatDate(invention.FirstPublishedAt ?? invention.CreatedAt)}</dd>
<dt>Updated</dt>
<dd>{formatDate(invention.ModifiedAt)}</dd>
<dt>Invention id</dt>
<dd>
<code>{invention.InventionId}</code>
</dd>
</dl>
</section>
</main>
)
}
+9
View File
@@ -2005,6 +2005,15 @@ button[type='submit']:disabled {
background: rgb(0 0 0 / 80%);
}
/* `/image/<id>`: the one photo, fitted to the page. */
.image-page img {
display: block;
max-width: 100%;
max-height: 80vh;
margin: 0 auto;
border-radius: 9px;
}
.photo-modal img {
display: block;
max-width: 94vw;