import { ExplorerController } from "$/lib/api/explorer"; import { ProfileController } from "$/lib/api/profile"; import type { ExplorerNavigation, ExplorerTargetKind, ExplorerViewLevel, ExplorerViewState, } from "$/lib/api/types/explorer"; import { NAVIGATION_EVENT } from "$/lib/constants/events"; import { consumeQueuedExplorerTarget } from "$/lib/explorer-navigation"; import { listen } from "@tauri-apps/api/event"; import * as logger from "@tauri-apps/plugin-log"; import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; import { produce } from "solid-js/store"; import { Motion, Presence } from "solid-motionone"; import { createExplorerState } from "./explorer-state"; import { ExplorerBreadcrumb } from "./ExplorerBreadcrumb"; import { ExplorerUrlBar } from "./ExplorerUrlBar"; import { CollectionView } from "./views/CollectionView"; import { PdsView } from "./views/PdsView"; import { RecordView } from "./views/RecordView"; import { RepoView } from "./views/RepoView"; function resolveParentInput(view: ExplorerViewState): string | null { switch (view.level) { case "record": { if (view.resolved?.did && view.resolved?.collection) { return `at://${view.resolved.did}/${view.resolved.collection}`; } break; } case "collection": { if (view.resolved?.did) { return `at://${view.resolved.did}`; } break; } case "repo": { if (view.resolved?.pdsUrl) { return view.resolved.pdsUrl; } break; } } return null; } function extractCollections(repoData: Record): Array<{ nsid: string }> { const collections: Array<{ nsid: string }> = []; const collectionsData = repoData.collections; if (Array.isArray(collectionsData)) { for (const collection of collectionsData) { if (typeof collection === "string") { collections.push({ nsid: collection }); } } } return collections.toSorted((left, right) => left.nsid.localeCompare(right.nsid)); } function hasCachedLexiconIcon(icons: Record, collection: string) { return Object.prototype.hasOwnProperty.call(icons, collection); } function parseExplorerTargetFromHash(hash: string) { const queryIndex = hash.indexOf("?"); if (queryIndex === -1 || queryIndex === hash.length - 1) { return null; } const params = new URLSearchParams(hash.slice(queryIndex + 1)); const value = params.get("target"); if (!value) { return null; } try { return decodeURIComponent(value); } catch { return value; } } export function ExplorerPanel() { const explorer = createExplorerState(); const [clearingIconCache, setClearingIconCache] = createSignal(false); const [statusMessage, setStatusMessage] = createSignal<{ kind: "error" | "success"; text: string } | null>(null); let resolveRequestId = 0; const canGoBack = createMemo(() => explorer.canGoBack()); const canGoForward = createMemo(() => explorer.canGoForward()); const breadcrumb = createMemo(() => explorer.getBreadcrumb()); const canExport = createMemo(() => !!explorer.state.current?.resolved?.did); function setCurrentView(view: ExplorerViewState) { explorer.setState("current", view); } function updateCurrentView(updater: (draft: ExplorerViewState) => void) { explorer.setState(produce((draft) => { if (!draft.current) return; updater(draft.current); if (draft.historyIndex >= 0) { const currentHistory = draft.history[draft.historyIndex]; if (currentHistory && currentHistory !== draft.current) { updater(currentHistory); } } })); } async function hydrateLexiconIcons(collections: string[], options?: { force?: boolean }) { const pendingCollections = [...new Set(collections)].filter((collection) => collection.trim().length > 0).filter(( collection, ) => options?.force || !hasCachedLexiconIcon(explorer.state.lexiconIcons, collection)); if (pendingCollections.length === 0) { return; } try { const icons = await ExplorerController.getLexiconFavicons(pendingCollections); explorer.mergeLexiconIcons(icons); } catch (error) { logger.warn("Failed to load lexicon favicons for explorer", { keyValues: { collections: pendingCollections.join(","), error: String(error) }, }); } } function currentLexiconCollections(): string[] { const current = explorer.state.current; if (!current) { return []; } if (current.repoData) { return current.repoData.collections.map((collection) => collection.nsid); } if (current.collectionData) { return [current.collectionData.collection]; } if (current.resolved?.collection) { return [current.resolved.collection]; } return []; } async function handleResolveInput(input: string) { if (!input.trim()) return; const submittedInput = input.trim(); const requestId = ++resolveRequestId; setStatusMessage(null); explorer.setInputValue(submittedInput); setCurrentView({ level: "repo", input: submittedInput, resolved: null, loading: true, error: null, data: null }); try { const resolved = await ExplorerController.resolveInput(submittedInput); if (requestId !== resolveRequestId) return; const level = resolved.targetKind as ExplorerViewLevel; const viewState = { level, input: submittedInput, resolved, loading: true, error: null, data: null }; setCurrentView(viewState); explorer.setInputValue(resolved.normalizedInput); let finalViewState: ExplorerViewState = viewState; switch (resolved.targetKind) { case "pds": { if (resolved.pdsUrl) { const serverView = await ExplorerController.describeServer(resolved.pdsUrl); finalViewState = { ...viewState, loading: false, pdsData: { repos: serverView.repos, server: serverView.server, cursor: serverView.cursor }, }; } break; } case "repo": { if (resolved.did) { const [repoData, profile] = await Promise.all([ ExplorerController.describeRepo(resolved.did), ProfileController.getProfile(resolved.did).catch(() => null), ]); const profileData = profile?.status === "available" ? profile.profile : null; const collections = extractCollections(repoData); finalViewState = { ...viewState, loading: false, repoData: { collections, did: resolved.did, handle: resolved.handle || resolved.did, pdsUrl: resolved.pdsUrl, socialSummary: profileData ? { followerCount: profileData.followersCount ?? null, followingCount: profileData.followsCount ?? null, } : null, }, }; } break; } case "collection": { if (resolved.did && resolved.collection) { const listData = await ExplorerController.listRecords(resolved.did, resolved.collection); finalViewState = { ...viewState, loading: false, collectionData: { records: (listData.records as Array>) || [], cursor: (listData.cursor as string) || null, did: resolved.did, collection: resolved.collection, loadingMore: false, }, }; } break; } case "record": { if (resolved.did && resolved.collection && resolved.rkey) { const [recordData, labels] = await Promise.all([ ExplorerController.getRecord(resolved.did, resolved.collection, resolved.rkey), resolved.uri ? ExplorerController.queryLabels(resolved.uri).catch(() => ({ labels: [] })) : Promise.resolve({ labels: [] }), ]); finalViewState = { ...viewState, loading: false, recordData: { record: (recordData.value as Record) || {}, cid: (recordData.cid as string) || null, uri: resolved.uri || "", labels: (labels.labels as Array>) || [], }, }; } break; } } if (requestId !== resolveRequestId) return; explorer.pushView(finalViewState); if (finalViewState.repoData) { void hydrateLexiconIcons(finalViewState.repoData.collections.map((collection) => collection.nsid)); } else if (finalViewState.collectionData) { void hydrateLexiconIcons([finalViewState.collectionData.collection]); } } catch (error) { if (requestId !== resolveRequestId) return; setCurrentView({ level: "repo", input: submittedInput, resolved: null, loading: false, error: String(error), data: null, }); } } function handleBack() { if (explorer.goBack()) { const current = explorer.state.current; if (current) { explorer.setInputValue(current.resolved?.normalizedInput || current.input); } } } function handleForward() { if (explorer.goForward()) { const current = explorer.state.current; if (current) { explorer.setInputValue(current.resolved?.normalizedInput || current.input); } } } function handleNavigateUp() { const current = explorer.state.current; if (!current?.resolved) return; const parentInput = resolveParentInput(current); if (parentInput) { void handleResolveInput(parentInput); } } function handleBreadcrumbClick(level: ExplorerTargetKind) { const current = explorer.state.current; if (!current?.resolved) return; const resolved = current.resolved; let targetInput: string | null = null; switch (level) { case "pds": { if (resolved.pdsUrl) targetInput = resolved.pdsUrl; break; } case "repo": { if (resolved.did) targetInput = `at://${resolved.did}`; break; } case "collection": { if (resolved.did && resolved.collection) { targetInput = `at://${resolved.did}/${resolved.collection}`; } break; } case "record": { if (resolved.uri) targetInput = resolved.uri; break; } } if (targetInput) { void handleResolveInput(targetInput); } } async function handleLoadMore() { const current = explorer.state.current; const collectionData = current?.collectionData; if (!collectionData?.cursor || collectionData.loadingMore) return; updateCurrentView((draft) => { if (draft.collectionData) { draft.collectionData.loadingMore = true; } }); try { const nextPage = await ExplorerController.listRecords( collectionData.did, collectionData.collection, collectionData.cursor, ); const nextRecords = (nextPage.records as Array>) || []; const nextCursor = (nextPage.cursor as string) || null; updateCurrentView((draft) => { if (!draft.collectionData) return; draft.collectionData.records = [...draft.collectionData.records, ...nextRecords]; draft.collectionData.cursor = nextCursor; draft.collectionData.loadingMore = false; }); } catch (error) { updateCurrentView((draft) => { if (draft.collectionData) { draft.collectionData.loadingMore = false; } }); setStatusMessage({ kind: "error", text: String(error) }); } } async function handleExport() { const did = explorer.state.current?.resolved?.did; if (!did) return; try { const result = await ExplorerController.exportRepoCar(did); setStatusMessage({ kind: "success", text: `Saved CAR export to ${result.path}` }); } catch (error) { setStatusMessage({ kind: "error", text: String(error) }); } } async function handleClearIconCache() { if (clearingIconCache()) { return; } setClearingIconCache(true); setStatusMessage(null); try { await ExplorerController.clearLexiconFaviconCache(); explorer.resetLexiconIcons(); setStatusMessage({ kind: "success", text: "Cleared explorer icon cache." }); const collections = currentLexiconCollections(); if (collections.length > 0) { await hydrateLexiconIcons(collections, { force: true }); } } catch (error) { setStatusMessage({ kind: "error", text: String(error) }); } finally { setClearingIconCache(false); } } function handleRepoClick(did: string) { void handleResolveInput(`at://${did}`); } function handleCollectionClick(did: string, collection: string) { void handleResolveInput(`at://${did}/${collection}`); } function handleRecordClick(did: string, collection: string, rkey: string) { void handleResolveInput(`at://${did}/${collection}/${rkey}`); } function handleKeyDown(event: KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key === "l") { event.preventDefault(); const input = document.querySelector("[data-explorer-input]") as HTMLInputElement; input?.focus(); input?.select(); return; } if ( event.key === "Backspace" && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) ) { event.preventDefault(); handleNavigateUp(); return; } if ((event.metaKey || event.ctrlKey) && event.key === "[") { event.preventDefault(); handleBack(); return; } if ((event.metaKey || event.ctrlKey) && event.key === "]") { event.preventDefault(); handleForward(); return; } } onMount(() => { let unlisten: (() => void) | undefined; const pendingTarget = consumeQueuedExplorerTarget() ?? parseExplorerTargetFromHash(globalThis.location.hash); void listen(NAVIGATION_EVENT, (event) => { const target = event.payload.target; void handleResolveInput(target.uri ?? target.normalizedInput); }).then((dispose) => { unlisten = dispose; }); document.addEventListener("keydown", handleKeyDown); if (pendingTarget) { void handleResolveInput(pendingTarget); } onCleanup(() => { unlisten?.(); document.removeEventListener("keydown", handleKeyDown); }); }); const currentView = createMemo(() => explorer.state.current); return (
0}> {(message) => (
{message().text}
)}
}> {(view) => (
{view().error}
handleCollectionClick(view().repoData!.did, collection)} pdsUrl={view().repoData!.pdsUrl} onPdsClick={() => { const pdsUrl = view().repoData?.pdsUrl; if (pdsUrl) { void handleResolveInput(pdsUrl); } }} socialSummary={view().repoData!.socialSummary} /> handleRecordClick(view().collectionData!.did, view().collectionData!.collection, rkey)} />
)}
); } function InitialEmptyPanel(props: { onExampleClick: (value: string) => void | Promise }) { const examples = [{ label: "@handle", value: "@alice.bsky.social" }, { label: "did", value: "did:plc:alice" }, { label: "at://", value: "at://did:plc:alice/app.bsky.feed.post/123", }, { label: "PDS URL", value: "https://pds.example.com" }]; return (

AT Protocol Explorer

Start from a handle, DID, URI, or PDS.

Browse repositories, collections, records, and server metadata without leaving Lazurite.

{(example) => ( )}

Tip: start with @ to get handle suggestions in the explorer bar.

); } function EmptyPanel() { return (

Enter an AT URI to explore

Try: at://did:plc:xyz/app.bsky.feed.post/123

); } function ExplorerSkeleton() { return (
{() =>
}
); }