diff --git a/src/components/actor-app.tsx b/src/components/actor-app.tsx index 4871e9b..059d840 100644 --- a/src/components/actor-app.tsx +++ b/src/components/actor-app.tsx @@ -1,5 +1,5 @@ -import type { CSSProperties } from "react"; -import { useEffect, useState } from "react"; +import type { CSSProperties, FormEvent } from "react"; +import { useEffect, useRef, useState } from "react"; import { BlueskyProfile } from "~/components/bluesky-profile"; import { IdentityCard } from "~/components/identity-card"; @@ -7,11 +7,14 @@ import { MasonryGrid } from "~/components/masonry-grid"; import { ProfileSection } from "~/components/profile-section"; import { ProfileSections } from "~/components/profile-sections"; import { resolveMiniDoc } from "~/lib/atproto/mini-doc"; +import { hashString } from "~/lib/did-random"; import { getBlueskyProfile } from "~/lib/providers/bluesky"; +import { searchBlueskyActors } from "~/lib/providers/bluesky-search"; import { getDidAtmosphere } from "~/lib/theme/did-atmosphere"; import { getDidCardRadius } from "~/lib/theme/did-card-radius"; import { getDidTheme } from "~/lib/theme/did-colors"; import type { BlueskyProfile as BlueskyProfileData } from "~/lib/providers/bluesky"; +import type { BlueskyActorSearchResult } from "~/lib/providers/bluesky-search"; import type { AtprotoMiniIdentity } from "~/lib/atproto/mini-doc"; export function ActorApp() { @@ -145,19 +148,260 @@ function getPathIdentifier(pathname: string | null): string | null { } function Home() { + const [query, setQuery] = useState(""); + const { actors, isSearching } = useActorSearch(query); + const { isOpen, openPanel, searchAreaRef } = useSearchPanel(); + const readingLabel = getSigilReadingLabel(query, actors); + + function visitPresence(event: FormEvent) { + event.preventDefault(); + + const identifier = query.trim().replace(/^@/, ""); + if (identifier) { + window.location.assign(`/${encodeURIComponent(identifier)}`); + } + } + return ( -
-
-

haunt

-

Try visiting an atproto profile.

- - View an example profile → - +
+
+

haunt.at // an atmosphere compendium

+ +
+
+ +

{readingLabel}

+
+ +

+ Haunt compendium +

+ +
+
+
+ { + setQuery(event.target.value); + openPanel(); + }} + onFocus={openPanel} + placeholder="summon a presence" + autoComplete="off" + /> +
+
+ + +
+
+ +
+ how it works +
+

+ Enter an AT Protocol handle. Haunt resolves it to a true name + (DID), then assembles its public traces from across the + Atmosphere. +

+

+ A true name is stable. It determines a haunt's form: its + color, atmosphere, and geometry. +

+
+
); } +function useSearchPanel() { + const searchAreaRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + function closePanel(event: PointerEvent) { + if ( + event.target instanceof Node && + !searchAreaRef.current?.contains(event.target) + ) { + setIsOpen(false); + } + } + + document.addEventListener("pointerdown", closePanel); + return () => document.removeEventListener("pointerdown", closePanel); + }, []); + + return { isOpen, openPanel: () => setIsOpen(true), searchAreaRef }; +} + +function getSigilReadingLabel( + query: string, + actors: BlueskyActorSearchResult[], +): string { + const identifier = query.trim().replace(/^@/, ""); + const normalizedIdentifier = identifier.toLowerCase(); + const matchingActor = actors.find( + (actor) => + actor.handle.toLowerCase() === normalizedIdentifier || + actor.did.toLowerCase() === normalizedIdentifier, + ); + + return matchingActor ? `reading: ${matchingActor.handle}` : "unresolved"; +} + +type HauntSigilProps = { + seed: string; +}; + +function HauntSigil({ seed }: HauntSigilProps) { + const pattern = getHauntSigilPattern(seed); + + return ( + + ); +} + +function getHauntSigilPattern(seed: string) { + const hash = hashString(seed); + const hue = 190 + (hash % 35); + + return { + color: `hsl(${hue} 38% 76%)`, + dashArray: `${4 + (hash % 8)} ${3 + ((hash >>> 3) % 8)}`, + rotation: (hash >>> 6) % 60, + }; +} + +type SearchResultsProps = { + actors: BlueskyActorSearchResult[]; + isOpen: boolean; +}; + +type LoadActorsOptions = { + query: string; + setActors: (actors: BlueskyActorSearchResult[]) => void; + setIsSearching: (isSearching: boolean) => void; + signal: AbortSignal; +}; + +function SearchResults({ actors, isOpen }: SearchResultsProps) { + if (!isOpen) return null; + + if (actors.length > 0) { + return ( + + ); + } + + return null; +} + +function useActorSearch(query: string) { + const [actors, setActors] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + const normalizedQuery = query.trim(); + if (normalizedQuery.length < 2) { + setActors([]); + setIsSearching(false); + return; + } + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => { + void loadActors({ + query: normalizedQuery, + signal: controller.signal, + setActors, + setIsSearching, + }); + }, 300); + + setIsSearching(true); + return () => { + controller.abort(); + window.clearTimeout(timeoutId); + }; + }, [query]); + + return { actors, isSearching }; +} + +async function loadActors({ + query, + signal, + setActors, + setIsSearching, +}: LoadActorsOptions) { + try { + const actors = await searchBlueskyActors(query, signal); + if (!signal.aborted) { + setActors(actors); + setIsSearching(false); + } + } catch { + if (!signal.aborted) { + setActors([]); + setIsSearching(false); + } + } +} + function LoadingPage() { return (
diff --git a/src/lib/providers/bluesky-search.ts b/src/lib/providers/bluesky-search.ts new file mode 100644 index 0000000..7162245 --- /dev/null +++ b/src/lib/providers/bluesky-search.ts @@ -0,0 +1,33 @@ +const BLUESKY_APPVIEW_URL = "https://public.api.bsky.app"; + +export type BlueskyActorSearchResult = { + avatar?: string; + did: string; + displayName?: string; + handle: string; +}; + +type SearchActorsResponse = { + actors: BlueskyActorSearchResult[]; +}; + +export async function searchBlueskyActors( + query: string, + signal: AbortSignal, +): Promise { + const params = new URLSearchParams({ + q: query, + limit: "8", + }); + const response = await fetch( + `${BLUESKY_APPVIEW_URL}/xrpc/app.bsky.actor.searchActors?${params}`, + { signal }, + ); + + if (!response.ok) { + throw new Error("Could not search."); + } + + const data = (await response.json()) as SearchActorsResponse; + return data.actors; +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 3d31767..ab8928d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -16,6 +16,223 @@ body { background-size: 108% 108%; } +.home-page { + background-color: #10090d; + background-image: + radial-gradient(circle at 16% 12%, #2b111d 0, transparent 36rem), + radial-gradient(circle at 82% 88%, #1e1016 0, transparent 32rem); + color: #e8ddcc; +} + +.home-content { + padding-top: clamp(1.5rem, 4vw, 3rem); +} + +.home-index-label { + color: color-mix(in srgb, currentColor 50%, transparent); + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.home-invocation { + display: grid; + justify-items: center; + margin-top: clamp(7rem, 20vh, 13rem); + text-align: center; +} + +.home-sigil { + color: var(--home-sigil-color, #9cc6d1); + fill: none; + height: 6.75rem; + opacity: 0.88; + stroke: currentColor; + stroke-width: 1.25; + width: 6.75rem; +} + +.home-sigil-node { + fill: currentColor; +} + +.home-invocation-sigil { + display: grid; + gap: 0.35rem; + justify-items: center; +} + +.home-invocation-sigil .home-sigil { + height: clamp(8rem, 18vw, 10rem); + width: clamp(8rem, 18vw, 10rem); +} + +.home-invocation-sigil p { + color: color-mix(in srgb, currentColor 48%, transparent); + font-size: 0.6875rem; + letter-spacing: 0.08em; + line-height: 1.3; + text-transform: uppercase; + white-space: nowrap; +} + +.home-search { + border-bottom: 1px solid color-mix(in srgb, #a98345 66%, transparent); +} + +.home-search-area { + margin-top: 2.25rem; + max-width: 25rem; + position: relative; + width: min(100%, 25rem); + z-index: 1; +} + +.home-search-input { + background-color: transparent; + font-size: 1rem; + min-width: 0; + padding: 0.625rem 0 0.75rem; + width: 100%; +} + +.home-search-input:focus { + outline: none; +} + +.home-search:focus-within { + border-color: #d4ae6b; +} + +.home-search-results { + background-color: #170e13; + border: 1px solid color-mix(in srgb, #a98345 48%, transparent); + border-radius: 0.25rem; + inset-inline: 0; + margin-top: 0.5rem; + max-height: 16rem; + overflow-y: auto; + position: absolute; + top: 100%; + box-shadow: 0 1rem 2rem rgb(0 0 0 / 0.24); +} + +.home-search-result { + align-items: center; + display: grid; + gap: 0.625rem; + grid-template-columns: 2rem minmax(0, 1fr); + padding: 0.5rem 0.625rem; + text-align: left; +} + +.home-search-result:hover, +.home-search-result:focus-visible { + background-color: color-mix(in srgb, #6e2435 30%, transparent); + outline: none; +} + +.home-search-result:focus-visible { + outline: 2px solid #d4ae6b; + outline-offset: -2px; +} + +.home-search-results li + li { + border-top: 1px solid color-mix(in srgb, #a98345 28%, transparent); +} + +.home-search-avatar { + background-color: #2b111d; + border-radius: 9999px; + flex: none; + height: 2rem; + object-fit: cover; + width: 2rem; +} + +.home-search-copy { + min-width: 0; +} + +.home-search-name { + font-size: 0.875rem; + font-weight: 650; +} + +.home-search-handle, +.home-search-status { + color: color-mix(in srgb, currentColor 55%, transparent); + font-size: 0.8125rem; + line-height: 1.5; +} + +.home-search-status { + inset-inline: 0; + margin-top: 0.5rem; + position: absolute; + top: 100%; +} + +.home-how-it-works { + color: color-mix(in srgb, currentColor 56%, transparent); + margin: clamp(3rem, 8vh, 5rem) auto 0; + max-width: 28rem; + text-align: center; +} + +.home-how-it-works summary { + cursor: pointer; + font-size: 0.8125rem; + list-style: none; + margin-inline: auto; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 0.2em; + width: fit-content; +} + +.home-how-it-works summary::-webkit-details-marker { + display: none; +} + +.home-how-it-works summary:focus-visible { + outline: 2px solid #d4ae6b; + outline-offset: 3px; +} + +.home-how-it-works-copy { + display: grid; + gap: 0.875rem; + line-height: 1.55; + margin-top: 1.25rem; + text-align: left; +} + +.home-how-it-works-copy dl { + display: grid; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.home-how-it-works-copy dl div { + display: grid; + gap: 0.75rem; + grid-template-columns: 4.75rem 1fr; +} + +.home-how-it-works-copy dt { + color: #d4ae6b; + font-size: 0.8125rem; + font-weight: 650; +} + +@media (max-width: 24rem) { + .home-sigil { + height: 5.25rem; + width: 5.25rem; + } +} + .profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));