atmosphere explorer pds.ls
tool typescript atproto

Compare changes

Choose any two refs to compare.

+2825 -1060
+9 -3
src/components/navbar.tsx
··· 1 import * as TID from "@atcute/tid"; 2 import { A, Params } from "@solidjs/router"; 3 - import { createEffect, createSignal, Show } from "solid-js"; 4 import { isTouchDevice } from "../layout"; 5 import { didDocCache } from "../utils/api"; 6 import { addToClipboard } from "../utils/copy"; ··· 43 } 44 }); 45 46 return ( 47 <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 48 {/* PDS Level */} ··· 165 </Tooltip> 166 <div class="flex min-w-0 gap-1 py-0.5 font-medium"> 167 <span class="shrink-0">{props.params.rkey}</span> 168 - <Show when={TID.validate(props.params.rkey!)}> 169 <span class="truncate text-neutral-500 dark:text-neutral-400"> 170 - ({localDateFromTimestamp(TID.parse(props.params.rkey!).timestamp / 1000)}) 171 </span> 172 </Show> 173 </div>
··· 1 import * as TID from "@atcute/tid"; 2 import { A, Params } from "@solidjs/router"; 3 + import { createEffect, createMemo, createSignal, Show } from "solid-js"; 4 import { isTouchDevice } from "../layout"; 5 import { didDocCache } from "../utils/api"; 6 import { addToClipboard } from "../utils/copy"; ··· 43 } 44 }); 45 46 + const rkeyTimestamp = createMemo(() => { 47 + if (!props.params.rkey || !TID.validate(props.params.rkey)) return undefined; 48 + const timestamp = TID.parse(props.params.rkey).timestamp / 1000; 49 + return timestamp <= Date.now() ? timestamp : undefined; 50 + }); 51 + 52 return ( 53 <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 54 {/* PDS Level */} ··· 171 </Tooltip> 172 <div class="flex min-w-0 gap-1 py-0.5 font-medium"> 173 <span class="shrink-0">{props.params.rkey}</span> 174 + <Show when={rkeyTimestamp()}> 175 <span class="truncate text-neutral-500 dark:text-neutral-400"> 176 + ({localDateFromTimestamp(rkeyTimestamp()!)}) 177 </span> 178 </Show> 179 </div>
+101 -78
src/components/lexicon-schema.tsx
··· 124 }; 125 126 return ( 127 - <> 128 - <Show when={props.refType}> 129 - <button 130 - type="button" 131 - onClick={handleClick} 132 - class="inline-block cursor-pointer truncate rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 hover:bg-blue-200 hover:underline active:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 dark:active:bg-blue-900/50" 133 - > 134 - {displayType} 135 - </button> 136 - </Show> 137 - <Show when={!props.refType}> 138 - <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"> 139 - {displayType} 140 - </span> 141 - </Show> 142 - </> 143 ); 144 }; 145 146 const UnionBadges = (props: { refs: string[] }) => ( 147 - <div class="flex flex-wrap gap-2"> 148 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For> 149 </div> 150 ); 151 152 - const ConstraintsList = (props: { property: LexiconProperty }) => ( 153 - <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400"> 154 - <Show when={props.property.minLength !== undefined}> 155 - <span>minLength: {props.property.minLength}</span> 156 - </Show> 157 - <Show when={props.property.maxLength !== undefined}> 158 - <span>maxLength: {props.property.maxLength}</span> 159 - </Show> 160 - <Show when={props.property.maxGraphemes !== undefined}> 161 - <span>maxGraphemes: {props.property.maxGraphemes}</span> 162 - </Show> 163 - <Show when={props.property.minGraphemes !== undefined}> 164 - <span>minGraphemes: {props.property.minGraphemes}</span> 165 - </Show> 166 - <Show when={props.property.minimum !== undefined}> 167 - <span>min: {props.property.minimum}</span> 168 - </Show> 169 - <Show when={props.property.maximum !== undefined}> 170 - <span>max: {props.property.maximum}</span> 171 - </Show> 172 - <Show when={props.property.maxSize !== undefined}> 173 - <span>maxSize: {props.property.maxSize}</span> 174 - </Show> 175 - <Show when={props.property.accept}> 176 - <span>accept: [{props.property.accept!.join(", ")}]</span> 177 - </Show> 178 - <Show when={props.property.enum}> 179 - <span>enum: [{props.property.enum!.join(", ")}]</span> 180 - </Show> 181 - <Show when={props.property.const}> 182 - <span>const: {props.property.const?.toString()}</span> 183 - </Show> 184 - <Show when={props.property.default !== undefined}> 185 - <span>default: {JSON.stringify(props.property.default)}</span> 186 - </Show> 187 - <Show when={props.property.knownValues}> 188 - <span>knownValues: [{props.property.knownValues!.join(", ")}]</span> 189 - </Show> 190 - <Show when={props.property.closed}> 191 - <span>closed: true</span> 192 - </Show> 193 - </div> 194 - ); 195 196 const PropertyRow = (props: { 197 name: string; ··· 217 return ( 218 <div class="flex flex-col gap-2 py-3"> 219 <Show when={!props.hideNameType}> 220 - <div class="flex flex-wrap items-center gap-2"> 221 - <span class="font-mono text-sm font-semibold">{props.name}</span> 222 <Show when={!props.property.refs}> 223 <TypeBadge 224 type={props.property.type} ··· 227 /> 228 </Show> 229 <Show when={props.property.refs}> 230 - <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"> 231 - union 232 - </span> 233 </Show> 234 <Show when={props.required}> 235 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span> ··· 244 </Show> 245 <Show when={props.property.items}> 246 <div class="flex flex-col gap-2"> 247 - <div class="flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400"> 248 - <span class="font-semibold">items:</span> 249 <Show when={!props.property.items!.refs}> 250 <TypeBadge 251 type={props.property.items!.type} ··· 254 /> 255 </Show> 256 <Show when={props.property.items!.refs}> 257 - <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"> 258 - union 259 - </span> 260 </Show> 261 </div> 262 <Show when={props.property.items!.refs}> ··· 292 <button 293 type="button" 294 onClick={handleClick} 295 - class="cursor-pointer rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 hover:bg-blue-200 hover:underline active:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 dark:active:bg-blue-900/50" 296 > 297 {props.nsid} 298 </button> ··· 314 return ( 315 <div class="flex flex-col gap-2 py-3"> 316 <div class="flex flex-wrap items-center gap-2"> 317 - <span class="font-mono text-sm font-semibold">#{props.index + 1}</span> 318 <span 319 class={`rounded px-1.5 py-0.5 font-mono text-xs font-semibold ${resourceColor(props.permission.resource)}`} 320 > ··· 328 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 329 Collections: 330 </span> 331 - <div class="flex flex-wrap gap-1"> 332 <For each={props.permission.collection}>{(col) => <NsidLink nsid={col} />}</For> 333 </div> 334 </div> ··· 356 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 357 Lexicon Methods: 358 </span> 359 - <div class="flex flex-wrap gap-1"> 360 <For each={props.permission.lxm}>{(method) => <NsidLink nsid={method} />}</For> 361 </div> 362 </div> ··· 681 <For each={props.def.errors}> 682 {(error) => ( 683 <div class="flex flex-col gap-1 py-2"> 684 - <div class="font-mono text-sm font-semibold">{error.name}</div> 685 <Show when={error.description}> 686 <p class="text-sm text-neutral-700 dark:text-neutral-300"> 687 {error.description} ··· 734 735 736 737 - 738 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400"> 739 <span> 740 - <span class="font-semibold">Lexicon version: </span> 741 - <span class="font-mono">{props.schema.lexicon}</span> 742 </span> 743 </div> 744 <Show when={props.schema.description}>
··· 124 }; 125 126 return ( 127 + <Show 128 + when={props.refType} 129 + fallback={ 130 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">{displayType}</span> 131 + } 132 + > 133 + <button 134 + type="button" 135 + onClick={handleClick} 136 + class="inline-block cursor-pointer truncate font-mono text-xs text-blue-500 hover:underline dark:text-blue-400" 137 + > 138 + {displayType} 139 + </button> 140 + </Show> 141 ); 142 }; 143 144 const UnionBadges = (props: { refs: string[] }) => ( 145 + <div class="flex flex-col items-start gap-1"> 146 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For> 147 </div> 148 ); 149 150 + const ConstraintsList = (props: { property: LexiconProperty }) => { 151 + const valueClass = "text-neutral-600 dark:text-neutral-400"; 152 + return ( 153 + <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs"> 154 + <Show when={props.property.minLength !== undefined}> 155 + <span> 156 + minLength: <span class={valueClass}>{props.property.minLength}</span> 157 + </span> 158 + </Show> 159 + <Show when={props.property.maxLength !== undefined}> 160 + <span> 161 + maxLength: <span class={valueClass}>{props.property.maxLength}</span> 162 + </span> 163 + </Show> 164 + <Show when={props.property.maxGraphemes !== undefined}> 165 + <span> 166 + maxGraphemes: <span class={valueClass}>{props.property.maxGraphemes}</span> 167 + </span> 168 + </Show> 169 + <Show when={props.property.minGraphemes !== undefined}> 170 + <span> 171 + minGraphemes: <span class={valueClass}>{props.property.minGraphemes}</span> 172 + </span> 173 + </Show> 174 + <Show when={props.property.minimum !== undefined}> 175 + <span> 176 + min: <span class={valueClass}>{props.property.minimum}</span> 177 + </span> 178 + </Show> 179 + <Show when={props.property.maximum !== undefined}> 180 + <span> 181 + max: <span class={valueClass}>{props.property.maximum}</span> 182 + </span> 183 + </Show> 184 + <Show when={props.property.maxSize !== undefined}> 185 + <span> 186 + maxSize: <span class={valueClass}>{props.property.maxSize}</span> 187 + </span> 188 + </Show> 189 + <Show when={props.property.accept}> 190 + <span> 191 + accept: <span class={valueClass}>[{props.property.accept!.join(", ")}]</span> 192 + </span> 193 + </Show> 194 + <Show when={props.property.enum}> 195 + <span> 196 + enum: <span class={valueClass}>[{props.property.enum!.join(", ")}]</span> 197 + </span> 198 + </Show> 199 + <Show when={props.property.const}> 200 + <span> 201 + const: <span class={valueClass}>{props.property.const?.toString()}</span> 202 + </span> 203 + </Show> 204 + <Show when={props.property.default !== undefined}> 205 + <span> 206 + default: <span class={valueClass}>{JSON.stringify(props.property.default)}</span> 207 + </span> 208 + </Show> 209 + <Show when={props.property.knownValues}> 210 + <span> 211 + knownValues: <span class={valueClass}>[{props.property.knownValues!.join(", ")}]</span> 212 + </span> 213 + </Show> 214 + <Show when={props.property.closed}> 215 + <span> 216 + closed: <span class={valueClass}>true</span> 217 + </span> 218 + </Show> 219 + </div> 220 + ); 221 + }; 222 223 const PropertyRow = (props: { 224 name: string; ··· 244 return ( 245 <div class="flex flex-col gap-2 py-3"> 246 <Show when={!props.hideNameType}> 247 + <div class="flex flex-wrap items-baseline gap-2"> 248 + <span class="font-semibold">{props.name}</span> 249 <Show when={!props.property.refs}> 250 <TypeBadge 251 type={props.property.type} ··· 254 /> 255 </Show> 256 <Show when={props.property.refs}> 257 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 258 </Show> 259 <Show when={props.required}> 260 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span> ··· 269 </Show> 270 <Show when={props.property.items}> 271 <div class="flex flex-col gap-2"> 272 + <div class="flex items-baseline gap-2 text-xs"> 273 + <span class="font-medium">items:</span> 274 <Show when={!props.property.items!.refs}> 275 <TypeBadge 276 type={props.property.items!.type} ··· 279 /> 280 </Show> 281 <Show when={props.property.items!.refs}> 282 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 283 </Show> 284 </div> 285 <Show when={props.property.items!.refs}> ··· 315 <button 316 type="button" 317 onClick={handleClick} 318 + class="cursor-pointer font-mono text-xs text-blue-500 hover:underline dark:text-blue-400" 319 > 320 {props.nsid} 321 </button> ··· 337 return ( 338 <div class="flex flex-col gap-2 py-3"> 339 <div class="flex flex-wrap items-center gap-2"> 340 + <span class="font-semibold">#{props.index + 1}</span> 341 <span 342 class={`rounded px-1.5 py-0.5 font-mono text-xs font-semibold ${resourceColor(props.permission.resource)}`} 343 > ··· 351 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 352 Collections: 353 </span> 354 + <div class="flex flex-col items-start gap-1"> 355 <For each={props.permission.collection}>{(col) => <NsidLink nsid={col} />}</For> 356 </div> 357 </div> ··· 379 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 380 Lexicon Methods: 381 </span> 382 + <div class="flex flex-col items-start gap-1"> 383 <For each={props.permission.lxm}>{(method) => <NsidLink nsid={method} />}</For> 384 </div> 385 </div> ··· 704 <For each={props.def.errors}> 705 {(error) => ( 706 <div class="flex flex-col gap-1 py-2"> 707 + <div class="font-semibold">{error.name}</div> 708 <Show when={error.description}> 709 <p class="text-sm text-neutral-700 dark:text-neutral-300"> 710 {error.description} ··· 757 758 759 760 + <h2 class="text-lg font-semibold">{props.schema.id}</h2> 761 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400"> 762 <span> 763 + <span class="font-medium">Lexicon version: </span> 764 + <span>{props.schema.lexicon}</span> 765 </span> 766 </div> 767 <Show when={props.schema.description}>
+7 -1
src/index.tsx
··· 13 import { RecordView } from "./views/record.tsx"; 14 import { RepoView } from "./views/repo.tsx"; 15 import { Settings } from "./views/settings.tsx"; 16 - import { StreamView } from "./views/stream.tsx"; 17 18 render( 19 () => (
··· 13 import { RecordView } from "./views/record.tsx"; 14 import { RepoView } from "./views/repo.tsx"; 15 import { Settings } from "./views/settings.tsx"; 16 + import { StreamView } from "./views/stream"; 17 18 render( 19 () => ( 20 + <Router root={Layout}> 21 + <Route path="/" component={Home} /> 22 + <Route path={["/jetstream", "/firehose", "/spacedust"]} component={StreamView} /> 23 + <Route path="/labels" component={LabelView} /> 24 + <Route path="/car" component={CarView} /> 25 + <Route path="/car/explore" component={ExploreToolView} />
+130
src/views/stream/stats.tsx
···
··· 1 + import { For, Show } from "solid-js"; 2 + import { STREAM_CONFIGS, StreamType } from "./config"; 3 + 4 + export type StreamStats = { 5 + connectedAt?: number; 6 + totalEvents: number; 7 + eventsPerSecond: number; 8 + eventTypes: Record<string, number>; 9 + collections: Record<string, number>; 10 + }; 11 + 12 + const formatUptime = (ms: number) => { 13 + const seconds = Math.floor(ms / 1000); 14 + const minutes = Math.floor(seconds / 60); 15 + const hours = Math.floor(minutes / 60); 16 + 17 + if (hours > 0) { 18 + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; 19 + } else if (minutes > 0) { 20 + return `${minutes}m ${seconds % 60}s`; 21 + } else { 22 + return `${seconds}s`; 23 + } 24 + }; 25 + 26 + export const StreamStatsPanel = (props: { 27 + stats: StreamStats; 28 + currentTime: number; 29 + streamType: StreamType; 30 + showAllEvents?: boolean; 31 + }) => { 32 + const config = () => STREAM_CONFIGS[props.streamType]; 33 + const uptime = () => (props.stats.connectedAt ? props.currentTime - props.stats.connectedAt : 0); 34 + 35 + const shouldShowEventTypes = () => { 36 + if (!config().showEventTypes) return false; 37 + if (props.streamType === "jetstream") return props.showAllEvents === true; 38 + return true; 39 + }; 40 + 41 + const topCollections = () => 42 + Object.entries(props.stats.collections) 43 + .sort(([, a], [, b]) => b - a) 44 + .slice(0, 5); 45 + 46 + const topEventTypes = () => 47 + Object.entries(props.stats.eventTypes) 48 + .sort(([, a], [, b]) => b - a) 49 + .slice(0, 5); 50 + 51 + return ( 52 + <Show when={props.stats.connectedAt !== undefined}> 53 + <div class="w-full text-sm"> 54 + <div class="mb-1 font-semibold">Statistics</div> 55 + <div class="flex flex-wrap justify-between gap-x-4 gap-y-2"> 56 + <div> 57 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Uptime</div> 58 + <div class="font-mono">{formatUptime(uptime())}</div> 59 + </div> 60 + <div> 61 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Total Events</div> 62 + <div class="font-mono">{props.stats.totalEvents.toLocaleString()}</div> 63 + </div> 64 + <div> 65 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Events/sec</div> 66 + <div class="font-mono">{props.stats.eventsPerSecond.toFixed(1)}</div> 67 + </div> 68 + <div> 69 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Avg/sec</div> 70 + <div class="font-mono"> 71 + {uptime() > 0 ? ((props.stats.totalEvents / uptime()) * 1000).toFixed(1) : "0.0"} 72 + </div> 73 + </div> 74 + </div> 75 + 76 + <Show when={topEventTypes().length > 0 && shouldShowEventTypes()}> 77 + <div class="mt-2"> 78 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Event Types</div> 79 + <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> 80 + <For each={topEventTypes()}> 81 + {([type, count]) => { 82 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 83 + return ( 84 + <> 85 + <span class="text-neutral-700 dark:text-neutral-300">{type}</span> 86 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 87 + {count.toLocaleString()} 88 + </span> 89 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 90 + {percentage}% 91 + </span> 92 + </> 93 + ); 94 + }} 95 + </For> 96 + </div> 97 + </div> 98 + </Show> 99 + 100 + <Show when={topCollections().length > 0}> 101 + <div class="mt-2"> 102 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400"> 103 + {config().collectionsLabel} 104 + </div> 105 + <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> 106 + <For each={topCollections()}> 107 + {([collection, count]) => { 108 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 109 + return ( 110 + <> 111 + <span class="min-w-0 truncate text-neutral-700 dark:text-neutral-300"> 112 + {collection} 113 + </span> 114 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 115 + {count.toLocaleString()} 116 + </span> 117 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 118 + {percentage}% 119 + </span> 120 + </> 121 + ); 122 + }} 123 + </For> 124 + </div> 125 + </div> 126 + </Show> 127 + </div> 128 + </Show> 129 + ); 130 + };
+130
src/components/hover-card/base.tsx
···
··· 1 + import { A } from "@solidjs/router"; 2 + import { createSignal, JSX, onCleanup, Show } from "solid-js"; 3 + import { Portal } from "solid-js/web"; 4 + import { isTouchDevice } from "../../layout"; 5 + 6 + interface HoverCardProps { 7 + /** Link href - if provided, renders an A tag */ 8 + href?: string; 9 + /** Link/trigger label text */ 10 + label?: string; 11 + /** Open link in new tab */ 12 + newTab?: boolean; 13 + /** Called when hover starts (for prefetching) */ 14 + onHover?: () => void; 15 + /** Delay in ms before showing card and calling onHover (default: 0) */ 16 + hoverDelay?: number; 17 + /** Custom trigger element - if provided, overrides href/label */ 18 + trigger?: JSX.Element; 19 + /** Additional classes for the wrapper span */ 20 + class?: string; 21 + /** Additional classes for the link/label */ 22 + labelClass?: string; 23 + /** Additional classes for the preview container */ 24 + previewClass?: string; 25 + /** Preview content */ 26 + children: JSX.Element; 27 + } 28 + 29 + const HoverCard = (props: HoverCardProps) => { 30 + const [show, setShow] = createSignal(false); 31 + 32 + const [previewHeight, setPreviewHeight] = createSignal(0); 33 + const [anchorRect, setAnchorRect] = createSignal<DOMRect | null>(null); 34 + let anchorRef!: HTMLSpanElement; 35 + let previewRef!: HTMLDivElement; 36 + let resizeObserver: ResizeObserver | null = null; 37 + let hoverTimeout: number | null = null; 38 + 39 + const setupResizeObserver = (el: HTMLDivElement) => { 40 + resizeObserver?.disconnect(); 41 + previewRef = el; 42 + resizeObserver = new ResizeObserver(() => { 43 + if (previewRef) setPreviewHeight(previewRef.offsetHeight); 44 + }); 45 + resizeObserver.observe(el); 46 + }; 47 + 48 + onCleanup(() => { 49 + resizeObserver?.disconnect(); 50 + if (hoverTimeout !== null) { 51 + clearTimeout(hoverTimeout); 52 + } 53 + }); 54 + 55 + const isOverflowing = (previewHeight: number) => { 56 + const rect = anchorRect(); 57 + return rect && rect.top + previewHeight + 32 > window.innerHeight; 58 + }; 59 + 60 + const getPreviewStyle = () => { 61 + const rect = anchorRect(); 62 + if (!rect) return {}; 63 + 64 + const left = rect.left + rect.width / 2; 65 + const overflowing = isOverflowing(previewHeight()); 66 + const gap = 4; 67 + 68 + return { 69 + left: `${left}px`, 70 + top: overflowing ? `${rect.top - gap}px` : `${rect.bottom + gap}px`, 71 + transform: overflowing ? "translate(-50%, -100%)" : "translate(-50%, 0)", 72 + }; 73 + }; 74 + 75 + const handleMouseEnter = () => { 76 + const delay = props.hoverDelay ?? 0; 77 + setAnchorRect(anchorRef.getBoundingClientRect()); 78 + 79 + if (delay > 0) { 80 + hoverTimeout = window.setTimeout(() => { 81 + props.onHover?.(); 82 + setShow(true); 83 + hoverTimeout = null; 84 + }, delay); 85 + } else { 86 + props.onHover?.(); 87 + setShow(true); 88 + } 89 + }; 90 + 91 + const handleMouseLeave = () => { 92 + if (hoverTimeout !== null) { 93 + clearTimeout(hoverTimeout); 94 + hoverTimeout = null; 95 + } 96 + setShow(false); 97 + }; 98 + 99 + return ( 100 + <span 101 + ref={anchorRef} 102 + class={`group/hover-card relative ${props.class || "inline"}`} 103 + onMouseEnter={handleMouseEnter} 104 + onMouseLeave={handleMouseLeave} 105 + > 106 + {props.trigger ?? ( 107 + <A 108 + class={`text-blue-500 hover:underline active:underline dark:text-blue-400 ${props.labelClass || ""}`} 109 + href={props.href!} 110 + target={props.newTab ? "_blank" : undefined} 111 + > 112 + {props.label} 113 + </A> 114 + )} 115 + <Show when={show() && !isTouchDevice}> 116 + <Portal> 117 + <div 118 + ref={setupResizeObserver} 119 + style={getPreviewStyle()} 120 + class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none fixed z-50 block overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700 ${props.previewClass ?? "max-h-80 w-max max-w-sm font-mono text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg"}`} 121 + > 122 + {props.children} 123 + </div> 124 + </Portal> 125 + </Show> 126 + </span> 127 + ); 128 + }; 129 + 130 + export default HoverCard;
+108
src/components/hover-card/did.tsx
···
··· 1 + import { getPdsEndpoint, type DidDocument } from "@atcute/identity"; 2 + import { createSignal, Show } from "solid-js"; 3 + import { resolveDidDoc } from "../../utils/api"; 4 + import HoverCard from "./base"; 5 + 6 + interface DidHoverCardProps { 7 + did: string; 8 + newTab?: boolean; 9 + class?: string; 10 + labelClass?: string; 11 + trigger?: any; 12 + hoverDelay?: number; 13 + } 14 + 15 + interface DidInfo { 16 + handle?: string; 17 + pds?: string; 18 + loading: boolean; 19 + error?: string; 20 + } 21 + 22 + const didCache = new Map<string, DidInfo>(); 23 + 24 + const prefetchDid = async (did: string) => { 25 + if (didCache.has(did)) return; 26 + 27 + didCache.set(did, { loading: true }); 28 + 29 + try { 30 + const doc: DidDocument = await resolveDidDoc(did as `did:${string}:${string}`); 31 + 32 + const handle = doc.alsoKnownAs?.find((aka) => aka.startsWith("at://"))?.replace("at://", ""); 33 + 34 + const pds = getPdsEndpoint(doc)?.replace("https://", "").replace("http://", ""); 35 + 36 + didCache.set(did, { handle, pds, loading: false }); 37 + } catch (err: any) { 38 + didCache.set(did, { loading: false, error: err.message || "Failed to resolve" }); 39 + } 40 + }; 41 + 42 + const DidHoverCard = (props: DidHoverCardProps) => { 43 + const [didInfo, setDidInfo] = createSignal<DidInfo | null>(null); 44 + 45 + const handlePrefetch = () => { 46 + prefetchDid(props.did); 47 + 48 + const cached = didCache.get(props.did); 49 + setDidInfo(cached || { loading: true }); 50 + 51 + if (!cached || cached.loading) { 52 + const pollInterval = setInterval(() => { 53 + const updated = didCache.get(props.did); 54 + if (updated && !updated.loading) { 55 + setDidInfo(updated); 56 + clearInterval(pollInterval); 57 + } 58 + }, 100); 59 + 60 + setTimeout(() => clearInterval(pollInterval), 10000); 61 + } 62 + }; 63 + 64 + return ( 65 + <HoverCard 66 + href={`/at://${props.did}`} 67 + label={props.did} 68 + newTab={props.newTab} 69 + onHover={handlePrefetch} 70 + hoverDelay={props.hoverDelay ?? 300} 71 + trigger={props.trigger} 72 + class={props.class} 73 + labelClass={props.labelClass} 74 + previewClass="w-max max-w-xs font-sans text-sm" 75 + > 76 + <Show when={didInfo()?.loading}> 77 + <div class="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"> 78 + <span class="iconify lucide--loader-circle animate-spin" /> 79 + Loading... 80 + </div> 81 + </Show> 82 + <Show when={didInfo()?.error}> 83 + <div class="text-sm text-red-500 dark:text-red-400">{didInfo()?.error}</div> 84 + </Show> 85 + <Show when={!didInfo()?.loading && !didInfo()?.error}> 86 + <div class="flex flex-col gap-1"> 87 + <Show when={didInfo()?.handle}> 88 + <div class="flex items-center gap-2"> 89 + <span class="iconify lucide--at-sign text-neutral-500 dark:text-neutral-400" /> 90 + <span>{didInfo()?.handle}</span> 91 + </div> 92 + </Show> 93 + <Show when={didInfo()?.pds}> 94 + <div class="flex items-center gap-2"> 95 + <span class="iconify lucide--hard-drive text-neutral-500 dark:text-neutral-400" /> 96 + <span>{didInfo()?.pds}</span> 97 + </div> 98 + </Show> 99 + <Show when={!didInfo()?.handle && !didInfo()?.pds}> 100 + <div class="text-neutral-500 dark:text-neutral-400">No info available</div> 101 + </Show> 102 + </div> 103 + </Show> 104 + </HoverCard> 105 + ); 106 + }; 107 + 108 + export default DidHoverCard;
+119
src/components/hover-card/record.tsx
···
··· 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 + import { ActorIdentifier } from "@atcute/lexicons"; 3 + import { createSignal, Show } from "solid-js"; 4 + import { getPDS } from "../../utils/api"; 5 + import { JSONValue } from "../json"; 6 + import HoverCard from "./base"; 7 + 8 + interface RecordHoverCardProps { 9 + uri: string; 10 + newTab?: boolean; 11 + class?: string; 12 + labelClass?: string; 13 + trigger?: any; 14 + hoverDelay?: number; 15 + } 16 + 17 + const recordCache = new Map<string, { value: unknown; loading: boolean; error?: string }>(); 18 + 19 + const parseAtUri = (uri: string) => { 20 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 21 + if (!match) return null; 22 + return { repo: match[1], collection: match[2], rkey: match[3] }; 23 + }; 24 + 25 + const prefetchRecord = async (uri: string) => { 26 + if (recordCache.has(uri)) return; 27 + 28 + const parsed = parseAtUri(uri); 29 + if (!parsed) return; 30 + 31 + recordCache.set(uri, { value: null, loading: true }); 32 + 33 + try { 34 + const pds = await getPDS(parsed.repo); 35 + const rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 36 + const res = await rpc.get("com.atproto.repo.getRecord", { 37 + params: { 38 + repo: parsed.repo as ActorIdentifier, 39 + collection: parsed.collection as `${string}.${string}.${string}`, 40 + rkey: parsed.rkey, 41 + }, 42 + }); 43 + 44 + if (!res.ok) { 45 + recordCache.set(uri, { value: null, loading: false, error: res.data.error }); 46 + return; 47 + } 48 + 49 + recordCache.set(uri, { value: res.data.value, loading: false }); 50 + } catch (err: any) { 51 + recordCache.set(uri, { value: null, loading: false, error: err.message || "Failed to fetch" }); 52 + } 53 + }; 54 + 55 + const RecordHoverCard = (props: RecordHoverCardProps) => { 56 + const [record, setRecord] = createSignal<{ 57 + value: unknown; 58 + loading: boolean; 59 + error?: string; 60 + } | null>(null); 61 + 62 + const parsed = () => parseAtUri(props.uri); 63 + 64 + const handlePrefetch = () => { 65 + prefetchRecord(props.uri); 66 + 67 + // Start polling for cache updates 68 + const cached = recordCache.get(props.uri); 69 + setRecord(cached || { value: null, loading: true }); 70 + 71 + if (!cached || cached.loading) { 72 + const pollInterval = setInterval(() => { 73 + const updated = recordCache.get(props.uri); 74 + if (updated && !updated.loading) { 75 + setRecord(updated); 76 + clearInterval(pollInterval); 77 + } 78 + }, 100); 79 + 80 + setTimeout(() => clearInterval(pollInterval), 10000); 81 + } 82 + }; 83 + 84 + return ( 85 + <HoverCard 86 + href={`/${props.uri}`} 87 + label={props.uri} 88 + newTab={props.newTab} 89 + onHover={handlePrefetch} 90 + hoverDelay={props.hoverDelay ?? 300} 91 + trigger={props.trigger} 92 + class={props.class} 93 + labelClass={props.labelClass} 94 + > 95 + <Show when={record()?.loading}> 96 + <div class="flex items-center gap-2 font-sans text-sm text-neutral-500 dark:text-neutral-400"> 97 + <span class="iconify lucide--loader-circle animate-spin" /> 98 + Loading... 99 + </div> 100 + </Show> 101 + <Show when={record()?.error}> 102 + <div class="font-sans text-sm text-red-500 dark:text-red-400">{record()?.error}</div> 103 + </Show> 104 + <Show when={record()?.value && !record()?.loading}> 105 + <div class="font-mono text-xs wrap-break-word"> 106 + <JSONValue 107 + data={record()?.value as any} 108 + repo={parsed()?.repo || ""} 109 + truncate 110 + newTab 111 + hideBlobs 112 + /> 113 + </div> 114 + </Show> 115 + </HoverCard> 116 + ); 117 + }; 118 + 119 + export default RecordHoverCard;
+1 -5
src/components/dropdown.tsx
··· 72 ); 73 }; 74 75 - export const ActionMenu = (props: { 76 - label: string; 77 - icon: string; 78 - onClick: () => void; 79 - }) => { 80 const ctx = useContext(MenuContext); 81 82 return (
··· 72 ); 73 }; 74 75 + export const ActionMenu = (props: { label: string; icon: string; onClick: () => void }) => { 76 const ctx = useContext(MenuContext); 77 78 return (
+79 -6
src/utils/app-urls.ts
··· 1 2 3 4 5 6 ··· 17 18 19 20 21 22 23 24 25 26 - "deer.social": App.Bluesky, 27 - "main.bsky.dev": App.Bluesky, 28 - "social.daniela.lol": App.Bluesky, 29 - "tangled.org": App.Tangled, 30 - "frontpage.fyi": App.Frontpage, 31 - "pinksea.art": App.Pinksea,
··· 1 2 3 + export enum App { 4 + Bluesky, 5 + Tangled, 6 + Pinksea, 7 + Frontpage, 8 + } 9 + 10 + export const appName = { 11 + [App.Bluesky]: "Bluesky", 12 + [App.Tangled]: "Tangled", 13 + [App.Pinksea]: "Pinksea", 14 + [App.Frontpage]: "Frontpage", 15 + }; 16 + 17 + export const appList: Record<AppUrl, App> = { 18 + 19 + 20 + "bsky.app": App.Bluesky, 21 + "catsky.social": App.Bluesky, 22 + "deer.aylac.top": App.Bluesky, 23 + "deer.social": App.Bluesky, 24 + "main.bsky.dev": App.Bluesky, 25 + "witchsky.app": App.Bluesky, 26 + "tangled.org": App.Tangled, 27 + "frontpage.fyi": App.Frontpage, 28 + "pinksea.art": App.Pinksea, 29 + }; 30 31 + export const appHandleLink: Record<App, (url: string[]) => string> = { 32 33 34 ··· 45 46 47 48 + return `at://${user}/app.bsky.graph.follow/${rkey}`; 49 + } 50 + } else { 51 + return `at://${user}/app.bsky.actor.profile/self`; 52 + } 53 + } else if (baseType === "starter-pack") { 54 + return `at://${user}/app.bsky.graph.starterpack/${path[2]}`; 55 56 57 58 59 60 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + return `at://${path[0]}`; 103 + }, 104 + };
+46 -16
src/components/backlinks.tsx
··· 4 import { localDateFromTimestamp } from "../utils/date.js"; 5 import { Button } from "./button.jsx"; 6 import { Favicon } from "./favicon.jsx"; 7 8 type BacklinksProps = { 9 target: string; ··· 41 42 43 44 45 - 46 - 47 - 48 - 49 {({ did, collection, rkey }) => { 50 const timestamp = 51 TID.validate(rkey) ? localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) : null; 52 return ( 53 - <a 54 - href={`/at://${did}/${collection}/${rkey}`} 55 - class="grid grid-cols-[auto_1fr_auto] items-center gap-x-1 px-2 py-1.5 font-mono text-xs select-none hover:bg-neutral-200/50 active:bg-neutral-200/50 sm:gap-x-3 sm:px-3 dark:hover:bg-neutral-700/50 dark:active:bg-neutral-700/50" 56 - > 57 - <span class="text-blue-500 dark:text-blue-400">{rkey}</span> 58 - <span class="truncate text-neutral-700 dark:text-neutral-300" title={did}> 59 - {did} 60 - </span> 61 - <span class="text-neutral-500 tabular-nums dark:text-neutral-400"> 62 - {timestamp ?? ""} 63 - </span> 64 - </a> 65 ); 66 }} 67 </For>
··· 4 import { localDateFromTimestamp } from "../utils/date.js"; 5 import { Button } from "./button.jsx"; 6 import { Favicon } from "./favicon.jsx"; 7 + import DidHoverCard from "./hover-card/did.jsx"; 8 + import RecordHoverCard from "./hover-card/record.jsx"; 9 10 type BacklinksProps = { 11 target: string; ··· 43 44 45 46 + }); 47 48 + return ( 49 + <Show when={links()} fallback={<p class="px-3 py-2 text-center text-neutral-500">Loadingโ€ฆ</p>}> 50 + <For each={links()!.linking_records}> 51 {({ did, collection, rkey }) => { 52 const timestamp = 53 TID.validate(rkey) ? localDateFromTimestamp(TID.parse(rkey).timestamp / 1000) : null; 54 + const uri = `at://${did}/${collection}/${rkey}`; 55 return ( 56 + <RecordHoverCard 57 + uri={uri} 58 + class="block" 59 + trigger={ 60 + <a 61 + href={`/${uri}`} 62 + class="grid grid-cols-[auto_1fr_auto] items-center gap-x-1 px-2 py-1.5 font-mono text-xs select-none hover:bg-neutral-200/50 sm:gap-x-3 sm:px-3 dark:hover:bg-neutral-700/50" 63 + > 64 + <span class="text-blue-500 dark:text-blue-400">{rkey}</span> 65 + <DidHoverCard 66 + did={did} 67 + class="min-w-0" 68 + trigger={ 69 + <a 70 + href={`/at://${did}`} 71 + class="block truncate text-neutral-700 hover:underline dark:text-neutral-300" 72 + onClick={(e) => e.stopPropagation()} 73 + > 74 + {did} 75 + </a> 76 + } 77 + /> 78 + <span class="text-neutral-500 tabular-nums dark:text-neutral-400"> 79 + {timestamp ?? ""} 80 + </span> 81 + </a> 82 + } 83 + /> 84 ); 85 }} 86 </For> 87 + 88 + 89 + 90 + 91 + <div class="p-2"> 92 + <Button 93 + onClick={() => setMore(true)} 94 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 w-full rounded-md border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 95 + > 96 + Load more 97 + </Button>
+7 -8
src/views/car/explore.tsx
··· 528 529 530 531 - 532 - 533 - 534 - 535 - 536 - 537 - 538 539 540 ··· 544 </Show> 545 </button> 546 } 547 - previewClass="max-h-80 w-max max-w-sm text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg" 548 > 549 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 550 </HoverCard>
··· 528 529 530 531 + }} 532 + class="flex w-full items-baseline gap-1 text-left" 533 + > 534 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 535 + {entry.key} 536 + </span> 537 + <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 538 539 540 ··· 544 </Show> 545 </button> 546 } 547 > 548 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 549 </HoverCard>
+10 -1
src/components/json.tsx
··· 283 <Show when={mediaLoaded()}> 284 <button 285 onclick={() => setHide(true)} 286 - class="absolute top-1 right-1 flex items-center rounded-lg bg-neutral-900/70 p-1.5 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/media:opacity-100 hover:bg-neutral-900/80 active:bg-neutral-900/90 dark:bg-neutral-100/70 dark:text-neutral-900 dark:hover:bg-neutral-100/80 dark:active:bg-neutral-100/90" 287 > 288 <span class="iconify lucide--eye-off text-base"></span> 289 </button>
··· 283 <Show when={mediaLoaded()}> 284 <button 285 onclick={() => setHide(true)} 286 + class="absolute top-1 right-1 flex items-center rounded-lg bg-neutral-700/70 p-1.5 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/media:opacity-100 hover:bg-neutral-700 active:bg-neutral-800 dark:bg-neutral-100/70 dark:text-neutral-900 dark:hover:bg-neutral-100 dark:active:bg-neutral-200" 287 > 288 <span class="iconify lucide--eye-off text-base"></span> 289 </button> 290 + 291 + 292 + <Show when={hide()}> 293 + <button 294 + onclick={() => setHide(false)} 295 + class="flex items-center gap-1 rounded-md bg-neutral-200 px-2 py-1.5 text-sm transition-colors hover:bg-neutral-300 active:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 296 + > 297 + <span class="iconify lucide--image"></span> 298 + <span class="font-sans">Show media</span>
+126 -2
src/auth/account.tsx
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 - import { createSignal, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 17 retrieveSession, 18 saveSessionToStorage, 19 } from "./session-manager.js"; 20 - import { agent, openManager, sessions, setAgent, setOpenManager, setSessions } from "./state.js"; 21 22 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 const removeSession = async (did: Did) => { ··· 73 beforeRedirect: (account) => resumeSession(account as Did), 74 }); 75 76 const handleAccountClick = async (did: Did) => { 77 try { 78 await resumeSession(did);
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 + import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 17 retrieveSession, 18 saveSessionToStorage, 19 } from "./session-manager.js"; 20 + import { 21 + agent, 22 + openManager, 23 + pendingPermissionEdit, 24 + sessions, 25 + setAgent, 26 + setOpenManager, 27 + setPendingPermissionEdit, 28 + setSessions, 29 + } from "./state.js"; 30 31 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 32 const removeSession = async (did: Did) => { ··· 82 beforeRedirect: (account) => resumeSession(account as Did), 83 }); 84 85 + createEffect(() => { 86 + const pending = pendingPermissionEdit(); 87 + if (pending) { 88 + scopeFlow.initiateWithRedirect(pending); 89 + setPendingPermissionEdit(null); 90 + } 91 + }); 92 + 93 const handleAccountClick = async (did: Did) => { 94 try { 95 await resumeSession(did); 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + setShowingAddAccount(false); 126 + scopeFlow.cancel(); 127 + }} 128 + alignTop 129 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-full max-w-sm rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 mx-3 shadow-md dark:border-neutral-700" 130 + > 131 + <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 132 + <div class="mb-2 px-1 font-semibold"> 133 + <span>Switch account</span> 134 + </div> 135 + <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 136 + <For each={Object.keys(sessions)}> 137 + {(did) => ( 138 + <div class="flex w-full items-center justify-between"> 139 + <A 140 + href={`/at://${did}`} 141 + onClick={() => setOpenManager(false)} 142 + class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 143 + > 144 + <Show 145 + when={avatars[did as Did]} 146 + fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 147 + > 148 + <img src={getThumbnailUrl(avatars[did as Did])} class="size-6 rounded-full" /> 149 + </Show> 150 + </A> 151 + <button 152 + class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 153 + onclick={() => handleAccountClick(did as Did)} 154 + > 155 + <span class="truncate">{sessions[did]?.handle || did}</span> 156 + <Show when={did === agent()?.sub && sessions[did].signedIn}> 157 + <span class="iconify lucide--circle-check shrink-0 text-blue-500 dark:text-blue-400"></span> 158 + </Show> 159 + <Show when={!sessions[did].signedIn}> 160 + <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 161 + </Show> 162 + </button> 163 + <AccountDropdown 164 + did={did as Did} 165 + onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 166 + /> 167 + </div> 168 + )} 169 + </For> 170 + </div> 171 + <button 172 + onclick={() => setShowingAddAccount(true)} 173 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 174 + > 175 + <span class="iconify lucide--plus"></span> 176 + <span>Add account</span> 177 + </button> 178 + </Show> 179 + 180 + <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 181 + <Login onCancel={() => setShowingAddAccount(false)} /> 182 + </Show> 183 + 184 + <Show when={scopeFlow.showScopeSelector()}> 185 + <ScopeSelector 186 + initialScopes={parseScopeString( 187 + sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 188 + )} 189 + onConfirm={scopeFlow.complete} 190 + onCancel={() => { 191 + scopeFlow.cancel(); 192 + setShowingAddAccount(false); 193 + }} 194 + /> 195 + </Show> 196 + </Modal> 197 + <button 198 + onclick={() => setOpenManager(true)} 199 + class={`flex items-center rounded-md ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 200 + > 201 + {agent() && avatars[agent()!.sub] ? 202 + <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" />
+1
src/auth/state.ts
··· 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 export const [openManager, setOpenManager] = createSignal(false);
··· 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 export const [openManager, setOpenManager] = createSignal(false); 16 + export const [pendingPermissionEdit, setPendingPermissionEdit] = createSignal<string | null>(null);
+38
src/components/permission-button.tsx
···
··· 1 + import { JSX } from "solid-js"; 2 + import { hasUserScope } from "../auth/scope-utils"; 3 + import { showPermissionPrompt } from "./permission-prompt"; 4 + import Tooltip from "./tooltip"; 5 + 6 + export interface PermissionButtonProps { 7 + scope: "create" | "update" | "delete" | "blob"; 8 + tooltip: string; 9 + class?: string; 10 + disabledClass?: string; 11 + onClick: () => void; 12 + children: JSX.Element; 13 + } 14 + 15 + export const PermissionButton = (props: PermissionButtonProps) => { 16 + const hasPermission = () => hasUserScope(props.scope); 17 + 18 + const handleClick = () => { 19 + if (hasPermission()) { 20 + props.onClick(); 21 + } else { 22 + showPermissionPrompt(props.scope); 23 + } 24 + }; 25 + 26 + const baseClass = 27 + props.class || 28 + "flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600"; 29 + const disabledClass = props.disabledClass || "flex items-center rounded-sm p-1.5 opacity-40"; 30 + 31 + return ( 32 + <Tooltip text={hasPermission() ? props.tooltip : `${props.tooltip} (permission required)`}> 33 + <button class={hasPermission() ? baseClass : disabledClass} onclick={handleClick}> 34 + {props.children} 35 + </button> 36 + </Tooltip> 37 + ); 38 + };
+52
src/components/permission-prompt.tsx
···
··· 1 + import { createSignal } from "solid-js"; 2 + import { GRANULAR_SCOPES } from "../auth/scope-utils"; 3 + import { agent, setOpenManager, setPendingPermissionEdit } from "../auth/state"; 4 + import { Button } from "./button"; 5 + import { Modal } from "./modal"; 6 + 7 + type ScopeId = "create" | "update" | "delete" | "blob"; 8 + 9 + const [requestedScope, setRequestedScope] = createSignal<ScopeId | null>(null); 10 + 11 + export const showPermissionPrompt = (scope: ScopeId) => { 12 + setRequestedScope(scope); 13 + }; 14 + 15 + export const PermissionPromptContainer = () => { 16 + const scopeLabel = () => { 17 + const scope = GRANULAR_SCOPES.find((s) => s.id === requestedScope()); 18 + return scope?.label.toLowerCase() || requestedScope(); 19 + }; 20 + 21 + const handleEditPermissions = () => { 22 + setRequestedScope(null); 23 + if (agent()) { 24 + setPendingPermissionEdit(agent()!.sub); 25 + setOpenManager(true); 26 + } 27 + }; 28 + 29 + return ( 30 + <Modal 31 + open={requestedScope() !== null} 32 + onClose={() => setRequestedScope(null)} 33 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[calc(100%-2rem)] max-w-md rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 34 + > 35 + <h2 class="mb-2 font-semibold">Permission required</h2> 36 + <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400"> 37 + You need the "{scopeLabel()}" permission to perform this action. 38 + </p> 39 + <div class="flex justify-end gap-2"> 40 + <Button onClick={() => setRequestedScope(null)}>Cancel</Button> 41 + <Button 42 + onClick={handleEditPermissions} 43 + classList={{ 44 + "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!": true, 45 + }} 46 + > 47 + Edit permissions 48 + </Button> 49 + </div> 50 + </Modal> 51 + ); 52 + };
+31 -28
src/views/record.tsx
··· 9 import { Title } from "@solidjs/meta"; 10 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 11 import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 12 - import { hasUserScope } from "../auth/scope-utils"; 13 import { agent } from "../auth/state"; 14 import { Backlinks } from "../components/backlinks.jsx"; 15 import { Button } from "../components/button.jsx"; ··· 26 import { Modal } from "../components/modal.jsx"; 27 import { pds } from "../components/navbar.jsx"; 28 import { addNotification, removeNotification } from "../components/notification.jsx"; 29 - import Tooltip from "../components/tooltip.jsx"; 30 import { 31 didDocumentResolver, 32 resolveLexiconAuthority, ··· 406 </div> 407 <div class="flex gap-0.5"> 408 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 409 - <Show when={hasUserScope("update")}> 410 - <RecordEditor create={false} record={record()?.value} refetch={refetch} /> 411 - </Show> 412 - <Show when={hasUserScope("delete")}> 413 - <Tooltip text="Delete"> 414 - <button 415 - class="flex items-center rounded-sm p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 416 - onclick={() => setOpenDelete(true)} 417 > 418 - <span class="iconify lucide--trash-2"></span> 419 - </button> 420 - </Tooltip> 421 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 422 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 423 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 424 - <div class="flex justify-end gap-2"> 425 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 426 - <Button 427 - onClick={deleteRecord} 428 - class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 429 - > 430 - Delete 431 - </Button> 432 - </div> 433 - </div> 434 - </Modal> 435 - </Show> 436 </Show> 437 <MenuProvider> 438 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5">
··· 9 import { Title } from "@solidjs/meta"; 10 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 11 import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 12 import { agent } from "../auth/state"; 13 import { Backlinks } from "../components/backlinks.jsx"; 14 import { Button } from "../components/button.jsx"; ··· 25 import { Modal } from "../components/modal.jsx"; 26 import { pds } from "../components/navbar.jsx"; 27 import { addNotification, removeNotification } from "../components/notification.jsx"; 28 + import { PermissionButton } from "../components/permission-button.jsx"; 29 import { 30 didDocumentResolver, 31 resolveLexiconAuthority, ··· 405 </div> 406 <div class="flex gap-0.5"> 407 <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 408 + <RecordEditor 409 + create={false} 410 + record={record()?.value} 411 + refetch={refetch} 412 + scope="update" 413 + /> 414 + <PermissionButton 415 + scope="delete" 416 + tooltip="Delete" 417 + onClick={() => setOpenDelete(true)} 418 + > 419 + <span class="iconify lucide--trash-2"></span> 420 + </PermissionButton> 421 + <Modal 422 + open={openDelete()} 423 + onClose={() => setOpenDelete(false)} 424 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 425 + > 426 + <h2 class="mb-2 font-semibold">Delete this record?</h2> 427 + <div class="flex justify-end gap-2"> 428 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 429 + <Button 430 + onClick={deleteRecord} 431 + classList={{ 432 + "bg-red-500! border-none! text-white! hover:bg-red-400! active:bg-red-400!": true, 433 + }} 434 > 435 + Delete 436 + </Button> 437 + </div> 438 + </Modal> 439 </Show> 440 <MenuProvider> 441 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5">
+46 -1
src/components/modal.tsx
··· 5 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 } 9 10 export const Modal = (props: ModalProps) => { ··· 12 <Show when={props.open}> 13 <div 14 data-modal 15 - class="fixed inset-0 z-50 h-full max-h-none w-full max-w-none bg-transparent text-neutral-900 dark:text-neutral-200" 16 classList={{ 17 "pointer-events-none": props.nonBlocking, 18 }} 19 ref={(node) => { 20 const handleEscape = (e: KeyboardEvent) => {
··· 5 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 + alignTop?: boolean; 9 + contentClass?: string; 10 } 11 12 export const Modal = (props: ModalProps) => { ··· 14 <Show when={props.open}> 15 <div 16 data-modal 17 + class="fixed inset-0 z-50 flex h-full max-h-none w-full max-w-none justify-center bg-transparent text-neutral-900 dark:text-neutral-200" 18 classList={{ 19 "pointer-events-none": props.nonBlocking, 20 + "items-start pt-18": props.alignTop, 21 + "items-center": !props.alignTop, 22 }} 23 ref={(node) => { 24 const handleEscape = (e: KeyboardEvent) => { 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + } 56 + }} 57 + > 58 + <div 59 + class={`transition-all starting:scale-95 starting:opacity-0 ${props.contentClass ?? ""}`} 60 + > 61 + {props.children} 62 + </div> 63 + </div> 64 + </Show> 65 + );
+363 -315
src/views/stream/index.tsx
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 import { JSONValue } from "../../components/json"; 7 - import { StickyOverlay } from "../../components/sticky"; 8 import { TextInput } from "../../components/text-input"; 9 import { StreamStats, StreamStatsPanel } from "./stats"; 10 11 const LIMIT = 20; 12 - type Parameter = { name: string; param: string | string[] | undefined }; 13 14 - const StreamView = () => { 15 const [searchParams, setSearchParams] = useSearchParams(); 16 - const [parameters, setParameters] = createSignal<Parameter[]>([]); 17 - const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 18 - const [records, setRecords] = createSignal<Array<any>>([]); 19 const [connected, setConnected] = createSignal(false); 20 const [paused, setPaused] = createSignal(false); 21 const [notice, setNotice] = createSignal(""); 22 23 24 25 26 27 28 29 30 ··· 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 ··· 67 68 69 70 71 72 73 74 75 ··· 77 78 79 80 81 82 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 - 126 - 127 - 128 - 129 - 130 - 131 - 132 - 133 - 134 - 135 - 136 - 137 - 138 - 139 - 140 - 141 - 142 - 143 - 144 - 145 - 146 - 147 - 148 - 149 - 150 - 151 - 152 - 153 - 154 - 155 - 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 - 164 - 165 - 166 - 167 - 168 - 169 - 170 - 171 - 172 - 173 - 174 - 175 - 176 - 177 - 178 - 179 - 180 - 181 - 182 - 183 - 184 - 185 - 186 - 187 - 188 - 189 - 190 - 191 - 192 - 193 - 194 - 195 - 196 - 197 - 198 - 199 - 200 - 201 - 202 - 203 - 204 - 205 - 206 - 207 - 208 - 209 - 210 - 211 - 212 - 213 - 214 - 215 - 216 - 217 - 218 - 219 - 220 - 221 - 222 - 223 - 224 - 225 - 226 - 227 - 228 - 229 - 230 - 231 - 232 - 233 - 234 - 235 - 236 - 237 - 238 - 239 - 240 - 241 - 242 - 243 - 244 - 245 - 246 - 247 - 248 - 249 - 250 - 251 - 252 - 253 - 254 - 255 - 256 - 257 - 258 - 259 - 260 - 261 - 262 - 263 - 264 - 265 266 return ( 267 <> 268 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 269 - <div class="flex w-full flex-col items-center"> 270 <div class="flex gap-4 font-medium"> 271 - <A 272 - class="flex items-center gap-1 border-b-2" 273 - 274 - 275 - 276 - 277 - 278 - 279 - 280 - 281 - 282 - 283 284 - </A> 285 </div> 286 <Show when={!connected()}> 287 - <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 288 <label class="flex items-center justify-end gap-x-1"> 289 - <span class="min-w-20">Instance</span> 290 <TextInput 291 292 - 293 - 294 - 295 - 296 - 297 - 298 - 299 - 300 - 301 - 302 - 303 - 304 - 305 - 306 - 307 - 308 - 309 - 310 - 311 - 312 - 313 - 314 - 315 - 316 - 317 - 318 - 319 - 320 - 321 - 322 - 323 - 324 - 325 - 326 - 327 - 328 - 329 - 330 - 331 - 332 - 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - 345 - 346 - 347 - 348 - 349 - 350 </form> 351 </Show> 352 <Show when={connected()}> 353 - <StickyOverlay> 354 - <div class="flex w-full flex-col gap-2 p-1"> 355 - <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 356 - <div class="font-semibold">Parameters</div> 357 - <For each={parameters()}> 358 - {(param) => ( 359 - <Show when={param.param}> 360 - <div class="text-sm"> 361 - <div class="text-xs text-neutral-500 dark:text-neutral-400"> 362 - {param.name} 363 - </div> 364 - <div class="text-neutral-700 dark:text-neutral-300">{param.param}</div> 365 - </div> 366 - </Show> 367 - )} 368 - </For> 369 - </div> 370 - <StreamStatsPanel stats={stats()} currentTime={currentTime()} /> 371 - <div class="flex justify-end gap-2"> 372 - <button 373 - type="button" 374 - ontouchstart={(e) => { 375 - e.preventDefault(); 376 - requestAnimationFrame(() => togglePause()); 377 - }} 378 - onclick={togglePause} 379 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 380 - > 381 - {paused() ? "Resume" : "Pause"} 382 - </button> 383 - <button 384 - type="button" 385 - ontouchstart={(e) => { 386 - e.preventDefault(); 387 - requestAnimationFrame(() => disconnect()); 388 - }} 389 - onclick={disconnect} 390 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 391 - > 392 - Disconnect 393 - </button> 394 - </div> 395 </div> 396 - </StickyOverlay> 397 </Show> 398 <Show when={notice().length}> 399 <div class="text-red-500 dark:text-red-400">{notice()}</div> 400 </Show> 401 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:text-sm md:w-3xl"> 402 - <For each={records().toReversed()}> 403 - {(rec) => ( 404 - <div class="pb-2"> 405 - <JSONValue data={rec} repo={rec.did ?? rec.repo} hideBlobs /> 406 - </div> 407 - )} 408 - </For> 409 - </div> 410 </div> 411 </> 412 );
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 + import DidHoverCard from "../../components/hover-card/did"; 7 import { JSONValue } from "../../components/json"; 8 import { TextInput } from "../../components/text-input"; 9 + import { addToClipboard } from "../../utils/copy"; 10 + import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 11 import { StreamStats, StreamStatsPanel } from "./stats"; 12 13 const LIMIT = 20; 14 15 + const TYPE_COLORS: Record<string, string> = { 16 + create: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", 17 + update: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", 18 + delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", 19 + identity: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", 20 + account: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", 21 + sync: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", 22 + }; 23 + 24 + const StreamRecordItem = (props: { record: any; streamType: StreamType }) => { 25 + const [expanded, setExpanded] = createSignal(false); 26 + const config = () => STREAM_CONFIGS[props.streamType]; 27 + const info = () => config().parseRecord(props.record); 28 + 29 + const displayType = () => { 30 + const i = info(); 31 + return i.type === "commit" || i.type === "link" ? i.action : i.type; 32 + }; 33 + 34 + const copyRecord = (e: MouseEvent) => { 35 + e.stopPropagation(); 36 + addToClipboard(JSON.stringify(props.record, null, 2)); 37 + }; 38 + 39 + return ( 40 + <div class="flex flex-col gap-2"> 41 + <div class="flex items-start gap-1"> 42 + <button 43 + type="button" 44 + onclick={() => setExpanded(!expanded())} 45 + class="dark:hover:bg-dark-200 flex min-w-0 flex-1 items-start gap-2 rounded p-1 text-left hover:bg-neutral-200/70" 46 + > 47 + <span class="mt-0.5 shrink-0 text-neutral-400 dark:text-neutral-500"> 48 + {expanded() ? 49 + <span class="iconify lucide--chevron-down"></span> 50 + : <span class="iconify lucide--chevron-right"></span>} 51 + </span> 52 + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> 53 + <div class="flex items-center gap-x-1.5 sm:gap-x-2"> 54 + <span 55 + class={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${TYPE_COLORS[displayType()!] || "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300"}`} 56 + > 57 + {displayType()} 58 + </span> 59 + <Show when={info().collection && info().collection !== info().type}> 60 + <span class="min-w-0 truncate text-neutral-600 dark:text-neutral-300"> 61 + {info().collection} 62 + </span> 63 + </Show> 64 + <Show when={info().rkey}> 65 + <span class="truncate text-neutral-400 dark:text-neutral-500">{info().rkey}</span> 66 + </Show> 67 + </div> 68 + <div class="flex flex-col gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:flex-row sm:items-center dark:text-neutral-400"> 69 + <Show when={info().did}> 70 + <span class="w-fit" onclick={(e) => e.stopPropagation()}> 71 + <DidHoverCard newTab did={info().did!} /> 72 + </span> 73 + </Show> 74 + <Show when={info().time}> 75 + <span>{info().time}</span> 76 + </Show> 77 + </div> 78 + </div> 79 + </button> 80 + <Show when={expanded()}> 81 + <button 82 + type="button" 83 + onclick={copyRecord} 84 + class="flex size-6 shrink-0 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-600 active:bg-neutral-300 sm:size-7 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:active:bg-neutral-600" 85 + > 86 + <span class="iconify lucide--copy"></span> 87 + </button> 88 + </Show> 89 + </div> 90 + <Show when={expanded()}> 91 + <div class="ml-6.5"> 92 + <div class="w-full text-xs wrap-anywhere whitespace-pre-wrap md:w-2xl"> 93 + <JSONValue newTab data={props.record} repo={info().did ?? ""} hideBlobs /> 94 + </div> 95 + </div> 96 + </Show> 97 + </div> 98 + ); 99 + }; 100 + 101 + export const StreamView = () => { 102 const [searchParams, setSearchParams] = useSearchParams(); 103 + const streamType = getStreamType(useLocation().pathname); 104 + const config = () => STREAM_CONFIGS[streamType]; 105 + 106 + const [records, setRecords] = createSignal<any[]>([]); 107 const [connected, setConnected] = createSignal(false); 108 const [paused, setPaused] = createSignal(false); 109 const [notice, setNotice] = createSignal(""); 110 + const [parameters, setParameters] = createSignal<{ name: string; value?: string }[]>([]); 111 + const [stats, setStats] = createSignal<StreamStats>({ 112 + totalEvents: 0, 113 + eventsPerSecond: 0, 114 115 + collections: {}, 116 + }); 117 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 118 119 + let socket: WebSocket; 120 + let firehose: Firehose; 121 + let formRef!: HTMLFormElement; 122 123 + let rafId: number | null = null; 124 + let statsIntervalId: number | null = null; 125 + let statsUpdateIntervalId: number | null = null; 126 + let currentSecondEventCount = 0; 127 + let totalEventsCount = 0; 128 + let eventTypesMap: Record<string, number> = {}; 129 + let collectionsMap: Record<string, number> = {}; 130 131 + const addRecord = (record: any) => { 132 + currentSecondEventCount++; 133 + totalEventsCount++; 134 135 + const rawEventType = record.kind || record.$type || "unknown"; 136 + const eventType = rawEventType.includes("#") ? rawEventType.split("#").pop() : rawEventType; 137 + eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 138 139 + if (eventType !== "account" && eventType !== "identity") { 140 + const collection = 141 + record.commit?.collection || 142 + record.op?.path?.split("/")[0] || 143 + record.link?.source || 144 + "unknown"; 145 + collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 146 + } 147 148 149 ··· 155 156 157 158 + }; 159 160 + const disconnect = () => { 161 + if (!config().useFirehoseLib) socket?.close(); 162 + else firehose?.close(); 163 164 + if (rafId !== null) { 165 + cancelAnimationFrame(rafId); 166 + rafId = null; 167 168 169 170 171 172 173 + clearInterval(statsUpdateIntervalId); 174 + statsUpdateIntervalId = null; 175 + } 176 177 + pendingRecords = []; 178 + totalEventsCount = 0; 179 + eventTypesMap = {}; 180 + collectionsMap = {}; 181 + setConnected(false); 182 + setPaused(false); 183 + setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 184 + }; 185 186 + const connectStream = async (formData: FormData) => { 187 + setNotice(""); 188 + if (connected()) { 189 + disconnect(); 190 191 + } 192 + setRecords([]); 193 194 + const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 195 + const url = config().buildUrl(instance, formData); 196 197 + // Save all form fields to URL params 198 + const params: Record<string, string | undefined> = { instance }; 199 + config().fields.forEach((field) => { 200 + params[field.searchParam] = formData.get(field.name)?.toString(); 201 + }); 202 + setSearchParams(params); 203 204 + // Build parameters display 205 + setParameters([ 206 + { name: "Instance", value: instance }, 207 + ...config() 208 + .fields.filter((f) => f.type !== "checkbox") 209 + .map((f) => ({ name: f.label, value: formData.get(f.name)?.toString() })), 210 + ...config() 211 + .fields.filter((f) => f.type === "checkbox" && formData.get(f.name) === "on") 212 + .map((f) => ({ name: f.label, value: "on" })), 213 + ]); 214 215 + setConnected(true); 216 + const now = Date.now(); 217 + setCurrentTime(now); 218 219 + totalEventsCount = 0; 220 + eventTypesMap = {}; 221 + collectionsMap = {}; 222 223 224 ··· 234 235 236 237 + })); 238 + }, 50); 239 240 + statsIntervalId = window.setInterval(() => { 241 + setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 242 + currentSecondEventCount = 0; 243 + setCurrentTime(Date.now()); 244 + }, 1000); 245 246 + if (!config().useFirehoseLib) { 247 + socket = new WebSocket(url); 248 + socket.addEventListener("message", (event) => { 249 + const rec = JSON.parse(event.data); 250 + const isFilteredEvent = rec.kind === "account" || rec.kind === "identity"; 251 + if (!isFilteredEvent || streamType !== "jetstream" || searchParams.allEvents === "on") 252 + addRecord(rec); 253 + }); 254 + socket.addEventListener("error", () => { 255 256 + disconnect(); 257 + }); 258 + } else { 259 + const cursor = formData.get("cursor")?.toString(); 260 + firehose = new Firehose({ 261 + relay: url, 262 + cursor: cursor, 263 264 265 ··· 267 268 269 270 + }); 271 + firehose.on("commit", (commit) => { 272 + for (const op of commit.ops) { 273 + addRecord({ 274 + $type: commit.$type, 275 + repo: commit.repo, 276 + seq: commit.seq, 277 278 + rev: commit.rev, 279 + since: commit.since, 280 + op: op, 281 + }); 282 + } 283 + }); 284 + firehose.on("identity", (identity) => addRecord(identity)); 285 + firehose.on("account", (account) => addRecord(account)); 286 + firehose.on("sync", (sync) => { 287 + addRecord({ 288 + $type: sync.$type, 289 + did: sync.did, 290 + rev: sync.rev, 291 + seq: sync.seq, 292 + time: sync.time, 293 + }); 294 + }); 295 + firehose.start(); 296 + } 297 + }; 298 299 + onMount(() => { 300 + if (searchParams.instance) { 301 + const formData = new FormData(); 302 + formData.append("instance", searchParams.instance.toString()); 303 + config().fields.forEach((field) => { 304 + const value = searchParams[field.searchParam]; 305 + if (value) formData.append(field.name, value.toString()); 306 + }); 307 + connectStream(formData); 308 + } 309 + }); 310 311 + onCleanup(() => { 312 + socket?.close(); 313 + firehose?.close(); 314 + if (rafId !== null) cancelAnimationFrame(rafId); 315 + if (statsIntervalId !== null) clearInterval(statsIntervalId); 316 + if (statsUpdateIntervalId !== null) clearInterval(statsUpdateIntervalId); 317 + }); 318 319 return ( 320 <> 321 + <Title>{config().label} - PDSls</Title> 322 + <div class="flex w-full flex-col items-center gap-2"> 323 + {/* Tab Navigation */} 324 <div class="flex gap-4 font-medium"> 325 + <For each={STREAM_TYPES}> 326 + {(type) => ( 327 + <A 328 + class="flex items-center gap-1 border-b-2" 329 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 330 + href={`/${type}`} 331 + > 332 + {STREAM_CONFIGS[type].label} 333 + </A> 334 + )} 335 + </For> 336 + </div> 337 338 + {/* Stream Description */} 339 + <div class="w-full px-2 text-center"> 340 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{config().description}</p> 341 </div> 342 + 343 + {/* Connection Form */} 344 <Show when={!connected()}> 345 + <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 346 <label class="flex items-center justify-end gap-x-1"> 347 + <span class="min-w-21 select-none">Instance</span> 348 <TextInput 349 + name="instance" 350 + value={searchParams.instance ?? config().defaultInstance} 351 + class="grow" 352 + /> 353 + </label> 354 + 355 + <For each={config().fields}> 356 + {(field) => ( 357 + <label class="flex items-center justify-end gap-x-1"> 358 + <Show when={field.type === "checkbox"}> 359 + <input 360 + type="checkbox" 361 + name={field.name} 362 + id={field.name} 363 + checked={searchParams[field.searchParam] === "on"} 364 + /> 365 + </Show> 366 + <span class="min-w-21 select-none">{field.label}</span> 367 + <Show when={field.type === "textarea"}> 368 + <textarea 369 + name={field.name} 370 + spellcheck={false} 371 + placeholder={field.placeholder} 372 + value={(searchParams[field.searchParam] as string) ?? ""} 373 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 374 + /> 375 + </Show> 376 + <Show when={field.type === "text"}> 377 + <TextInput 378 + name={field.name} 379 + placeholder={field.placeholder} 380 + value={(searchParams[field.searchParam] as string) ?? ""} 381 + class="grow" 382 + /> 383 + </Show> 384 + </label> 385 + )} 386 + </For> 387 388 + <div class="flex justify-end gap-2"> 389 + <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 390 + </div> 391 </form> 392 </Show> 393 + 394 + {/* Connected State */} 395 <Show when={connected()}> 396 + <div class="flex w-full flex-col gap-2 p-2"> 397 + <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 398 + <div class="font-semibold">Parameters</div> 399 + <For each={parameters()}> 400 + {(param) => ( 401 + <Show when={param.value}> 402 + <div class="text-sm"> 403 + <div class="text-xs text-neutral-500 dark:text-neutral-400">{param.name}</div> 404 + <div class="text-neutral-700 dark:text-neutral-300">{param.value}</div> 405 + </div> 406 + </Show> 407 + )} 408 + </For> 409 + </div> 410 + <StreamStatsPanel 411 + stats={stats()} 412 + currentTime={currentTime()} 413 + streamType={streamType} 414 + showAllEvents={searchParams.allEvents === "on"} 415 + /> 416 + <div class="flex justify-end gap-2"> 417 + <Button 418 + ontouchstart={(e) => { 419 + e.preventDefault(); 420 + requestAnimationFrame(() => setPaused(!paused())); 421 + }} 422 + onClick={() => setPaused(!paused())} 423 + > 424 + {paused() ? "Resume" : "Pause"} 425 + </Button> 426 + <Button 427 + ontouchstart={(e) => { 428 + e.preventDefault(); 429 + requestAnimationFrame(() => disconnect()); 430 + }} 431 + onClick={disconnect} 432 + > 433 + Disconnect 434 + </Button> 435 </div> 436 + </div> 437 </Show> 438 + 439 + {/* Error Notice */} 440 <Show when={notice().length}> 441 <div class="text-red-500 dark:text-red-400">{notice()}</div> 442 </Show> 443 + 444 + {/* Records List */} 445 + <Show when={connected() || records().length > 0}> 446 + <div class="flex min-h-280 w-full flex-col gap-2 font-mono text-xs [overflow-anchor:auto] sm:text-sm"> 447 + <For each={records().toReversed()}> 448 + {(rec) => ( 449 + <div class="[overflow-anchor:none]"> 450 + <StreamRecordItem record={rec} streamType={streamType} /> 451 + </div> 452 + )} 453 + </For> 454 + <div class="h-px [overflow-anchor:auto]" /> 455 + </div> 456 + </Show> 457 </div> 458 </> 459 ); 460 + };
+221
src/views/stream/config.ts
···
··· 1 + import { localDateFromTimestamp } from "../../utils/date"; 2 + 3 + export type StreamType = "jetstream" | "firehose" | "spacedust"; 4 + 5 + export type FormField = { 6 + name: string; 7 + label: string; 8 + type: "text" | "textarea" | "checkbox"; 9 + placeholder?: string; 10 + searchParam: string; 11 + }; 12 + 13 + export type RecordInfo = { 14 + type: string; 15 + did?: string; 16 + collection?: string; 17 + rkey?: string; 18 + action?: string; 19 + time?: string; 20 + }; 21 + 22 + export type StreamConfig = { 23 + label: string; 24 + description: string; 25 + icon: string; 26 + defaultInstance: string; 27 + fields: FormField[]; 28 + useFirehoseLib: boolean; 29 + buildUrl: (instance: string, formData: FormData) => string; 30 + parseRecord: (record: any) => RecordInfo; 31 + showEventTypes: boolean; 32 + collectionsLabel: string; 33 + }; 34 + 35 + export const STREAM_CONFIGS: Record<StreamType, StreamConfig> = { 36 + jetstream: { 37 + label: "Jetstream", 38 + description: "A simplified event stream with support for collection and DID filtering.", 39 + icon: "lucide--radio-tower", 40 + defaultInstance: "wss://jetstream1.us-east.bsky.network/subscribe", 41 + useFirehoseLib: false, 42 + showEventTypes: true, 43 + collectionsLabel: "Top Collections", 44 + fields: [ 45 + { 46 + name: "collections", 47 + label: "Collections", 48 + type: "textarea", 49 + placeholder: "Comma-separated list of collections", 50 + searchParam: "collections", 51 + }, 52 + { 53 + name: "dids", 54 + label: "DIDs", 55 + type: "textarea", 56 + placeholder: "Comma-separated list of DIDs", 57 + searchParam: "dids", 58 + }, 59 + { 60 + name: "cursor", 61 + label: "Cursor", 62 + type: "text", 63 + placeholder: "Leave empty for live-tail", 64 + searchParam: "cursor", 65 + }, 66 + { 67 + name: "allEvents", 68 + label: "Show account and identity events", 69 + type: "checkbox", 70 + searchParam: "allEvents", 71 + }, 72 + ], 73 + buildUrl: (instance, formData) => { 74 + let url = instance + "?"; 75 + 76 + const collections = formData.get("collections")?.toString().split(","); 77 + collections?.forEach((c) => { 78 + if (c.trim().length) url += `wantedCollections=${c.trim()}&`; 79 + }); 80 + 81 + const dids = formData.get("dids")?.toString().split(","); 82 + dids?.forEach((d) => { 83 + if (d.trim().length) url += `wantedDids=${d.trim()}&`; 84 + }); 85 + 86 + const cursor = formData.get("cursor")?.toString(); 87 + if (cursor?.length) url += `cursor=${cursor}&`; 88 + 89 + return url.replace(/[&?]$/, ""); 90 + }, 91 + parseRecord: (rec) => { 92 + const collection = rec.commit?.collection || rec.kind; 93 + const rkey = rec.commit?.rkey; 94 + const action = rec.commit?.operation; 95 + const time = rec.time_us ? localDateFromTimestamp(rec.time_us / 1000) : undefined; 96 + return { type: rec.kind, did: rec.did, collection, rkey, action, time }; 97 + }, 98 + }, 99 + 100 + firehose: { 101 + label: "Firehose", 102 + description: "The raw event stream from a relay or PDS.", 103 + icon: "lucide--rss", 104 + defaultInstance: "wss://bsky.network", 105 + useFirehoseLib: true, 106 + showEventTypes: true, 107 + collectionsLabel: "Top Collections", 108 + fields: [ 109 + { 110 + name: "cursor", 111 + label: "Cursor", 112 + type: "text", 113 + placeholder: "Leave empty for live-tail", 114 + searchParam: "cursor", 115 + }, 116 + ], 117 + buildUrl: (instance, _formData) => { 118 + let url = instance; 119 + url = url.replace("/xrpc/com.atproto.sync.subscribeRepos", ""); 120 + if (!(url.startsWith("wss://") || url.startsWith("ws://"))) { 121 + url = "wss://" + url; 122 + } 123 + return url; 124 + }, 125 + parseRecord: (rec) => { 126 + const type = rec.$type?.split("#").pop() || rec.$type; 127 + const did = rec.repo ?? rec.did; 128 + const pathParts = rec.op?.path?.split("/") || []; 129 + const collection = pathParts[0]; 130 + const rkey = pathParts[1]; 131 + const time = rec.time ? localDateFromTimestamp(Date.parse(rec.time)) : undefined; 132 + return { type, did, collection, rkey, action: rec.op?.action, time }; 133 + }, 134 + }, 135 + 136 + spacedust: { 137 + label: "Spacedust", 138 + description: "A stream of links showing interactions across the network.", 139 + icon: "lucide--link", 140 + defaultInstance: "wss://spacedust.microcosm.blue/subscribe", 141 + useFirehoseLib: false, 142 + showEventTypes: false, 143 + collectionsLabel: "Top Sources", 144 + fields: [ 145 + { 146 + name: "sources", 147 + label: "Sources", 148 + type: "textarea", 149 + placeholder: "e.g. app.bsky.graph.follow:subject", 150 + searchParam: "sources", 151 + }, 152 + { 153 + name: "subjectDids", 154 + label: "Subject DIDs", 155 + type: "textarea", 156 + placeholder: "Comma-separated list of DIDs", 157 + searchParam: "subjectDids", 158 + }, 159 + { 160 + name: "subjects", 161 + label: "Subjects", 162 + type: "textarea", 163 + placeholder: "Comma-separated list of AT URIs", 164 + searchParam: "subjects", 165 + }, 166 + { 167 + name: "instant", 168 + label: "Instant mode (bypass 21s delay buffer)", 169 + type: "checkbox", 170 + searchParam: "instant", 171 + }, 172 + ], 173 + buildUrl: (instance, formData) => { 174 + let url = instance + "?"; 175 + 176 + const sources = formData.get("sources")?.toString().split(","); 177 + sources?.forEach((s) => { 178 + if (s.trim().length) url += `wantedSources=${s.trim()}&`; 179 + }); 180 + 181 + const subjectDids = formData.get("subjectDids")?.toString().split(","); 182 + subjectDids?.forEach((d) => { 183 + if (d.trim().length) url += `wantedSubjectDids=${d.trim()}&`; 184 + }); 185 + 186 + const subjects = formData.get("subjects")?.toString().split(","); 187 + subjects?.forEach((s) => { 188 + if (s.trim().length) url += `wantedSubjects=${encodeURIComponent(s.trim())}&`; 189 + }); 190 + 191 + const instant = formData.get("instant")?.toString(); 192 + if (instant === "on") url += `instant=true&`; 193 + 194 + return url.replace(/[&?]$/, ""); 195 + }, 196 + parseRecord: (rec) => { 197 + const source = rec.link?.source; 198 + const sourceRecord = rec.link?.source_record; 199 + const uriParts = sourceRecord?.replace("at://", "").split("/") || []; 200 + const did = uriParts[0]; 201 + const collection = uriParts[1]; 202 + const rkey = uriParts[2]; 203 + return { 204 + type: rec.kind, 205 + did, 206 + collection: source || collection, 207 + rkey, 208 + action: rec.link?.operation, 209 + time: undefined, 210 + }; 211 + }, 212 + }, 213 + }; 214 + 215 + export const STREAM_TYPES = Object.keys(STREAM_CONFIGS) as StreamType[]; 216 + 217 + export const getStreamType = (pathname: string): StreamType => { 218 + if (pathname === "/firehose") return "firehose"; 219 + if (pathname === "/spacedust") return "spacedust"; 220 + return "jetstream"; 221 + };
+7 -3
src/styles/index.css
··· 47 48 49 50 51 52 - 53 - 54 - --svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M1.468%2010.977c-.55%201.061-.961%201.751-1.359%202.741a1.508%201.508%200%201%200%202.8%201.124l.227-.574v-.002c.28-.71.52-1.316.81-1.862.328-.018.702-.02%201.125-.023h.053c.77-.005%201.697-.01%202.497-.172s1.791-.545%202.229-1.57c.119-.278.239-.688.134-1.105h.151c.422%200%201.017.001%201.548-.143.62-.17%201.272-.569%201.558-1.41a1.52%201.52%200%200%200%20.034-.925l.084-.015.042-.007c.363-.063.849-.148%201.264-.304.404-.15%201.068-.488%201.267-1.262.113-.44.1-.908-.154-1.33a1.7%201.7%200%200%200-.36-.414c.112-.14.253-.333.35-.547.17-.371.257-.916-.089-1.45-.393-.604-1.066-.71-1.4-.737a6%206%200%200%200-.985.026%201.2%201.2%200%200%200-.156-.275c-.371-.496-.947-.538-1.272-.53-.655.018-1.167.31-1.538.61-.194.159-.657.806-.808.974%200-.603-.581-.91-.99-.973-.794-.123-1.285.388-1.742.973-.57.73-1.01%201.668-1.531%202.373-.18-.117-.393-.39-.733-.375-.56.026-.932.406-1.173.666-.419.452-.685%201.273-.867%201.885-.197.885-.332%201.258-.491%202.228a9.4%209.4%200%200%200-.144%201.677c-.109.213-.234.443-.381.728%22%20fill%3D%22%23639431%22%2F%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M4.714%204.78c.16.14.349.306.755.165.266-.093.61-.695.993-1.367.56-.982%201.205-2.114%201.816-2.02.738.114.693.523.658.837-.025.22-.044.394.216.387.264-.006.521-.317.82-.678.413-.498.904-1.092%201.602-1.11.492-.014.484.198.476.413-.005.138-.01.276.116.358.123.08.434.053.79.02.573-.052%201.265-.114%201.497.243.204.314-.056.626-.305.925-.21.254-.414.498-.321.726.076.186.231.291.383.394.25.168.491.33.361.834-.136.533-.96.677-1.732.812-.646.113-1.257.22-1.397.544-.088.203.058.297.222.403.195.127.415.27.292.633-.29.85-1.254.85-2.16.85-.777%200-1.51%200-1.735.537-.13.31.067.365.282.425.264.074.557.155.315.723-.464%201.087-2.195%201.096-3.78%201.105-.58.004-1.141.007-1.613.063a.18.18%200%200%200-.13.083c-.434.713-.742%201.496-1.07%202.332l-.221.559a.486.486%200%201%201-.903-.363c.373-.928.803-1.781%201.273-2.564.767-1.413%202.28-3.147%203.88-4.45%201.423-1.184%202.782-2.071%204.364-2.744.198-.084.139-.316-.068-.256-1.403.405-2.643%201.21-3.928%202.02-1.399.881-2.57%202.073-3.291%202.94-.127.153-.405.027-.365-.168.313-1.523.636-2.92%201.11-3.432.45-.485.603-.35.798-.18%22%20fill%3D%22%23d9ea72%22%2F%3E%3C%2Fsvg%3E"); 55 } 56 57 @keyframes slideIn {
··· 47 48 49 50 + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037c-.856 3.061-3.978 3.842-6.755 3.37c4.854.826 6.089 3.562 3.422 6.299c-5.065 5.196-7.28-1.304-7.847-2.97c-.104-.305-.152-.448-.153-.327c0-.121-.05.022-.153.327c-.568 1.666-2.782 8.166-7.847 2.97c-2.667-2.737-1.432-5.473 3.422-6.3c-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026'/%3E%3C/svg%3E"); 51 + } 52 53 + .i-raycast-light { 54 + --svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22228%22%20height%3D%22228%22%20viewBox%3D%220%200%20228%20228%22%20fill%3D%22none%22%3E%3Cg%20clip-path%3D%22url(%23a)%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22m227.987%20113.987-11.89%2011.903-45.11-45.11V56.987zM114.039%200l-11.89%2011.89%2045.097%2045.097h23.793zM88.521%2025.518%2076.618%2037.42l19.58%2019.566h23.792zm82.518%2082.53v23.794l19.501%2019.566%2011.903-11.89zm-6.859%2044.19%206.807-6.808h-88.47V56.987l-6.807%206.82L62.94%2051.1%2051.05%2062.99l12.758%2012.772-6.82%206.756v13.627L37.421%2076.566%2025.518%2088.47l31.469%2031.47v27.229L11.89%20102.097%200%20113.987l114.039%20114%2011.903-11.89-45.11-45.11h27.229l31.47%2031.482%2011.903-11.903-19.579-19.579h13.627l6.808-6.807%2012.771%2012.759%2011.891-11.891z%22%20fill%3D%22%23ff6363%22%2F%3E%3C%2Fg%3E%3Cdefs%3E%3CclipPath%20id%3D%22a%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M0%200h228v228H0z%22%2F%3E%3C%2FclipPath%3E%3C%2Fdefs%3E%3C%2Fsvg%3E"); 55 + } 56 57 + .i-raycast-dark { 58 + --svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22228%22%20height%3D%22228%22%20viewBox%3D%220%200%20228%20228%22%20fill%3D%22none%22%3E%3Cg%20clip-path%3D%22url(%23a)%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M57%20147.207V171L-.052%20113.948l11.955-11.864zM80.793%20171H57l57.052%2057.052%2011.903-11.903zm135.317-45.11L228%20114%20114%200l-11.89%2011.89L147.155%2057h-27.229L88.482%2025.583%2076.58%2037.473l19.58%2019.579H82.53v88.469H171v-13.614l19.579%2019.579%2011.89-11.903L171%20108.048V80.819zM62.952%2051.049l-11.89%2011.903L63.82%2075.71l11.89-11.903zM164.193%20152.29l-11.852%2011.89%2012.759%2012.759%2011.903-11.891zM37.421%2076.58%2025.53%2088.47%2057%20119.951V96.145zM131.842%20171h-23.794l31.483%2031.469%2011.89-11.89z%22%20fill%3D%22%23ff6363%22%2F%3E%3C%2Fg%3E%3Cdefs%3E%3CclipPath%20id%3D%22a%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M0%200h228v228H0z%22%2F%3E%3C%2FclipPath%3E%3C%2Fdefs%3E%3C%2Fsvg%3E"); 59 } 60 61 @keyframes slideIn {
+14 -14
src/layout.tsx
··· 9 import { NavBar } from "./components/navbar.jsx"; 10 import { NotificationContainer } from "./components/notification.jsx"; 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 - import { Search, SearchButton, showSearch } from "./components/search.jsx"; 13 import { themeEvent } from "./components/theme.jsx"; 14 import { resolveHandle } from "./utils/api.js"; 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 </Show> 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 128 <header 129 - class={`dark:shadow-dark-700 mb-3 flex w-full items-center justify-between rounded-xl border-[0.5px] border-neutral-300 bg-neutral-50 bg-size-[95%] bg-right bg-no-repeat p-2 pl-3 shadow-xs [--header-bg:#fafafa] [--trans-blue:#5BCEFA90] [--trans-pink:#F5A9B890] [--trans-white:#FFFFFF90] dark:border-neutral-700 dark:bg-neutral-800 dark:[--header-bg:#262626] dark:[--trans-blue:#5BCEFAa0] dark:[--trans-pink:#F5A9B8a0] dark:[--trans-white:#FFFFFFa0] ${localStorage.getItem("hrt") === "true" ? "bg-[linear-gradient(to_left,transparent_10%,var(--header-bg)_85%),linear-gradient(to_bottom,var(--trans-blue)_0%,var(--trans-blue)_20%,var(--trans-pink)_20%,var(--trans-pink)_40%,var(--trans-white)_40%,var(--trans-white)_60%,var(--trans-pink)_60%,var(--trans-pink)_80%,var(--trans-blue)_80%,var(--trans-blue)_100%)]" : ""}`} 130 style={{ 131 "background-image": 132 props.params.repo && props.params.repo in headers ? ··· 149 /> 150 </Show> 151 </A> 152 - <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 px-1 py-0.5 dark:bg-neutral-800/60"> 153 - <SearchButton /> 154 <Show when={agent()}> 155 <RecordEditor create={true} scope="create" /> 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 164 165 ··· 170 </div> 171 </header> 172 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 173 - <Show when={showSearch() || location.pathname === "/"}> 174 - <Search /> 175 - </Show> 176 <Show when={props.params.pds}> 177 <NavBar params={props.params} /> 178 </Show>
··· 9 import { NavBar } from "./components/navbar.jsx"; 10 import { NotificationContainer } from "./components/notification.jsx"; 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 + import { Search, SearchButton } from "./components/search.jsx"; 13 import { themeEvent } from "./components/theme.jsx"; 14 import { resolveHandle } from "./utils/api.js"; 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 </Show> 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 128 <header 129 + class={`dark:shadow-dark-700 mb-3 flex h-13 w-full items-center justify-between rounded-xl border-[0.5px] border-neutral-300 bg-neutral-50 bg-size-[95%] bg-right bg-no-repeat p-2 pl-3 shadow-xs [--header-bg:#fafafa] [--trans-blue:#5BCEFA90] [--trans-pink:#F5A9B890] [--trans-white:#FFFFFF90] dark:border-neutral-700 dark:bg-neutral-800 dark:[--header-bg:#262626] dark:[--trans-blue:#5BCEFAa0] dark:[--trans-pink:#F5A9B8a0] dark:[--trans-white:#FFFFFFa0] ${localStorage.getItem("hrt") === "true" ? "bg-[linear-gradient(to_left,transparent_10%,var(--header-bg)_85%),linear-gradient(to_bottom,var(--trans-blue)_0%,var(--trans-blue)_20%,var(--trans-pink)_20%,var(--trans-pink)_40%,var(--trans-white)_40%,var(--trans-white)_60%,var(--trans-pink)_60%,var(--trans-pink)_80%,var(--trans-blue)_80%,var(--trans-blue)_100%)]" : ""}`} 130 style={{ 131 "background-image": 132 props.params.repo && props.params.repo in headers ? ··· 149 /> 150 </Show> 151 </A> 152 + <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 p-1 dark:bg-neutral-800/60"> 153 + <div class="mr-1"> 154 + <SearchButton /> 155 + </div> 156 <Show when={agent()}> 157 <RecordEditor create={true} scope="create" /> 158 + </Show> 159 + <AccountManager /> 160 + <MenuProvider> 161 + <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-md p-1.5"> 162 + <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 163 + <NavMenu href="/firehose" label="Firehose" icon="lucide--rss" /> 164 + <NavMenu href="/spacedust" label="Spacedust" icon="lucide--orbit" /> 165 166 167 ··· 172 </div> 173 </header> 174 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 175 + <Search /> 176 <Show when={props.params.pds}> 177 <NavBar params={props.params} /> 178 </Show>
+396 -319
pnpm-lock.yaml
··· 35 36 37 38 39 40 ··· 85 86 87 88 89 90 91 92 93 94 95 96 97 ··· 128 129 130 131 132 133 134 ··· 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 ··· 813 814 815 816 817 818 819 820 ··· 844 845 846 847 848 849 850 851 ··· 905 906 907 908 909 910 911 912 ··· 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 - 1217 - 1218 - 1219 - 1220 - 1221 - 1222 - 1223 - 1224 - 1225 - 1226 - 1227 - 1228 - 1229 - 1230 - 1231 - 1232 - 1233 - 1234 - 1235 - 1236 - 1237 - 1238 - 1239 - 1240 - 1241 - 1242 - 1243 - 1244 - 1245 - 1246 - 1247 - 1248 - 1249 - 1250 - 1251 - 1252 - 1253 - 1254 - 1255 - 1256 - 1257 - 1258 - 1259 - 1260 - 1261 - 1262 - 1263 - 1264 - 1265 - 1266 - 1267 - 1268 - 1269 - 1270 - 1271 - 1272 - 1273 - 1274 - 1275 - 1276 - 1277 - 1278 - 1279 - 1280 - 1281 - 1282 - 1283 - 1284 - 1285 - 1286 - 1287 - 1288 - 1289 - 1290 - 1291 - 1292 - 1293 - 1294 - 1295 - 1296 - 1297 - 1298 - 1299 - 1300 - 1301 - 1302 - 1303 - 1304 - 1305 - 1306 - 1307 - 1308 - 1309 - 1310 - 1311 - 1312 - 1313 - 1314 - 1315 - 1316 - 1317 - 1318 - 1319 - 1320 - 1321 - 1322 - 1323 - 1324 - 1325 - 1326 - 1327 - 1328 - 1329 - 1330 - 1331 - 1332 - 1333 - 1334 - 1335 - 1336 - 1337 - 1338 - 1339 - 1340 - 1341 - 1342 - 1343 - 1344 - 1345 - 1346 - 1347 - 1348 - 1349 - 1350 - 1351 - 1352 - 1353 - 1354 - 1355 - 1356 - 1357 - 1358 - 1359 - 1360 - 1361 - 1362 - 1363 - 1364 - 1365 - 1366 - 1367 - 1368 - 1369 - 1370 - 1371 - 1372 - 1373 - 1374 - 1375 - 1376 - 1377 - 1378 - 1379 - 1380 - 1381 - 1382 - 1383 - 1384 - 1385 - 1386 - 1387 - 1388 - 1389 - 1390 - 1391 - 1392 - 1393 - 1394 - 1395 - 1396 - 1397 - 1398 - 1399 - 1400 - 1401 - 1402 - 1403 - 1404 - 1405 - 1406 - 1407 - 1408 - 1409 - resolution: {integrity: sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==} 1410 engines: {node: '>=10'} 1411 peerDependencies: 1412 seroval: ^1.0 1413 1414 - seroval@1.3.2: 1415 - resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} 1416 engines: {node: '>=10'} 1417 1418 - solid-js@1.9.10: 1419 - 1420 - 1421 - 1422 - 1423 - 1424 - 1425 - 1426 - 1427 - 1428 - 1429 - 1430 - 1431 - 1432 - 1433 - 1434 - 1435 - 1436 - 1437 - 1438 - 1439 - 1440 - 1441 - 1442 - 1443 - 1444 - 1445 - 1446 - 1447 - 1448 - 1449 - 1450 - 1451 - 1452 - 1453 - 1454 - 1455 - 1456 - 1457 - 1458 - 1459 - 1460 - 1461 - 1462 - 1463 - 1464 - 1465 - 1466 - 1467 - 1468 - 1469 - 1470 - 1471 - 1472 - 1473 - 1474 - 1475 - 1476 - 1477 - 1478 - 1479 - 1480 - 1481 - 1482 - 1483 - 1484 - 1485 - 1486 - 1487 - 1488 - 1489 - 1490 - 1491 - 1492 - 1493 - 1494 - 1495 - 1496 - 1497 - 1498 - 1499 - 1500 - 1501 - 1502 - 1503 - 1504 - 1505 - 1506 - 1507 - 1508 - 1509 - 1510 - 1511 - 1512 - 1513 - 1514 - 1515 - 1516 - 1517 - 1518 - 1519 - 1520 - 1521 - 1522 - 1523 - 1524 - 1525 - 1526 - 1527 - 1528 - 1529 - 1530 1531 1532 1533 ··· 1729 1730 1731 1732 1733 1734 1735 ··· 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 ··· 2315 2316 2317 2318 2319 2320 2321 2322 ··· 2340 2341 2342 2343 2344 2345 2346 ··· 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 ··· 2421 2422 2423 2424 2425 2426 2427 2428 ··· 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 ··· 2701 2702 2703 2704 - semver@6.3.1: {} 2705 2706 - seroval-plugins@1.3.3(seroval@1.3.2): 2707 dependencies: 2708 - seroval: 1.3.2 2709 - 2710 - seroval@1.3.2: {} 2711 2712 - solid-js@1.9.10: 2713 dependencies: 2714 - csstype: 3.2.3 2715 - seroval: 1.3.2 2716 - seroval-plugins: 1.3.3(seroval@1.3.2) 2717 2718 - solid-refresh@0.6.3(solid-js@1.9.10): 2719 - dependencies:
··· 35 36 37 38 + '@atcute/identity-resolver': 39 + specifier: ^1.2.2 40 + version: 1.2.2(@atcute/identity@1.1.3) 41 + '@atcute/lexicon-doc': 42 + specifier: ^2.0.6 43 + version: 2.0.6 44 45 46 ··· 91 92 93 94 + version: 0.5.2 95 + '@solidjs/meta': 96 + specifier: ^0.29.4 97 + version: 0.29.4(solid-js@1.9.11) 98 + '@solidjs/router': 99 + specifier: ^0.15.4 100 + version: 0.15.4(solid-js@1.9.11) 101 + codemirror: 102 + specifier: ^6.0.2 103 + version: 6.0.2 104 105 + specifier: ^3.0.1 106 + version: 3.0.1 107 + solid-js: 108 + specifier: ^1.9.11 109 + version: 1.9.11 110 + devDependencies: 111 + '@iconify-json/lucide': 112 + specifier: ^1.2.86 113 114 115 116 + version: 1.2.1(tailwindcss@4.1.18) 117 + '@tailwindcss/vite': 118 + specifier: ^4.1.18 119 + version: 4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 120 + prettier: 121 + specifier: ^3.8.1 122 + version: 3.8.1 123 + prettier-plugin-organize-imports: 124 + specifier: ^4.3.0 125 + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) 126 + prettier-plugin-tailwindcss: 127 + specifier: ^0.7.2 128 + version: 0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1) 129 + tailwindcss: 130 + specifier: ^4.1.18 131 + version: 4.1.18 132 133 134 + version: 5.9.3 135 + vite: 136 + specifier: ^7.3.1 137 + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 138 + vite-plugin-solid: 139 + specifier: ^2.11.10 140 + version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 141 142 + packages: 143 144 145 ··· 176 177 178 179 + '@atcute/identity@1.1.3': 180 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 181 182 + '@atcute/lexicon-doc@2.0.6': 183 + resolution: {integrity: sha512-iDYJkuom+tIw3zIvU1ggCEVFfReXKfOUtIhpY2kEg2kQeSfMB75F+8k1QOpeAQBetyWYmjsHqBuSUX9oQS6L1Q==} 184 185 186 ··· 718 719 720 721 + '@noble/secp256k1@3.0.0': 722 + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 723 724 + '@rollup/rollup-android-arm-eabi@4.56.0': 725 + resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} 726 + cpu: [arm] 727 + os: [android] 728 729 + '@rollup/rollup-android-arm64@4.56.0': 730 + resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} 731 + cpu: [arm64] 732 + os: [android] 733 734 + '@rollup/rollup-darwin-arm64@4.56.0': 735 + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} 736 + cpu: [arm64] 737 + os: [darwin] 738 739 + '@rollup/rollup-darwin-x64@4.56.0': 740 + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} 741 + cpu: [x64] 742 + os: [darwin] 743 744 + '@rollup/rollup-freebsd-arm64@4.56.0': 745 + resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} 746 + cpu: [arm64] 747 + os: [freebsd] 748 749 + '@rollup/rollup-freebsd-x64@4.56.0': 750 + resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==} 751 + cpu: [x64] 752 + os: [freebsd] 753 754 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 755 + resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} 756 + cpu: [arm] 757 + os: [linux] 758 759 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 760 + resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} 761 + cpu: [arm] 762 + os: [linux] 763 764 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 765 + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} 766 + cpu: [arm64] 767 + os: [linux] 768 769 + '@rollup/rollup-linux-arm64-musl@4.56.0': 770 + resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} 771 + cpu: [arm64] 772 + os: [linux] 773 774 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 775 + resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} 776 + cpu: [loong64] 777 + os: [linux] 778 779 + '@rollup/rollup-linux-loong64-musl@4.56.0': 780 + resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} 781 + cpu: [loong64] 782 + os: [linux] 783 784 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 785 + resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} 786 + cpu: [ppc64] 787 + os: [linux] 788 789 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 790 + resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} 791 + cpu: [ppc64] 792 + os: [linux] 793 794 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 795 + resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} 796 + cpu: [riscv64] 797 + os: [linux] 798 799 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 800 + resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} 801 + cpu: [riscv64] 802 + os: [linux] 803 804 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 805 + resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} 806 + cpu: [s390x] 807 + os: [linux] 808 809 + '@rollup/rollup-linux-x64-gnu@4.56.0': 810 + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} 811 + cpu: [x64] 812 + os: [linux] 813 814 + '@rollup/rollup-linux-x64-musl@4.56.0': 815 + resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} 816 + cpu: [x64] 817 + os: [linux] 818 819 + '@rollup/rollup-openbsd-x64@4.56.0': 820 + resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} 821 + cpu: [x64] 822 + os: [openbsd] 823 824 + '@rollup/rollup-openharmony-arm64@4.56.0': 825 + resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} 826 + cpu: [arm64] 827 + os: [openharmony] 828 829 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 830 + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} 831 + cpu: [arm64] 832 + os: [win32] 833 834 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 835 + resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} 836 + cpu: [ia32] 837 + os: [win32] 838 839 + '@rollup/rollup-win32-x64-gnu@4.56.0': 840 + resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} 841 + cpu: [x64] 842 + os: [win32] 843 844 + '@rollup/rollup-win32-x64-msvc@4.56.0': 845 + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} 846 + cpu: [x64] 847 + os: [win32] 848 849 850 ··· 967 968 969 970 + '@types/estree@1.0.8': 971 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 972 973 + '@types/node@25.0.10': 974 + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} 975 976 + acorn@8.15.0: 977 + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 978 979 980 ··· 1004 1005 1006 1007 + bun-types@1.3.6: 1008 + resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==} 1009 1010 + caniuse-lite@1.0.30001766: 1011 + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} 1012 1013 + codemirror@6.0.2: 1014 + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} 1015 1016 1017 ··· 1071 1072 1073 1074 + domutils@3.2.2: 1075 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 1076 1077 + electron-to-chromium@1.5.278: 1078 + resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} 1079 1080 + enhanced-resolve@5.18.4: 1081 + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} 1082 1083 1084 ··· 1375 1376 1377 1378 + prettier-plugin-svelte: 1379 + optional: true 1380 1381 + prettier@3.8.1: 1382 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 1383 + engines: {node: '>=14'} 1384 + hasBin: true 1385 1386 + resolve-pkg-maps@1.0.0: 1387 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 1388 1389 + rollup@4.56.0: 1390 + resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} 1391 + engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1392 + hasBin: true 1393 1394 1395 1396 1397 1398 1399 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 1400 + hasBin: true 1401 1402 + seroval-plugins@1.5.0: 1403 + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} 1404 engines: {node: '>=10'} 1405 peerDependencies: 1406 seroval: ^1.0 1407 1408 + seroval@1.5.0: 1409 + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} 1410 engines: {node: '>=10'} 1411 1412 + solid-js@1.9.11: 1413 + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} 1414 1415 + solid-refresh@0.6.3: 1416 + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} 1417 1418 1419 ··· 1615 1616 1617 1618 + '@atcute/lexicons': 1.2.6 1619 + '@badrap/valita': 0.4.6 1620 1621 + '@atcute/lexicon-doc@2.0.6': 1622 + dependencies: 1623 + '@atcute/identity': 1.1.3 1624 1625 1626 ··· 2116 2117 2118 2119 + '@noble/secp256k1@3.0.0': {} 2120 2121 + '@rollup/rollup-android-arm-eabi@4.56.0': 2122 + optional: true 2123 2124 + '@rollup/rollup-android-arm64@4.56.0': 2125 + optional: true 2126 2127 + '@rollup/rollup-darwin-arm64@4.56.0': 2128 + optional: true 2129 2130 + '@rollup/rollup-darwin-x64@4.56.0': 2131 + optional: true 2132 2133 + '@rollup/rollup-freebsd-arm64@4.56.0': 2134 + optional: true 2135 2136 + '@rollup/rollup-freebsd-x64@4.56.0': 2137 + optional: true 2138 2139 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 2140 + optional: true 2141 2142 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 2143 + optional: true 2144 2145 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 2146 + optional: true 2147 2148 + '@rollup/rollup-linux-arm64-musl@4.56.0': 2149 + optional: true 2150 2151 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 2152 + optional: true 2153 2154 + '@rollup/rollup-linux-loong64-musl@4.56.0': 2155 + optional: true 2156 2157 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 2158 + optional: true 2159 2160 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 2161 + optional: true 2162 2163 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 2164 + optional: true 2165 2166 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 2167 + optional: true 2168 2169 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 2170 + optional: true 2171 2172 + '@rollup/rollup-linux-x64-gnu@4.56.0': 2173 + optional: true 2174 2175 + '@rollup/rollup-linux-x64-musl@4.56.0': 2176 + optional: true 2177 2178 + '@rollup/rollup-openbsd-x64@4.56.0': 2179 + optional: true 2180 2181 + '@rollup/rollup-openharmony-arm64@4.56.0': 2182 + optional: true 2183 2184 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 2185 + optional: true 2186 2187 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 2188 + optional: true 2189 2190 + '@rollup/rollup-win32-x64-gnu@4.56.0': 2191 + optional: true 2192 2193 + '@rollup/rollup-win32-x64-msvc@4.56.0': 2194 + optional: true 2195 2196 + '@skyware/firehose@0.5.2': 2197 2198 2199 + '@atcute/cbor': 2.3.0 2200 + nanoevents: 9.1.0 2201 2202 + '@solidjs/meta@0.29.4(solid-js@1.9.11)': 2203 + dependencies: 2204 + solid-js: 1.9.11 2205 2206 + '@solidjs/router@0.15.4(solid-js@1.9.11)': 2207 + dependencies: 2208 + solid-js: 1.9.11 2209 2210 + '@standard-schema/spec@1.1.0': {} 2211 2212 2213 ··· 2267 2268 2269 2270 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 2271 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 2272 2273 + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2))': 2274 + dependencies: 2275 + '@tailwindcss/node': 4.1.18 2276 + '@tailwindcss/oxide': 4.1.18 2277 + tailwindcss: 4.1.18 2278 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2279 2280 + '@types/babel__core@7.20.5': 2281 + dependencies: 2282 2283 2284 ··· 2302 2303 2304 2305 + '@types/estree@1.0.8': {} 2306 2307 + '@types/node@25.0.10': 2308 + dependencies: 2309 + undici-types: 7.16.0 2310 2311 2312 ··· 2316 2317 2318 2319 + html-entities: 2.3.3 2320 + parse5: 7.3.0 2321 2322 + babel-preset-solid@1.9.10(@babel/core@7.28.6)(solid-js@1.9.11): 2323 + dependencies: 2324 + '@babel/core': 7.28.6 2325 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.28.6) 2326 + optionalDependencies: 2327 + solid-js: 1.9.11 2328 2329 + baseline-browser-mapping@2.9.17: {} 2330 2331 2332 2333 + browserslist@4.28.1: 2334 + dependencies: 2335 + baseline-browser-mapping: 2.9.17 2336 + caniuse-lite: 1.0.30001766 2337 + electron-to-chromium: 1.5.278 2338 + node-releases: 2.0.27 2339 + update-browserslist-db: 1.2.3(browserslist@4.28.1) 2340 2341 + bun-types@1.3.6: 2342 + dependencies: 2343 + '@types/node': 25.0.10 2344 2345 + caniuse-lite@1.0.30001766: {} 2346 2347 + codemirror@6.0.2: 2348 + dependencies: 2349 2350 2351 ··· 2409 2410 2411 2412 + domelementtype: 2.3.0 2413 + domhandler: 5.0.3 2414 2415 + electron-to-chromium@1.5.278: {} 2416 2417 + enhanced-resolve@5.18.4: 2418 + dependencies: 2419 2420 2421 ··· 2638 2639 2640 2641 + picocolors: 1.1.1 2642 + source-map-js: 1.2.1 2643 2644 + prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): 2645 + dependencies: 2646 + prettier: 3.8.1 2647 + typescript: 5.9.3 2648 2649 + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1): 2650 + dependencies: 2651 + prettier: 3.8.1 2652 + optionalDependencies: 2653 + prettier-plugin-organize-imports: 4.3.0(prettier@3.8.1)(typescript@5.9.3) 2654 2655 + prettier@3.8.1: {} 2656 2657 + resolve-pkg-maps@1.0.0: 2658 + optional: true 2659 2660 + rollup@4.56.0: 2661 + dependencies: 2662 + '@types/estree': 1.0.8 2663 + optionalDependencies: 2664 + '@rollup/rollup-android-arm-eabi': 4.56.0 2665 + '@rollup/rollup-android-arm64': 4.56.0 2666 + '@rollup/rollup-darwin-arm64': 4.56.0 2667 + '@rollup/rollup-darwin-x64': 4.56.0 2668 + '@rollup/rollup-freebsd-arm64': 4.56.0 2669 + '@rollup/rollup-freebsd-x64': 4.56.0 2670 + '@rollup/rollup-linux-arm-gnueabihf': 4.56.0 2671 + '@rollup/rollup-linux-arm-musleabihf': 4.56.0 2672 + '@rollup/rollup-linux-arm64-gnu': 4.56.0 2673 + '@rollup/rollup-linux-arm64-musl': 4.56.0 2674 + '@rollup/rollup-linux-loong64-gnu': 4.56.0 2675 + '@rollup/rollup-linux-loong64-musl': 4.56.0 2676 + '@rollup/rollup-linux-ppc64-gnu': 4.56.0 2677 + '@rollup/rollup-linux-ppc64-musl': 4.56.0 2678 + '@rollup/rollup-linux-riscv64-gnu': 4.56.0 2679 + '@rollup/rollup-linux-riscv64-musl': 4.56.0 2680 + '@rollup/rollup-linux-s390x-gnu': 4.56.0 2681 + '@rollup/rollup-linux-x64-gnu': 4.56.0 2682 + '@rollup/rollup-linux-x64-musl': 4.56.0 2683 + '@rollup/rollup-openbsd-x64': 4.56.0 2684 + '@rollup/rollup-openharmony-arm64': 4.56.0 2685 + '@rollup/rollup-win32-arm64-msvc': 4.56.0 2686 + '@rollup/rollup-win32-ia32-msvc': 4.56.0 2687 + '@rollup/rollup-win32-x64-gnu': 4.56.0 2688 + '@rollup/rollup-win32-x64-msvc': 4.56.0 2689 + fsevents: 2.3.3 2690 2691 + sax@1.4.4: {} 2692 2693 + semver@6.3.1: {} 2694 2695 + seroval-plugins@1.5.0(seroval@1.5.0): 2696 + dependencies: 2697 + seroval: 1.5.0 2698 2699 + seroval@1.5.0: {} 2700 2701 + solid-js@1.9.11: 2702 + dependencies: 2703 + csstype: 3.2.3 2704 + seroval: 1.5.0 2705 + seroval-plugins: 1.5.0(seroval@1.5.0) 2706 2707 + solid-refresh@0.6.3(solid-js@1.9.11): 2708 + dependencies: 2709 + '@babel/generator': 7.28.6 2710 + '@babel/helper-module-imports': 7.28.6 2711 + '@babel/types': 7.28.6 2712 + solid-js: 1.9.11 2713 + transitivePeerDependencies: 2714 + - supports-color 2715 2716 2717 ··· 2757 2758 2759 2760 + escalade: 3.2.0 2761 + picocolors: 1.1.1 2762 2763 + vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2764 dependencies: 2765 + '@babel/core': 7.28.6 2766 + '@types/babel__core': 7.20.5 2767 + babel-preset-solid: 1.9.10(@babel/core@7.28.6)(solid-js@1.9.11) 2768 + merge-anything: 5.1.7 2769 + solid-js: 1.9.11 2770 + solid-refresh: 0.6.3(solid-js@1.9.11) 2771 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2772 + vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 2773 + transitivePeerDependencies: 2774 + - supports-color 2775 2776 + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2): 2777 dependencies: 2778 + esbuild: 0.27.2 2779 + fdir: 6.5.0(picomatch@4.0.3) 2780 + picomatch: 4.0.3 2781 + postcss: 8.5.6 2782 + rollup: 4.56.0 2783 + tinyglobby: 0.2.15 2784 + optionalDependencies: 2785 + '@types/node': 25.0.10 2786 + fsevents: 2.3.3 2787 + jiti: 2.6.1 2788 + lightningcss: 1.30.2 2789 + tsx: 4.19.2 2790 + 2791 + vitefu@1.1.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2792 + optionalDependencies: 2793 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2794 + 2795 + w3c-keyname@2.2.8: {} 2796
+183 -40
src/views/pds.tsx
··· 81 > 82 <span class="iconify lucide--info text-neutral-600 dark:text-neutral-400"></span> 83 </button> 84 - <Modal open={openInfo()} onClose={() => setOpenInfo(false)}> 85 - <div class="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-max max-w-[90vw] rounded-lg border-[0.5px] border-neutral-300 bg-white p-3 shadow-md transition-opacity duration-200 sm:max-w-xl dark:border-neutral-700 starting:opacity-0"> 86 - <div class="mb-2 flex items-center justify-between gap-4"> 87 - <p class="truncate font-semibold">{repo.did}</p> 88 - <button 89 - onclick={() => setOpenInfo(false)} 90 - class="flex shrink-0 items-center rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 active:bg-neutral-200 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 91 - > 92 - <span class="iconify lucide--x"></span> 93 - </button> 94 - </div> 95 - <div class="grid grid-cols-[auto_1fr] items-baseline gap-x-1 gap-y-0.5 text-sm"> 96 - <span class="font-medium">Head:</span> 97 - <span class="wrap-anywhere text-neutral-700 dark:text-neutral-300">{repo.head}</span> 98 - 99 - <Show when={TID.validate(repo.rev)}> 100 - <span class="font-medium">Rev:</span> 101 - <div class="flex gap-1"> 102 - <span class="text-neutral-700 dark:text-neutral-300">{repo.rev}</span> 103 - <span class="text-neutral-600 dark:text-neutral-400">ยท</span> 104 - <span class="text-neutral-600 dark:text-neutral-400"> 105 - {localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000)} 106 - </span> 107 - </div> 108 - </Show> 109 110 - <Show when={repo.active !== undefined}> 111 - <span class="font-medium">Active:</span> 112 - <span 113 - class={`iconify self-center ${ 114 - repo.active ? 115 - "lucide--check text-green-500 dark:text-green-400" 116 - : "lucide--x text-red-500 dark:text-red-400" 117 - }`} 118 - ></span> 119 - </Show> 120 121 - <Show when={repo.status}> 122 - <span class="font-medium">Status:</span> 123 - <span class="text-neutral-700 dark:text-neutral-300">{repo.status}</span> 124 - </Show> 125 - </div> 126 </div> 127 </Modal> 128 </div>
··· 81 > 82 <span class="iconify lucide--info text-neutral-600 dark:text-neutral-400"></span> 83 </button> 84 + <Modal 85 + open={openInfo()} 86 + onClose={() => setOpenInfo(false)} 87 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-max max-w-[90vw] rounded-lg border-[0.5px] border-neutral-300 bg-white p-3 shadow-md sm:max-w-xl dark:border-neutral-700" 88 + > 89 + <div class="mb-2 flex items-center justify-between gap-4"> 90 + <p class="truncate font-semibold">{repo.did}</p> 91 + <button 92 + onclick={() => setOpenInfo(false)} 93 + class="flex shrink-0 items-center rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 active:bg-neutral-200 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 94 + > 95 + <span class="iconify lucide--x"></span> 96 + </button> 97 + </div> 98 + <div class="grid grid-cols-[auto_1fr] items-baseline gap-x-1 gap-y-0.5 text-sm"> 99 + <span class="font-medium">Head:</span> 100 + <span class="wrap-anywhere text-neutral-700 dark:text-neutral-300">{repo.head}</span> 101 102 + <Show when={TID.validate(repo.rev)}> 103 + <span class="font-medium">Rev:</span> 104 + <div class="flex gap-1"> 105 + <span class="text-neutral-700 dark:text-neutral-300">{repo.rev}</span> 106 + <span class="text-neutral-600 dark:text-neutral-400">ยท</span> 107 + <span class="text-neutral-600 dark:text-neutral-400"> 108 + {localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000)} 109 + </span> 110 + </div> 111 + </Show> 112 113 + <Show when={repo.active !== undefined}> 114 + <span class="font-medium">Active:</span> 115 + <span 116 + class={`iconify self-center ${ 117 + repo.active ? 118 + "lucide--check text-green-500 dark:text-green-400" 119 + : "lucide--x text-red-500 dark:text-red-400" 120 + }`} 121 + ></span> 122 + </Show> 123 + 124 + <Show when={repo.status}> 125 + <span class="font-medium">Status:</span> 126 + <span class="text-neutral-700 dark:text-neutral-300">{repo.status}</span> 127 + </Show> 128 </div> 129 </Modal> 130 </div> 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 pt-2 pb-4"> 254 + <div class="flex flex-col items-center gap-1 pb-2"> 255 + <p>{repos()?.length} loaded</p> 256 + <Show when={cursor()}> 257 + <Button 258 + onClick={() => refetch()} 259 + disabled={response.loading} 260 + classList={{ "w-20 justify-center": true }} 261 + > 262 + <Show 263 + when={!response.loading} 264 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 265 + > 266 + Load more 267 + </Show> 268 + </Button> 269 + </Show> 270 + </div> 271 + </div>
+26 -29
src/components/create/index.tsx
··· 92 93 94 95 96 97 ··· 265 266 267 268 269 270 ··· 330 331 332 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - 345 - 346 - 347 - 348 - 349 - 350 - 351 - 352 - 353 - 354 - 355 - 356 - 357 - 358 - 359 - 360 361 362 ··· 463 <button 464 class={ 465 hasPermission() ? 466 - `flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}` 467 - : `flex items-center p-1.5 opacity-40 ${props.create ? "rounded-lg" : "rounded-sm"}` 468 } 469 onclick={() => { 470 if (hasPermission()) {
··· 92 93 94 95 + embed: { 96 + $type: "app.bsky.embed.external", 97 + external: { 98 + uri: "https://pds.ls", 99 + title: "PDSls", 100 + description: "Browse the public data on atproto", 101 + }, 102 103 104 ··· 272 273 274 275 + <div class="flex flex-wrap items-center gap-1 text-sm"> 276 + <span>at://</span> 277 + <select 278 + class="dark:bg-dark-100 max-w-40 truncate rounded-md border border-neutral-200 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 279 + name="repo" 280 + id="repo" 281 + > 282 283 284 ··· 344 345 346 347 + </Show> 348 + <div class="flex justify-between gap-2"> 349 + <div class="relative" ref={insertMenuRef}> 350 + <Button onClick={() => setOpenInsertMenu(!openInsertMenu())}> 351 + <span class="iconify lucide--plus"></span> 352 + <span>Add</span> 353 + </Button> 354 + <Show when={openInsertMenu()}> 355 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 356 + <MenuItem 357 358 359 ··· 460 <button 461 class={ 462 hasPermission() ? 463 + `flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-md" : "rounded-sm"}` 464 + : `flex items-center p-1.5 opacity-40 ${props.create ? "rounded-md" : "rounded-sm"}` 465 } 466 onclick={() => { 467 if (hasPermission()) {
+338 -1
src/views/collection.tsx
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 - <span class="shrink-0 text-sm text-blue-500 dark:text-blue-400">{props.record.rkey}</span> 44 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 45 {props.record.cid} 46 </span>
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 44 + {props.record.rkey} 45 + </span> 46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 47 {props.record.cid} 48 </span> 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 316 + <Button 317 + onClick={deleteRecords} 318 + classList={{ 319 + "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!": 320 + recreate(), 321 + "text-white! border-none! bg-red-500! hover:bg-red-600! active:bg-red-700!": 322 + !recreate(), 323 + }} 324 + > 325 + {recreate() ? "Recreate" : "Delete"} 326 + </Button> 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + <Button onClick={() => refetch()}>Load more</Button> 378 + </Show> 379 + <Show when={response.loading}> 380 + <div class="iconify lucide--loader-circle w-20 animate-spin text-lg" /> 381 + </Show> 382 + </Show> 383 + </div>
+4 -4
src/auth/login.tsx
··· 32 }; 33 34 return ( 35 - <div class="flex flex-col gap-y-2 px-1"> 36 <Show when={!scopeFlow.showScopeSelector()}> 37 <Show when={props.onCancel}> 38 - <div class="mb-1 flex items-center gap-2"> 39 <button 40 onclick={handleCancel} 41 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 45 <div class="font-semibold">Add account</div> 46 </div> 47 </Show> 48 - <form class="flex flex-col gap-2" onsubmit={(e) => e.preventDefault()}> 49 <label for="username" class="hidden"> 50 Add account 51 </label> ··· 69 </div> 70 <button 71 onclick={() => initiateLogin(loginInput())} 72 - class="grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 73 > 74 Continue 75 </button>
··· 32 }; 33 34 return ( 35 + <div class="flex flex-col gap-y-3 px-1"> 36 <Show when={!scopeFlow.showScopeSelector()}> 37 <Show when={props.onCancel}> 38 + <div class="flex items-center gap-2"> 39 <button 40 onclick={handleCancel} 41 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 45 <div class="font-semibold">Add account</div> 46 </div> 47 </Show> 48 + <form class="flex flex-col gap-3" onsubmit={(e) => e.preventDefault()}> 49 <label for="username" class="hidden"> 50 Add account 51 </label> ··· 69 </div> 70 <button 71 onclick={() => initiateLogin(loginInput())} 72 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 73 > 74 Continue 75 </button>
+36 -26
src/auth/scope-selector.tsx
··· 44 }; 45 46 return ( 47 - <div class="flex flex-col gap-y-2"> 48 - <div class="mb-1 flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 51 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 52 53 54 - 55 - 56 - 57 - 58 - 59 - 60 - 61 - 62 - 63 - 64 - 65 - 66 - 67 - 68 - 69 - 70 - 71 - 72 - 73 - 74 - 75 - 76 - 77 </div> 78 <button 79 onclick={handleConfirm} 80 - class="mt-2 grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 81 > 82 Continue 83 </button>
··· 44 }; 45 46 return ( 47 + <div class="flex flex-col gap-y-3"> 48 + <div class="flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 51 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 52 53 54 + </button> 55 + <div class="font-semibold">Select permissions</div> 56 + </div> 57 + <div class="flex flex-col px-1"> 58 + <For each={GRANULAR_SCOPES}> 59 + {(scope) => { 60 + const isSelected = () => selectedScopes().has(scope.id); 61 + const isDisabled = () => scope.id === "blob" && isBlobDisabled(); 62 + 63 + return ( 64 + <button 65 + onclick={() => !isDisabled() && toggleScope(scope.id)} 66 + disabled={isDisabled()} 67 + class="group flex items-center gap-3 py-2" 68 + classList={{ "opacity-50": isDisabled() }} 69 + > 70 + <div 71 + class="flex size-5 items-center justify-center rounded border-2 transition-colors" 72 + classList={{ 73 + "bg-blue-500 border-transparent group-hover:bg-blue-600 group-active:bg-blue-400": 74 + isSelected() && !isDisabled(), 75 + "border-neutral-400 dark:border-neutral-500 group-hover:border-neutral-500 dark:group-hover:border-neutral-400 group-hover:bg-neutral-100 dark:group-hover:bg-neutral-800": 76 + !isSelected() && !isDisabled(), 77 + "border-neutral-300 dark:border-neutral-600": isDisabled(), 78 + }} 79 + > 80 + {isSelected() && <span class="iconify lucide--check text-sm text-white"></span>} 81 + </div> 82 + <span>{scope.label}</span> 83 + </button> 84 + ); 85 + }} 86 + </For> 87 </div> 88 <button 89 onclick={handleConfirm} 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 > 92 Continue 93 </button>
-14
src/utils/templates.ts
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 - "blue.linkat.board": (uri) => ({ 41 - label: "Linkat", 42 - link: `https://linkat.blue/${uri.repo}`, 43 - }), 44 "sh.tangled.actor.profile": (uri) => ({ 45 label: "Tangled", 46 link: `https://tangled.org/${uri.repo}`, ··· 51 link: `https://tangled.org/${uri.repo}/${record.name}`, 52 icon: "i-tangled", 53 }), 54 - "pub.leaflet.document": (uri) => ({ 55 - label: "Leaflet", 56 - link: `https://leaflet.pub/p/${uri.repo}/${uri.rkey}`, 57 - icon: "iconify-color i-leaflet", 58 - }), 59 - "pub.leaflet.publication": (uri) => ({ 60 - label: "Leaflet", 61 - link: `https://leaflet.pub/lish/${uri.repo}/${uri.rkey}`, 62 - icon: "iconify-color i-leaflet", 63 - }), 64 };
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 "sh.tangled.actor.profile": (uri) => ({ 41 label: "Tangled", 42 link: `https://tangled.org/${uri.repo}`, ··· 47 link: `https://tangled.org/${uri.repo}/${record.name}`, 48 icon: "i-tangled", 49 }), 50 };
-12
src/utils/types/lexicons.ts
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 - import { 21 - PubLeafletComment, 22 - PubLeafletDocument, 23 - PubLeafletGraphSubscription, 24 - PubLeafletPublication, 25 - } from "@atcute/leaflet"; 26 import { 27 ShTangledActorProfile, 28 ShTangledFeedStar, ··· 85 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 86 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 87 "sh.tangled.knot": ShTangledKnot.mainSchema, 88 - 89 - // Leaflet 90 - "pub.leaflet.comment": PubLeafletComment.mainSchema, 91 - "pub.leaflet.document": PubLeafletDocument.mainSchema, 92 - "pub.leaflet.graph.subscription": PubLeafletGraphSubscription.mainSchema, 93 - "pub.leaflet.publication": PubLeafletPublication.mainSchema, 94 };
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 import { 21 ShTangledActorProfile, 22 ShTangledFeedStar, ··· 79 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 80 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 81 "sh.tangled.knot": ShTangledKnot.mainSchema, 82 };
+1
.gitignore
··· 2 dist 3 .env 4 .DS_Store
··· 2 dist 3 .env 4 .DS_Store 5 + public/oauth-client-metadata.json
-13
public/oauth-client-metadata.json
··· 1 - { 2 - "client_id": "https://pdsls.dev/oauth-client-metadata.json", 3 - "client_name": "PDSls", 4 - "client_uri": "https://pdsls.dev", 5 - "logo_uri": "https://pdsls.dev/favicon.ico", 6 - "redirect_uris": ["https://pdsls.dev/"], 7 - "scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 8 - "grant_types": ["authorization_code", "refresh_token"], 9 - "response_types": ["code"], 10 - "token_endpoint_auth_method": "none", 11 - "application_type": "web", 12 - "dpop_bound_access_tokens": true 13 - }
···
+35
scripts/generate-oauth-metadata.js
···
··· 1 + import { mkdirSync, writeFileSync } from "fs"; 2 + import { dirname } from "path"; 3 + import { fileURLToPath } from "url"; 4 + 5 + const __filename = fileURLToPath(import.meta.url); 6 + const __dirname = dirname(__filename); 7 + 8 + const domain = process.env.APP_DOMAIN || "pdsls.dev"; 9 + const protocol = process.env.APP_PROTOCOL || "https"; 10 + const baseUrl = `${protocol}://${domain}`; 11 + 12 + const metadata = { 13 + client_id: `${baseUrl}/oauth-client-metadata.json`, 14 + client_name: "PDSls", 15 + client_uri: baseUrl, 16 + logo_uri: `${baseUrl}/favicon.ico`, 17 + redirect_uris: [`${baseUrl}/`], 18 + scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 19 + grant_types: ["authorization_code", "refresh_token"], 20 + response_types: ["code"], 21 + token_endpoint_auth_method: "none", 22 + application_type: "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + 26 + const outputPath = `${__dirname}/../public/oauth-client-metadata.json`; 27 + 28 + try { 29 + mkdirSync(dirname(outputPath), { recursive: true }); 30 + writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + "\n"); 31 + console.log(`Generated OAuth metadata for ${baseUrl}`); 32 + } catch (error) { 33 + console.error("Failed to generate OAuth metadata:", error); 34 + process.exit(1); 35 + }
public/avatar/bad-example.com.jpg

This is a binary file and will not be displayed.

public/avatar/futur.blue.jpg

This is a binary file and will not be displayed.

public/avatar/hailey.at.jpg

This is a binary file and will not be displayed.

public/avatar/jaz.sh.jpg

This is a binary file and will not be displayed.

public/avatar/jcsalterego.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/juli.ee.jpg

This is a binary file and will not be displayed.

public/avatar/mary.my.id.jpg

This is a binary file and will not be displayed.

public/avatar/retr0.id.jpg

This is a binary file and will not be displayed.

+21 -18
src/components/favicon.tsx
··· 1 - import { createSignal, JSX, Show } from "solid-js"; 2 3 export const Favicon = (props: { 4 authority: string; ··· 8 const domain = () => props.authority.split(".").reverse().join("."); 9 10 const content = ( 11 - <> 12 - <Show when={!loaded()}> 13 - <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 14 - </Show> 15 - <img 16 - src={ 17 - ["bsky.app", "bsky.chat"].includes(domain()) ? 18 - "https://web-cdn.bsky.app/static/apple-touch-icon.png" 19 - : `https://${domain()}/favicon.ico` 20 - } 21 - alt="" 22 - class="h-4 w-4" 23 - classList={{ hidden: !loaded() }} 24 - onLoad={() => setLoaded(true)} 25 - onError={() => setLoaded(false)} 26 - /> 27 - </> 28 ); 29 30 return props.wrapper ?
··· 1 + import { createSignal, JSX, Match, Show, Switch } from "solid-js"; 2 3 export const Favicon = (props: { 4 authority: string; ··· 8 const domain = () => props.authority.split(".").reverse().join("."); 9 10 const content = ( 11 + <Switch> 12 + <Match when={domain() === "tangled.sh"}> 13 + <span class="iconify i-tangled size-4" /> 14 + </Match> 15 + <Match when={["bsky.app", "bsky.chat"].includes(domain())}> 16 + <img src="https://web-cdn.bsky.app/static/apple-touch-icon.png" class="size-4" /> 17 + </Match> 18 + <Match when={true}> 19 + <Show when={!loaded()}> 20 + <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 21 + </Show> 22 + <img 23 + src={`https://${domain()}/favicon.ico`} 24 + class="size-4" 25 + classList={{ hidden: !loaded() }} 26 + onLoad={() => setLoaded(true)} 27 + onError={() => setLoaded(false)} 28 + /> 29 + </Match> 30 + </Switch> 31 ); 32 33 return props.wrapper ?
+77 -56
src/views/home.tsx
··· 1 2 3 4 ··· 19 20 21 22 23 - 24 - 25 - 26 - 27 - 28 - 29 - 30 - 31 - 32 - 33 - 34 - 35 - 36 - 37 - 38 - 39 - 40 - 41 - 42 43 44 45 {/* Welcome Section */} 46 <div class="flex flex-col gap-4"> 47 <div class="flex flex-col gap-1"> 48 - <h1 class="text-lg font-semibold">Atmosphere Explorer</h1> 49 <div class="text-sm text-neutral-600 dark:text-neutral-300"> 50 <p> 51 Browse the public data on the{" "} ··· 58 59 60 61 62 63 64 65 66 67 68 69 70 71 - 72 - 73 - 74 - 75 - 76 - 77 - 78 - 79 - 80 - 81 - 82 - 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 <div class="flex flex-col gap-2"> 103 <A 104 href="/jetstream" 105 - class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 transition-colors hover:text-blue-500 dark:hover:text-blue-400" 106 > 107 <div class="iconify lucide--radio-tower" /> 108 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 115 </A> 116 <A 117 href="/firehose" 118 - class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 transition-colors hover:text-blue-500 dark:hover:text-blue-400" 119 > 120 <div class="iconify lucide--rss" /> 121 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 128 </A> 129 <A 130 href="/spacedust" 131 - class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 transition-colors hover:text-blue-500 dark:hover:text-blue-400" 132 > 133 <div class="iconify lucide--orbit" /> 134 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 144 <div class="flex flex-col gap-2"> 145 <A 146 href="/labels" 147 - class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 transition-colors hover:text-blue-500 dark:hover:text-blue-400" 148 > 149 <div class="iconify lucide--tag" /> 150 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 157 </A> 158 <A 159 href="/car" 160 - class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 transition-colors hover:text-blue-500 dark:hover:text-blue-400" 161 > 162 <div class="iconify lucide--folder-archive" /> 163 <span class="underline decoration-transparent group-hover:decoration-current">
··· 1 + import { A } from "@solidjs/router"; 2 + import { For, JSX } from "solid-js"; 3 + import { setOpenManager } from "../auth/state"; 4 + import { Button } from "../components/button"; 5 + import { SearchButton } from "../components/search"; 6 7 + type ProfileData = { 8 + did: string; 9 10 11 ··· 26 27 28 29 + { did: "did:plc:7vimlesenouvuaqvle42yhvo", handle: "juli.ee" }, 30 + { did: "did:plc:oisofpd7lj26yvgiivf3lxsi", handle: "hailey.at" }, 31 + { did: "did:plc:vwzwgnygau7ed7b7wt5ux7y2", handle: "retr0.id" }, 32 + { did: "did:plc:vc7f4oafdgxsihk4cry2xpze", handle: "jcsalterego.bsky.social" }, 33 + { did: "did:plc:uu5axsmbm2or2dngy4gwchec", handle: "futur.blue" }, 34 + { did: "did:plc:ia76kvnndjutgedggx2ibrem", handle: "mary.my.id" }, 35 + { did: "did:plc:hdhoaan3xa3jiuq4fg4mefid", handle: "bad-example.com" }, 36 + { did: "did:plc:q6gjnaw2blty4crticxkmujt", handle: "jaz.sh" }, 37 + { did: "did:plc:jrtgsidnmxaen4offglr5lsh", handle: "quilling.dev" }, 38 + { did: "did:plc:3c6vkaq7xf5kz3va3muptjh5", handle: "aylac.top" }, 39 + { did: "did:plc:gwd5r7dbg3zv6dhv75hboa3f", handle: "mofu.run" }, 40 + { did: "did:plc:tzrpqyerzt37pyj54hh52xrz", handle: "rainy.pet" }, 41 + { did: "did:plc:qx7in36j344d7qqpebfiqtew", handle: "futanari.observer" }, 42 + { did: "did:plc:ucaezectmpny7l42baeyooxi", handle: "sapphic.moe" }, 43 + { did: "did:plc:6v6jqsy7swpzuu53rmzaybjy", handle: "computer.fish" }, 44 + { did: "did:plc:w4nvvt6feq2l3qgnwl6a7g7d", handle: "emilia.wtf" }, 45 + { did: "did:plc:xwhsmuozq3mlsp56dyd7copv", handle: "paizuri.moe" }, 46 + { did: "did:plc:aokggmp5jzj4nc5jifhiplqc", handle: "dreary.blacksky.app" }, 47 + { did: "did:plc:k644h4rq5bjfzcetgsa6tuby", handle: "natalie.sh" }, 48 + { did: "did:plc:ttdrpj45ibqunmfhdsb4zdwq", handle: "nekomimi.pet" }, 49 + { did: "did:plc:fz2tul67ziakfukcwa3vdd5d", handle: "nullekko.moe" }, 50 + { did: "did:plc:qxichs7jsycphrsmbujwqbfb", handle: "isabelroses.com" }, 51 + { did: "did:plc:fnvdhaoe7b5abgrtvzf4ttl5", handle: "isuggest.selfce.st" }, 52 + { did: "did:plc:p5yjdr64h7mk5l3kh6oszryk", handle: "blooym.dev" }, 53 + { did: "did:plc:hvakvedv6byxhufjl23mfmsd", handle: "number-one-warned.rat.mom" }, 54 + { did: "did:plc:6if5m2yo6kroprmmency3gt5", handle: "olaren.dev" }, 55 + { did: "did:plc:w7adfxpixpi77e424cjjxnxy", handle: "anyaustin.bsky.social" }, 56 + { did: "did:plc:h6as5sk7tfqvvnqvfrlnnwqn", handle: "cwonus.org" }, 57 + { did: "did:plc:mo7bk6gblylupvhetkqmndrv", handle: "claire.on-her.computer" }, 58 + { did: "did:plc:73gqgbnvpx5syidcponjrics", handle: "coil-habdle.ebil.club" }, 59 + ]; 60 61 + const profiles = [...allExampleProfiles].sort(() => Math.random() - 0.5).slice(0, 3); 62 63 64 65 {/* Welcome Section */} 66 <div class="flex flex-col gap-4"> 67 <div class="flex flex-col gap-1"> 68 + <h1 class="text-lg font-medium">Atmosphere Explorer</h1> 69 <div class="text-sm text-neutral-600 dark:text-neutral-300"> 70 <p> 71 Browse the public data on the{" "} ··· 78 79 80 81 + </div> 82 83 + {/* Example Repos */} 84 + <section class="mb-1 flex flex-col gap-3"> 85 + <div class="flex justify-between"> 86 + <For each={profiles}> 87 + {(profile) => ( 88 89 90 91 92 93 + src={`/avatar/${profile.handle}.jpg`} 94 + alt={`Bluesky profile picture of ${profile.handle}`} 95 + class="size-16 rounded-full ring-2 ring-transparent transition-all group-hover:ring-blue-500 active:ring-blue-500 dark:group-hover:ring-blue-400 dark:active:ring-blue-400" 96 + classList={{ 97 + "animate-[spin_5s_linear_infinite] [animation-play-state:paused] group-hover:[animation-play-state:running]": 98 + profile.handle === "coil-habdle.ebil.club", 99 + }} 100 + /> 101 + <span class="w-full truncate text-center text-xs text-neutral-600 dark:text-neutral-300"> 102 + @{profile.handle} 103 104 105 106 + </For> 107 + </div> 108 + </section> 109 + <div class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400"> 110 + <SearchButton /> 111 + <span>to find any account</span> 112 + </div> 113 + <div class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400"> 114 + <Button onClick={() => setOpenManager(true)}> 115 + <span class="iconify lucide--user-round"></span> 116 + Sign in 117 + </Button> 118 + <span>to manage records</span> 119 + </div> 120 + </div> 121 122 + <div class="flex flex-col gap-4 text-sm"> 123 <div class="flex flex-col gap-2"> 124 <A 125 href="/jetstream" 126 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 127 > 128 <div class="iconify lucide--radio-tower" /> 129 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 136 </A> 137 <A 138 href="/firehose" 139 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 140 > 141 <div class="iconify lucide--rss" /> 142 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 149 </A> 150 <A 151 href="/spacedust" 152 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 153 > 154 <div class="iconify lucide--orbit" /> 155 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 165 <div class="flex flex-col gap-2"> 166 <A 167 href="/labels" 168 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 169 > 170 <div class="iconify lucide--tag" /> 171 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 178 </A> 179 <A 180 href="/car" 181 + class="group grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-0.5 text-neutral-700 transition-colors hover:text-blue-500 dark:text-neutral-300 dark:hover:text-blue-400" 182 > 183 <div class="iconify lucide--folder-archive" /> 184 <span class="underline decoration-transparent group-hover:decoration-current">
public/avatar/aylac.top.jpg

This is a binary file and will not be displayed.

public/avatar/computer.fish.jpg

This is a binary file and will not be displayed.

public/avatar/dreary.blacksky.app.jpg

This is a binary file and will not be displayed.

public/avatar/emilia.wtf.jpg

This is a binary file and will not be displayed.

public/avatar/futanari.observer.jpg

This is a binary file and will not be displayed.

public/avatar/mofu.run.jpg

This is a binary file and will not be displayed.

public/avatar/natalie.sh.jpg

This is a binary file and will not be displayed.

public/avatar/nekomimi.pet.jpg

This is a binary file and will not be displayed.

public/avatar/nullekko.moe.jpg

This is a binary file and will not be displayed.

public/avatar/paizuri.moe.jpg

This is a binary file and will not be displayed.

public/avatar/quilling.dev.jpg

This is a binary file and will not be displayed.

public/avatar/rainy.pet.jpg

This is a binary file and will not be displayed.

public/avatar/sapphic.moe.jpg

This is a binary file and will not be displayed.

public/avatar/blooym.dev.jpg

This is a binary file and will not be displayed.

public/avatar/isabelroses.com.jpg

This is a binary file and will not be displayed.

public/avatar/isuggest.selfce.st.jpg

This is a binary file and will not be displayed.

public/avatar/anyaustin.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/claire.on-her.computer.jpg

This is a binary file and will not be displayed.

public/avatar/cwonus.org.jpg

This is a binary file and will not be displayed.

public/avatar/number-one-warned.rat.mom.jpg

This is a binary file and will not be displayed.

public/avatar/olaren.dev.jpg

This is a binary file and will not be displayed.

public/avatar/coil-habdle.ebil.club.jpg

This is a binary file and will not be displayed.

+2 -7
package.json
··· 10 "build": "vite build", 11 "serve": "vite preview" 12 }, 13 - "pnpm": { 14 - "overrides": { 15 - "seroval": "^1.4.1" 16 - } 17 - }, 18 "devDependencies": { 19 "@iconify-json/lucide": "^1.2.86", 20 "@iconify/tailwind4": "^1.2.1", 21 "@tailwindcss/vite": "^4.1.18", 22 - "prettier": "^3.8.0", 23 "prettier-plugin-organize-imports": "^4.3.0", 24 "prettier-plugin-tailwindcss": "^0.7.2", 25 "tailwindcss": "^4.1.18", ··· 60 "@solidjs/router": "^0.15.4", 61 "codemirror": "^6.0.2", 62 "native-file-system-adapter": "^3.0.1", 63 - "solid-js": "^1.9.10" 64 }, 65 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 66 }
··· 10 "build": "vite build", 11 "serve": "vite preview" 12 }, 13 "devDependencies": { 14 "@iconify-json/lucide": "^1.2.86", 15 "@iconify/tailwind4": "^1.2.1", 16 "@tailwindcss/vite": "^4.1.18", 17 + "prettier": "^3.8.1", 18 "prettier-plugin-organize-imports": "^4.3.0", 19 "prettier-plugin-tailwindcss": "^0.7.2", 20 "tailwindcss": "^4.1.18", ··· 55 "@solidjs/router": "^0.15.4", 56 "codemirror": "^6.0.2", 57 "native-file-system-adapter": "^3.0.1", 58 + "solid-js": "^1.9.11" 59 }, 60 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 61 }
+3 -1
src/components/button.tsx
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 children?: JSX.Element; 10 } 11 ··· 16 disabled={props.disabled ?? false} 17 class={ 18 props.class ?? 19 - "dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 20 } 21 classList={props.classList} 22 onClick={props.onClick} 23 > 24 {props.children} 25 </button>
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 + ontouchstart?: (e: TouchEvent) => void; 10 children?: JSX.Element; 11 } 12 ··· 17 disabled={props.disabled ?? false} 18 class={ 19 props.class ?? 20 + "dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300" 21 } 22 classList={props.classList} 23 onClick={props.onClick} 24 + ontouchstart={props.ontouchstart} 25 > 26 {props.children} 27 </button>
+1 -1
src/components/notification.tsx
··· 87 </Show> 88 <Show when={notification.onCancel}> 89 <button 90 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 mt-1 rounded border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 91 onClick={(e) => { 92 e.stopPropagation(); 93 notification.onCancel?.();
··· 87 </Show> 88 <Show when={notification.onCancel}> 89 <button 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 dark:bg-dark-300 mt-1 rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 onClick={(e) => { 92 e.stopPropagation(); 93 notification.onCancel?.();
+1 -1
src/components/text-input.tsx
··· 25 disabled={props.disabled} 26 required={props.required} 27 class={ 28 - "dark:bg-dark-100 rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 29 props.class 30 } 31 onInput={props.onInput}
··· 25 disabled={props.disabled} 26 required={props.required} 27 class={ 28 + "dark:bg-dark-100 rounded-md bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 29 props.class 30 } 31 onInput={props.onInput}
+13 -5
src/views/blob.tsx
··· 50 <p> 51 {blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""} 52 </p> 53 - <Show when={!response.loading && cursor()}> 54 - <Button onClick={() => refetch()}>Load more</Button> 55 - </Show> 56 - <Show when={response.loading}> 57 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 58 </Show> 59 </div> 60 </div>
··· 50 <p> 51 {blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""} 52 </p> 53 + <Show when={cursor()}> 54 + <Button 55 + onClick={() => refetch()} 56 + disabled={response.loading} 57 + classList={{ "w-20 justify-center": true }} 58 + > 59 + <Show 60 + when={!response.loading} 61 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 62 + > 63 + Load more 64 + </Show> 65 + </Button> 66 </Show> 67 </div> 68 </div>
+1 -1
src/views/car/shared.tsx
··· 123 </p> 124 <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 </div> 126 - <label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 127 <input 128 type="file" 129 accept={isIOS ? undefined : ".car,application/vnd.ipld.car"}
··· 123 </p> 124 <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 </div> 126 + <label class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-300 bg-neutral-50 px-2.5 py-1.5 text-sm text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300"> 127 <input 128 type="file" 129 accept={isIOS ? undefined : ".car,application/vnd.ipld.car"}
+4 -2
src/views/labels.tsx
··· 277 <Button 278 onClick={handleLoadMore} 279 disabled={loading()} 280 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-20 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 281 > 282 <Show 283 when={!loading()} 284 - fallback={<span class="iconify lucide--loader-circle animate-spin" />} 285 > 286 Load more 287 </Show>
··· 277 <Button 278 onClick={handleLoadMore} 279 disabled={loading()} 280 + classList={{ "w-20 justify-center": true }} 281 > 282 <Show 283 when={!loading()} 284 + fallback={ 285 + <span class="iconify lucide--loader-circle animate-spin text-base" /> 286 + } 287 > 288 Load more 289 </Show>
+28 -26
src/views/repo.tsx
··· 9 createSignal, 10 ErrorBoundary, 11 For, 12 - onMount, 13 Show, 14 Suspense, 15 } from "solid-js"; ··· 29 removeNotification, 30 updateNotification, 31 } from "../components/notification.jsx"; 32 - import { TextInput } from "../components/text-input.jsx"; 33 import Tooltip from "../components/tooltip.jsx"; 34 import { 35 didDocCache, 36 - 37 38 39 ··· 53 const [didDoc, setDidDoc] = createSignal<DidDocument>(); 54 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 55 const [filter, setFilter] = createSignal<string>(); 56 - const [showFilter, setShowFilter] = createSignal(false); 57 const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({}); 58 const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 59 let rpc: Client; ··· 328 </Show> 329 <MenuProvider> 330 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5"> 331 - <Show 332 - when={!error() && (!location.hash || location.hash.startsWith("#collections"))} 333 - > 334 - <ActionMenu 335 - label="Filter collections" 336 - icon="lucide--filter" 337 - onClick={() => setShowFilter(!showFilter())} 338 - /> 339 - </Show> 340 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" /> 341 <NavMenu 342 href={`/jetstream?dids=${params.repo}`} ··· 423 </ErrorBoundary> 424 </Show> 425 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 426 - <Show when={showFilter()}> 427 - <TextInput 428 - name="filter" 429 - placeholder="Filter collections" 430 - onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 431 - class="grow" 432 - ref={(node) => { 433 - onMount(() => node.focus()); 434 - }} 435 - /> 436 - </Show> 437 <div 438 - class="flex flex-col text-sm wrap-anywhere" 439 - classList={{ "-mt-1": !showFilter() }} 440 > 441 <Show 442 when={Object.keys(nsids() ?? {}).length != 0} ··· 650 </div> 651 </div> 652 </Show> 653 </> 654 ); 655 };
··· 9 createSignal, 10 ErrorBoundary, 11 For, 12 Show, 13 Suspense, 14 } from "solid-js"; ··· 28 removeNotification, 29 updateNotification, 30 } from "../components/notification.jsx"; 31 import Tooltip from "../components/tooltip.jsx"; 32 + import { isTouchDevice } from "../layout.jsx"; 33 import { 34 didDocCache, 35 + labelerCache, 36 37 38 ··· 52 const [didDoc, setDidDoc] = createSignal<DidDocument>(); 53 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 54 const [filter, setFilter] = createSignal<string>(); 55 const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({}); 56 const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 57 let rpc: Client; ··· 326 </Show> 327 <MenuProvider> 328 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5"> 329 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" /> 330 <NavMenu 331 href={`/jetstream?dids=${params.repo}`} ··· 412 </ErrorBoundary> 413 </Show> 414 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 415 <div 416 + class={`flex flex-col ${isTouchDevice ? "pb-12" : "pb-16"} text-sm wrap-anywhere`} 417 > 418 <Show 419 when={Object.keys(nsids() ?? {}).length != 0} ··· 627 </div> 628 </div> 629 </Show> 630 + 631 + <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 632 + <div class={`fixed ${isTouchDevice ? "bottom-8" : "bottom-12"} z-10 w-full max-w-lg`}> 633 + <div 634 + class="dark:bg-dark-200 dark:shadow-dark-700 mx-3 flex cursor-text items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 shadow-sm dark:border-neutral-700" 635 + onClick={(e) => { 636 + const input = e.currentTarget.querySelector("input"); 637 + if (e.target !== input) input?.focus(); 638 + }} 639 + > 640 + <span class="iconify lucide--filter text-neutral-500 dark:text-neutral-400"></span> 641 + <input 642 + type="text" 643 + spellcheck={false} 644 + autocapitalize="off" 645 + autocomplete="off" 646 + class="grow py-2 select-none placeholder:text-sm focus:outline-none" 647 + name="filter" 648 + placeholder="Filter collections..." 649 + value={filter() ?? ""} 650 + onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 651 + /> 652 + </div> 653 + </div> 654 + </Show> 655 </> 656 ); 657 };
+8 -2
src/utils/api.ts
··· 62 throw new Error("Not a valid DID identifier"); 63 } 64 65 - const doc = await didDocumentResolver().resolve(did); 66 - didDocCache[did] = doc; 67 68 const pds = getPdsEndpoint(doc); 69 const labeler = getLabelerEndpoint(doc);
··· 62 throw new Error("Not a valid DID identifier"); 63 } 64 65 + let doc: DidDocument; 66 + try { 67 + doc = await didDocumentResolver().resolve(did); 68 + didDocCache[did] = doc; 69 + } catch (e) { 70 + console.error(e); 71 + throw new Error("Error during did document resolution"); 72 + } 73 74 const pds = getPdsEndpoint(doc); 75 const labeler = getLabelerEndpoint(doc);
+1 -1
index.html
··· 6 <link rel="icon" href="/favicon.ico" /> 7 <meta property="og:title" content="PDSls" /> 8 <meta property="og:type" content="website" /> 9 - <meta property="og:url" content="https://pdsls.dev" /> 10 <meta property="og:description" content="Browse the public data on atproto" /> 11 <meta property="description" content="Browse the public data on atproto" /> 12 <link rel="manifest" href="/manifest.json" />
··· 6 <link rel="icon" href="/favicon.ico" /> 7 <meta property="og:title" content="PDSls" /> 8 <meta property="og:type" content="website" /> 9 + <meta property="og:url" content="https://pds.ls" /> 10 <meta property="og:description" content="Browse the public data on atproto" /> 11 <meta property="description" content="Browse the public data on atproto" /> 12 <link rel="manifest" href="/manifest.json" />