diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 80842d6..260f4a9 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -761,6 +761,278 @@ function roomIdFromPath(path: string): number | null { return match ? Number.parseInt(match[1], 10) : null } +/** Public account shape, from `GET /account/:id` or `/account/search`. */ +interface PublicAccount { + accountId: number + username: string + displayName: string + profileImage: string + bannerImage: string + createdAt: string +} + +/** A player's public photo, from the `ImagesPlayer` projection. */ +interface PublicPhoto { + SavedImageId: number + ImageName: string + CreatedAt: string + CheerCount: number +} + +/** A player's public room — the narrow shape `/rooms/createdby/:id` serves. */ +interface PublicRoom { + RoomId: number + Name: string + ImageName: string + Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number } +} + +/** Prefix-search accounts by username — backs the header search bar. */ +async function searchPlayers(query: string): Promise { + if (query.trim() === '') return [] + return call(`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}`) +} + +const fetchPublicAccount = (username: string): Promise => + searchPlayers(username).then( + (matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null + ) + +const fetchPublicPhotos = (accountId: number): Promise => + call(`${where().api}/api/images/v4/player/${accountId}`) + +const fetchPublicRooms = (accountId: number): Promise => + call(`${where().rooms}/rooms/ownedby/${accountId}`) + +/** The `/u/` path, or null for any other path. */ +function usernameFromPath(path: string): string | null { + const match = /^\/u\/([^/]+)$/.exec(path) + return match ? decodeURIComponent(match[1]) : null +} + +// Off for now, not gone: the search bar is hidden from the nav while `/u/:name` profiles +// stay reachable by URL. Flip this to put it back — nothing else was removed. +const SHOW_PLAYER_SEARCH: boolean = false + +/** + * The header search bar — a rec.net-style bubble dropdown of matching players as you + * type. Debounced so it doesn't fire a search per keystroke; closes on selecting a + * result, on Escape, or on clicking outside it. + */ +function PlayerSearch({ navigate }: { navigate: Navigate }) { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (query.trim() === '') { + setResults([]) + return + } + const timeout = setTimeout(() => { + void searchPlayers(query) + .then((r) => setResults(r.slice(0, 6))) + .catch(() => setResults([])) + }, 250) + return () => clearTimeout(timeout) + }, [query]) + + useEffect(() => { + const onClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', onClickOutside) + return () => document.removeEventListener('mousedown', onClickOutside) + }, []) + + const go = (username: string) => { + setOpen(false) + setQuery('') + navigate(`/u/${encodeURIComponent(username)}`) + } + + return ( +
+ { + setQuery(e.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + onKeyDown={(e) => { + if (e.key === 'Escape') setOpen(false) + if (e.key === 'Enter' && results[0]) go(results[0].username) + }} + /> + {open && results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} +
+ ) +} + +/** + * A grid of photo thumbnails; clicking one opens it full size. + * + * The thumbnail is the img worker's cached 256px variant; the popup asks for the same key + * with NO transform, which is the original upload. The popup is a native `` opened + * with `showModal()`, so Escape, the backdrop, and keeping focus inside it are the + * browser's work rather than ours — all that's added is closing on a click anywhere, since + * there is nothing in it to interact with but the photo. + * + * Safe to call `where()` in render: a caller only has photos to pass once a fetch that + * went through it has resolved (see MyRooms). + */ +function PhotoGrid({ photos }: { photos: PublicPhoto[] }) { + const [open, setOpen] = useState(null) + const dialogRef = useRef(null) + + useEffect(() => { + if (open) dialogRef.current?.showModal() + }, [open]) + + return ( + <> +
+ {photos.map((p) => ( + + ))} +
+ {/* Mounted only while open, so the full-size image isn't fetched until asked for. + `onClose` covers Escape, which closes the dialog without going through us. */} + {open && ( + setOpen(null)} onClick={() => setOpen(null)}> + + + )} + + ) +} + +/** + * A player's public profile page (`/u/`) — banner, avatar, bio-adjacent + * info, their public rooms, and their public photos. Read-only: this is the + * "rec.net-style profile" other players browse to, not the owner's own dashboard + * (that stays on `/account`). + */ +function PlayerPage({ username, navigate }: { username: string; navigate: Navigate }) { + const [account, setAccount] = useState(undefined) + const [rooms, setRooms] = useState(null) + const [photos, setPhotos] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + setAccount(undefined) + setRooms(null) + setPhotos(null) + void fetchPublicAccount(username) + .then((a) => { + setAccount(a) + if (a) { + void fetchPublicRooms(a.accountId).then(setRooms).catch(() => setRooms([])) + void fetchPublicPhotos(a.accountId).then(setPhotos).catch(() => setPhotos([])) + } + }) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + }, [username]) + + if (error) { + return ( +
+

{error}

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

Loading…

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

There's no player called @{username}.

+
+ ) + } + + const created = new Date(account.createdAt) + + return ( +
+
+ {account.bannerImage && ( + + )} +
+ +
+

{account.displayName || account.username}

+

+ @{account.username} + {!Number.isNaN(created.getTime()) && ` · joined ${created.toLocaleDateString()}`} +

+
+
+
+ +
+

Rooms

+ {rooms === null ? ( +

Loading…

+ ) : rooms.length === 0 ? ( +

No public rooms.

+ ) : ( +
    + {rooms.map((room) => ( +
  • + + +
    + ^{room.Name} +

    {room.Stats.VisitCount.toLocaleString()} visits

    +
    + +
  • + ))} +
+ )} +
+ +
+

Photos

+ {photos === null ? ( +

Loading…

+ ) : photos.length === 0 ? ( +

No public photos.

+ ) : ( + + )} +
+
+ ) +} + export function App() { // undefined = still checking the session; null = signed out. const [account, setAccount] = useState(undefined) @@ -769,6 +1041,7 @@ export function App() { const [config, setConfig] = useState(undefined) const { path, search, navigate } = useRouter() const roomId = roomIdFromPath(path) + const lookupUsername = usernameFromPath(path) useEffect(() => { // Config first, and everything else after it: it carries the hostnames every other @@ -843,6 +1116,8 @@ export function App() { // its data comes from `/api/stats/online`, which the existing `/api/*` allowlist // entry already sends to the Worker. + ) : lookupUsername !== null ? ( + ) : roomId !== null ? ( ) : ( @@ -896,6 +1171,7 @@ function NavBar({ RecFlare