diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index e3dabf9..208b134 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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/` 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/`) — 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 ( +
+ {error ? ( +

{error}

+ ) : image === undefined ? ( +

Loading…

+ ) : image === null ? ( +

+ There's no image here — it's private, or it doesn't exist. +

+ ) : ( + + )} +
+ ) +} + /** * A player's public profile page (`/u/`) — 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. + + ) : 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. + ) : lookupUsername !== null ? ( ) : roomId !== null ? ( diff --git a/apps/www/src/client/Item.tsx b/apps/www/src/client/Item.tsx index d3a091c..eb9fdac 100644 --- a/apps/www/src/client/Item.tsx +++ b/apps/www/src/client/Item.tsx @@ -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/`, the + * other store share link) lives here too, at the bottom. */ /** The `/d/store/customavataritem/` path, or null for any other path. */ @@ -271,3 +273,154 @@ export function ItemPage({ ) } + +/** The `/d/store/invention/` 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=` 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 { + const found = await call( + `${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/` — 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(undefined) + const [creator, setCreator] = useState(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 ( +
+

{error}

+
+ ) + } + if (invention === undefined) { + return ( +
+

Loading…

+
+ ) + } + if (invention === null) { + return ( +
+

+ There's no invention here — it hasn't been published, or it doesn't exist. +

+
+ ) + } + + const price = invention.Price > 0 ? `${invention.Price.toLocaleString()} tokens` : 'Free' + + return ( +
+
+ {invention.ImageName ? ( + + ) : ( +
+ )} +
+
+

{invention.Name}

+
+ {creator && ( +

+ by{' '} + { + e.preventDefault() + navigate(`/u/${encodeURIComponent(creator.username)}`) + }} + > + {creator.displayName || creator.username} + +

+ )} +

{invention.Description || 'No description.'}

+

+ {price} + {invention.IsFeatured ? ' · Featured' : ''} + {invention.IsCertifiedInvention ? ' · Certified' : ''} +

+
+
+ +
+

About this invention

+
+
Price
+
{price}
+
Downloads
+
{invention.NumDownloads.toLocaleString()}
+
Cheers
+
{invention.CheerCount.toLocaleString()}
+
Tags
+
+ {invention.Tags?.length ? invention.Tags.map((t) => `#${t.Tag}`).join(' ') : 'None'} +
+
Published
+
{formatDate(invention.FirstPublishedAt ?? invention.CreatedAt)}
+
Updated
+
{formatDate(invention.ModifiedAt)}
+
Invention id
+
+ {invention.InventionId} +
+
+
+
+ ) +} diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 7bded8a..e45ac6a 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -2005,6 +2005,15 @@ button[type='submit']:disabled { background: rgb(0 0 0 / 80%); } +/* `/image/`: 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;