mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] add share links from invention/image
This commit is contained in:
@@ -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's no image here — it's private, or it doesn'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 ? (
|
||||
|
||||
@@ -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's no invention here — it hasn't been published, or it doesn'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user