diff --git a/README.md b/README.md index 4332838..5e03696 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ An observer for FOLK atproto records. Resolve a handle, DID, or `at://` URI and inspect every collection, record, and identity behind it — read straight from the atproto repos that hold them. +A nerd/artist switch sits next to the wordmark. Nerd is the view described +above. Artist keeps the title and the search box and answers with the player +alone — avatar, name, badges — no collections, trees, or raw JSON. The mode +lives in the URL, so every view stays shareable and statically rendered. + ## Development ```bash @@ -16,6 +21,8 @@ Open http://localhost:3000. ## Routes - `/` - search for a handle, DID, or `at://` URI +- `/?mode=artist` - the same search, stripped to the player it finds +- `/player/[actor]` - one player: avatar, name, badges, nothing technical - `/at/[actor]` - repo overview - `/at/[actor]/[collection]` - records in a collection - `/at/[actor]/[collection]/[rkey]` - a single record diff --git a/public/mode/artist.webp b/public/mode/artist.webp new file mode 100644 index 0000000000000000000000000000000000000000..06ca5b8dc6e7ca0a405bcfc45655b95c85b37b99 GIT binary patch literal 2510 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 literal 0 HcmV?d00001 diff --git a/public/mode/nerd.webp b/public/mode/nerd.webp new file mode 100644 index 0000000000000000000000000000000000000000..b666e4617b8dbec478e7c8e6c1ff85c96048f055 GIT binary patch literal 2226 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 K>uJHjzyJW8^jEh4 literal 0 HcmV?d00001 diff --git a/src/app/at/[actor]/page.tsx b/src/app/at/[actor]/page.tsx index fc6e256..812ec61 100644 --- a/src/app/at/[actor]/page.tsx +++ b/src/app/at/[actor]/page.tsx @@ -5,6 +5,7 @@ import { Breadcrumbs } from "@/components/breadcrumbs"; import { CollectionTree } from "@/components/collection-tree"; import { IdentityCard } from "@/components/identity-card"; import { JsonLd } from "@/components/json-ld"; +import { ModeSwitch } from "@/components/mode-switch"; import { List, Row, RowLabel } from "@/components/ui/list"; import { EmptyState, ErrorMessage } from "@/components/ui/message"; import { PageShell } from "@/components/ui/page-shell"; @@ -20,7 +21,7 @@ import { partitionCollections } from "@/lib/atproto/repo"; import { AtprotoError } from "@/lib/atproto/resolve"; import { normalizeActor } from "@/lib/atproto/uri"; import { breadcrumbList } from "@/lib/jsonld"; -import { collectionHref, decodeParam, repoHref } from "@/lib/links"; +import { collectionHref, decodeParam, playerHref, repoHref } from "@/lib/links"; import { absoluteUrl, hostOf, plural, truncate } from "@/lib/seo"; export async function generateMetadata({ @@ -97,7 +98,16 @@ export default async function RepoPage({ params }: PageProps<"/at/[actor]">) { ], }} /> - + + } + /> folk collections @@ -128,7 +138,16 @@ export default async function RepoPage({ params }: PageProps<"/at/[actor]">) { error instanceof AtprotoError ? error.message : "Unexpected failure"; return ( - + + } + /> {message} ); diff --git a/src/app/globals.css b/src/app/globals.css index 7bcad96..9ee410f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -21,6 +21,8 @@ --font-display: "Eurocine-Black", "Helvetica Neue", Helvetica, sans-serif; --radius: 8px; + + --squircle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Cpath d='M0,50 C0,0 0,0 50,0 S100,0 100,50 100,100 50,100 0,100 0,50' fill='%23000'/%3E%3C/svg%3E"); } *, diff --git a/src/app/page.module.css b/src/app/page.module.css index f5ca7e3..ed36212 100644 --- a/src/app/page.module.css +++ b/src/app/page.module.css @@ -1,3 +1,12 @@ +/* Wordmark left, mode switch right, wrapping onto two lines on narrow screens + rather than squeezing the mark. */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} .lede { color: var(--text-faint); diff --git a/src/app/page.tsx b/src/app/page.tsx index 12ee852..dcea7d7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { ModeSwitch } from "@/components/mode-switch"; +import { PlayerGrid } from "@/components/player-grid"; import { SearchForm } from "@/components/search-form"; import { List, Row, RowLabel, RowMeta, RowNote } from "@/components/ui/list"; import { EmptyState, ErrorMessage } from "@/components/ui/message"; @@ -9,17 +11,21 @@ import { SectionTitle } from "@/components/ui/section-title"; import { Wordmark } from "@/components/wordmark"; import { actorId, normalizeActor, parseAtUri } from "@/lib/atproto/uri"; import { getFolkLexicons, getFolkRepos, summarize } from "@/lib/folk"; -import { hrefForAtUri, repoHref } from "@/lib/links"; +import { hrefForAtUri, playerHref, repoHref } from "@/lib/links"; +import { parseMode } from "@/lib/mode"; +import { listPlayers } from "@/lib/player"; import { SITE_DESCRIPTION, SITE_LEDE, + SITE_LEDE_ARTIST, SITE_NAME, SITE_TAGLINE, } from "@/lib/seo"; import styles from "./page.module.css"; export const metadata: Metadata = { - // `?invalid=` is a rendering detail of the search form, never a distinct page. + // `?invalid=` is a rendering detail of the search form and `?mode=` is a + // choice of view, never a distinct page. alternates: { canonical: "/" }, openGraph: { type: "website", @@ -42,8 +48,61 @@ async function observe(formData: FormData) { redirect(`/?invalid=${encodeURIComponent(input)}`); } +/** + * Same input, one destination. An artist who pastes a full record uri still + * lands on the player it belongs to - the record itself is nerd territory. + */ +async function findPlayer(formData: FormData) { + "use server"; + const input = String(formData.get("q") ?? "").trim(); + + const uri = parseAtUri(input); + if (uri) redirect(playerHref(actorId(uri.actor))); + + const actor = normalizeActor(input); + if (actor) redirect(playerHref(actorId(actor))); + + redirect(`/?mode=artist&invalid=${encodeURIComponent(input)}`); +} + export default async function Home({ searchParams }: PageProps<"/">) { - const { invalid } = await searchParams; + const { invalid, mode: modeParam } = await searchParams; + const mode = parseMode(modeParam); + + const header = ( +
+ + +
+ ); + + if (mode === "artist") { + const players = await listPlayers(); + return ( + + {header} +

{SITE_LEDE_ARTIST}

+ + + {invalid && ( + Could not read that as a name: {invalid} + )} + + {players.length > 0 ? ( + <> + folk players + + + ) : ( + No players found yet. + )} +
+ ); + } const { lexicons } = await getFolkLexicons().catch(() => ({ lexicons: [] })); const repos = await getFolkRepos(lexicons).catch(() => []); @@ -51,7 +110,7 @@ export default async function Home({ searchParams }: PageProps<"/">) { return ( - + {header}

{SITE_LEDE}

diff --git a/src/app/player/[actor]/opengraph-image.tsx b/src/app/player/[actor]/opengraph-image.tsx new file mode 100644 index 0000000..c72e377 --- /dev/null +++ b/src/app/player/[actor]/opengraph-image.tsx @@ -0,0 +1,47 @@ +import { ImageResponse } from "next/og"; +import { decodeParam } from "@/lib/links"; +import { OG_CONTENT_TYPE, OG_SIZE, OgCard, OgFallback } from "@/lib/og"; +import { loadOgArt } from "@/lib/og-art"; +import { loadPlayer } from "@/lib/player"; + +export const alt = "a FOLK player on folk.observer"; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +const MAX_CHIPS = 3; + +export default async function Image({ + params, +}: { + params: Promise<{ actor: string }>; +}) { + const raw = decodeParam((await params).actor); + + try { + const player = await loadPlayer(raw); + // Trait-map portraits would have to be flattened into one image first, + // which Satori cannot do, so only composited blobs reach the art slot. + const art = await loadOgArt(player.portrait?.blobUrl ?? null); + + const chips = player.badges + .map((badge) => badge.name) + .filter((name): name is string => Boolean(name)) + .slice(0, MAX_CHIPS); + + return new ImageResponse( + , + size, + ); + } catch { + return new ImageResponse( + OgFallback("player", `Could not find ${raw}`), + size, + ); + } +} diff --git a/src/app/player/[actor]/page.module.css b/src/app/player/[actor]/page.module.css new file mode 100644 index 0000000..104012b --- /dev/null +++ b/src/app/player/[actor]/page.module.css @@ -0,0 +1,9 @@ +/* Wordmark left, mode switch right, wrapping onto two lines on narrow screens + rather than squeezing the mark. Shared shape with the home page header. */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} diff --git a/src/app/player/[actor]/page.tsx b/src/app/player/[actor]/page.tsx new file mode 100644 index 0000000..f4b37d0 --- /dev/null +++ b/src/app/player/[actor]/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ModeSwitch } from "@/components/mode-switch"; +import { PlayerBadges, PlayerCard } from "@/components/player-card"; +import { ErrorMessage } from "@/components/ui/message"; +import { PageShell } from "@/components/ui/page-shell"; +import { Wordmark } from "@/components/wordmark"; +import { canonicalActor, loadIdentity } from "@/lib/atproto/cached"; +import { AtprotoError } from "@/lib/atproto/resolve"; +import { normalizeActor } from "@/lib/atproto/uri"; +import { decodeParam, playerHref, repoHref } from "@/lib/links"; +import { homeHref } from "@/lib/mode"; +import { loadPlayer } from "@/lib/player"; +import { plural, truncate } from "@/lib/seo"; +import styles from "./page.module.css"; + +/** One line about a player that never mentions a repo, a DID, or a host. */ +function describe(name: string, badges: number, registered: boolean): string { + if (!registered) return `${name} has not made a FOLK profile yet.`; + return badges > 0 + ? `${name} on FOLK, with ${plural(badges, "badge")}.` + : `${name} on FOLK.`; +} + +export async function generateMetadata({ + params, +}: PageProps<"/player/[actor]">): Promise { + const raw = decodeParam((await params).actor); + + try { + const identity = await loadIdentity(raw); + const player = await loadPlayer(raw); + const canonical = playerHref(canonicalActor(identity)); + const description = describe( + player.name, + player.badges.length, + player.registered, + ); + + return { + title: player.name, + description: truncate(description), + alternates: { canonical }, + openGraph: { + type: "profile", + url: canonical, + title: player.name, + description: truncate(description), + }, + }; + } catch { + // An unresolvable handle is a dead URL, not a page worth indexing. + return { title: raw, robots: { index: false, follow: false } }; + } +} + +export default async function PlayerPage({ + params, +}: PageProps<"/player/[actor]">) { + const raw = decodeParam((await params).actor); + if (!normalizeActor(raw)) notFound(); + + const header = ( +
+ + + + +
+ ); + + try { + const player = await loadPlayer(raw); + return ( + + {header} + + + + ); + } catch (error) { + // Artists get the outcome, not the protocol step that produced it. + const message = + error instanceof AtprotoError + ? `Could not find anyone called ${raw}.` + : "Something went wrong looking that up."; + return ( + + {header} + {message} + + ); + } +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 834ca5f..5c03dff 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from "next"; import { getFolkLexicons, getFolkRepos } from "@/lib/folk"; -import { collectionHref, repoHref } from "@/lib/links"; +import { collectionHref, playerHref, repoHref } from "@/lib/links"; +import { PROFILE_COLLECTION } from "@/lib/player"; import { absoluteUrl } from "@/lib/seo"; /** @@ -35,6 +36,18 @@ export default async function sitemap(): Promise { changeFrequency: "daily" as const, priority: 0.8, }, + // The artist view of the same repo, but only where there is a player to + // show: without a profile record the page is an empty frame. + ...(repo.collections.includes(PROFILE_COLLECTION) + ? [ + { + url: absoluteUrl(playerHref(actor)), + lastModified: now, + changeFrequency: "daily" as const, + priority: 0.8, + }, + ] + : []), ...repo.collections.map((nsid) => ({ url: absoluteUrl(collectionHref(actor, nsid)), lastModified: now, diff --git a/src/components/avatar-portrait.module.css b/src/components/avatar-portrait.module.css index 3a1b554..4832991 100644 --- a/src/components/avatar-portrait.module.css +++ b/src/components/avatar-portrait.module.css @@ -10,6 +10,18 @@ border: 1px solid var(--border); } +/* Overrides the frame's border and radius, so it has to follow it. */ +.squircle { + border: none; + border-radius: 0; + -webkit-mask-image: var(--squircle); + mask-image: var(--squircle); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} + .layer { position: absolute; inset: 0; diff --git a/src/components/avatar-portrait.tsx b/src/components/avatar-portrait.tsx index e6e8652..76c98ef 100644 --- a/src/components/avatar-portrait.tsx +++ b/src/components/avatar-portrait.tsx @@ -5,9 +5,11 @@ export function AvatarPortrait({ layers, size = 160, pixelated = true, + shape = "rounded", }: { layers: Layer[]; size?: number; + shape?: "rounded" | "squircle"; /** * Catalog trait art is stacked at its native size and upscaled, so it keeps * nearest-neighbour sampling. A composited avatar blob is downscaled into the @@ -20,7 +22,11 @@ export function AvatarPortrait({ return (
@@ -28,6 +31,7 @@ export function Breadcrumbs({ {rkey} )} + {actions && {actions}} ); } diff --git a/src/components/mode-switch.module.css b/src/components/mode-switch.module.css new file mode 100644 index 0000000..25f599b --- /dev/null +++ b/src/components/mode-switch.module.css @@ -0,0 +1,40 @@ +.switch { + display: inline-flex; + flex-shrink: 0; + padding: 2px; + gap: 2px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--raised); +} + +.option { + display: flex; + align-items: center; + justify-content: center; + padding: 0.3rem 0.6rem; + border-radius: 999px; + transition: background-color 120ms ease; +} + +.icon { + display: block; + opacity: 0.4; + filter: grayscale(1); + transition: + opacity 120ms ease, + filter 120ms ease; +} + +.option:hover .icon { + opacity: 0.75; +} + +.on { + background: color-mix(in srgb, var(--grass) 16%, transparent); +} + +.on .icon { + opacity: 1; + filter: none; +} diff --git a/src/components/mode-switch.tsx b/src/components/mode-switch.tsx new file mode 100644 index 0000000..b859a4b --- /dev/null +++ b/src/components/mode-switch.tsx @@ -0,0 +1,58 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { Mode } from "@/lib/mode"; +import styles from "./mode-switch.module.css"; + +const ICON = 22; + +const OPTIONS = [ + { + mode: "nerd", + src: "/mode/nerd.webp", + label: "nerd view: repos, collections, raw records", + }, + { + mode: "artist", + src: "/mode/artist.webp", + label: "artist view: just the player, their face and badges", + }, +] as const; + +export function ModeSwitch({ + mode, + nerdHref = "/", + artistHref = "/?mode=artist", +}: { + mode: Mode; + nerdHref?: string; + artistHref?: string; +}) { + const hrefs = { nerd: nerdHref, artist: artistHref }; + + return ( + + ); +} diff --git a/src/components/player-card.module.css b/src/components/player-card.module.css new file mode 100644 index 0000000..3160019 --- /dev/null +++ b/src/components/player-card.module.css @@ -0,0 +1,108 @@ +.player { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.15rem; + padding: 2.5rem 0 0.5rem; +} + +/* Same footprint as the portrait, so a missing profile does not reflow. */ +.faceless { + width: 200px; + height: 200px; + background: var(--raised); + -webkit-mask-image: var(--squircle); + mask-image: var(--squircle); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} + +.name { + margin: 1.1rem 0 0; + font-size: 1.5rem; + line-height: 1.2; + font-weight: 700; + overflow-wrap: anywhere; +} + +.handle { + margin: 0; + font-size: 0.8rem; + color: var(--text-faint); + overflow-wrap: anywhere; +} + +.note { + margin: 0.8rem 0 0; + font-size: 0.8rem; + color: var(--text-faint); +} + +.badges { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.5rem; + margin: 2.5rem 0 0; + padding: 0; + list-style: none; +} + +@media (min-width: 40rem) { + .badges { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.badge { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.6rem 0.85rem 0.6rem 0.6rem; + border: 1px solid var(--border-soft); + border-radius: 14px; + background: var(--raised); +} + +/* The mask goes on the tile so it clips the background too, keeping the shape + while art is missing or still loading. Size matches BADGE_SIZE. */ +.tile { + width: 56px; + height: 56px; + flex-shrink: 0; + background: var(--bg); + -webkit-mask-image: var(--squircle); + mask-image: var(--squircle); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} + +.art { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +.badgeName { + font-size: 0.9rem; + overflow-wrap: anywhere; +} + +.description { + font-size: 0.75rem; + line-height: 1.45; + color: var(--text-faint); + overflow-wrap: anywhere; +} diff --git a/src/components/player-card.tsx b/src/components/player-card.tsx new file mode 100644 index 0000000..116b26a --- /dev/null +++ b/src/components/player-card.tsx @@ -0,0 +1,63 @@ +import type { Player, PlayerBadge } from "@/lib/player"; +import { AvatarPortrait } from "./avatar-portrait"; +import styles from "./player-card.module.css"; + +const PORTRAIT_SIZE = 200; +const BADGE_SIZE = 56; + +/** A face and a name. Anything technical lives on the repo page instead. */ +export function PlayerCard({ player }: { player: Player }) { + return ( +
+ {player.portrait ? ( + + ) : ( +
+ ); +} + +export function PlayerBadges({ badges }: { badges: PlayerBadge[] }) { + if (badges.length === 0) return null; + + return ( +
    + {badges.map((badge) => ( + // Keyed by the award: the same badge can be awarded twice. +
  • +
    + {badge.imageUrl && ( + // biome-ignore lint/performance/noImgElement: blobs come from arbitrary PDS hosts + + )} +
    +
    + {badge.name ?? "badge"} + {badge.description && ( + {badge.description} + )} +
    +
  • + ))} +
+ ); +} diff --git a/src/components/player-grid.module.css b/src/components/player-grid.module.css new file mode 100644 index 0000000..391ae07 --- /dev/null +++ b/src/components/player-grid.module.css @@ -0,0 +1,51 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.player { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + padding: 1rem 0.6rem; + border: 1px solid transparent; + border-radius: 14px; + text-align: center; + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.player:hover { + background: var(--raised); + border-color: var(--border-soft); +} + +.faceless { + width: 84px; + height: 84px; + background: var(--raised); + -webkit-mask-image: var(--squircle); + mask-image: var(--squircle); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} + +.name { + margin-top: 0.6rem; + font-size: 0.85rem; + overflow-wrap: anywhere; +} + +.handle { + font-size: 0.7rem; + color: var(--text-faint); + overflow-wrap: anywhere; +} diff --git a/src/components/player-grid.tsx b/src/components/player-grid.tsx new file mode 100644 index 0000000..e20597f --- /dev/null +++ b/src/components/player-grid.tsx @@ -0,0 +1,34 @@ +import Link from "next/link"; +import { playerHref } from "@/lib/links"; +import type { PlayerSummary } from "@/lib/player"; +import { AvatarPortrait } from "./avatar-portrait"; +import styles from "./player-grid.module.css"; + +const SIZE = 84; + +export function PlayerGrid({ players }: { players: PlayerSummary[] }) { + return ( +
    + {players.map((player) => ( +
  • + + {player.portrait ? ( + + ) : ( +
  • + ))} +
+ ); +} diff --git a/src/components/portrait-card.tsx b/src/components/portrait-card.tsx index a3ddec1..4ac1d2a 100644 --- a/src/components/portrait-card.tsx +++ b/src/components/portrait-card.tsx @@ -34,6 +34,7 @@ export function PortraitCard({ }, ]} pixelated={false} + shape="squircle" />
blob
@@ -42,7 +43,7 @@ export function PortraitCard({ ) : ( <> - +
{portrait.layers.map((l) => (
diff --git a/src/components/search-form.tsx b/src/components/search-form.tsx index e438264..3d4d31e 100644 --- a/src/components/search-form.tsx +++ b/src/components/search-form.tsx @@ -6,21 +6,26 @@ import styles from "./search-form.module.css"; */ export function SearchForm({ action, + placeholder = "handle, did, or at:// uri", + submitLabel = "observe", }: { action: (formData: FormData) => void | Promise; + /** Artists get plain language here; the parser accepts the same input. */ + placeholder?: string; + submitLabel?: string; }) { return (
); diff --git a/src/components/ui/page-shell.module.css b/src/components/ui/page-shell.module.css index 63458ab..fdab545 100644 --- a/src/components/ui/page-shell.module.css +++ b/src/components/ui/page-shell.module.css @@ -3,7 +3,7 @@ position: relative; z-index: 1; width: 100%; - max-width: 640px; + max-width: 680px; margin: 0 auto; padding: 4rem 1.25rem 5rem; flex: 1; diff --git a/src/components/wordmark.tsx b/src/components/wordmark.tsx index da04a09..41f1a03 100644 --- a/src/components/wordmark.tsx +++ b/src/components/wordmark.tsx @@ -8,10 +8,21 @@ const geistPixel = Geist_Pixel({ fallback: ["ui-monospace", "monospace"], }); -/** The site wordmark. Screen readers get the plain string, not the two spans. */ -export function Wordmark({ className }: { className?: string }) { +/** + * The site wordmark. Screen readers get the plain string, not the two spans. + * + * `as` exists because the mark is only the page's heading on the home page; on + * a player page the person's name is, and two `h1`s would fight over it. + */ +export function Wordmark({ + className, + as: Tag = "h1", +}: { + className?: string; + as?: "h1" | "div"; +}) { return ( -

OBSERVER -

+ ); } diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts index d8599a1..9ea0417 100644 --- a/src/lib/avatar.ts +++ b/src/lib/avatar.ts @@ -1,3 +1,4 @@ +import { cache } from "react"; import { blobCid, blobUrl, getRecord } from "./atproto/repo"; import { resolveActor } from "./atproto/resolve"; import { LEXICON_AUTHORITY } from "./folk"; @@ -49,31 +50,33 @@ export function avatarBlobUrl( } /** The catalog as published by the game. No local copy: if it is unreachable, no portrait. */ -export async function getAvatarCatalog(): Promise<{ - catalog: AvatarCatalog; - pds: string; - did: string; -} | null> { - try { - const authority = await resolveActor({ - type: "handle", - handle: LEXICON_AUTHORITY, - }); - const record = await getRecord( - authority.pds, - authority.did, - CATALOG, - "self", - ); - return { - catalog: record.value as unknown as AvatarCatalog, - pds: authority.pds, - did: authority.did, - }; - } catch { - return null; - } -} +export const getAvatarCatalog = cache( + async (): Promise<{ + catalog: AvatarCatalog; + pds: string; + did: string; + } | null> => { + try { + const authority = await resolveActor({ + type: "handle", + handle: LEXICON_AUTHORITY, + }); + const record = await getRecord( + authority.pds, + authority.did, + CATALOG, + "self", + ); + return { + catalog: record.value as unknown as AvatarCatalog, + pds: authority.pds, + did: authority.did, + }; + } catch { + return null; + } + }, +); /** * Resolve a profile's chosen trait keys against the catalog into an ordered list diff --git a/src/lib/links.ts b/src/lib/links.ts index e6e987c..92f9199 100644 --- a/src/lib/links.ts +++ b/src/lib/links.ts @@ -13,6 +13,11 @@ export function repoHref(actor: string): string { return `/at/${encodeURIComponent(actor)}`; } +/** The artist-facing view of a repo: one player, no technical surface. */ +export function playerHref(actor: string): string { + return `/player/${encodeURIComponent(actor)}`; +} + export function collectionHref(actor: string, collection: string): string { return `${repoHref(actor)}/${encodeURIComponent(collection)}`; } diff --git a/src/lib/mode.ts b/src/lib/mode.ts new file mode 100644 index 0000000..1cdc41a --- /dev/null +++ b/src/lib/mode.ts @@ -0,0 +1,12 @@ +/** Nerd is the full repo view; artist is the player alone. */ +export type Mode = "nerd" | "artist"; + +export const DEFAULT_MODE: Mode = "nerd"; + +export function parseMode(value: string | string[] | undefined): Mode { + return value === "artist" ? "artist" : DEFAULT_MODE; +} + +export function homeHref(mode: Mode): string { + return mode === "artist" ? "/?mode=artist" : "/"; +} diff --git a/src/lib/player.ts b/src/lib/player.ts new file mode 100644 index 0000000..4ea16b3 --- /dev/null +++ b/src/lib/player.ts @@ -0,0 +1,177 @@ +import { + displayActor, + loadIdentity, + loadRecord, + loadRecords, +} from "./atproto/cached"; +import { blobCid } from "./atproto/repo"; +import type { Identity } from "./atproto/resolve"; +import { + avatarBlobUrl, + buildDrawPlan, + getAvatarCatalog, + type Layer, +} from "./avatar"; +import { BADGE_AWARD, type Badge, loadBadge } from "./badge"; +import { getFolkLexicons, getFolkRepos } from "./folk"; + +export const PROFILE_COLLECTION = "lol.folk.profile"; +const PROFILE_RKEY = "self"; +const MAX_BADGES = 50; + +export type PlayerPortrait = { + layers: Layer[]; + pixelated: boolean; + /** Set only for a composited blob, the one shape Satori can put on a card. */ + blobUrl: string | null; +}; + +export type PlayerBadge = Badge & { awardUri: string }; + +export type PlayerSummary = { + /** How the player is addressed in a URL: verified handle, else the DID. */ + actor: string; + handle: string | null; + name: string; + portrait: PlayerPortrait | null; +}; + +export type Player = { + did: string; + handle: string | null; + name: string; + /** False when the identity resolves but holds no `lol.folk.profile`. */ + registered: boolean; + portrait: PlayerPortrait | null; + badges: PlayerBadge[]; +}; + +function str(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * Two avatar shapes are live in the wild: a composited blob in the player's own + * repo, and a map of trait keys resolved against art the lexicon authority + * publishes. The record decides which, not its age. + */ +async function buildPortrait( + avatar: unknown, + owner: { pds: string; did: string }, +): Promise { + const cid = blobCid(avatar); + if (cid) { + const url = avatarBlobUrl(avatar, owner); + if (url) { + return { + layers: [{ kind: "image", url, slot: "avatar", key: "blob" }], + pixelated: false, + blobUrl: url, + }; + } + } + + const resolved = await getAvatarCatalog(); + if (!resolved) return null; + + // A blob's own keys (`ref`, `size`, `mimeType`) are not slot ids. + const stored = cid ? {} : ((avatar ?? {}) as Record); + const layers = buildDrawPlan(resolved.catalog, stored, { + pds: resolved.pds, + did: resolved.did, + }); + return layers.length > 0 ? { layers, pixelated: true, blobUrl: null } : null; +} + +async function loadBadges(identity: Identity): Promise { + const { records } = await loadRecords( + identity.pds, + identity.did, + BADGE_AWARD, + MAX_BADGES, + ).catch(() => ({ records: [] })); + + const badges = await Promise.all( + records.map(async (record) => { + const badge = await loadBadge(BADGE_AWARD, record.value, { + pds: identity.pds, + did: identity.did, + uri: record.uri, + display: displayActor(identity), + }); + return badge && { ...badge, awardUri: record.uri }; + }), + ); + + return badges.filter((badge): badge is PlayerBadge => Boolean(badge)); +} + +/** Everyone on the network holding a FOLK profile, face and name only. */ +export async function listPlayers(): Promise { + const { lexicons } = await getFolkLexicons().catch(() => ({ lexicons: [] })); + const repos = await getFolkRepos(lexicons).catch(() => []); + + const players = await Promise.all( + repos + .filter((repo) => repo.collections.includes(PROFILE_COLLECTION)) + .map(async (repo) => { + try { + const identity = await loadIdentity(repo.did); + const profile = await loadRecord( + identity.pds, + identity.did, + PROFILE_COLLECTION, + PROFILE_RKEY, + ); + return { + actor: repo.handleVerified && repo.handle ? repo.handle : repo.did, + handle: identity.handle, + name: + str(profile.value.displayName) ?? identity.handle ?? identity.did, + portrait: await buildPortrait(profile.value.avatar, { + pds: identity.pds, + did: identity.did, + }), + }; + } catch { + return null; + } + }), + ); + + return players.filter((player): player is PlayerSummary => player !== null); +} + +/** + * Throws only when the identity itself will not resolve. An actor with no FOLK + * profile is a valid answer, just an empty one. + */ +export async function loadPlayer(actor: string): Promise { + const identity = await loadIdentity(actor); + + const profile = await loadRecord( + identity.pds, + identity.did, + PROFILE_COLLECTION, + PROFILE_RKEY, + ).catch(() => null); + + const [portrait, badges] = await Promise.all([ + profile + ? buildPortrait(profile.value.avatar, { + pds: identity.pds, + did: identity.did, + }) + : null, + loadBadges(identity), + ]); + + return { + did: identity.did, + handle: identity.handle, + name: str(profile?.value.displayName) ?? identity.handle ?? identity.did, + registered: profile !== null, + portrait, + badges, + }; +} diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 1fd6404..1ab7a44 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -11,6 +11,10 @@ export const SITE_TAGLINE = "an observer for FOLK atproto records"; export const SITE_LEDE = "Look, don't touch. Every record here lives in someone else's repo."; +/** The same invitation, with none of the protocol in it. */ +export const SITE_LEDE_ARTIST = + "Type a handle and meet the player behind it — face, name, badges."; + export const SITE_DESCRIPTION = "Read FOLK records straight from the atproto repos that hold them. Resolve a handle, DID, or at:// URI and inspect every collection, record, and identity behind it.";