diff --git a/README.md b/README.md index a79a12e..678053b 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ This repo is intentionally lightweight. It is not the main `social-cli` repo; it - `thread-cards/` — generator for monochrome Sensemaker header cards. - `blog/` — older/draft long-form materials. -## Interview viewer +## Interviewer -The viewer renders any Sensemaker-format interview from public ATProto records. +Interviewer renders any Sensemaker-format interview from public ATProto records. ```bash bun install diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..b3ebddd --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + diff --git a/src/main.tsx b/src/main.tsx index 948bc41..499370f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -40,6 +40,7 @@ type Interview = { } const DEFAULT_SESSION = "at://did:plc:4j7exarb62djxycrgdfhuulr/network.sensemaker.session/3mmet7dmrel2h" +const DEFAULT_INTERVIEWER = "did:plc:4j7exarb62djxycrgdfhuulr" const COLLECTIONS = { session: "network.sensemaker.session", question: "network.sensemaker.question", @@ -125,6 +126,13 @@ async function loadInterview(sessionUri: string): Promise { return { sessionUri, session, questions, answers } } +async function loadSessions(repo: string): Promise>> { + const did = await resolveHandle(repo) + const pds = await resolvePds(did) + return (await listRecords(pds, did, COLLECTIONS.session)) + .sort((a, b) => b.value.createdAt.localeCompare(a.value.createdAt)) +} + function formatDate(value: string): string { return new Intl.DateTimeFormat("en", { month: "long", day: "numeric", year: "numeric" }).format(new Date(value)) } @@ -147,11 +155,15 @@ function InterviewPage() { const params = new URLSearchParams(window.location.search) const [input, setInput] = useState(params.get("session") ?? DEFAULT_SESSION) const [sessionUri, setSessionUri] = useState(input) + const [repoInput, setRepoInput] = useState(params.get("repo") ?? DEFAULT_INTERVIEWER) + const [view, setView] = useState<"interview" | "index">(params.get("view") === "index" ? "index" : "interview") const [data, setData] = useState(null) + const [sessions, setSessions] = useState>>([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) useEffect(() => { + if (view !== "interview") return let alive = true setLoading(true) setError(null) @@ -160,7 +172,19 @@ function InterviewPage() { .catch((err) => { if (alive) setError(err instanceof Error ? err.message : String(err)) }) .finally(() => { if (alive) setLoading(false) }) return () => { alive = false } - }, [sessionUri]) + }, [sessionUri, view]) + + useEffect(() => { + if (view !== "index") return + let alive = true + setLoading(true) + setError(null) + loadSessions(repoInput) + .then((records) => { if (alive) setSessions(records) }) + .catch((err) => { if (alive) setError(err instanceof Error ? err.message : String(err)) }) + .finally(() => { if (alive) setLoading(false) }) + return () => { alive = false } + }, [repoInput, view]) const answerByQuestion = useMemo(() => { const map = new Map>() @@ -168,39 +192,82 @@ function InterviewPage() { return map }, [data]) - function submit(event: React.FormEvent) { - event.preventDefault() - const next = input.trim() - if (!next) return + function loadSession(event?: React.FormEvent, nextUri = input.trim()) { + event?.preventDefault() + if (!nextUri) return const url = new URL(window.location.href) - url.searchParams.set("session", next) + url.searchParams.set("session", nextUri) + url.searchParams.set("view", "interview") window.history.replaceState({}, "", url) - setSessionUri(next) + setInput(nextUri) + setSessionUri(nextUri) + setView("interview") + } + + function loadIndex(event?: React.FormEvent) { + event?.preventDefault() + const url = new URL(window.location.href) + url.searchParams.set("repo", repoInput) + url.searchParams.set("view", "index") + window.history.replaceState({}, "", url) + setView("index") } return (
-

Sensemaker interviews

-

{data?.session.title ?? "Interview viewer"}

+

Interviewer

+

{data?.session.title ?? "Interviewer"}

ATProto-native Q&A rendered as a quiet public record.

- + Sensemaker
-
- -
- setInput(event.target.value)} /> - -
-
+ + + {view === "interview" ? ( +
loadSession(event)}> + +
+ setInput(event.target.value)} /> + +
+
+ ) : ( +
loadIndex(event)}> + +
+ setRepoInput(event.target.value)} /> + +
+
+ )} - {loading &&

Loading from the interviewer's and subject's PDSes…

} + {loading &&

Loading from ATProto PDSes…

} {error &&

{error}

} - {data && !loading && ( + {view === "index" && !loading && !error && ( +
+ {sessions.map((session) => ( +
+
+

{session.value.topic ?? "Interview"}

+

{session.value.title}

+

{session.value.introduction ?? "ATProto-native interview session."}

+

{session.uri}

+
+ +
+ ))} + {sessions.length === 0 &&

No interview sessions found.

} +
+ )} + + {view === "interview" && data && !loading && (
diff --git a/src/styles.css b/src/styles.css index c5d5eb4..d287fa6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -67,14 +67,58 @@ h1 { font-size: 22px; line-height: 1.35; } -.wordmark { +.brand-logo { + width: 76px; + height: 76px; + object-fit: contain; + filter: invert(1); + opacity: 0.84; + margin-top: 6px; +} +.tabs { + display: flex; + gap: 10px; + margin: 24px 0 0; +} +.tabs button { color: var(--muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 13px; - letter-spacing: 0.12em; - text-transform: uppercase; - margin-top: 12px; - opacity: 0.75; + background: transparent; + border: 1px solid var(--line); +} +.tabs button.active { + color: #050505; + background: var(--fg); + border-color: var(--fg); +} +.session-list { + display: grid; + gap: 16px; + margin-top: 32px; +} +.session-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 24px; + align-items: center; + padding: 24px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 22px; +} +.session-row h2 { + margin: 0; + max-width: 760px; + font-family: Newsreader, Georgia, serif; + font-size: clamp(28px, 3.5vw, 44px); + line-height: 1.04; + letter-spacing: -0.025em; +} +.session-row p:not(.eyebrow):not(.uri) { + max-width: 720px; + margin: 14px 0 0; + color: var(--muted); + font-size: 18px; + line-height: 1.45; } .loader { margin: 28px 0 42px; @@ -215,8 +259,10 @@ dd { @media (max-width: 720px) { main { width: min(100vw - 24px, 980px); padding-top: 28px; } .site-header { grid-template-columns: 1fr; } - .wordmark { display: none; } + .brand-logo { display: none; } .loader div { flex-direction: column; } + .tabs { flex-direction: column; } + .session-row { grid-template-columns: 1fr; } dl { grid-template-columns: 1fr; } .qa { grid-template-columns: 1fr; gap: 14px; padding: 34px 0; } .number { font-size: 30px; }