mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[www] add share links from invention/image
This commit is contained in:
@@ -28,7 +28,7 @@ import {
|
|||||||
where,
|
where,
|
||||||
} from './api'
|
} from './api'
|
||||||
import { DeviceLinkPage } from './DeviceLink'
|
import { DeviceLinkPage } from './DeviceLink'
|
||||||
import { customAvatarItemIdFromPath, ItemPage } from './Item'
|
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
|
||||||
import { ModerationPage, PlayerBanStatus } from './Moderation'
|
import { ModerationPage, PlayerBanStatus } from './Moderation'
|
||||||
import { StatsPage } from './Stats'
|
import { StatsPage } from './Stats'
|
||||||
import { StudioAccessPage } from './StudioAccess'
|
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
|
* 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
|
* 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 roomId = roomIdFromPath(path)
|
||||||
const lookupUsername = usernameFromPath(path)
|
const lookupUsername = usernameFromPath(path)
|
||||||
const customAvatarItemId = customAvatarItemIdFromPath(path)
|
const customAvatarItemId = customAvatarItemIdFromPath(path)
|
||||||
|
const imageId = imageIdFromPath(path)
|
||||||
|
const inventionId = inventionIdFromPath(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
|
||||||
@@ -2100,6 +2158,14 @@ export function App() {
|
|||||||
signedIn={account === undefined ? undefined : account !== null}
|
signedIn={account === undefined ? undefined : account !== null}
|
||||||
navigate={navigate}
|
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 ? (
|
) : lookupUsername !== null ? (
|
||||||
<PlayerPage username={lookupUsername} config={config} navigate={navigate} />
|
<PlayerPage username={lookupUsername} config={config} navigate={navigate} />
|
||||||
) : roomId !== null ? (
|
) : roomId !== null ? (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { call, usernamesFor, where } from './api'
|
import { call, usernamesFor, where } from './api'
|
||||||
|
|
||||||
import type { CustomAvatarItem } from '../../../api/src/custom-avatar-items-db'
|
import type { CustomAvatarItem } from '../../../api/src/custom-avatar-items-db'
|
||||||
|
import type { SavedInvention } from '../../../api/src/inventions-db'
|
||||||
import type { PublicAccount } from './api'
|
import type { PublicAccount } from './api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +24,8 @@ import type { PublicAccount } from './api'
|
|||||||
* store says too.
|
* store says too.
|
||||||
*
|
*
|
||||||
* Its own file for the reason Stats.tsx and Moderation.tsx are: a whole surface rather
|
* 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. */
|
/** The `/d/store/customavataritem/<uuid>` path, or null for any other path. */
|
||||||
@@ -271,3 +273,154 @@ export function ItemPage({
|
|||||||
</main>
|
</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%);
|
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 {
|
.photo-modal img {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 94vw;
|
max-width: 94vw;
|
||||||
|
|||||||
Reference in New Issue
Block a user