diff --git a/scripts/generate-metadata.js b/scripts/generate-metadata.js index 61061c8..5a42ccc 100644 --- a/scripts/generate-metadata.js +++ b/scripts/generate-metadata.js @@ -21,7 +21,8 @@ const configs = { client_uri: baseUrl, logo_uri: `${baseUrl}/favicon.ico`, redirect_uris: [`${baseUrl}/`], - scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", + scope: + "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/* space:*?authority=*&action=read_self", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", diff --git a/src/auth/scope-flow.ts b/src/auth/scope-flow.ts index 4b06e23..5120ef0 100644 --- a/src/auth/scope-flow.ts +++ b/src/auth/scope-flow.ts @@ -27,7 +27,7 @@ export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => { setShowScopeSelector(true); }; - const complete = async (scopeString: string, scopeIds: string) => { + const complete = async (scopeString: string) => { try { const account = pendingAccount(); @@ -39,8 +39,6 @@ export const useOAuthScopeFlow = (options: UseOAuthScopeFlowOptions = {}) => { } catch {} } - localStorage.setItem("pendingScopes", scopeIds); - options.onRedirecting?.(); const authUrl = await createAuthorizationUrl({ diff --git a/src/auth/scope-selector.tsx b/src/auth/scope-selector.tsx index fc2a2e4..80339b1 100644 --- a/src/auth/scope-selector.tsx +++ b/src/auth/scope-selector.tsx @@ -1,15 +1,16 @@ import { createSignal, For } from "solid-js"; -import { buildScopeString, GRANULAR_SCOPES, scopeIdsToString } from "./scope-utils"; +import { buildScopeString, GRANULAR_SCOPES } from "./scope-utils"; interface ScopeSelectorProps { - onConfirm: (scopeString: string, scopeIds: string) => void; + onConfirm: (scopeString: string) => void; onCancel: () => void; initialScopes?: Set; } export const ScopeSelector = (props: ScopeSelectorProps) => { const [selectedScopes, setSelectedScopes] = createSignal>( + // Space access is deliberately opt-in while the protocol is in alpha. props.initialScopes || new Set(["create", "update", "delete", "blob"]), ); @@ -38,10 +39,7 @@ export const ScopeSelector = (props: ScopeSelectorProps) => { }; const handleConfirm = () => { - const scopes = selectedScopes(); - const scopeString = buildScopeString(scopes); - const scopeIds = scopeIdsToString(scopes); - props.onConfirm(scopeString, scopeIds); + props.onConfirm(buildScopeString(selectedScopes())); }; return ( diff --git a/src/auth/scope-utils.ts b/src/auth/scope-utils.ts index 0eda945..d0f623d 100644 --- a/src/auth/scope-utils.ts +++ b/src/auth/scope-utils.ts @@ -1,5 +1,8 @@ import { agent, sessions } from "./state"; +export const SPACE_READ_SCOPE_ID = "space-read" as const; +export const SPACE_READ_SCOPE = "space:*?authority=*&action=read_self"; + export const GRANULAR_SCOPES = [ { id: "create", @@ -21,7 +24,14 @@ export const GRANULAR_SCOPES = [ scope: "blob:*/*", label: "Upload blobs", }, -]; + { + id: SPACE_READ_SCOPE_ID, + scope: SPACE_READ_SCOPE, + label: "Read your Space records (alpha)", + }, +] as const; + +export type ScopeId = (typeof GRANULAR_SCOPES)[number]["id"]; const BASE_SCOPES = ["atproto"]; @@ -40,14 +50,26 @@ export const parseScopeString = (scopeIdsString: string): Set => { return new Set(ids.filter((id) => id !== "atproto")); }; +export const oauthScopeStringToIds = (scopeString: string): Set => { + const granted = new Set(scopeString.split(" ").filter(Boolean)); + return new Set(GRANULAR_SCOPES.filter(({ scope }) => granted.has(scope)).map(({ id }) => id)); +}; + const hasScope = (grantedScopes: string | undefined, scopeId: string): boolean => { if (!grantedScopes) return false; return grantedScopes.split(",").includes(scopeId); }; -export const hasUserScope = (scopeId: string): boolean => { - if (!agent()) return false; - const grantedScopes = sessions[agent()!.sub]?.grantedScopes; +export const hasUserScope = (scopeId: ScopeId): boolean => { + const currentAgent = agent(); + if (!currentAgent) return false; + + const configuredScope = GRANULAR_SCOPES.find(({ id }) => id === scopeId)?.scope; + if (configuredScope && currentAgent.session.token.scope) { + return currentAgent.session.token.scope.split(" ").includes(configuredScope); + } + + const grantedScopes = sessions[currentAgent.sub]?.grantedScopes; if (!grantedScopes) return true; return hasScope(grantedScopes, scopeId); }; diff --git a/src/auth/session-manager.ts b/src/auth/session-manager.ts index 6ac8570..ffed13a 100644 --- a/src/auth/session-manager.ts +++ b/src/auth/session-manager.ts @@ -8,6 +8,7 @@ import { } from "@atcute/oauth-browser-client"; import { resolveDidDoc } from "../lib/api"; +import { oauthScopeStringToIds, scopeIdsToString } from "./scope-utils"; import { Sessions, setAgent, setSessions } from "./state"; export const saveSessionToStorage = (sessions: Sessions) => { @@ -54,8 +55,7 @@ export const retrieveSession = async (): Promise => { localStorage.setItem("lastSignedIn", did); - const grantedScopes = localStorage.getItem("pendingScopes") || "atproto"; - localStorage.removeItem("pendingScopes"); + const grantedScopes = scopeIdsToString(oauthScopeStringToIds(auth.session.token.scope)); const sessions = loadSessionsFromStorage(); const newSessions: Sessions = sessions || {}; @@ -73,6 +73,9 @@ export const retrieveSession = async (): Promise => { const rpc = new Client({ handler: new OAuthUserAgent(session) }); const res = await rpc.get("com.atproto.server.getSession"); newSessions[lastSignedIn].signedIn = true; + newSessions[lastSignedIn].grantedScopes = scopeIdsToString( + oauthScopeStringToIds(session.token.scope), + ); saveSessionToStorage(newSessions); if (!res.ok) throw res.data.error; return session; diff --git a/src/components/json.tsx b/src/components/json.tsx index 56489ea..f12b4a5 100644 --- a/src/components/json.tsx +++ b/src/components/json.tsx @@ -7,6 +7,7 @@ import { createSignal, ErrorBoundary, For, + onCleanup, Show, useContext, } from "solid-js"; @@ -30,6 +31,7 @@ interface JSONContext { path?: string; preview?: boolean; depth?: number; + fetchBlob?: (cid: string) => Promise; } const JSONCtx = createContext(); @@ -296,7 +298,7 @@ const JSONObject = (props: { data: { [x: string]: JSONType } }) => { const blob: AtBlob = props.data as any; const canShowMedia = () => - ctx.pds && + (ctx.pds || ctx.fetchBlob) && !ctx.hideBlobs && (blob.mimeType.startsWith("image/") || blob.mimeType === "video/mp4" || @@ -304,52 +306,92 @@ const JSONObject = (props: { data: { [x: string]: JSONType } }) => { const MediaDisplay = () => { const [overrideShow, setOverrideShow] = createSignal(false); + const [mediaError, setMediaError] = createSignal(false); const hidden = () => hideMedia() && !overrideShow(); + let objectUrl: string | undefined; - const [imageUrl] = createResource( - () => (blob.mimeType.startsWith("image/") ? blob.ref.$link : null), + const [mediaUrl] = createResource( + () => blob.ref.$link, async (cid) => { + setMediaError(false); + + if (ctx.fetchBlob) { + try { + const data = await ctx.fetchBlob(cid); + objectUrl = URL.createObjectURL(data); + return objectUrl; + } catch { + setMediaError(true); + return; + } + } + const url = `${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${ctx.repo}&cid=${cid}`; - await new Promise((resolve) => { - const img = new Image(); - img.src = url; - img.onload = () => resolve(); - img.onerror = () => resolve(); - }); + if (blob.mimeType.startsWith("image/")) { + await new Promise((resolve) => { + const img = new Image(); + img.src = url; + img.onload = () => resolve(); + img.onerror = () => resolve(); + }); + } return url; }, ); + onCleanup(() => { + if (objectUrl) URL.revokeObjectURL(objectUrl); + }); + + const LoadingMedia = () => ( +
+ +
+ ); + return (
- - - -
- } - > - + + Failed to load media + + } + > + + }> + + + + + Failed to load video}> + + + } + > + }> + + + + + }> + + - - - Failed to load video}> - - - - - @@ -419,6 +461,7 @@ export const JSONValue = (props: { hideBlobs?: boolean; keyLinks?: boolean; preview?: boolean; + fetchBlob?: (cid: string) => Promise; }) => { return ( diff --git a/src/components/permission-button.tsx b/src/components/permission-button.tsx index 08d2354..668a347 100644 --- a/src/components/permission-button.tsx +++ b/src/components/permission-button.tsx @@ -1,11 +1,11 @@ import { JSX } from "solid-js"; -import { hasUserScope } from "../auth/scope-utils"; +import { hasUserScope, type ScopeId } from "../auth/scope-utils"; import { showPermissionPrompt } from "./permission-prompt"; import Tooltip from "./tooltip"; interface PermissionButtonProps { - scope: "create" | "update" | "delete" | "blob"; + scope: ScopeId; tooltip?: string; class?: string; disabledClass?: string; diff --git a/src/components/permission-prompt.tsx b/src/components/permission-prompt.tsx index 6cba05e..68f0afd 100644 --- a/src/components/permission-prompt.tsx +++ b/src/components/permission-prompt.tsx @@ -1,12 +1,10 @@ import { createEffect, createSignal } from "solid-js"; -import { GRANULAR_SCOPES } from "../auth/scope-utils"; +import { GRANULAR_SCOPES, type ScopeId } from "../auth/scope-utils"; import { agent, setOpenManager, setPendingPermissionEdit } from "../auth/state"; import { Button } from "./button"; import { Modal } from "./modal"; -type ScopeId = "create" | "update" | "delete" | "blob"; - const [requestedScope, setRequestedScope] = createSignal(null); export const showPermissionPrompt = (scope: ScopeId) => { diff --git a/src/index.tsx b/src/index.tsx index 6a36b4e..70c9d6f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,12 @@ import { RecordView } from "./views/record.tsx"; import { BlobDebugView } from "./views/repo/blob-debug.tsx"; import { RepoLayout, repoPreload } from "./views/repo/index.tsx"; import { Settings } from "./views/settings.tsx"; +import { + SpaceCollectionLayout, + SpaceLayout, + SpaceRecordView, + SpacesLayout, +} from "./views/spaces/index.tsx"; import { StreamView } from "./views/stream"; render( @@ -24,6 +30,16 @@ render( + + + + + + + + + + diff --git a/src/layout.tsx b/src/layout.tsx index 89b862d..16df3dc 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -241,6 +241,7 @@ const Layout = (props: RouteSectionProps) => { + diff --git a/src/lib/spaces.ts b/src/lib/spaces.ts new file mode 100644 index 0000000..2015b8f --- /dev/null +++ b/src/lib/spaces.ts @@ -0,0 +1,192 @@ +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; + +import type { JSONType } from "../components/json"; + +export interface SpaceView { + uri: string; +} + +export interface SpaceRecord { + collection: string; + rkey: string; + cid: string; + value?: JSONType; +} + +export interface ListSpacesResult { + cursor?: string; + spaces: SpaceView[]; +} + +export interface ListSpaceRecordsResult { + cursor?: string; + records: SpaceRecord[]; +} + +export interface GetSpaceRecordResult { + uri: string; + cid: string; + value: JSONType; +} + +export interface ParsedSpaceUri { + authority: string; + type: string; + skey: string; +} + +const isObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const getErrorMessage = (data: unknown, status: number) => { + if (isObject(data)) { + if (typeof data.message === "string" && data.message) return data.message; + if (typeof data.error === "string" && data.error) return data.error; + } + return `Spaces request failed with HTTP ${status}`; +}; + +const query = async ( + auth: OAuthUserAgent, + method: string, + params: Record, +): Promise => { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) search.set(key, String(value)); + } + + const suffix = search.size ? `?${search}` : ""; + const response = await auth.handle(`/xrpc/${method}${suffix}`, { + headers: { accept: "application/json" }, + }); + + let data: unknown; + try { + data = await response.json(); + } catch { + data = undefined; + } + + if (!response.ok) throw new Error(getErrorMessage(data, response.status)); + return data; +}; + +export const parseSpaceUri = (uri: string): ParsedSpaceUri | undefined => { + const match = /^at:\/\/([^/]+)\/space\/([^/]+)\/([^/]+)$/.exec(uri); + if (!match) return; + return { authority: match[1], type: match[2], skey: match[3] }; +}; + +export const listSpaces = async ( + auth: OAuthUserAgent, + options: { cursor?: string; limit?: number } = {}, +): Promise => { + const data = await query(auth, "com.atproto.space.listSpaces", { + cursor: options.cursor, + limit: options.limit ?? 100, + }); + + if (!isObject(data) || !Array.isArray(data.spaces)) { + throw new Error("The PDS returned an invalid Spaces response"); + } + + return { + cursor: typeof data.cursor === "string" ? data.cursor : undefined, + spaces: data.spaces.filter( + (space): space is SpaceView => isObject(space) && typeof space.uri === "string", + ), + }; +}; + +export const listSpaceRecords = async ( + auth: OAuthUserAgent, + space: string, + repo: string, + options: { + collection?: string; + cursor?: string; + excludeValues?: boolean; + limit?: number; + reverse?: boolean; + } = {}, +): Promise => { + const data = await query(auth, "com.atproto.space.listRecords", { + space, + repo, + collection: options.collection, + cursor: options.cursor, + excludeValues: options.excludeValues, + limit: options.limit ?? 1000, + reverse: options.reverse, + }); + + if (!isObject(data) || !Array.isArray(data.records)) { + throw new Error("The PDS returned an invalid Space records response"); + } + + return { + cursor: typeof data.cursor === "string" ? data.cursor : undefined, + records: data.records.filter( + (record): record is SpaceRecord => + isObject(record) && + typeof record.collection === "string" && + typeof record.rkey === "string" && + typeof record.cid === "string", + ), + }; +}; + +export const getSpaceRecord = async ( + auth: OAuthUserAgent, + space: string, + repo: string, + collection: string, + rkey: string, +): Promise => { + const data = await query(auth, "com.atproto.space.getRecord", { + space, + repo, + collection, + rkey, + }); + + if ( + !isObject(data) || + typeof data.uri !== "string" || + typeof data.cid !== "string" || + data.value === undefined + ) { + throw new Error("The PDS returned an invalid Space record response"); + } + + return { + uri: data.uri, + cid: data.cid, + value: data.value as JSONType, + }; +}; + +export const getSpaceBlob = async ( + auth: OAuthUserAgent, + space: string, + repo: string, + cid: string, +): Promise => { + const search = new URLSearchParams({ space, repo, cid }); + const response = await auth.handle(`/xrpc/com.atproto.space.getBlob?${search}`, { + headers: { accept: "*/*" }, + }); + + if (!response.ok) { + let data: unknown; + try { + data = await response.json(); + } catch { + data = undefined; + } + throw new Error(getErrorMessage(data, response.status)); + } + + return response.blob(); +}; diff --git a/src/views/home.tsx b/src/views/home.tsx index 89fca1e..f91df7b 100644 --- a/src/views/home.tsx +++ b/src/views/home.tsx @@ -39,6 +39,7 @@ const getAtProtocolUrl = () => { const CardContent = (props: { icon: string | JSX.Element; title: string; + badge?: JSX.Element; description: string; accent: Accent; }) => ( @@ -50,6 +51,7 @@ const CardContent = (props: { props.icon )} {props.title} + {props.badge} {props.description} @@ -78,6 +80,7 @@ const LinkCard = (props: { href: string; icon: string | JSX.Element; title: string; + badge?: JSX.Element; description: string; accent: Accent; }) => ( @@ -85,6 +88,7 @@ const LinkCard = (props: { @@ -170,6 +174,19 @@ export const Home = () => { + + Alpha + + } + description="Preview your non-public records" + accent="blue" + /> +
{ + const auth = useSpacesAuth(); + const params = useParams(); + const hidden = () => !!params.rkey; + const [records, setRecords] = createSignal([]); + const [cursor, setCursor] = createSignal(); + const [filter, setFilter] = createSignal(""); + const [loaded, setLoaded] = createSignal(false); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(); + let activeKey = ""; + let requestVersion = 0; + + const space = () => makeSpaceRef(params.spaceAuthority!, params.spaceType!, params.skey!); + + const filteredRecords = createMemo(() => { + const value = filter().trim().toLowerCase(); + if (!value) return records(); + return records().filter((record) => { + const recordValue = record.value === undefined ? "" : JSON.stringify(record.value); + return `${record.rkey}\n${record.cid}\n${recordValue}`.toLowerCase().includes(value); + }); + }); + + const loadRecords = async (reset = false, version = requestVersion) => { + if (loading() && !reset) return; + + setLoading(true); + setError(undefined); + try { + const result = await listSpaceRecords(auth(), space(), auth().sub, { + collection: params.collection, + cursor: reset ? undefined : cursor(), + limit: RECORDS_PER_PAGE, + reverse: true, + }); + if (version !== requestVersion) return; + setRecords((current) => (reset ? result.records : [...current, ...result.records])); + setCursor(result.cursor); + setLoaded(true); + } catch (err) { + if (version !== requestVersion) return; + setError(err instanceof Error ? err.message : "Could not load Space records"); + setLoaded(true); + } finally { + if (version === requestVersion) setLoading(false); + } + }; + + createEffect(() => { + const key = `${auth().sub}\n${space()}\n${params.collection}`; + if (key !== activeKey) { + activeKey = key; + requestVersion += 1; + setRecords([]); + setCursor(undefined); + setFilter(""); + setLoaded(false); + setLoading(false); + setError(undefined); + } + if (!hidden() && !loaded() && !loading()) void loadRecords(true, requestVersion); + }); + + return ( + +
+
+ + + {filteredRecords().length.toLocaleString()} matching record + {filteredRecords().length === 1 ? "" : "s"} + · + + {records().length.toLocaleString()} {cursor() ? "loaded " : ""}record + {records().length === 1 ? "" : "s"} + + + View schema + +
+ +
+ setFilter(event.currentTarget.value)} + class="w-full text-sm" + /> +
+ + + + + + {(message) => } + + 0)}> +
+ + {(record) => { + const timestamp = () => + TID.validate(record.rkey) ? TID.parse(record.rkey).timestamp / 1000 : undefined; + + return ( + + + {record.rkey} + + + {record.cid} + + + {(value) => ( + + {localDateFromTimestamp(value())} + + )} + + + } + > + + No value returned + + } + > + + + + ); + }} + +
+ + + + + + + + +
+
+
+ ); +}; + +export const SpaceCollectionLayout = (props: RouteSectionProps) => { + const params = useParams(); + const hasChild = () => !!params.rkey; + const key = () => + `${params.spaceAuthority}/${params.spaceType}/${params.skey}/${params.collection}`; + + return ( + }> + {props.children} + + ); +}; diff --git a/src/views/spaces/context.tsx b/src/views/spaces/context.tsx new file mode 100644 index 0000000..85cf8ca --- /dev/null +++ b/src/views/spaces/context.tsx @@ -0,0 +1,44 @@ +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { type Accessor, createContext, type Setter, useContext } from "solid-js"; + +export const SpacesAuthContext = createContext>(); + +export const useSpacesAuth = () => { + const auth = useContext(SpacesAuthContext); + if (!auth) throw new Error("Spaces auth context is missing"); + return auth; +}; + +type SpaceRecordMetadata = { + cid: Accessor; + setCid: Setter; +}; + +export const SpaceRecordMetadataContext = createContext(); + +export const useSpaceRecordMetadata = () => { + const metadata = useContext(SpaceRecordMetadataContext); + if (!metadata) throw new Error("Space record metadata context is missing"); + return metadata; +}; + +export const makeSpaceRef = (authority: string, type: string, skey: string) => + `at://${authority}/space/${type}/${skey}`; + +export const makeSpacePath = (authority: string, type: string, skey: string) => + `/spaces/${authority}/${type}/${skey}`; + +export const makeSpaceCollectionPath = ( + authority: string, + type: string, + skey: string, + collection: string, +) => `${makeSpacePath(authority, type, skey)}/${collection}`; + +export const makeSpaceRecordPath = ( + authority: string, + type: string, + skey: string, + collection: string, + rkey: string, +) => `${makeSpaceCollectionPath(authority, type, skey, collection)}/${rkey}`; diff --git a/src/views/spaces/index.tsx b/src/views/spaces/index.tsx new file mode 100644 index 0000000..c77c99d --- /dev/null +++ b/src/views/spaces/index.tsx @@ -0,0 +1,239 @@ +import { A, type RouteSectionProps, useParams } from "@solidjs/router"; +import { createEffect, createSignal, For, Show } from "solid-js"; + +import { hasUserScope, SPACE_READ_SCOPE_ID } from "../../auth/scope-utils"; +import { + agent, + setOpenManager, + setPendingPermissionEdit, + setShowAddAccount, +} from "../../auth/state"; +import { Button } from "../../components/button.jsx"; +import { Favicon } from "../../components/favicon.jsx"; +import { NestedLayout } from "../../components/nested-layout.jsx"; +import { listSpaces, parseSpaceUri, type SpaceView } from "../../lib/spaces.js"; +import { + makeSpacePath, + SpaceRecordMetadataContext, + SpacesAuthContext, + useSpacesAuth, +} from "./context.jsx"; +import { SpacesNav } from "./nav.jsx"; +import { EmptyState, ErrorNotice, LoadingState } from "./shared.jsx"; + +const SignInPrompt = () => { + const signIn = () => { + setOpenManager(true); + setShowAddAccount(true); + }; + + return ( +
+
+

Sign in to preview Spaces

+

+ Spaces data is non-public and must be requested from your PDS with OAuth. +

+
+ +
+ ); +}; + +const PermissionPrompt = () => { + const editPermissions = () => { + const auth = agent(); + if (!auth) return; + setPendingPermissionEdit(auth.sub); + setOpenManager(true); + }; + + return ( +
+
+

Space permission required

+

+ Re-authorize this account and enable “Read your Space records (alpha)”. It is opt-in and + read-only. +

+
+ +
+ ); +}; + +const SpacesIndex = () => { + const auth = useSpacesAuth(); + const params = useParams(); + const hidden = () => !!params.spaceAuthority; + const [spaces, setSpaces] = createSignal([]); + const [cursor, setCursor] = createSignal(); + const [loaded, setLoaded] = createSignal(false); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(); + let activeKey = ""; + let requestVersion = 0; + + const loadSpaces = async (reset = false, version = requestVersion) => { + if (loading() && !reset) return; + + setLoading(true); + setError(undefined); + try { + const result = await listSpaces(auth(), { cursor: reset ? undefined : cursor() }); + if (version !== requestVersion) return; + setSpaces((current) => (reset ? result.spaces : [...current, ...result.spaces])); + setCursor(result.cursor); + setLoaded(true); + } catch (err) { + if (version !== requestVersion) return; + setError(err instanceof Error ? err.message : "Could not load Spaces"); + setLoaded(true); + } finally { + if (version === requestVersion) setLoading(false); + } + }; + + createEffect(() => { + const key = `${auth().sub}\n${auth().session.token.scope}`; + if (key !== activeKey) { + activeKey = key; + requestVersion += 1; + setSpaces([]); + setCursor(undefined); + setLoaded(false); + setError(undefined); + setLoading(false); + } + if (!hidden() && !loaded() && !loading()) void loadSpaces(true, requestVersion); + }); + + return ( + +
+

+ Preview non-public records stored in your signed-in account’s PDS. +

+ + + {(message) => ( +
+ +

+ The account’s PDS may not be running the Spaces alpha yet. +

+
+ )} +
+ + + + + + + + + + + + + + +
+
+ ); +}; + +export const SpacesLayout = (props: RouteSectionProps) => { + const params = useParams(); + const hasChild = () => !!params.spaceAuthority; + const [recordCid, setRecordCid] = createSignal(); + + createEffect(() => { + if (params.rkey) { + document.title = `${params.rkey} - ${params.collection} - Spaces - PDSls`; + } else if (params.collection) { + document.title = `${params.collection} - Spaces - PDSls`; + } else if (params.spaceType) { + document.title = `${params.spaceType} - Spaces - PDSls`; + } else { + document.title = "Spaces - PDSls"; + } + }); + + return ( + +
+ +
+ }> + {(auth) => ( + }> + + }> + {props.children} + + + + )} + +
+
+
+ ); +}; + +export { SpaceCollectionLayout } from "./collection.jsx"; +export { SpaceRecordView } from "./record.jsx"; +export { SpaceLayout } from "./space.jsx"; diff --git a/src/views/spaces/nav.tsx b/src/views/spaces/nav.tsx new file mode 100644 index 0000000..80230b9 --- /dev/null +++ b/src/views/spaces/nav.tsx @@ -0,0 +1,128 @@ +import { A, useParams } from "@solidjs/router"; +import { type JSX, Show } from "solid-js"; + +import { Favicon } from "../../components/favicon.jsx"; +import { makeSpaceCollectionPath, makeSpacePath, useSpaceRecordMetadata } from "./context.jsx"; + +const HierarchyRow = (props: { + icon?: string; + leading?: JSX.Element; + label: JSX.Element; + detail?: JSX.Element; + href?: string; + badge?: JSX.Element; + preserveLabel?: boolean; +}) => { + const Content = (contentProps: { linked: boolean }) => ( + <> + + } + > + {(leading) => leading()} + + + + {props.label} + + + + {props.detail} + + + + {props.badge} + + ); + + const classes = + "group flex min-h-6 w-full min-w-0 items-center gap-2 rounded-md border-[0.5px] border-transparent px-2 text-left transition-all duration-200 hover:border-neutral-300 hover:bg-neutral-50/40 sm:min-h-7 dark:hover:border-neutral-600 dark:hover:bg-neutral-800/40"; + + return ( + + +
+ } + > + {(href) => ( + + + + )} + + ); +}; + +export const SpacesNav = () => { + const params = useParams(); + const recordMetadata = useSpaceRecordMetadata(); + const hasSpace = () => !!params.spaceAuthority && !!params.spaceType && !!params.skey; + const spacePath = () => + hasSpace() ? makeSpacePath(params.spaceAuthority!, params.spaceType!, params.skey!) : undefined; + const collectionPath = () => + spacePath() && params.collection + ? makeSpaceCollectionPath( + params.spaceAuthority!, + params.spaceType!, + params.skey!, + params.collection, + ) + : undefined; + + return ( + + ); +}; diff --git a/src/views/spaces/record.tsx b/src/views/spaces/record.tsx new file mode 100644 index 0000000..510ba5c --- /dev/null +++ b/src/views/spaces/record.tsx @@ -0,0 +1,69 @@ +import { useParams } from "@solidjs/router"; +import { createEffect, createSignal, onCleanup, Show } from "solid-js"; + +import { JSONValue } from "../../components/json.jsx"; +import { getSpaceBlob, getSpaceRecord, type GetSpaceRecordResult } from "../../lib/spaces.js"; +import { makeSpaceRef, useSpaceRecordMetadata, useSpacesAuth } from "./context.jsx"; +import { ErrorNotice, LoadingState } from "./shared.jsx"; + +export const SpaceRecordView = () => { + const auth = useSpacesAuth(); + const recordMetadata = useSpaceRecordMetadata(); + const params = useParams(); + const [record, setRecord] = createSignal(); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(); + let requestVersion = 0; + + const space = () => makeSpaceRef(params.spaceAuthority!, params.spaceType!, params.skey!); + + createEffect(() => { + requestVersion += 1; + const version = requestVersion; + setRecord(undefined); + recordMetadata.setCid(undefined); + setError(undefined); + + setLoading(true); + void getSpaceRecord(auth(), space(), auth().sub, params.collection!, params.rkey!) + .then((result) => { + if (version === requestVersion) { + setRecord(result); + recordMetadata.setCid(result.cid); + } + }) + .catch((err) => { + if (version === requestVersion) { + setError(err instanceof Error ? err.message : "Could not load the Space record"); + } + }) + .finally(() => { + if (version === requestVersion) setLoading(false); + }); + }); + + onCleanup(() => recordMetadata.setCid(undefined)); + + return ( +
+ + + + + {(message) => } + + + {(value) => ( +
+ getSpaceBlob(auth(), space(), auth().sub, cid)} + /> +
+ )} +
+
+ ); +}; diff --git a/src/views/spaces/shared.tsx b/src/views/spaces/shared.tsx new file mode 100644 index 0000000..3586fb3 --- /dev/null +++ b/src/views/spaces/shared.tsx @@ -0,0 +1,19 @@ +export const ErrorNotice = (props: { message: string }) => ( +
+ {props.message} +
+); + +export const LoadingState = (props: { label: string }) => ( +
+ + {props.label} +
+); + +export const EmptyState = (props: { icon: string; message: string }) => ( +
+ +

{props.message}

+
+); diff --git a/src/views/spaces/space.tsx b/src/views/spaces/space.tsx new file mode 100644 index 0000000..b33fd3d --- /dev/null +++ b/src/views/spaces/space.tsx @@ -0,0 +1,172 @@ +import { A, type RouteSectionProps, useParams } from "@solidjs/router"; +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; + +import { Button } from "../../components/button.jsx"; +import { Favicon } from "../../components/favicon.jsx"; +import { NestedLayout } from "../../components/nested-layout.jsx"; +import { TextInput } from "../../components/text-input.jsx"; +import { listSpaceRecords, type SpaceRecord } from "../../lib/spaces.js"; +import { makeSpaceCollectionPath, makeSpaceRef, useSpacesAuth } from "./context.jsx"; +import { EmptyState, ErrorNotice, LoadingState } from "./shared.jsx"; + +const SpaceView = () => { + const auth = useSpacesAuth(); + const params = useParams(); + const hidden = () => !!params.collection; + const [records, setRecords] = createSignal([]); + const [cursor, setCursor] = createSignal(); + const [filter, setFilter] = createSignal(""); + const [loaded, setLoaded] = createSignal(false); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(); + let activeKey = ""; + let requestVersion = 0; + + const space = () => makeSpaceRef(params.spaceAuthority!, params.spaceType!, params.skey!); + + const collections = createMemo(() => { + const grouped = new Map(); + for (const record of records()) { + grouped.set(record.collection, (grouped.get(record.collection) ?? 0) + 1); + } + return Array.from(grouped.entries()).sort(([a], [b]) => a.localeCompare(b)); + }); + + const filteredCollections = createMemo(() => { + const value = filter().trim().toLowerCase(); + if (!value) return collections(); + return collections().filter(([collection]) => collection.toLowerCase().includes(value)); + }); + + const loadRecords = async (reset = false, version = requestVersion) => { + if (loading() && !reset) return; + + setLoading(true); + setError(undefined); + try { + const result = await listSpaceRecords(auth(), space(), auth().sub, { + cursor: reset ? undefined : cursor(), + excludeValues: true, + limit: 1000, + }); + if (version !== requestVersion) return; + setRecords((current) => (reset ? result.records : [...current, ...result.records])); + setCursor(result.cursor); + setLoaded(true); + } catch (err) { + if (version !== requestVersion) return; + setError(err instanceof Error ? err.message : "Could not load Space collections"); + setLoaded(true); + } finally { + if (version === requestVersion) setLoading(false); + } + }; + + createEffect(() => { + const key = `${auth().sub}\n${space()}`; + if (key !== activeKey) { + activeKey = key; + requestVersion += 1; + setRecords([]); + setCursor(undefined); + setFilter(""); + setLoaded(false); + setLoading(false); + setError(undefined); + } + if (!hidden() && !loaded() && !loading()) void loadRecords(true, requestVersion); + }); + + return ( + +
+ + + + + {(message) => } + + 0)}> +
+ {collections().length.toLocaleString()} + {cursor() ? "+" : ""} collection{collections().length === 1 ? "" : "s"} + · + {records().length.toLocaleString()} {cursor() ? "loaded " : ""}record + {records().length === 1 ? "" : "s"} +
+ +
+ setFilter(event.currentTarget.value)} + class="w-full text-sm" + /> +
+ + + + + + + + + + +
+
+
+ ); +}; + +export const SpaceLayout = (props: RouteSectionProps) => { + const params = useParams(); + const hasChild = () => !!params.collection; + const key = () => `${params.spaceAuthority}/${params.spaceType}/${params.skey}`; + + return ( + }> + {props.children} + + ); +};