atmosphere explorer pds.ls
tool typescript atproto

Compare changes

Choose any two refs to compare.

+2709 -940
+16 -5
src/views/blob.tsx
··· 47 47 48 48 49 49 50 - 50 + <p> 51 51 {blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""} 52 52 </p> 53 - <Show when={!response.loading && cursor()}> 54 - <Button onClick={() => refetch()}>Load More</Button> 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> 55 66 </Show> 56 - <Show when={response.loading}> 57 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 67 + </div> 68 + </div>
+22 -7
src/components/navbar.tsx
··· 1 + import * as TID from "@atcute/tid"; 1 2 import { A, Params } from "@solidjs/router"; 2 - import { createEffect, createSignal, Show } from "solid-js"; 3 + import { createEffect, createMemo, createSignal, Show } from "solid-js"; 3 4 import { isTouchDevice } from "../layout"; 4 5 import { didDocCache } from "../utils/api"; 5 6 import { addToClipboard } from "../utils/copy"; 7 + import { localDateFromTimestamp } from "../utils/date"; 6 8 import Tooltip from "./tooltip"; 7 9 8 10 export const [pds, setPDS] = createSignal<string>(); ··· 38 40 39 41 40 42 43 + } 44 + }); 41 45 42 - 43 - 44 - 45 - 46 - 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 */} 47 55 48 56 49 57 ··· 161 169 <Tooltip text="Record"> 162 170 <span class="iconify lucide--file-json text-neutral-500 transition-colors duration-200 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200"></span> 163 171 </Tooltip> 164 - <span class="py-0.5 font-medium">{props.params.rkey}</span> 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> 165 180 </div> 166 181 <CopyButton 167 182 content={`at://${props.params.repo}/${props.params.collection}/${props.params.rkey}`}
+9
src/utils/format.ts
··· 1 + const formatFileSize = (bytes: number): string => { 2 + if (bytes === 0) return "0 B"; 3 + const k = 1024; 4 + const sizes = ["B", "KB", "MB", "GB"]; 5 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 6 + return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`; 7 + }; 8 + 9 + export { formatFileSize };
+101 -78
src/components/lexicon-schema.tsx
··· 124 124 }; 125 125 126 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 - </> 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> 143 141 ); 144 142 }; 145 143 146 144 const UnionBadges = (props: { refs: string[] }) => ( 147 - <div class="flex flex-wrap gap-2"> 145 + <div class="flex flex-col items-start gap-1"> 148 146 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For> 149 147 </div> 150 148 ); 151 149 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 - ); 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 + }; 195 222 196 223 const PropertyRow = (props: { 197 224 name: string; ··· 217 244 return ( 218 245 <div class="flex flex-col gap-2 py-3"> 219 246 <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> 247 + <div class="flex flex-wrap items-baseline gap-2"> 248 + <span class="font-semibold">{props.name}</span> 222 249 <Show when={!props.property.refs}> 223 250 <TypeBadge 224 251 type={props.property.type} ··· 227 254 /> 228 255 </Show> 229 256 <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> 257 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 233 258 </Show> 234 259 <Show when={props.required}> 235 260 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span> ··· 244 269 </Show> 245 270 <Show when={props.property.items}> 246 271 <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> 272 + <div class="flex items-baseline gap-2 text-xs"> 273 + <span class="font-medium">items:</span> 249 274 <Show when={!props.property.items!.refs}> 250 275 <TypeBadge 251 276 type={props.property.items!.type} ··· 254 279 /> 255 280 </Show> 256 281 <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> 282 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 260 283 </Show> 261 284 </div> 262 285 <Show when={props.property.items!.refs}> ··· 292 315 <button 293 316 type="button" 294 317 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" 318 + class="cursor-pointer font-mono text-xs text-blue-500 hover:underline dark:text-blue-400" 296 319 > 297 320 {props.nsid} 298 321 </button> ··· 314 337 return ( 315 338 <div class="flex flex-col gap-2 py-3"> 316 339 <div class="flex flex-wrap items-center gap-2"> 317 - <span class="font-mono text-sm font-semibold">#{props.index + 1}</span> 340 + <span class="font-semibold">#{props.index + 1}</span> 318 341 <span 319 342 class={`rounded px-1.5 py-0.5 font-mono text-xs font-semibold ${resourceColor(props.permission.resource)}`} 320 343 > ··· 328 351 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 329 352 Collections: 330 353 </span> 331 - <div class="flex flex-wrap gap-1"> 354 + <div class="flex flex-col items-start gap-1"> 332 355 <For each={props.permission.collection}>{(col) => <NsidLink nsid={col} />}</For> 333 356 </div> 334 357 </div> ··· 356 379 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 357 380 Lexicon Methods: 358 381 </span> 359 - <div class="flex flex-wrap gap-1"> 382 + <div class="flex flex-col items-start gap-1"> 360 383 <For each={props.permission.lxm}>{(method) => <NsidLink nsid={method} />}</For> 361 384 </div> 362 385 </div> ··· 681 704 <For each={props.def.errors}> 682 705 {(error) => ( 683 706 <div class="flex flex-col gap-1 py-2"> 684 - <div class="font-mono text-sm font-semibold">{error.name}</div> 707 + <div class="font-semibold">{error.name}</div> 685 708 <Show when={error.description}> 686 709 <p class="text-sm text-neutral-700 dark:text-neutral-300"> 687 710 {error.description} ··· 734 757 735 758 736 759 737 - 760 + <h2 class="text-lg font-semibold">{props.schema.id}</h2> 738 761 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400"> 739 762 <span> 740 - <span class="font-semibold">Lexicon version: </span> 741 - <span class="font-mono">{props.schema.lexicon}</span> 763 + <span class="font-medium">Lexicon version: </span> 764 + <span>{props.schema.lexicon}</span> 742 765 </span> 743 766 </div> 744 767 <Show when={props.schema.description}>
+7 -1
src/index.tsx
··· 13 13 import { RecordView } from "./views/record.tsx"; 14 14 import { RepoView } from "./views/repo.tsx"; 15 15 import { Settings } from "./views/settings.tsx"; 16 - import { StreamView } from "./views/stream.tsx"; 16 + import { StreamView } from "./views/stream"; 17 17 18 18 render( 19 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 72 ); 73 73 }; 74 74 75 - export const ActionMenu = (props: { 76 - label: string; 77 - icon: string; 78 - onClick: () => void; 79 - }) => { 75 + export const ActionMenu = (props: { label: string; icon: string; onClick: () => void }) => { 80 76 const ctx = useContext(MenuContext); 81 77 82 78 return (
+79 -6
src/utils/app-urls.ts
··· 1 1 2 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 + }; 3 30 31 + export const appHandleLink: Record<App, (url: string[]) => string> = { 4 32 5 33 6 34 ··· 17 45 18 46 19 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]}`; 20 55 21 56 22 57 23 58 24 59 25 60 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, 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 + };
-1
src/views/car/explore.tsx
··· 544 544 </Show> 545 545 </button> 546 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 547 > 549 548 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 550 549 </HoverCard>
+126 -2
src/auth/account.tsx
··· 1 1 import { Did } from "@atcute/lexicons"; 2 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 3 import { A } from "@solidjs/router"; 4 - import { createSignal, For, onMount, Show } from "solid-js"; 4 + import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 5 5 import { createStore, produce } from "solid-js/store"; 6 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 7 import { Modal } from "../components/modal.jsx"; ··· 17 17 retrieveSession, 18 18 saveSessionToStorage, 19 19 } from "./session-manager.js"; 20 - import { agent, openManager, sessions, setAgent, setOpenManager, setSessions } from "./state.js"; 20 + import { 21 + agent, 22 + openManager, 23 + pendingPermissionEdit, 24 + sessions, 25 + setAgent, 26 + setOpenManager, 27 + setPendingPermissionEdit, 28 + setSessions, 29 + } from "./state.js"; 21 30 22 31 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 32 const removeSession = async (did: Did) => { ··· 73 82 beforeRedirect: (account) => resumeSession(account as Did), 74 83 }); 75 84 85 + createEffect(() => { 86 + const pending = pendingPermissionEdit(); 87 + if (pending) { 88 + scopeFlow.initiateWithRedirect(pending); 89 + setPendingPermissionEdit(null); 90 + } 91 + }); 92 + 76 93 const handleAccountClick = async (did: Did) => { 77 94 try { 78 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 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 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 9 import { Title } from "@solidjs/meta"; 10 10 import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 11 11 import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 12 - import { hasUserScope } from "../auth/scope-utils"; 13 12 import { agent } from "../auth/state"; 14 13 import { Backlinks } from "../components/backlinks.jsx"; 15 14 import { Button } from "../components/button.jsx"; ··· 26 25 import { Modal } from "../components/modal.jsx"; 27 26 import { pds } from "../components/navbar.jsx"; 28 27 import { addNotification, removeNotification } from "../components/notification.jsx"; 29 - import Tooltip from "../components/tooltip.jsx"; 28 + import { PermissionButton } from "../components/permission-button.jsx"; 30 29 import { 31 30 didDocumentResolver, 32 31 resolveLexiconAuthority, ··· 406 405 </div> 407 406 <div class="flex gap-0.5"> 408 407 <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)} 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 + }} 417 434 > 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> 435 + Delete 436 + </Button> 437 + </div> 438 + </Modal> 436 439 </Show> 437 440 <MenuProvider> 438 441 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5">
+46 -1
src/components/modal.tsx
··· 5 5 onClose?: () => void; 6 6 closeOnClick?: boolean; 7 7 nonBlocking?: boolean; 8 + alignTop?: boolean; 9 + contentClass?: string; 8 10 } 9 11 10 12 export const Modal = (props: ModalProps) => { ··· 12 14 <Show when={props.open}> 13 15 <div 14 16 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" 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" 16 18 classList={{ 17 19 "pointer-events-none": props.nonBlocking, 20 + "items-start pt-18": props.alignTop, 21 + "items-center": !props.alignTop, 18 22 }} 19 23 ref={(node) => { 20 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 + );
+189 -6
src/views/pds.tsx
··· 78 78 79 79 80 80 81 - 81 + > 82 82 <span class="iconify lucide--info text-neutral-600 dark:text-neutral-400"></span> 83 83 </button> 84 - <Modal open={openInfo()} onClose={() => setOpenInfo(false)}> 85 - <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-70 left-[50%] w-max max-w-[90vw] -translate-x-1/2 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 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>
+363 -315
src/views/stream/index.tsx
··· 3 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 5 import { Button } from "../../components/button"; 6 + import DidHoverCard from "../../components/hover-card/did"; 6 7 import { JSONValue } from "../../components/json"; 7 - import { StickyOverlay } from "../../components/sticky"; 8 8 import { TextInput } from "../../components/text-input"; 9 + import { addToClipboard } from "../../utils/copy"; 10 + import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 9 11 import { StreamStats, StreamStatsPanel } from "./stats"; 10 12 11 13 const LIMIT = 20; 12 - type Parameter = { name: string; param: string | string[] | undefined }; 13 14 14 - const StreamView = () => { 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="shrink-0 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 = () => { 15 102 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>>([]); 103 + const streamType = getStreamType(useLocation().pathname); 104 + const config = () => STREAM_CONFIGS[streamType]; 105 + 106 + const [records, setRecords] = createSignal<any[]>([]); 19 107 const [connected, setConnected] = createSignal(false); 20 108 const [paused, setPaused] = createSignal(false); 21 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, 22 114 115 + collections: {}, 116 + }); 117 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 23 118 119 + let socket: WebSocket; 120 + let firehose: Firehose; 121 + let formRef!: HTMLFormElement; 24 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> = {}; 25 130 131 + const addRecord = (record: any) => { 132 + currentSecondEventCount++; 133 + totalEventsCount++; 26 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; 27 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 + } 28 147 29 148 30 149 ··· 36 155 37 156 38 157 158 + }; 39 159 160 + const disconnect = () => { 161 + if (!config().useFirehoseLib) socket?.close(); 162 + else firehose?.close(); 40 163 164 + if (rafId !== null) { 165 + cancelAnimationFrame(rafId); 166 + rafId = null; 41 167 42 168 43 169 44 170 45 171 46 172 173 + clearInterval(statsUpdateIntervalId); 174 + statsUpdateIntervalId = null; 175 + } 47 176 177 + pendingRecords = []; 178 + totalEventsCount = 0; 179 + eventTypesMap = {}; 180 + collectionsMap = {}; 181 + setConnected(false); 182 + setPaused(false); 183 + setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 184 + }; 48 185 186 + const connectStream = async (formData: FormData) => { 187 + setNotice(""); 188 + if (connected()) { 189 + disconnect(); 49 190 191 + } 192 + setRecords([]); 50 193 194 + const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 195 + const url = config().buildUrl(instance, formData); 51 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); 52 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 + ]); 53 214 215 + setConnected(true); 216 + const now = Date.now(); 217 + setCurrentTime(now); 54 218 219 + totalEventsCount = 0; 220 + eventTypesMap = {}; 221 + collectionsMap = {}; 55 222 56 223 57 224 ··· 67 234 68 235 69 236 237 + })); 238 + }, 50); 70 239 240 + statsIntervalId = window.setInterval(() => { 241 + setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 242 + currentSecondEventCount = 0; 243 + setCurrentTime(Date.now()); 244 + }, 1000); 71 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", () => { 72 255 256 + disconnect(); 257 + }); 258 + } else { 259 + const cursor = formData.get("cursor")?.toString(); 260 + firehose = new Firehose({ 261 + relay: url, 262 + cursor: cursor, 73 263 74 264 75 265 ··· 77 267 78 268 79 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, 80 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 + }; 81 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 + }); 82 310 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 - 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 + }); 265 318 266 319 return ( 267 320 <> 268 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 269 - <div class="flex w-full flex-col items-center"> 321 + <Title>{config().label} - PDSls</Title> 322 + <div class="flex w-full flex-col items-center gap-2"> 323 + {/* Tab Navigation */} 270 324 <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 - 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> 283 337 284 - </A> 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> 285 341 </div> 342 + 343 + {/* Connection Form */} 286 344 <Show when={!connected()}> 287 - <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 345 + <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 288 346 <label class="flex items-center justify-end gap-x-1"> 289 - <span class="min-w-20">Instance</span> 347 + <span class="min-w-21 select-none">Instance</span> 290 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> 291 387 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 - 388 + <div class="flex justify-end gap-2"> 389 + <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 390 + </div> 350 391 </form> 351 392 </Show> 393 + 394 + {/* Connected State */} 352 395 <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> 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> 395 435 </div> 396 - </StickyOverlay> 436 + </div> 397 437 </Show> 438 + 439 + {/* Error Notice */} 398 440 <Show when={notice().length}> 399 441 <div class="text-red-500 dark:text-red-400">{notice()}</div> 400 442 </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> 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> 410 457 </div> 411 458 </> 412 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 47 48 48 49 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 + } 50 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 + } 51 56 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"); 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"); 55 59 } 56 60 57 61 @keyframes slideIn {
+14 -14
src/layout.tsx
··· 9 9 import { NavBar } from "./components/navbar.jsx"; 10 10 import { NotificationContainer } from "./components/notification.jsx"; 11 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 - import { Search, SearchButton, showSearch } from "./components/search.jsx"; 12 + import { Search, SearchButton } from "./components/search.jsx"; 13 13 import { themeEvent } from "./components/theme.jsx"; 14 14 import { resolveHandle } from "./utils/api.js"; 15 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 126 </Show> 127 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 128 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%)]" : ""}`} 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 130 style={{ 131 131 "background-image": 132 132 props.params.repo && props.params.repo in headers ? ··· 149 149 /> 150 150 </Show> 151 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 /> 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> 154 156 <Show when={agent()}> 155 157 <RecordEditor create={true} scope="create" /> 156 - 157 - 158 - 159 - 160 - 161 - 162 - 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" /> 163 165 164 166 165 167 ··· 170 172 </div> 171 173 </header> 172 174 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 173 - <Show when={showSearch() || location.pathname === "/"}> 174 - <Search /> 175 - </Show> 175 + <Search /> 176 176 <Show when={props.params.pds}> 177 177 <NavBar params={props.params} /> 178 178 </Show>
+396 -319
pnpm-lock.yaml
··· 35 35 36 36 37 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 38 44 39 45 40 46 ··· 85 91 86 92 87 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 88 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 89 113 90 114 91 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 92 132 93 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)) 94 141 142 + packages: 95 143 96 144 97 145 ··· 128 176 129 177 130 178 179 + '@atcute/identity@1.1.3': 180 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 131 181 182 + '@atcute/lexicon-doc@2.0.6': 183 + resolution: {integrity: sha512-iDYJkuom+tIw3zIvU1ggCEVFfReXKfOUtIhpY2kEg2kQeSfMB75F+8k1QOpeAQBetyWYmjsHqBuSUX9oQS6L1Q==} 132 184 133 185 134 186 ··· 666 718 667 719 668 720 721 + '@noble/secp256k1@3.0.0': 722 + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 669 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] 670 728 729 + '@rollup/rollup-android-arm64@4.56.0': 730 + resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} 731 + cpu: [arm64] 732 + os: [android] 671 733 734 + '@rollup/rollup-darwin-arm64@4.56.0': 735 + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} 736 + cpu: [arm64] 737 + os: [darwin] 672 738 739 + '@rollup/rollup-darwin-x64@4.56.0': 740 + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} 741 + cpu: [x64] 742 + os: [darwin] 673 743 744 + '@rollup/rollup-freebsd-arm64@4.56.0': 745 + resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} 746 + cpu: [arm64] 747 + os: [freebsd] 674 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] 675 753 754 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 755 + resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} 756 + cpu: [arm] 757 + os: [linux] 676 758 759 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 760 + resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} 761 + cpu: [arm] 762 + os: [linux] 677 763 764 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 765 + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} 766 + cpu: [arm64] 767 + os: [linux] 678 768 769 + '@rollup/rollup-linux-arm64-musl@4.56.0': 770 + resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} 771 + cpu: [arm64] 772 + os: [linux] 679 773 774 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 775 + resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} 776 + cpu: [loong64] 777 + os: [linux] 680 778 779 + '@rollup/rollup-linux-loong64-musl@4.56.0': 780 + resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} 781 + cpu: [loong64] 782 + os: [linux] 681 783 784 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 785 + resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} 786 + cpu: [ppc64] 787 + os: [linux] 682 788 789 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 790 + resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} 791 + cpu: [ppc64] 792 + os: [linux] 683 793 794 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 795 + resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} 796 + cpu: [riscv64] 797 + os: [linux] 684 798 799 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 800 + resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} 801 + cpu: [riscv64] 802 + os: [linux] 685 803 804 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 805 + resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} 806 + cpu: [s390x] 807 + os: [linux] 686 808 809 + '@rollup/rollup-linux-x64-gnu@4.56.0': 810 + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} 811 + cpu: [x64] 812 + os: [linux] 687 813 814 + '@rollup/rollup-linux-x64-musl@4.56.0': 815 + resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} 816 + cpu: [x64] 817 + os: [linux] 688 818 819 + '@rollup/rollup-openbsd-x64@4.56.0': 820 + resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} 821 + cpu: [x64] 822 + os: [openbsd] 689 823 824 + '@rollup/rollup-openharmony-arm64@4.56.0': 825 + resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} 826 + cpu: [arm64] 827 + os: [openharmony] 690 828 829 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 830 + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} 831 + cpu: [arm64] 832 + os: [win32] 691 833 834 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 835 + resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} 836 + cpu: [ia32] 837 + os: [win32] 692 838 839 + '@rollup/rollup-win32-x64-gnu@4.56.0': 840 + resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} 841 + cpu: [x64] 842 + os: [win32] 693 843 844 + '@rollup/rollup-win32-x64-msvc@4.56.0': 845 + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} 846 + cpu: [x64] 847 + os: [win32] 694 848 695 849 696 850 ··· 813 967 814 968 815 969 970 + '@types/estree@1.0.8': 971 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 816 972 973 + '@types/node@25.0.10': 974 + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} 817 975 976 + acorn@8.15.0: 977 + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 818 978 819 979 820 980 ··· 844 1004 845 1005 846 1006 1007 + bun-types@1.3.6: 1008 + resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==} 847 1009 1010 + caniuse-lite@1.0.30001766: 1011 + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} 848 1012 1013 + codemirror@6.0.2: 1014 + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} 849 1015 850 1016 851 1017 ··· 905 1071 906 1072 907 1073 1074 + domutils@3.2.2: 1075 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 908 1076 1077 + electron-to-chromium@1.5.278: 1078 + resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} 909 1079 1080 + enhanced-resolve@5.18.4: 1081 + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} 910 1082 911 1083 912 1084 ··· 1203 1375 1204 1376 1205 1377 1378 + prettier-plugin-svelte: 1379 + optional: true 1206 1380 1381 + prettier@3.8.1: 1382 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 1383 + engines: {node: '>=14'} 1384 + hasBin: true 1207 1385 1386 + resolve-pkg-maps@1.0.0: 1387 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 1208 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 1209 1393 1210 1394 1211 1395 1212 1396 1213 1397 1214 1398 1399 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 1400 + hasBin: true 1215 1401 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==} 1402 + seroval-plugins@1.5.0: 1403 + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} 1410 1404 engines: {node: '>=10'} 1411 1405 peerDependencies: 1412 1406 seroval: ^1.0 1413 1407 1414 - seroval@1.3.2: 1415 - resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} 1408 + seroval@1.5.0: 1409 + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} 1416 1410 engines: {node: '>=10'} 1417 1411 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 - 1412 + solid-js@1.9.11: 1413 + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} 1530 1414 1415 + solid-refresh@0.6.3: 1416 + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} 1531 1417 1532 1418 1533 1419 ··· 1729 1615 1730 1616 1731 1617 1618 + '@atcute/lexicons': 1.2.6 1619 + '@badrap/valita': 0.4.6 1732 1620 1621 + '@atcute/lexicon-doc@2.0.6': 1622 + dependencies: 1623 + '@atcute/identity': 1.1.3 1733 1624 1734 1625 1735 1626 ··· 2225 2116 2226 2117 2227 2118 2119 + '@noble/secp256k1@3.0.0': {} 2228 2120 2121 + '@rollup/rollup-android-arm-eabi@4.56.0': 2122 + optional: true 2229 2123 2124 + '@rollup/rollup-android-arm64@4.56.0': 2125 + optional: true 2230 2126 2127 + '@rollup/rollup-darwin-arm64@4.56.0': 2128 + optional: true 2231 2129 2130 + '@rollup/rollup-darwin-x64@4.56.0': 2131 + optional: true 2232 2132 2133 + '@rollup/rollup-freebsd-arm64@4.56.0': 2134 + optional: true 2233 2135 2136 + '@rollup/rollup-freebsd-x64@4.56.0': 2137 + optional: true 2234 2138 2139 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 2140 + optional: true 2235 2141 2142 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 2143 + optional: true 2236 2144 2145 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 2146 + optional: true 2237 2147 2148 + '@rollup/rollup-linux-arm64-musl@4.56.0': 2149 + optional: true 2238 2150 2151 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 2152 + optional: true 2239 2153 2154 + '@rollup/rollup-linux-loong64-musl@4.56.0': 2155 + optional: true 2240 2156 2157 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 2158 + optional: true 2241 2159 2160 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 2161 + optional: true 2242 2162 2163 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 2164 + optional: true 2243 2165 2166 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 2167 + optional: true 2244 2168 2169 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 2170 + optional: true 2245 2171 2172 + '@rollup/rollup-linux-x64-gnu@4.56.0': 2173 + optional: true 2246 2174 2175 + '@rollup/rollup-linux-x64-musl@4.56.0': 2176 + optional: true 2247 2177 2178 + '@rollup/rollup-openbsd-x64@4.56.0': 2179 + optional: true 2248 2180 2181 + '@rollup/rollup-openharmony-arm64@4.56.0': 2182 + optional: true 2249 2183 2184 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 2185 + optional: true 2250 2186 2187 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 2188 + optional: true 2251 2189 2190 + '@rollup/rollup-win32-x64-gnu@4.56.0': 2191 + optional: true 2252 2192 2193 + '@rollup/rollup-win32-x64-msvc@4.56.0': 2194 + optional: true 2253 2195 2196 + '@skyware/firehose@0.5.2': 2254 2197 2255 2198 2199 + '@atcute/cbor': 2.3.0 2200 + nanoevents: 9.1.0 2256 2201 2202 + '@solidjs/meta@0.29.4(solid-js@1.9.11)': 2203 + dependencies: 2204 + solid-js: 1.9.11 2257 2205 2206 + '@solidjs/router@0.15.4(solid-js@1.9.11)': 2207 + dependencies: 2208 + solid-js: 1.9.11 2258 2209 2210 + '@standard-schema/spec@1.1.0': {} 2259 2211 2260 2212 2261 2213 ··· 2315 2267 2316 2268 2317 2269 2270 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 2271 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 2318 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) 2319 2279 2280 + '@types/babel__core@7.20.5': 2281 + dependencies: 2320 2282 2321 2283 2322 2284 ··· 2340 2302 2341 2303 2342 2304 2305 + '@types/estree@1.0.8': {} 2343 2306 2307 + '@types/node@25.0.10': 2308 + dependencies: 2309 + undici-types: 7.16.0 2344 2310 2345 2311 2346 2312 ··· 2350 2316 2351 2317 2352 2318 2319 + html-entities: 2.3.3 2320 + parse5: 7.3.0 2353 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 2354 2328 2329 + baseline-browser-mapping@2.9.17: {} 2355 2330 2356 2331 2357 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) 2358 2340 2341 + bun-types@1.3.6: 2342 + dependencies: 2343 + '@types/node': 25.0.10 2359 2344 2345 + caniuse-lite@1.0.30001766: {} 2360 2346 2347 + codemirror@6.0.2: 2348 + dependencies: 2361 2349 2362 2350 2363 2351 ··· 2421 2409 2422 2410 2423 2411 2412 + domelementtype: 2.3.0 2413 + domhandler: 5.0.3 2424 2414 2415 + electron-to-chromium@1.5.278: {} 2425 2416 2417 + enhanced-resolve@5.18.4: 2418 + dependencies: 2426 2419 2427 2420 2428 2421 ··· 2645 2638 2646 2639 2647 2640 2641 + picocolors: 1.1.1 2642 + source-map-js: 1.2.1 2648 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 2649 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) 2650 2654 2655 + prettier@3.8.1: {} 2651 2656 2657 + resolve-pkg-maps@1.0.0: 2658 + optional: true 2652 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 2653 2690 2691 + sax@1.4.4: {} 2654 2692 2693 + semver@6.3.1: {} 2655 2694 2695 + seroval-plugins@1.5.0(seroval@1.5.0): 2696 + dependencies: 2697 + seroval: 1.5.0 2656 2698 2699 + seroval@1.5.0: {} 2657 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) 2658 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 2659 2715 2660 2716 2661 2717 ··· 2701 2757 2702 2758 2703 2759 2704 - semver@6.3.1: {} 2760 + escalade: 3.2.0 2761 + picocolors: 1.1.1 2705 2762 2706 - seroval-plugins@1.3.3(seroval@1.3.2): 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)): 2707 2764 dependencies: 2708 - seroval: 1.3.2 2709 - 2710 - seroval@1.3.2: {} 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 2711 2775 2712 - solid-js@1.9.10: 2776 + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2): 2713 2777 dependencies: 2714 - csstype: 3.2.3 2715 - seroval: 1.3.2 2716 - seroval-plugins: 1.3.3(seroval@1.3.2) 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: {} 2717 2796 2718 - solid-refresh@0.6.3(solid-js@1.9.10): 2719 - dependencies:
+12 -15
src/components/create/index.tsx
··· 344 344 345 345 346 346 347 - 348 - 349 - 350 - 351 - 352 - 353 - 354 - 355 - 356 - 357 - 358 - 359 - 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 360 357 361 358 362 359 ··· 463 460 <button 464 461 class={ 465 462 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"}` 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"}` 468 465 } 469 466 onclick={() => { 470 467 if (hasPermission()) {
+338 -1
src/views/collection.tsx
··· 40 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 41 trigger={ 42 42 <> 43 - <span class="shrink-0 text-sm text-blue-500 dark:text-blue-400">{props.record.rkey}</span> 43 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 44 + {props.record.rkey} 45 + </span> 44 46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 45 47 {props.record.cid} 46 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 32 }; 33 33 34 34 return ( 35 - <div class="flex flex-col gap-y-2 px-1"> 35 + <div class="flex flex-col gap-y-3 px-1"> 36 36 <Show when={!scopeFlow.showScopeSelector()}> 37 37 <Show when={props.onCancel}> 38 - <div class="mb-1 flex items-center gap-2"> 38 + <div class="flex items-center gap-2"> 39 39 <button 40 40 onclick={handleCancel} 41 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 45 <div class="font-semibold">Add account</div> 46 46 </div> 47 47 </Show> 48 - <form class="flex flex-col gap-2" onsubmit={(e) => e.preventDefault()}> 48 + <form class="flex flex-col gap-3" onsubmit={(e) => e.preventDefault()}> 49 49 <label for="username" class="hidden"> 50 50 Add account 51 51 </label> ··· 69 69 </div> 70 70 <button 71 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" 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 73 > 74 74 Continue 75 75 </button>
+3 -3
src/auth/scope-selector.tsx
··· 44 44 }; 45 45 46 46 return ( 47 - <div class="flex flex-col gap-y-2"> 48 - <div class="mb-1 flex items-center gap-2"> 47 + <div class="flex flex-col gap-y-3"> 48 + <div class="flex items-center gap-2"> 49 49 <button 50 50 onclick={props.onCancel} 51 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" ··· 77 77 </div> 78 78 <button 79 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" 80 + 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" 81 81 > 82 82 Continue 83 83 </button>
-14
src/utils/templates.ts
··· 37 37 link: `https://pinksea.art/${uri.repo}`, 38 38 icon: "i-pinksea", 39 39 }), 40 - "blue.linkat.board": (uri) => ({ 41 - label: "Linkat", 42 - link: `https://linkat.blue/${uri.repo}`, 43 - }), 44 40 "sh.tangled.actor.profile": (uri) => ({ 45 41 label: "Tangled", 46 42 link: `https://tangled.org/${uri.repo}`, ··· 51 47 link: `https://tangled.org/${uri.repo}/${record.name}`, 52 48 icon: "i-tangled", 53 49 }), 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 50 };
-12
src/utils/types/lexicons.ts
··· 17 17 AppBskyLabelerService, 18 18 ChatBskyActorDeclaration, 19 19 } from "@atcute/bluesky"; 20 - import { 21 - PubLeafletComment, 22 - PubLeafletDocument, 23 - PubLeafletGraphSubscription, 24 - PubLeafletPublication, 25 - } from "@atcute/leaflet"; 26 20 import { 27 21 ShTangledActorProfile, 28 22 ShTangledFeedStar, ··· 85 79 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 86 80 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 87 81 "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 82 };
+1
.gitignore
··· 2 2 dist 3 3 .env 4 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"; 1 + import { createSignal, JSX, Match, Show, Switch } from "solid-js"; 2 2 3 3 export const Favicon = (props: { 4 4 authority: string; ··· 8 8 const domain = () => props.authority.split(".").reverse().join("."); 9 9 10 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 - </> 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> 28 31 ); 29 32 30 33 return props.wrapper ?
+77 -56
src/views/home.tsx
··· 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"; 1 6 7 + type ProfileData = { 8 + did: string; 2 9 3 10 4 11 ··· 19 26 20 27 21 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 + ]; 22 60 23 - 24 - 25 - 26 - 27 - 28 - 29 - 30 - 31 - 32 - 33 - 34 - 35 - 36 - 37 - 38 - 39 - 40 - 41 - 61 + const profiles = [...allExampleProfiles].sort(() => Math.random() - 0.5).slice(0, 3); 42 62 43 63 44 64 45 65 {/* Welcome Section */} 46 66 <div class="flex flex-col gap-4"> 47 67 <div class="flex flex-col gap-1"> 48 - <h1 class="text-lg font-semibold">Atmosphere Explorer</h1> 68 + <h1 class="text-lg font-medium">Atmosphere Explorer</h1> 49 69 <div class="text-sm text-neutral-600 dark:text-neutral-300"> 50 70 <p> 51 71 Browse the public data on the{" "} ··· 58 78 59 79 60 80 81 + </div> 61 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) => ( 62 88 63 89 64 90 65 91 66 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} 67 103 68 104 69 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> 70 121 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 - 122 + <div class="flex flex-col gap-4 text-sm"> 102 123 <div class="flex flex-col gap-2"> 103 124 <A 104 125 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" 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" 106 127 > 107 128 <div class="iconify lucide--radio-tower" /> 108 129 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 115 136 </A> 116 137 <A 117 138 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" 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" 119 140 > 120 141 <div class="iconify lucide--rss" /> 121 142 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 128 149 </A> 129 150 <A 130 151 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" 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" 132 153 > 133 154 <div class="iconify lucide--orbit" /> 134 155 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 144 165 <div class="flex flex-col gap-2"> 145 166 <A 146 167 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" 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" 148 169 > 149 170 <div class="iconify lucide--tag" /> 150 171 <span class="underline decoration-transparent group-hover:decoration-current"> ··· 157 178 </A> 158 179 <A 159 180 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" 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" 161 182 > 162 183 <div class="iconify lucide--folder-archive" /> 163 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 10 "build": "vite build", 11 11 "serve": "vite preview" 12 12 }, 13 - "pnpm": { 14 - "overrides": { 15 - "seroval": "^1.4.1" 16 - } 17 - }, 18 13 "devDependencies": { 19 14 "@iconify-json/lucide": "^1.2.86", 20 15 "@iconify/tailwind4": "^1.2.1", 21 16 "@tailwindcss/vite": "^4.1.18", 22 - "prettier": "^3.8.0", 17 + "prettier": "^3.8.1", 23 18 "prettier-plugin-organize-imports": "^4.3.0", 24 19 "prettier-plugin-tailwindcss": "^0.7.2", 25 20 "tailwindcss": "^4.1.18", ··· 60 55 "@solidjs/router": "^0.15.4", 61 56 "codemirror": "^6.0.2", 62 57 "native-file-system-adapter": "^3.0.1", 63 - "solid-js": "^1.9.10" 58 + "solid-js": "^1.9.11" 64 59 }, 65 60 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 66 61 }
+3 -1
src/components/button.tsx
··· 6 6 class?: string; 7 7 classList?: Record<string, boolean | undefined>; 8 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 + ontouchstart?: (e: TouchEvent) => void; 9 10 children?: JSX.Element; 10 11 } 11 12 ··· 16 17 disabled={props.disabled ?? false} 17 18 class={ 18 19 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 + "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-xs text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300" 20 21 } 21 22 classList={props.classList} 22 23 onClick={props.onClick} 24 + ontouchstart={props.ontouchstart} 23 25 > 24 26 {props.children} 25 27 </button>
+1 -1
src/components/notification.tsx
··· 87 87 </Show> 88 88 <Show when={notification.onCancel}> 89 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" 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 91 onClick={(e) => { 92 92 e.stopPropagation(); 93 93 notification.onCancel?.();
+1 -1
src/components/text-input.tsx
··· 25 25 disabled={props.disabled} 26 26 required={props.required} 27 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 " + 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 29 props.class 30 30 } 31 31 onInput={props.onInput}
+1 -1
src/views/car/shared.tsx
··· 123 123 </p> 124 124 <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 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"> 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 127 <input 128 128 type="file" 129 129 accept={isIOS ? undefined : ".car,application/vnd.ipld.car"}
+4 -2
src/views/labels.tsx
··· 277 277 <Button 278 278 onClick={handleLoadMore} 279 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" 280 + classList={{ "w-20 justify-center": true }} 281 281 > 282 282 <Show 283 283 when={!loading()} 284 - fallback={<span class="iconify lucide--loader-circle animate-spin" />} 284 + fallback={ 285 + <span class="iconify lucide--loader-circle animate-spin text-base" /> 286 + } 285 287 > 286 288 Load more 287 289 </Show>