atmosphere explorer pds.ls
tool typescript atproto

Compare changes

Choose any two refs to compare.

+4873 -773
+350 -23
src/views/car.tsx
··· 8 9 10 11 12 - 13 - 14 - 15 - 16 - 17 18 19 ··· 103 } 104 } 105 106 - // Now parse records using fromStream 107 - const stream = file.stream(); 108 - await using repo = fromStream(stream); 109 - 110 const collections = new Map<string, RecordEntry[]>(); 111 const result: Archive = { 112 file, ··· 114 entries: [], 115 }; 116 117 - for await (const entry of repo) { 118 - let list = collections.get(entry.collection); 119 - if (list === undefined) { 120 - collections.set(entry.collection, (list = [])); 121 - result.entries.push({ 122 - name: entry.collection, 123 - entries: list, 124 }); 125 } 126 - 127 - const record = toJsonValue(entry.record); 128 - list.push({ 129 - key: entry.rkey, 130 - cid: entry.cid.$link, 131 - record, 132 - }); 133 } 134 135 setArchive(result);
··· 8 9 10 11 + import { isTouchDevice } from "../layout.jsx"; 12 + import { localDateFromTimestamp } from "../utils/date.js"; 13 14 + const isIOS = 15 + /iPad|iPhone|iPod/.test(navigator.userAgent) || 16 + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 17 + 18 + // Convert CBOR-decoded objects to JSON-friendly format 19 + const toJsonValue = (obj: unknown): JSONType => { 20 + if (obj === null || obj === undefined) return null; 21 22 23 ··· 107 } 108 } 109 110 const collections = new Map<string, RecordEntry[]>(); 111 const result: Archive = { 112 file, ··· 114 entries: [], 115 }; 116 117 + const stream = file.stream(); 118 + const repo = fromStream(stream); 119 + try { 120 + for await (const entry of repo) { 121 + let list = collections.get(entry.collection); 122 + if (list === undefined) { 123 + collections.set(entry.collection, (list = [])); 124 + result.entries.push({ 125 + name: entry.collection, 126 + entries: list, 127 + }); 128 + } 129 + 130 + const record = toJsonValue(entry.record); 131 + list.push({ 132 + key: entry.rkey, 133 + cid: entry.cid.$link, 134 + record, 135 }); 136 } 137 + } finally { 138 + await repo.dispose(); 139 } 140 141 setArchive(result); 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 + <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"> 240 + <input 241 + type="file" 242 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 243 + onChange={props.onFileChange} 244 + class="hidden" 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 + 316 + 317 + 318 + 319 + 320 + 321 + 322 + 323 + 324 + 325 + 326 + 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 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 + 378 + 379 + 380 + 381 + 382 + 383 + 384 + 385 + 386 + 387 + 388 + 389 + 390 + 391 + 392 + 393 + 394 + 395 + 396 + 397 + 398 + 399 + 400 + 401 + 402 + 403 + 404 + 405 + 406 + 407 + 408 + 409 + 410 + 411 + 412 + 413 + 414 + 415 + 416 + 417 + 418 + 419 + 420 + 421 + 422 + 423 + 424 + 425 + 426 + 427 + 428 + 429 + 430 + 431 + 432 + 433 + 434 + 435 + 436 + 437 + 438 + 439 + 440 + 441 + 442 + props.onRoute({ type: "collection", collection: entry }); 443 + } 444 + }} 445 + class="flex w-full items-center gap-2 rounded p-2 text-left text-sm hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-800 dark:active:bg-neutral-700" 446 + > 447 + <span 448 + class="truncate font-medium" 449 + classList={{ 450 + "text-neutral-700 dark:text-neutral-300": hasSingleEntry, 451 + "text-blue-400": !hasSingleEntry, 452 + }} 453 + > 454 + {entry.name} 455 + 456 + 457 + <Show when={hasSingleEntry}> 458 + <span class="iconify lucide--chevron-right shrink-0 text-xs text-neutral-500" /> 459 + <span class="truncate font-medium text-blue-400">{entry.entries[0].key}</span> 460 + </Show> 461 + 462 + <Show when={!hasSingleEntry}>
+15 -1
src/components/notification.tsx
··· 7 progress?: number; 8 total?: number; 9 type?: "info" | "success" | "error"; 10 }; 11 12 const [notifications, setNotifications] = createStore<Notification[]>([]); ··· 48 "animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id), 49 "animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id), 50 }} 51 - onClick={() => removeNotification(notification.id)} 52 > 53 <div class="flex items-center gap-2 text-sm"> 54 <Show when={notification.progress !== undefined}> ··· 82 {notification.progress}% 83 </div> 84 </Show> 85 </div> 86 </Show> 87 </div>
··· 7 progress?: number; 8 total?: number; 9 type?: "info" | "success" | "error"; 10 + onCancel?: () => void; 11 }; 12 13 const [notifications, setNotifications] = createStore<Notification[]>([]); ··· 49 "animate-[slideIn_0.25s_ease-in]": !removingIds().has(notification.id), 50 "animate-[slideOut_0.25s_ease-in]": removingIds().has(notification.id), 51 }} 52 + onClick={() => 53 + notification.progress === undefined && removeNotification(notification.id) 54 + } 55 > 56 <div class="flex items-center gap-2 text-sm"> 57 <Show when={notification.progress !== undefined}> ··· 85 {notification.progress}% 86 </div> 87 </Show> 88 + <Show when={notification.onCancel}> 89 + <button 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 dark:bg-dark-300 mt-1 rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 + onClick={(e) => { 92 + e.stopPropagation(); 93 + notification.onCancel?.(); 94 + }} 95 + > 96 + Cancel 97 + </button> 98 + </Show> 99 </div> 100 </Show> 101 </div>
+3 -3
src/components/tooltip.tsx
··· 2 import { isTouchDevice } from "../layout"; 3 4 const Tooltip = (props: { text: string; children: JSX.Element }) => ( 5 - <div class="group/tooltip relative flex items-center"> 6 {props.children} 7 <Show when={!isTouchDevice}> 8 <span 9 style={`transform: translate(-50%, 28px)`} 10 - class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`} 11 > 12 {props.text} 13 </span> 14 </Show> 15 - </div> 16 ); 17 18 export default Tooltip;
··· 2 import { isTouchDevice } from "../layout"; 3 4 const Tooltip = (props: { text: string; children: JSX.Element }) => ( 5 + <span class="group/tooltip relative inline-flex items-center"> 6 {props.children} 7 <Show when={!isTouchDevice}> 8 <span 9 style={`transform: translate(-50%, 28px)`} 10 + class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs font-normal whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`} 11 > 12 {props.text} 13 </span> 14 </Show> 15 + </span> 16 ); 17 18 export default Tooltip;
+44
src/views/car/index.tsx
···
··· 1 + import { Title } from "@solidjs/meta"; 2 + import { A } from "@solidjs/router"; 3 + 4 + export const CarView = () => { 5 + return ( 6 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 7 + <Title>Archive tools - PDSls</Title> 8 + <div class="flex flex-col gap-y-1"> 9 + <h1 class="text-lg font-semibold">Archive tools</h1> 10 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 11 + Tools for working with CAR (Content Addressable aRchive) files. 12 + </p> 13 + </div> 14 + 15 + <div class="flex flex-col gap-3"> 16 + <A 17 + href="explore" 18 + class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 19 + > 20 + <span class="iconify lucide--folder-search mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 21 + <div class="flex flex-col gap-1"> 22 + <span class="font-medium">Explore archive</span> 23 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 24 + Browse records inside a repository archive 25 + </span> 26 + </div> 27 + </A> 28 + 29 + <A 30 + href="unpack" 31 + class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800" 32 + > 33 + <span class="iconify lucide--file-archive mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" /> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-medium">Unpack archive</span> 36 + <span class="text-sm text-neutral-600 dark:text-neutral-400"> 37 + Extract records from an archive into a ZIP file 38 + </span> 39 + </div> 40 + </A> 41 + </div> 42 + </div> 43 + ); 44 + };
+152
src/views/car/logger.tsx
···
··· 1 + import { For } from "solid-js"; 2 + import { createMutable } from "solid-js/store"; 3 + 4 + interface LogEntry { 5 + type: "log" | "info" | "warn" | "error"; 6 + at: number; 7 + msg: string; 8 + } 9 + 10 + interface PendingLogEntry { 11 + msg: string; 12 + } 13 + 14 + export const createLogger = () => { 15 + const pending = createMutable<PendingLogEntry[]>([]); 16 + 17 + let backlog: LogEntry[] | undefined = []; 18 + let push = (entry: LogEntry) => { 19 + backlog!.push(entry); 20 + }; 21 + 22 + return { 23 + internal: { 24 + get pending() { 25 + return pending; 26 + }, 27 + attach(fn: (entry: LogEntry) => void) { 28 + if (backlog !== undefined) { 29 + for (let idx = 0, len = backlog.length; idx < len; idx++) { 30 + fn(backlog[idx]); 31 + } 32 + backlog = undefined; 33 + } 34 + push = fn; 35 + }, 36 + }, 37 + log(msg: string) { 38 + push({ type: "log", at: Date.now(), msg }); 39 + }, 40 + info(msg: string) { 41 + push({ type: "info", at: Date.now(), msg }); 42 + }, 43 + warn(msg: string) { 44 + push({ type: "warn", at: Date.now(), msg }); 45 + }, 46 + error(msg: string) { 47 + push({ type: "error", at: Date.now(), msg }); 48 + }, 49 + progress(initialMsg: string, throttleMs = 500) { 50 + pending.unshift({ msg: initialMsg }); 51 + 52 + let entry: PendingLogEntry | undefined = pending[0]; 53 + 54 + return { 55 + update: throttle((msg: string) => { 56 + if (entry !== undefined) { 57 + entry.msg = msg; 58 + } 59 + }, throttleMs), 60 + destroy() { 61 + if (entry !== undefined) { 62 + const index = pending.indexOf(entry); 63 + pending.splice(index, 1); 64 + entry = undefined; 65 + } 66 + }, 67 + [Symbol.dispose]() { 68 + this.destroy(); 69 + }, 70 + }; 71 + }, 72 + }; 73 + }; 74 + 75 + export type Logger = ReturnType<typeof createLogger>; 76 + 77 + const formatter = new Intl.DateTimeFormat("en-US", { timeStyle: "short", hour12: false }); 78 + 79 + export const LoggerView = (props: { logger: Logger }) => { 80 + return ( 81 + <ul class="flex flex-col font-mono text-xs empty:hidden"> 82 + <For each={props.logger.internal.pending}> 83 + {(entry) => ( 84 + <li class="flex gap-2 px-4 py-1 whitespace-pre-wrap"> 85 + <span class="shrink-0 font-medium whitespace-pre-wrap text-neutral-400">-----</span> 86 + <span class="wrap-break-word">{entry.msg}</span> 87 + </li> 88 + )} 89 + </For> 90 + 91 + <div 92 + ref={(node) => { 93 + props.logger.internal.attach(({ type, at, msg }) => { 94 + let ecn = `flex gap-2 whitespace-pre-wrap px-4 py-1`; 95 + let tcn = `shrink-0 whitespace-pre-wrap font-medium`; 96 + if (type === "log") { 97 + tcn += ` text-neutral-500`; 98 + } else if (type === "info") { 99 + ecn += ` bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`; 100 + tcn += ` text-blue-500`; 101 + } else if (type === "warn") { 102 + ecn += ` bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300`; 103 + tcn += ` text-amber-500`; 104 + } else if (type === "error") { 105 + ecn += ` bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`; 106 + tcn += ` text-red-500`; 107 + } 108 + 109 + const item = ( 110 + <li class={ecn}> 111 + <span class={tcn}>{formatter.format(at)}</span> 112 + <span class="wrap-break-word">{msg}</span> 113 + </li> 114 + ); 115 + 116 + if (item instanceof Node) { 117 + node.after(item); 118 + } 119 + }); 120 + }} 121 + /> 122 + </ul> 123 + ); 124 + }; 125 + 126 + const throttle = <T extends (...args: any[]) => void>(func: T, wait: number) => { 127 + let timeout: ReturnType<typeof setTimeout> | null = null; 128 + let lastArgs: Parameters<T> | null = null; 129 + let lastCallTime = 0; 130 + 131 + const invoke = () => { 132 + func(...lastArgs!); 133 + lastCallTime = Date.now(); 134 + timeout = null; 135 + }; 136 + 137 + return (...args: Parameters<T>) => { 138 + const now = Date.now(); 139 + const timeSinceLastCall = now - lastCallTime; 140 + 141 + lastArgs = args; 142 + 143 + if (timeSinceLastCall >= wait) { 144 + if (timeout !== null) { 145 + clearTimeout(timeout); 146 + } 147 + invoke(); 148 + } else if (timeout === null) { 149 + timeout = setTimeout(invoke, wait - timeSinceLastCall); 150 + } 151 + }; 152 + };
+146
src/views/car/shared.tsx
···
··· 1 + import * as CBOR from "@atcute/cbor"; 2 + import * as CID from "@atcute/cid"; 3 + import { A } from "@solidjs/router"; 4 + import { Show } from "solid-js"; 5 + import { type JSONType } from "../../components/json.jsx"; 6 + 7 + export const isIOS = 8 + /iPad|iPhone|iPod/.test(navigator.userAgent) || 9 + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); 10 + 11 + // Convert CBOR-decoded objects to JSON-friendly format 12 + export const toJsonValue = (obj: unknown): JSONType => { 13 + if (obj === null || obj === undefined) return null; 14 + 15 + if (CID.isCidLink(obj)) { 16 + return { $link: obj.$link }; 17 + } 18 + 19 + if ( 20 + obj && 21 + typeof obj === "object" && 22 + "version" in obj && 23 + "codec" in obj && 24 + "digest" in obj && 25 + "bytes" in obj 26 + ) { 27 + try { 28 + return { $link: CID.toString(obj as CID.Cid) }; 29 + } catch {} 30 + } 31 + 32 + if (CBOR.isBytes(obj)) { 33 + return { $bytes: obj.$bytes }; 34 + } 35 + 36 + if (Array.isArray(obj)) { 37 + return obj.map(toJsonValue); 38 + } 39 + 40 + if (typeof obj === "object") { 41 + const result: Record<string, JSONType> = {}; 42 + for (const [key, value] of Object.entries(obj)) { 43 + result[key] = toJsonValue(value); 44 + } 45 + return result; 46 + } 47 + 48 + return obj as JSONType; 49 + }; 50 + 51 + export interface Archive { 52 + file: File; 53 + did: string; 54 + entries: CollectionEntry[]; 55 + } 56 + 57 + export interface CollectionEntry { 58 + name: string; 59 + entries: RecordEntry[]; 60 + } 61 + 62 + export interface RecordEntry { 63 + key: string; 64 + cid: string; 65 + record: JSONType; 66 + } 67 + 68 + export type View = 69 + | { type: "repo" } 70 + | { type: "collection"; collection: CollectionEntry } 71 + | { type: "record"; collection: CollectionEntry; record: RecordEntry }; 72 + 73 + export const WelcomeView = (props: { 74 + title: string; 75 + subtitle: string; 76 + loading: boolean; 77 + progress?: number; 78 + error?: string; 79 + onFileChange: (e: Event) => void; 80 + onDrop: (e: DragEvent) => void; 81 + onDragOver: (e: DragEvent) => void; 82 + }) => { 83 + return ( 84 + <div class="flex w-full max-w-3xl flex-col gap-y-4 px-2"> 85 + <div class="flex flex-col gap-y-1"> 86 + <div class="flex items-center gap-2 text-lg"> 87 + <A 88 + href="/car" 89 + class="flex size-7 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200" 90 + > 91 + <span class="iconify lucide--arrow-left" /> 92 + </A> 93 + <h1 class="font-semibold">{props.title}</h1> 94 + </div> 95 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{props.subtitle}</p> 96 + </div> 97 + 98 + <div 99 + class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500" 100 + onDrop={props.onDrop} 101 + onDragOver={props.onDragOver} 102 + > 103 + <Show 104 + when={!props.loading} 105 + fallback={ 106 + <div class="flex flex-col items-center gap-2"> 107 + <span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" /> 108 + <span class="text-sm font-medium text-neutral-600 dark:text-neutral-400"> 109 + Reading CAR file... 110 + </span> 111 + <Show when={props.progress && props.progress > 0}> 112 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 113 + {props.progress?.toLocaleString()} records processed 114 + </span> 115 + </Show> 116 + </div> 117 + } 118 + > 119 + <span class="iconify lucide--folder-archive text-3xl text-neutral-400" /> 120 + <div class="text-center"> 121 + <p class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 122 + Drag and drop a CAR file here 123 + </p> 124 + <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 + </div> 126 + <label class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-300 bg-neutral-50 px-2.5 py-1.5 text-sm text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300"> 127 + <input 128 + type="file" 129 + accept={isIOS ? undefined : ".car,application/vnd.ipld.car"} 130 + onChange={props.onFileChange} 131 + class="hidden" 132 + /> 133 + <span class="iconify lucide--upload text-sm" /> 134 + Choose file 135 + </label> 136 + </Show> 137 + </div> 138 + 139 + <Show when={props.error}> 140 + <div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"> 141 + {props.error} 142 + </div> 143 + </Show> 144 + </div> 145 + ); 146 + };
+249
src/views/car/unpack.tsx
···
··· 1 + import { fromStream } from "@atcute/repo"; 2 + import { zip, type ZipEntry } from "@mary/zip"; 3 + import { Title } from "@solidjs/meta"; 4 + import { FileSystemWritableFileStream, showSaveFilePicker } from "native-file-system-adapter"; 5 + import { createSignal, onCleanup } from "solid-js"; 6 + import { createDropHandler, createFileChangeHandler, handleDragOver } from "./file-handlers.js"; 7 + import { createLogger, LoggerView } from "./logger.jsx"; 8 + import { isIOS, toJsonValue, WelcomeView } from "./shared.jsx"; 9 + 10 + // Check if browser natively supports File System Access API 11 + const hasNativeFileSystemAccess = "showSaveFilePicker" in window; 12 + 13 + // HACK: Disable compression on WebKit due to an error being thrown 14 + const isWebKit = 15 + isIOS || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)); 16 + 17 + const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g; 18 + const filenamify = (name: string) => { 19 + return name.replace(INVALID_CHAR_RE, "~"); 20 + }; 21 + 22 + export const UnpackToolView = () => { 23 + const logger = createLogger(); 24 + const [pending, setPending] = createSignal(false); 25 + 26 + let abortController: AbortController | undefined; 27 + 28 + onCleanup(() => { 29 + abortController?.abort(); 30 + }); 31 + 32 + const unpackToZip = async (file: File) => { 33 + abortController?.abort(); 34 + abortController = new AbortController(); 35 + const signal = abortController.signal; 36 + 37 + setPending(true); 38 + logger.log(`Starting extraction`); 39 + 40 + let repo: Awaited<ReturnType<typeof fromStream>> | undefined; 41 + 42 + const stream = file.stream(); 43 + repo = fromStream(stream); 44 + 45 + try { 46 + let count = 0; 47 + 48 + // On Safari/browsers without native File System Access API, use blob download 49 + if (!hasNativeFileSystemAccess) { 50 + const chunks: BlobPart[] = []; 51 + 52 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 53 + const progress = logger.progress(`Unpacking records (0 entries)`); 54 + 55 + try { 56 + for await (const entry of repo) { 57 + if (signal.aborted) return; 58 + 59 + try { 60 + const record = toJsonValue(entry.record); 61 + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 62 + const data = JSON.stringify(record, null, 2); 63 + 64 + yield { filename, data, compress: isWebKit ? false : "deflate" }; 65 + count++; 66 + progress.update(`Unpacking records (${count} entries)`); 67 + } catch { 68 + // Skip entries with invalid data 69 + } 70 + } 71 + } finally { 72 + progress[Symbol.dispose]?.(); 73 + } 74 + }; 75 + 76 + for await (const chunk of zip(entryGenerator())) { 77 + if (signal.aborted) return; 78 + chunks.push(chunk as BlobPart); 79 + } 80 + 81 + if (signal.aborted) return; 82 + 83 + logger.log(`${count} records extracted`); 84 + logger.log(`Creating download...`); 85 + 86 + const blob = new Blob(chunks, { type: "application/zip" }); 87 + const url = URL.createObjectURL(blob); 88 + const a = document.createElement("a"); 89 + a.href = url; 90 + a.download = `${file.name.replace(/\.car$/, "")}.zip`; 91 + document.body.appendChild(a); 92 + a.click(); 93 + document.body.removeChild(a); 94 + URL.revokeObjectURL(url); 95 + 96 + logger.log(`Finished! Download started.`); 97 + setPending(false); 98 + return; 99 + } 100 + 101 + // Native File System Access API path 102 + let writable: FileSystemWritableFileStream | undefined; 103 + 104 + // Create async generator that yields ZipEntry as we read from CAR 105 + const entryGenerator = async function* (): AsyncGenerator<ZipEntry> { 106 + const progress = logger.progress(`Unpacking records (0 entries)`); 107 + 108 + try { 109 + for await (const entry of repo) { 110 + if (signal.aborted) return; 111 + 112 + // Prompt for save location on first record 113 + if (writable === undefined) { 114 + const waiting = logger.progress(`Waiting for user...`); 115 + 116 + try { 117 + const fd = await showSaveFilePicker({ 118 + suggestedName: `${file.name.replace(/\.car$/, "")}.zip`, 119 + // @ts-expect-error: ponyfill doesn't have full typings 120 + id: "car-unpack", 121 + startIn: "downloads", 122 + types: [ 123 + { 124 + description: "ZIP archive", 125 + accept: { "application/zip": [".zip"] }, 126 + }, 127 + ], 128 + }).catch((err) => { 129 + if (err instanceof DOMException && err.name === "AbortError") { 130 + logger.warn(`File picker was cancelled`); 131 + } else { 132 + logger.warn(`Something went wrong when opening the file picker`); 133 + } 134 + return undefined; 135 + }); 136 + 137 + if (!fd) { 138 + logger.warn(`No file handle obtained`); 139 + return; 140 + } 141 + 142 + writable = await fd.createWritable(); 143 + 144 + if (writable === undefined) { 145 + logger.warn(`Failed to create writable stream`); 146 + return; 147 + } 148 + } finally { 149 + waiting[Symbol.dispose]?.(); 150 + } 151 + } 152 + 153 + try { 154 + const record = toJsonValue(entry.record); 155 + const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`; 156 + const data = JSON.stringify(record, null, 2); 157 + 158 + yield { filename, data, compress: isWebKit ? false : "deflate" }; 159 + count++; 160 + progress.update(`Unpacking records (${count} entries)`); 161 + } catch { 162 + // Skip entries with invalid data 163 + } 164 + } 165 + } finally { 166 + progress[Symbol.dispose]?.(); 167 + } 168 + }; 169 + 170 + // Stream entries directly to zip, then to file 171 + let writeCount = 0; 172 + for await (const chunk of zip(entryGenerator())) { 173 + if (signal.aborted) { 174 + await writable?.abort(); 175 + return; 176 + } 177 + if (writable === undefined) { 178 + // User cancelled file picker 179 + setPending(false); 180 + return; 181 + } 182 + writeCount++; 183 + // Await every 100th write to apply backpressure 184 + if (writeCount % 100 === 0) { 185 + await writable.write(chunk); 186 + } else { 187 + writable.write(chunk); // Fire and forget 188 + } 189 + } 190 + 191 + if (signal.aborted) return; 192 + 193 + if (writable === undefined) { 194 + logger.warn(`CAR file has no records`); 195 + setPending(false); 196 + return; 197 + } 198 + 199 + logger.log(`${count} records extracted`); 200 + 201 + { 202 + const flushProgress = logger.progress(`Flushing writes...`); 203 + try { 204 + await writable.close(); 205 + logger.log(`Finished! File saved successfully.`); 206 + } catch (err) { 207 + logger.error(`Failed to save file: ${err}`); 208 + throw err; // Re-throw to be caught by outer catch 209 + } finally { 210 + flushProgress[Symbol.dispose]?.(); 211 + } 212 + } 213 + } catch (err) { 214 + if (signal.aborted) return; 215 + logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`); 216 + } finally { 217 + await repo?.dispose(); 218 + if (!signal.aborted) { 219 + setPending(false); 220 + } 221 + } 222 + }; 223 + 224 + const handleFileChange = createFileChangeHandler(unpackToZip); 225 + 226 + // Wrap handleDrop to prevent multiple simultaneous uploads 227 + const baseDrop = createDropHandler(unpackToZip); 228 + const handleDrop = (e: DragEvent) => { 229 + if (pending()) return; 230 + baseDrop(e); 231 + }; 232 + 233 + return ( 234 + <> 235 + <Title>Unpack archive - PDSls</Title> 236 + <WelcomeView 237 + title="Unpack archive" 238 + subtitle="Upload a CAR file to extract all records into a ZIP archive." 239 + loading={pending()} 240 + onFileChange={handleFileChange} 241 + onDrop={handleDrop} 242 + onDragOver={handleDragOver} 243 + /> 244 + <div class="w-full max-w-3xl px-2"> 245 + <LoggerView logger={logger} /> 246 + </div> 247 + </> 248 + ); 249 + };
+24
src/views/car/file-handlers.ts
···
··· 1 + export const isCarFile = (file: File): boolean => { 2 + return file.name.endsWith(".car") || file.type === "application/vnd.ipld.car"; 3 + }; 4 + 5 + export const createFileChangeHandler = (onFile: (file: File) => void) => (e: Event) => { 6 + const input = e.target as HTMLInputElement; 7 + const file = input.files?.[0]; 8 + if (file) { 9 + onFile(file); 10 + } 11 + input.value = ""; 12 + }; 13 + 14 + export const createDropHandler = (onFile: (file: File) => void) => (e: DragEvent) => { 15 + e.preventDefault(); 16 + const file = e.dataTransfer?.files?.[0]; 17 + if (file && isCarFile(file)) { 18 + onFile(file); 19 + } 20 + }; 21 + 22 + export const handleDragOver = (e: DragEvent) => { 23 + e.preventDefault(); 24 + };
+228 -42
src/views/home.tsx
··· 1 export const Home = () => { 2 return ( 3 - <div class="flex w-full flex-col gap-3 wrap-break-word"> 4 - <div class="flex flex-col gap-1"> 5 - <div> 6 - <span class="text-xl font-semibold">AT Protocol Explorer</span> 7 </div> 8 - <div class="flex items-center gap-1"> 9 - <div class="iconify lucide--search" /> 10 - <span> 11 - Browse the public data on{" "} 12 - <a class="underline hover:text-blue-400" href="https://atproto.com" target="_blank"> 13 - atproto 14 - </a> 15 - . 16 - </span> 17 - </div> 18 - <div class="flex items-center gap-1"> 19 - <div class="iconify lucide--link" /> 20 - <span> 21 - Backlinks support with{" "} 22 - <a 23 - href="https://constellation.microcosm.blue" 24 - class="underline hover:text-blue-400" 25 - target="_blank" 26 - > 27 - constellation 28 - </a> 29 - . 30 - </span> 31 - </div> 32 - <div class="flex items-center gap-1"> 33 - <div class="iconify lucide--user-round" /> 34 - <span>Login to manage records in your repository.</span> 35 - </div> 36 - <div class="flex items-center gap-1"> 37 - <div class="iconify lucide--radio-tower" /> 38 - <span>Jetstream and firehose streaming.</span> 39 - </div> 40 - <div class="flex items-center gap-1"> 41 - <div class="iconify lucide--tag" /> 42 - <span>Query labels from moderation services.</span> 43 </div> 44 </div> 45 - <div class="text-center text-sm text-neutral-600 dark:text-neutral-400"> 46 - <span class="italic"> 47 - Made by{" "}
··· 1 + import { A } from "@solidjs/router"; 2 + import { For, JSX } from "solid-js"; 3 + import { setOpenManager, setShowAddAccount } from "../auth/state"; 4 + import { Button } from "../components/button"; 5 + import { SearchButton } from "../components/search"; 6 + 7 + type ProfileData = { 8 + did: string; 9 + handle: string; 10 + }; 11 + 12 export const Home = () => { 13 + const FooterLink = (props: { 14 + href: string; 15 + color: string; 16 + darkColor?: string; 17 + children: JSX.Element; 18 + }) => ( 19 + <a 20 + href={props.href} 21 + class={`relative flex items-center gap-1.5 after:absolute after:bottom-0 after:left-0 after:h-px after:w-0 after:bg-current ${props.color} after:transition-[width] after:duration-300 after:ease-out hover:after:w-full ${props.darkColor ?? ""}`} 22 + target="_blank" 23 + > 24 + {props.children} 25 + </a> 26 + ); 27 + 28 + const allExampleProfiles: ProfileData[] = [ 29 + { did: "did:plc:7vimlesenouvuaqvle42yhvo", handle: "juli.ee" }, 30 + { did: "did:plc:oisofpd7lj26yvgiivf3lxsi", handle: "hailey.at" }, 31 + { did: "did:plc:vwzwgnygau7ed7b7wt5ux7y2", handle: "retr0.id" }, 32 + { did: "did:plc:vc7f4oafdgxsihk4cry2xpze", handle: "jcsalterego.bsky.social" }, 33 + { did: "did:plc:uu5axsmbm2or2dngy4gwchec", handle: "futur.blue" }, 34 + { did: "did:plc:ia76kvnndjutgedggx2ibrem", handle: "mary.my.id" }, 35 + { did: "did:plc:hdhoaan3xa3jiuq4fg4mefid", handle: "bad-example.com" }, 36 + { did: "did:plc:q6gjnaw2blty4crticxkmujt", handle: "jaz.sh" }, 37 + { did: "did:plc:jrtgsidnmxaen4offglr5lsh", handle: "quilling.dev" }, 38 + { did: "did:plc:3c6vkaq7xf5kz3va3muptjh5", handle: "aylac.top" }, 39 + { did: "did:plc:gwd5r7dbg3zv6dhv75hboa3f", handle: "mofu.run" }, 40 + { did: "did:plc:tzrpqyerzt37pyj54hh52xrz", handle: "rainy.pet" }, 41 + { did: "did:plc:qx7in36j344d7qqpebfiqtew", handle: "futanari.observer" }, 42 + { did: "did:plc:ucaezectmpny7l42baeyooxi", handle: "sapphic.moe" }, 43 + { did: "did:plc:6v6jqsy7swpzuu53rmzaybjy", handle: "computer.fish" }, 44 + { did: "did:plc:w4nvvt6feq2l3qgnwl6a7g7d", handle: "emilia.wtf" }, 45 + { did: "did:plc:xwhsmuozq3mlsp56dyd7copv", handle: "paizuri.moe" }, 46 + { did: "did:plc:aokggmp5jzj4nc5jifhiplqc", handle: "dreary.blacksky.app" }, 47 + { did: "did:plc:k644h4rq5bjfzcetgsa6tuby", handle: "natalie.sh" }, 48 + { did: "did:plc:ttdrpj45ibqunmfhdsb4zdwq", handle: "nekomimi.pet" }, 49 + { did: "did:plc:fz2tul67ziakfukcwa3vdd5d", handle: "nullekko.moe" }, 50 + { did: "did:plc:qxichs7jsycphrsmbujwqbfb", handle: "isabelroses.com" }, 51 + { did: "did:plc:fnvdhaoe7b5abgrtvzf4ttl5", handle: "isuggest.selfce.st" }, 52 + { did: "did:plc:p5yjdr64h7mk5l3kh6oszryk", handle: "blooym.dev" }, 53 + { did: "did:plc:hvakvedv6byxhufjl23mfmsd", handle: "number-one-warned.rat.mom" }, 54 + { did: "did:plc:6if5m2yo6kroprmmency3gt5", handle: "olaren.dev" }, 55 + { did: "did:plc:w7adfxpixpi77e424cjjxnxy", handle: "anyaustin.bsky.social" }, 56 + { did: "did:plc:h6as5sk7tfqvvnqvfrlnnwqn", handle: "cwonus.org" }, 57 + { did: "did:plc:mo7bk6gblylupvhetkqmndrv", handle: "claire.on-her.computer" }, 58 + { did: "did:plc:73gqgbnvpx5syidcponjrics", handle: "coil-habdle.ebil.club" }, 59 + ]; 60 + 61 + const profiles = [...allExampleProfiles].sort(() => Math.random() - 0.5).slice(0, 3); 62 + 63 return ( 64 + <div class="flex w-full flex-col gap-5 px-2 wrap-break-word"> 65 + {/* Welcome Section */} 66 + <div class="flex flex-col gap-4"> 67 + <div class="flex flex-col gap-1"> 68 + <h1 class="text-lg font-medium">Atmosphere Explorer</h1> 69 + <div class="text-sm text-neutral-600 dark:text-neutral-300"> 70 + <p> 71 + Browse the public data on the{" "} 72 + <a 73 + href="https://atproto.com" 74 + target="_blank" 75 + class="underline decoration-neutral-400 transition-colors hover:text-blue-500 hover:decoration-blue-500 dark:decoration-neutral-500 dark:hover:text-blue-400" 76 + > 77 + AT Protocol 78 + </a> 79 + </p> 80 + </div> 81 + </div> 82 + 83 + {/* Example Repos */} 84 + <section class="mb-1 flex flex-col gap-3"> 85 + <div class="flex justify-between"> 86 + <For each={profiles}> 87 + {(profile) => ( 88 + <A 89 + href={`/at://${profile.did}`} 90 + class="group flex min-w-0 basis-1/3 flex-col items-center gap-1.5 transition-transform hover:scale-105 active:scale-105" 91 + > 92 + <img 93 + src={`/avatar/${profile.handle}.jpg`} 94 + alt={`Bluesky profile picture of ${profile.handle}`} 95 + class="size-16 rounded-full ring-2 ring-transparent transition-all group-hover:ring-blue-500 active:ring-blue-500 dark:group-hover:ring-blue-400 dark:active:ring-blue-400" 96 + classList={{ 97 + "animate-[spin_5s_linear_infinite] [animation-play-state:paused] group-hover:[animation-play-state:running]": 98 + profile.handle === "coil-habdle.ebil.club", 99 + }} 100 + /> 101 + <span class="w-full truncate text-center text-xs text-neutral-600 dark:text-neutral-300"> 102 + @{profile.handle} 103 + </span> 104 + </A> 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 115 + onClick={() => { 116 + setOpenManager(true); 117 + setShowAddAccount(true); 118 + }} 119 + > 120 + <span class="iconify lucide--user-round"></span> 121 + Sign in 122 + </Button> 123 + <span>to manage records</span> 124 </div> 125 + </div> 126 + 127 + <div class="flex flex-col gap-4 text-sm"> 128 + <div class="flex flex-col gap-2"> 129 + <A 130 + href="/jetstream" 131 + 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 + > 133 + <div class="iconify lucide--radio-tower" /> 134 + <span class="underline decoration-transparent group-hover:decoration-current"> 135 + Jetstream 136 + </span> 137 + <div /> 138 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 139 + Event stream with filtering 140 + </span> 141 + </A> 142 + <A 143 + href="/firehose" 144 + 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" 145 + > 146 + <div class="iconify lucide--rss" /> 147 + <span class="underline decoration-transparent group-hover:decoration-current"> 148 + Firehose 149 + </span> 150 + <div /> 151 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 152 + Raw relay event stream 153 + </span> 154 + </A> 155 + <A 156 + href="/spacedust" 157 + 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" 158 + > 159 + <div class="iconify lucide--orbit" /> 160 + <span class="underline decoration-transparent group-hover:decoration-current"> 161 + Spacedust 162 + </span> 163 + <div /> 164 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 165 + Interaction links stream 166 + </span> 167 + </A> 168 + </div> 169 + 170 + <div class="flex flex-col gap-2"> 171 + <A 172 + href="/labels" 173 + 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" 174 + > 175 + <div class="iconify lucide--tag" /> 176 + <span class="underline decoration-transparent group-hover:decoration-current"> 177 + Labels 178 + </span> 179 + <div /> 180 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 181 + Query labeler services 182 + </span> 183 + </A> 184 + <A 185 + href="/car" 186 + 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" 187 + > 188 + <div class="iconify lucide--folder-archive" /> 189 + <span class="underline decoration-transparent group-hover:decoration-current"> 190 + Archive 191 + </span> 192 + <div /> 193 + <span class="text-xs text-neutral-500 dark:text-neutral-400"> 194 + Explore and unpack CAR files 195 + </span> 196 + </A> 197 </div> 198 </div> 199 + 200 + <div class="flex justify-center gap-1.5 text-sm text-neutral-600 sm:gap-2 dark:text-neutral-300"> 201 + <FooterLink 202 + href="https://juli.ee" 203 + color="after:text-rose-400" 204 + darkColor="dark:after:text-rose-300" 205 + > 206 + <span class="iconify lucide--terminal text-rose-400 dark:text-rose-300"></span> 207 + <span class="font-pecita">juliet</span> 208 + </FooterLink> 209 + {/* โ€ข */} 210 + {/* <FooterLink href="https://raycast.com/" color="after:text-[#FF6363]"> */} 211 + {/* <span class="iconify-color i-raycast-light block dark:hidden"></span> */} 212 + {/* <span class="iconify-color i-raycast-dark hidden dark:block"></span> */} 213 + {/* Raycast */} 214 + {/* </FooterLink> */}โ€ข 215 + <FooterLink 216 + href="https://bsky.app/profile/did:plc:6q5daed5gutiyerimlrnojnz" 217 + color="after:text-[#0085ff]" 218 + > 219 + <span class="simple-icons--bluesky iconify text-[#0085ff]"></span> 220 + Bluesky 221 + </FooterLink> 222 + โ€ข 223 + <FooterLink 224 + href="https://tangled.org/did:plc:6q5daed5gutiyerimlrnojnz/pdsls/" 225 + color="after:text-black" 226 + darkColor="dark:after:text-white" 227 + > 228 + <span class="iconify i-tangled text-black dark:text-white"></span> 229 + Source 230 + </FooterLink> 231 + </div> 232 + </div> 233 + );
+82 -46
src/components/navbar.tsx
··· 1 2 3 4 ··· 11 12 13 14 15 16 ··· 24 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 - 59 - 60 - 61 - 62 - 63 64 65 ··· 67 <A 68 end 69 href={pds()!} 70 - inactiveClass="text-blue-400 py-0.5 w-full font-medium hover:text-blue-500 transition-colors duration-150 dark:hover:text-blue-300" 71 > 72 {pds()} 73 </A> ··· 96 97 98 99 100 101 102 103 104 105 ··· 107 108 109 110 - <A 111 - end 112 - href={`/at://${props.params.repo}`} 113 - inactiveClass="flex grow min-w-0 gap-1 py-0.5 font-medium text-blue-400 hover:text-blue-500 transition-colors duration-150 dark:hover:text-blue-300" 114 - > 115 - <Show 116 - when={handle() !== props.params.repo} 117 118 119 ··· 124 125 126 127 128 129 ··· 137 138 139 140 - <A 141 - end 142 - href={`/at://${props.params.repo}/${props.params.collection}`} 143 - inactiveClass="text-blue-400 grow py-0.5 font-medium hover:text-blue-500 transition-colors duration-150 dark:hover:text-blue-300" 144 - > 145 - {props.params.collection} 146 - </A>
··· 1 + import * as TID from "@atcute/tid"; 2 + import { A, Params } from "@solidjs/router"; 3 + import { createEffect, createMemo, createSignal, Show } from "solid-js"; 4 + import { isTouchDevice } from "../layout"; 5 + import { didDocCache } from "../utils/api"; 6 + import { addToClipboard } from "../utils/copy"; 7 + import { localDateFromTimestamp } from "../utils/date"; 8 + import Tooltip from "./tooltip"; 9 10 + export const [pds, setPDS] = createSignal<string>(); 11 12 13 ··· 20 21 22 23 + class={`-mr-2 hidden items-center rounded px-2 py-1 text-neutral-500 transition-all duration-200 group-hover:flex hover:bg-neutral-200/70 hover:text-neutral-600 active:bg-neutral-300/70 sm:py-1.5 dark:text-neutral-400 dark:hover:bg-neutral-700/70 dark:hover:text-neutral-300 dark:active:bg-neutral-600/70`} 24 + aria-label="Copy to clipboard" 25 + > 26 + <span class="iconify lucide--copy"></span> 27 + </button> 28 + </Tooltip> 29 + </Show> 30 31 32 ··· 40 41 42 43 + } 44 + }); 45 46 + const rkeyTimestamp = createMemo(() => { 47 + if (!props.params.rkey || !TID.validate(props.params.rkey)) return undefined; 48 + const timestamp = TID.parse(props.params.rkey).timestamp / 1000; 49 + return timestamp <= Date.now() ? timestamp : undefined; 50 + }); 51 52 + return ( 53 + <nav class="flex w-full flex-col text-sm wrap-anywhere sm:text-base"> 54 + {/* PDS Level */} 55 56 57 58 + <span 59 + classList={{ 60 + "iconify shrink-0 transition-colors duration-200": true, 61 + "lucide--alert-triangle text-red-500 dark:text-red-400": 62 + pds() === "Missing PDS" && props.params.repo?.startsWith("did:"), 63 + "lucide--hard-drive text-neutral-500 group-hover:text-neutral-700 dark:text-neutral-400 dark:group-hover:text-neutral-200": 64 + pds() !== "Missing PDS" || !props.params.repo?.startsWith("did:"), 65 + }} 66 + ></span> 67 + </Tooltip> 68 + <Show when={pds() && (pds() !== "Missing PDS" || props.params.repo?.startsWith("did:"))}> 69 + <Show 70 + when={pds() === "Missing PDS"} 71 + fallback={ 72 73 74 ··· 76 <A 77 end 78 href={pds()!} 79 + inactiveClass="text-blue-500 py-0.5 w-full font-medium hover:text-blue-600 transition-colors duration-150 dark:text-blue-400 dark:hover:text-blue-300" 80 > 81 {pds()} 82 </A> ··· 105 106 107 108 + when={handle() !== props.params.repo} 109 + fallback={<span class="truncate">{props.params.repo}</span>} 110 + > 111 + <span class="max-w-full shrink-0 truncate">{handle()}</span> 112 + <span class="truncate text-neutral-500 dark:text-neutral-400"> 113 + ({props.params.repo}) 114 + </span> 115 116 117 118 119 + <A 120 + end 121 + href={`/at://${props.params.repo}`} 122 + inactiveClass="flex grow min-w-0 gap-1 py-0.5 font-medium text-blue-500 hover:text-blue-600 transition-colors duration-150 dark:text-blue-400 dark:hover:text-blue-300" 123 + > 124 + <Show 125 + when={handle() !== props.params.repo} 126 + fallback={<span class="truncate">{props.params.repo}</span>} 127 + > 128 + <span class="max-w-full shrink-0 truncate">{handle()}</span> 129 + <span class="truncate">({props.params.repo})</span> 130 + </Show> 131 + </A> 132 133 134 ··· 136 137 138 139 140 141 ··· 146 147 148 149 + <A 150 + end 151 + href={`/at://${props.params.repo}/${props.params.collection}`} 152 + inactiveClass="text-blue-500 dark:text-blue-400 grow py-0.5 font-medium hover:text-blue-600 transition-colors duration-150 dark:hover:text-blue-300" 153 + > 154 + {props.params.collection} 155 + </A> 156 157 158 ··· 166 167 168 169 + <Tooltip text="Record"> 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> 171 + </Tooltip> 172 + <div class="flex min-w-0 gap-1 py-0.5 font-medium"> 173 + <span>{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> 180 + </div> 181 + <CopyButton 182 + content={`at://${props.params.repo}/${props.params.collection}/${props.params.rkey}`}
+1 -1
src/components/theme.tsx
··· 43 44 return ( 45 <div class="flex flex-col gap-1"> 46 - <label class="select-none">Theme</label> 47 <div class="flex gap-2"> 48 <ThemeOption theme="auto" label="Auto" /> 49 <ThemeOption theme="light" label="Light" />
··· 43 44 return ( 45 <div class="flex flex-col gap-1"> 46 + <label class="font-medium select-none">Theme</label> 47 <div class="flex gap-2"> 48 <ThemeOption theme="auto" label="Auto" /> 49 <ThemeOption theme="light" label="Light" />
+28 -14
src/views/settings.tsx
··· 1 2 3 4 5 6 - 7 - 8 - 9 - 10 11 12 <div class="text-lg font-semibold">Settings</div> 13 <div class="flex flex-col gap-3"> 14 <div class="flex flex-col gap-1"> 15 - <label for="plcDirectory" class="select-none"> 16 PLC Directory 17 </label> 18 <TextInput 19 - 20 - 21 - 22 - 23 - 24 - 25 - 26 - 27 </div> 28 <ThemeSelection /> 29 <div class="flex flex-col gap-1"> 30 - <label class="select-none">Blob media preview</label> 31 <div class="flex gap-2"> 32 <button 33 classList={{ ··· 61 </button> 62 </div> 63 </div> 64 </div> 65 </div> 66 );
··· 1 2 3 4 + import { ThemeSelection } from "../components/theme.jsx"; 5 6 + export const [hideMedia, setHideMedia] = createSignal(localStorage.hideMedia === "true"); 7 + export const [plcDirectory, setPlcDirectory] = createSignal( 8 + localStorage.plcDirectory || "https://plc.directory", 9 + ); 10 11 + const Settings = () => { 12 + return ( 13 14 15 <div class="text-lg font-semibold">Settings</div> 16 <div class="flex flex-col gap-3"> 17 <div class="flex flex-col gap-1"> 18 + <label for="plcDirectory" class="font-medium select-none"> 19 PLC Directory 20 </label> 21 <TextInput 22 + id="plcDirectory" 23 + value={plcDirectory()} 24 + onInput={(e) => { 25 + const value = e.currentTarget.value; 26 + if (value.length) { 27 + localStorage.plcDirectory = value; 28 + setPlcDirectory(value); 29 + } else { 30 + localStorage.removeItem("plcDirectory"); 31 + setPlcDirectory("https://plc.directory"); 32 + } 33 + }} 34 + /> 35 </div> 36 <ThemeSelection /> 37 <div class="flex flex-col gap-1"> 38 + <label class="font-medium select-none">Blob media preview</label> 39 <div class="flex gap-2"> 40 <button 41 classList={{ ··· 69 </button> 70 </div> 71 </div> 72 + <div class="flex flex-col gap-1"> 73 + <label class="font-medium select-none">Version</label> 74 + <div class="text-sm text-neutral-600 dark:text-neutral-400"> 75 + {import.meta.env.VITE_APP_VERSION} 76 + </div> 77 + </div> 78 </div> 79 </div> 80 );
public/fonts/Figtree[wght].woff2 public/fonts/Figtree.woff2
+3
src/auth/state.ts
··· 12 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 export const [sessions, setSessions] = createStore<Sessions>();
··· 12 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 + export const [openManager, setOpenManager] = createSignal(false); 16 + export const [showAddAccount, setShowAddAccount] = createSignal(false); 17 + export const [pendingPermissionEdit, setPendingPermissionEdit] = createSignal<string | null>(null);
+36
src/components/favicon.tsx
···
··· 1 + import { createSignal, JSX, Match, Show, Switch } from "solid-js"; 2 + 3 + export const Favicon = (props: { 4 + authority: string; 5 + wrapper?: (children: JSX.Element) => JSX.Element; 6 + }) => { 7 + const [loaded, setLoaded] = createSignal(false); 8 + const domain = () => props.authority.split(".").reverse().join("."); 9 + 10 + const content = ( 11 + <Switch> 12 + <Match when={domain() === "tangled.sh"}> 13 + <span class="iconify i-tangled size-4" /> 14 + </Match> 15 + <Match when={["bsky.app", "bsky.chat"].includes(domain())}> 16 + <img src="https://web-cdn.bsky.app/static/apple-touch-icon.png" class="size-4" /> 17 + </Match> 18 + <Match when={true}> 19 + <Show when={!loaded()}> 20 + <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 21 + </Show> 22 + <img 23 + src={`https://${domain()}/favicon.ico`} 24 + class="size-4" 25 + classList={{ hidden: !loaded() }} 26 + onLoad={() => setLoaded(true)} 27 + onError={() => setLoaded(false)} 28 + /> 29 + </Match> 30 + </Switch> 31 + ); 32 + 33 + return props.wrapper ? 34 + props.wrapper(content) 35 + : <div class="flex h-5 w-4 shrink-0 items-center justify-center">{content}</div>; 36 + };
+57 -54
src/views/record.tsx
··· 6 7 8 9 10 11 ··· 16 17 18 19 20 21 ··· 40 41 42 43 44 45 ··· 383 384 385 386 - 387 - 388 - 389 - 390 - 391 - 392 - 393 - 394 - 395 - 396 - 397 - 398 - 399 - 400 - 401 - 402 - 403 - 404 - 405 - 406 - 407 - 408 - 409 - 410 - 411 - 412 - 413 - 414 - 415 - 416 - 417 - 418 - 419 - 420 - 421 - 422 - 423 - 424 - 425 - 426 - 427 - 428 - 429 - 430 - 431 - 432 - 433 - 434 - 435 - 436 - 437 - 438 - 439 440 441 ··· 471 </div> 472 </div> 473 <Show when={!location.hash || location.hash === "#record"}> 474 - <div class="w-max max-w-screen min-w-full px-4 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:px-2 sm:text-sm md:max-w-3xl"> 475 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} /> 476 </div> 477 </Show>
··· 6 7 8 9 + import { Title } from "@solidjs/meta"; 10 + import { A, useLocation, useNavigate, useParams } from "@solidjs/router"; 11 + import { createResource, createSignal, ErrorBoundary, Show, Suspense } from "solid-js"; 12 + import { agent } from "../auth/state"; 13 + import { Backlinks } from "../components/backlinks.jsx"; 14 + import { Button } from "../components/button.jsx"; 15 16 17 ··· 22 23 24 25 + import { Modal } from "../components/modal.jsx"; 26 + import { pds } from "../components/navbar.jsx"; 27 + import { addNotification, removeNotification } from "../components/notification.jsx"; 28 + import { PermissionButton } from "../components/permission-button.jsx"; 29 + import { 30 + didDocumentResolver, 31 + resolveLexiconAuthority, 32 33 34 ··· 53 54 55 56 + const schemaPromise = (async () => { 57 + let didDocPromise = documentCache.get(authority); 58 + if (!didDocPromise) { 59 + didDocPromise = didDocumentResolver().resolve(authority); 60 + documentCache.set(authority, didDocPromise); 61 + } 62 63 64 ··· 402 403 404 405 + </div> 406 + <div class="flex gap-0.5"> 407 + <Show when={agent() && agent()?.sub === record()?.uri.split("/")[2]}> 408 + <RecordEditor 409 + create={false} 410 + record={record()?.value} 411 + refetch={refetch} 412 + scope="update" 413 + /> 414 + <PermissionButton 415 + scope="delete" 416 + tooltip="Delete" 417 + onClick={() => setOpenDelete(true)} 418 + > 419 + <span class="iconify lucide--trash-2"></span> 420 + </PermissionButton> 421 + <Modal 422 + open={openDelete()} 423 + onClose={() => setOpenDelete(false)} 424 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 425 + > 426 + <h2 class="mb-2 font-semibold">Delete this record?</h2> 427 + <div class="flex justify-end gap-2"> 428 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 429 + <Button 430 + onClick={deleteRecord} 431 + classList={{ 432 + "bg-red-500! border-none! text-white! hover:bg-red-400! active:bg-red-400!": true, 433 + }} 434 + > 435 + Delete 436 + </Button> 437 + </div> 438 + </Modal> 439 + </Show> 440 + <MenuProvider> 441 + <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5"> 442 443 444 ··· 474 </div> 475 </div> 476 <Show when={!location.hash || location.hash === "#record"}> 477 + <div class="w-full max-w-screen min-w-full px-2 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:w-max sm:text-sm md:max-w-3xl"> 478 <JSONValue data={record()?.value as any} repo={record()!.uri.split("/")[2]} /> 479 </div> 480 </Show>
+1
src/utils/route-cache.ts
··· 5 cursor: string | undefined; 6 scrollY: number; 7 reverse: boolean; 8 } 9 10 type RouteCache = Record<string, CollectionCacheEntry>;
··· 5 cursor: string | undefined; 6 scrollY: number; 7 reverse: boolean; 8 + limit: number; 9 } 10 11 type RouteCache = Record<string, CollectionCacheEntry>;
+8 -3
src/auth/oauth-config.ts
··· 1 - import { configureOAuth, defaultIdentityResolver } from "@atcute/oauth-browser-client"; 2 import { didDocumentResolver, handleResolver } from "../utils/api"; 3 4 configureOAuth({ 5 metadata: { 6 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 7 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 8 }, 9 - identityResolver: defaultIdentityResolver({ 10 handleResolver: handleResolver, 11 - didDocumentResolver: didDocumentResolver, 12 }), 13 });
··· 1 + import { LocalActorResolver } from "@atcute/identity-resolver"; 2 + import { configureOAuth } from "@atcute/oauth-browser-client"; 3 import { didDocumentResolver, handleResolver } from "../utils/api"; 4 5 + const reactiveDidDocumentResolver = { 6 + resolve: async (did: string) => didDocumentResolver().resolve(did as any), 7 + }; 8 + 9 configureOAuth({ 10 metadata: { 11 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 12 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 13 }, 14 + identityResolver: new LocalActorResolver({ 15 handleResolver: handleResolver, 16 + didDocumentResolver: reactiveDidDocumentResolver, 17 }), 18 });
+33 -9
src/views/logs.tsx
··· 1 2 3 - 4 - 5 - 6 - 7 - 8 - import { createEffect, createResource, createSignal, For, Show } from "solid-js"; 9 import { localDateFromTimestamp } from "../utils/date.js"; 10 import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 11 12 type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; 13 ··· 23 !activePlcEvent() || diffs.some((d) => d.type.startsWith(activePlcEvent()!)); 24 25 const fetchPlcLogs = async () => { 26 - const res = await fetch( 27 - `${localStorage.plcDirectory ?? "https://plc.directory"}/${props.did}/log/audit`, 28 - ); 29 const json = await res.json(); 30 const logs = defs.indexedEntryLog.parse(json); 31 setRawLogs(logs);
··· 1 2 3 + defs, 4 + IndexedEntry, 5 + IndexedEntryLog, 6 + } from "@atcute/did-plc"; 7 + import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; 8 import { localDateFromTimestamp } from "../utils/date.js"; 9 import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 10 + import PlcValidateWorker from "../workers/plc-validate.ts?worker"; 11 + import { plcDirectory } from "./settings.jsx"; 12 13 type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; 14 ··· 24 !activePlcEvent() || diffs.some((d) => d.type.startsWith(activePlcEvent()!)); 25 26 const fetchPlcLogs = async () => { 27 + const res = await fetch(`${plcDirectory()}/${props.did}/log/audit`); 28 const json = await res.json(); 29 const logs = defs.indexedEntryLog.parse(json); 30 setRawLogs(logs); 31 + 32 + return Array.from(groupBy(opHistory, (item) => item.orig)); 33 + }; 34 + 35 + const [plcOps] = 36 + createResource<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>(fetchPlcLogs); 37 + 38 + let worker: Worker | undefined; 39 + onCleanup(() => worker?.terminate()); 40 + 41 + createEffect(() => { 42 + const logs = rawLogs(); 43 + if (logs) { 44 + setValidLog(undefined); 45 + worker?.terminate(); 46 + worker = new PlcValidateWorker(); 47 + worker.onmessage = (e: MessageEvent<{ valid: boolean }>) => { 48 + setValidLog(e.data.valid); 49 + worker?.terminate(); 50 + worker = undefined; 51 + }; 52 + worker.postMessage({ did: props.did, logs }); 53 + } 54 + }); 55 +
+1 -1
LICENSE
··· 1 - Copyright (c) 2024-2025 Juliet Philippe <m@juli.ee> 2 3 Permission to use, copy, modify, and/or distribute this software for any 4 purpose with or without fee is hereby granted.
··· 1 + Copyright (c) 2024-2026 Juliet Philippe <m@juli.ee> 2 3 Permission to use, copy, modify, and/or distribute this software for any 4 purpose with or without fee is hereby granted.
+2 -1
README.md
··· 1 - # PDSls - AT Protocol Explorer 2 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 ··· 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 - Query moderation labels. 12 13 ## Hacking 14
··· 1 + # PDSls - Atmosphere Explorer 2 3 Lightweight and client-side web app to navigate [atproto](https://atproto.com/). 4 ··· 9 - Jetstream and firehose (com.atproto.sync.subscribeRepos) streaming. 10 - Backlinks support with [constellation](https://constellation.microcosm.blue/). 11 - Query moderation labels. 12 + - Explore and unpack repository archives (CAR). 13 14 ## Hacking 15
+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 }; 125 126 return ( 127 - <> 128 - <Show when={props.refType}> 129 - <button 130 - type="button" 131 - onClick={handleClick} 132 - class="inline-block cursor-pointer truncate rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 hover:bg-blue-200 hover:underline active:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 dark:active:bg-blue-900/50" 133 - > 134 - {displayType} 135 - </button> 136 - </Show> 137 - <Show when={!props.refType}> 138 - <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"> 139 - {displayType} 140 - </span> 141 - </Show> 142 - </> 143 ); 144 }; 145 146 const UnionBadges = (props: { refs: string[] }) => ( 147 - <div class="flex flex-wrap gap-2"> 148 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For> 149 </div> 150 ); 151 152 - const ConstraintsList = (props: { property: LexiconProperty }) => ( 153 - <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400"> 154 - <Show when={props.property.minLength !== undefined}> 155 - <span>minLength: {props.property.minLength}</span> 156 - </Show> 157 - <Show when={props.property.maxLength !== undefined}> 158 - <span>maxLength: {props.property.maxLength}</span> 159 - </Show> 160 - <Show when={props.property.maxGraphemes !== undefined}> 161 - <span>maxGraphemes: {props.property.maxGraphemes}</span> 162 - </Show> 163 - <Show when={props.property.minGraphemes !== undefined}> 164 - <span>minGraphemes: {props.property.minGraphemes}</span> 165 - </Show> 166 - <Show when={props.property.minimum !== undefined}> 167 - <span>min: {props.property.minimum}</span> 168 - </Show> 169 - <Show when={props.property.maximum !== undefined}> 170 - <span>max: {props.property.maximum}</span> 171 - </Show> 172 - <Show when={props.property.maxSize !== undefined}> 173 - <span>maxSize: {props.property.maxSize}</span> 174 - </Show> 175 - <Show when={props.property.accept}> 176 - <span>accept: [{props.property.accept!.join(", ")}]</span> 177 - </Show> 178 - <Show when={props.property.enum}> 179 - <span>enum: [{props.property.enum!.join(", ")}]</span> 180 - </Show> 181 - <Show when={props.property.const}> 182 - <span>const: {props.property.const?.toString()}</span> 183 - </Show> 184 - <Show when={props.property.default !== undefined}> 185 - <span>default: {JSON.stringify(props.property.default)}</span> 186 - </Show> 187 - <Show when={props.property.knownValues}> 188 - <span>knownValues: [{props.property.knownValues!.join(", ")}]</span> 189 - </Show> 190 - <Show when={props.property.closed}> 191 - <span>closed: true</span> 192 - </Show> 193 - </div> 194 - ); 195 196 const PropertyRow = (props: { 197 name: string; ··· 217 return ( 218 <div class="flex flex-col gap-2 py-3"> 219 <Show when={!props.hideNameType}> 220 - <div class="flex flex-wrap items-center gap-2"> 221 - <span class="font-mono text-sm font-semibold">{props.name}</span> 222 <Show when={!props.property.refs}> 223 <TypeBadge 224 type={props.property.type} ··· 227 /> 228 </Show> 229 <Show when={props.property.refs}> 230 - <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"> 231 - union 232 - </span> 233 </Show> 234 <Show when={props.required}> 235 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span> ··· 244 </Show> 245 <Show when={props.property.items}> 246 <div class="flex flex-col gap-2"> 247 - <div class="flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400"> 248 - <span class="font-semibold">items:</span> 249 <Show when={!props.property.items!.refs}> 250 <TypeBadge 251 type={props.property.items!.type} ··· 254 /> 255 </Show> 256 <Show when={props.property.items!.refs}> 257 - <span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"> 258 - union 259 - </span> 260 </Show> 261 </div> 262 <Show when={props.property.items!.refs}> ··· 292 <button 293 type="button" 294 onClick={handleClick} 295 - class="cursor-pointer rounded bg-blue-100 px-1.5 py-0.5 font-mono text-xs text-blue-800 hover:bg-blue-200 hover:underline active:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:hover:bg-blue-900/50 dark:active:bg-blue-900/50" 296 > 297 {props.nsid} 298 </button> ··· 314 return ( 315 <div class="flex flex-col gap-2 py-3"> 316 <div class="flex flex-wrap items-center gap-2"> 317 - <span class="font-mono text-sm font-semibold">#{props.index + 1}</span> 318 <span 319 class={`rounded px-1.5 py-0.5 font-mono text-xs font-semibold ${resourceColor(props.permission.resource)}`} 320 > ··· 328 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 329 Collections: 330 </span> 331 - <div class="flex flex-wrap gap-1"> 332 <For each={props.permission.collection}>{(col) => <NsidLink nsid={col} />}</For> 333 </div> 334 </div> ··· 356 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 357 Lexicon Methods: 358 </span> 359 - <div class="flex flex-wrap gap-1"> 360 <For each={props.permission.lxm}>{(method) => <NsidLink nsid={method} />}</For> 361 </div> 362 </div> ··· 681 <For each={props.def.errors}> 682 {(error) => ( 683 <div class="flex flex-col gap-1 py-2"> 684 - <div class="font-mono text-sm font-semibold">{error.name}</div> 685 <Show when={error.description}> 686 <p class="text-sm text-neutral-700 dark:text-neutral-300"> 687 {error.description} ··· 734 735 736 737 - 738 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400"> 739 <span> 740 - <span class="font-semibold">Lexicon version: </span> 741 - <span class="font-mono">{props.schema.lexicon}</span> 742 </span> 743 </div> 744 <Show when={props.schema.description}>
··· 124 }; 125 126 return ( 127 + <Show 128 + when={props.refType} 129 + fallback={ 130 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">{displayType}</span> 131 + } 132 + > 133 + <button 134 + type="button" 135 + onClick={handleClick} 136 + class="inline-block cursor-pointer truncate font-mono text-xs text-blue-500 hover:underline dark:text-blue-400" 137 + > 138 + {displayType} 139 + </button> 140 + </Show> 141 ); 142 }; 143 144 const UnionBadges = (props: { refs: string[] }) => ( 145 + <div class="flex flex-col items-start gap-1"> 146 <For each={props.refs}>{(refType) => <TypeBadge type="union" refType={refType} />}</For> 147 </div> 148 ); 149 150 + const ConstraintsList = (props: { property: LexiconProperty }) => { 151 + const valueClass = "text-neutral-600 dark:text-neutral-400"; 152 + return ( 153 + <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs"> 154 + <Show when={props.property.minLength !== undefined}> 155 + <span> 156 + minLength: <span class={valueClass}>{props.property.minLength}</span> 157 + </span> 158 + </Show> 159 + <Show when={props.property.maxLength !== undefined}> 160 + <span> 161 + maxLength: <span class={valueClass}>{props.property.maxLength}</span> 162 + </span> 163 + </Show> 164 + <Show when={props.property.maxGraphemes !== undefined}> 165 + <span> 166 + maxGraphemes: <span class={valueClass}>{props.property.maxGraphemes}</span> 167 + </span> 168 + </Show> 169 + <Show when={props.property.minGraphemes !== undefined}> 170 + <span> 171 + minGraphemes: <span class={valueClass}>{props.property.minGraphemes}</span> 172 + </span> 173 + </Show> 174 + <Show when={props.property.minimum !== undefined}> 175 + <span> 176 + min: <span class={valueClass}>{props.property.minimum}</span> 177 + </span> 178 + </Show> 179 + <Show when={props.property.maximum !== undefined}> 180 + <span> 181 + max: <span class={valueClass}>{props.property.maximum}</span> 182 + </span> 183 + </Show> 184 + <Show when={props.property.maxSize !== undefined}> 185 + <span> 186 + maxSize: <span class={valueClass}>{props.property.maxSize}</span> 187 + </span> 188 + </Show> 189 + <Show when={props.property.accept}> 190 + <span> 191 + accept: <span class={valueClass}>[{props.property.accept!.join(", ")}]</span> 192 + </span> 193 + </Show> 194 + <Show when={props.property.enum}> 195 + <span> 196 + enum: <span class={valueClass}>[{props.property.enum!.join(", ")}]</span> 197 + </span> 198 + </Show> 199 + <Show when={props.property.const}> 200 + <span> 201 + const: <span class={valueClass}>{props.property.const?.toString()}</span> 202 + </span> 203 + </Show> 204 + <Show when={props.property.default !== undefined}> 205 + <span> 206 + default: <span class={valueClass}>{JSON.stringify(props.property.default)}</span> 207 + </span> 208 + </Show> 209 + <Show when={props.property.knownValues}> 210 + <span> 211 + knownValues: <span class={valueClass}>[{props.property.knownValues!.join(", ")}]</span> 212 + </span> 213 + </Show> 214 + <Show when={props.property.closed}> 215 + <span> 216 + closed: <span class={valueClass}>true</span> 217 + </span> 218 + </Show> 219 + </div> 220 + ); 221 + }; 222 223 const PropertyRow = (props: { 224 name: string; ··· 244 return ( 245 <div class="flex flex-col gap-2 py-3"> 246 <Show when={!props.hideNameType}> 247 + <div class="flex flex-wrap items-baseline gap-2"> 248 + <span class="font-semibold">{props.name}</span> 249 <Show when={!props.property.refs}> 250 <TypeBadge 251 type={props.property.type} ··· 254 /> 255 </Show> 256 <Show when={props.property.refs}> 257 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 258 </Show> 259 <Show when={props.required}> 260 <span class="text-xs font-semibold text-red-500 dark:text-red-400">required</span> ··· 269 </Show> 270 <Show when={props.property.items}> 271 <div class="flex flex-col gap-2"> 272 + <div class="flex items-baseline gap-2 text-xs"> 273 + <span class="font-medium">items:</span> 274 <Show when={!props.property.items!.refs}> 275 <TypeBadge 276 type={props.property.items!.type} ··· 279 /> 280 </Show> 281 <Show when={props.property.items!.refs}> 282 + <span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">union</span> 283 </Show> 284 </div> 285 <Show when={props.property.items!.refs}> ··· 315 <button 316 type="button" 317 onClick={handleClick} 318 + class="cursor-pointer font-mono text-xs text-blue-500 hover:underline dark:text-blue-400" 319 > 320 {props.nsid} 321 </button> ··· 337 return ( 338 <div class="flex flex-col gap-2 py-3"> 339 <div class="flex flex-wrap items-center gap-2"> 340 + <span class="font-semibold">#{props.index + 1}</span> 341 <span 342 class={`rounded px-1.5 py-0.5 font-mono text-xs font-semibold ${resourceColor(props.permission.resource)}`} 343 > ··· 351 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 352 Collections: 353 </span> 354 + <div class="flex flex-col items-start gap-1"> 355 <For each={props.permission.collection}>{(col) => <NsidLink nsid={col} />}</For> 356 </div> 357 </div> ··· 379 <span class="text-xs font-semibold text-neutral-500 dark:text-neutral-400"> 380 Lexicon Methods: 381 </span> 382 + <div class="flex flex-col items-start gap-1"> 383 <For each={props.permission.lxm}>{(method) => <NsidLink nsid={method} />}</For> 384 </div> 385 </div> ··· 704 <For each={props.def.errors}> 705 {(error) => ( 706 <div class="flex flex-col gap-1 py-2"> 707 + <div class="font-semibold">{error.name}</div> 708 <Show when={error.description}> 709 <p class="text-sm text-neutral-700 dark:text-neutral-300"> 710 {error.description} ··· 757 758 759 760 + <h2 class="text-lg font-semibold">{props.schema.id}</h2> 761 <div class="flex gap-4 text-sm text-neutral-600 dark:text-neutral-400"> 762 <span> 763 + <span class="font-medium">Lexicon version: </span> 764 + <span>{props.schema.lexicon}</span> 765 </span> 766 </div> 767 <Show when={props.schema.description}>
+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;
+209 -8
src/components/json.tsx
··· 13 import { resolveLexiconAuthority } from "../utils/api"; 14 import { formatFileSize } from "../utils/format"; 15 import { hideMedia } from "../views/settings"; 16 import { pds } from "./navbar"; 17 import { addNotification, removeNotification } from "./notification"; 18 - import RecordHoverCard from "./record-hover-card"; 19 import VideoPlayer from "./video-player"; 20 21 interface JSONContext { ··· 85 {isResourceUri(part) ? 86 <RecordHoverCard uri={part} newTab={ctx.newTab} /> 87 : isDid(part) ? 88 - <A 89 - class="text-blue-500 hover:underline active:underline dark:text-blue-400" 90 - href={`/at://${part}`} 91 - target={ctx.newTab ? "_blank" : "_self"} 92 - > 93 - {part} 94 - </A> 95 : isNsid(part.split("#")[0]) && props.isType ? 96 <button 97 type="button"
··· 13 import { resolveLexiconAuthority } from "../utils/api"; 14 import { formatFileSize } from "../utils/format"; 15 import { hideMedia } from "../views/settings"; 16 + import DidHoverCard from "./hover-card/did"; 17 + import RecordHoverCard from "./hover-card/record"; 18 import { pds } from "./navbar"; 19 import { addNotification, removeNotification } from "./notification"; 20 import VideoPlayer from "./video-player"; 21 22 interface JSONContext { ··· 86 {isResourceUri(part) ? 87 <RecordHoverCard uri={part} newTab={ctx.newTab} /> 88 : isDid(part) ? 89 + <DidHoverCard did={part} newTab={ctx.newTab} /> 90 : isNsid(part.split("#")[0]) && props.isType ? 91 <button 92 type="button" 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 + <Show when={mediaLoaded()}> 284 + <button 285 + onclick={() => setHide(true)} 286 + class="absolute top-1 right-1 flex items-center rounded-lg bg-neutral-700/70 p-1.5 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/media:opacity-100 hover:bg-neutral-700 active:bg-neutral-800 dark:bg-neutral-100/70 dark:text-neutral-900 dark:hover:bg-neutral-100 dark:active:bg-neutral-200" 287 + > 288 + <span class="iconify lucide--eye-off text-base"></span> 289 + </button> 290 + 291 + 292 + <Show when={hide()}> 293 + <button 294 + onclick={() => setHide(false)} 295 + class="flex items-center gap-1 rounded-md bg-neutral-200 px-2 py-1.5 text-sm transition-colors hover:bg-neutral-300 active:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 296 + > 297 + <span class="iconify lucide--image"></span> 298 + <span class="font-sans">Show media</span>
+1 -5
src/components/dropdown.tsx
··· 72 ); 73 }; 74 75 - export const ActionMenu = (props: { 76 - label: string; 77 - icon: string; 78 - onClick: () => void; 79 - }) => { 80 const ctx = useContext(MenuContext); 81 82 return (
··· 72 ); 73 }; 74 75 + export const ActionMenu = (props: { label: string; icon: string; onClick: () => void }) => { 76 const ctx = useContext(MenuContext); 77 78 return (
+7 -8
src/views/car/explore.tsx
··· 528 529 530 531 - 532 - 533 - 534 - 535 - 536 - 537 - 538 539 540 ··· 544 </Show> 545 </button> 546 } 547 - previewClass="max-h-80 w-max max-w-sm text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg" 548 > 549 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 550 </HoverCard>
··· 528 529 530 531 + }} 532 + class="flex w-full items-baseline gap-1 text-left" 533 + > 534 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 535 + {entry.key} 536 + </span> 537 + <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 538 539 540 ··· 544 </Show> 545 </button> 546 } 547 > 548 <JSONValue data={entry.record} repo={props.archive.did} truncate hideBlobs /> 549 </HoverCard>
+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 + };
+46 -1
src/components/modal.tsx
··· 5 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 } 9 10 export const Modal = (props: ModalProps) => { ··· 12 <Show when={props.open}> 13 <div 14 data-modal 15 - class="fixed inset-0 z-50 h-full max-h-none w-full max-w-none bg-transparent text-neutral-900 dark:text-neutral-200" 16 classList={{ 17 "pointer-events-none": props.nonBlocking, 18 }} 19 ref={(node) => { 20 const handleEscape = (e: KeyboardEvent) => {
··· 5 onClose?: () => void; 6 closeOnClick?: boolean; 7 nonBlocking?: boolean; 8 + alignTop?: boolean; 9 + contentClass?: string; 10 } 11 12 export const Modal = (props: ModalProps) => { ··· 14 <Show when={props.open}> 15 <div 16 data-modal 17 + class="fixed inset-0 z-50 flex h-full max-h-none w-full max-w-none justify-center bg-transparent text-neutral-900 dark:text-neutral-200" 18 classList={{ 19 "pointer-events-none": props.nonBlocking, 20 + "items-start pt-18": props.alignTop, 21 + "items-center": !props.alignTop, 22 }} 23 ref={(node) => { 24 const handleEscape = (e: KeyboardEvent) => { 25 + 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + } 56 + }} 57 + > 58 + <div 59 + class={`transition-all starting:scale-95 starting:opacity-0 ${props.contentClass ?? ""}`} 60 + > 61 + {props.children} 62 + </div> 63 + </div> 64 + </Show> 65 + );
+189 -6
src/views/pds.tsx
··· 78 79 80 81 - 82 <span class="iconify lucide--info text-neutral-600 dark:text-neutral-400"></span> 83 </button> 84 - <Modal open={openInfo()} onClose={() => setOpenInfo(false)}> 85 - <div class="dark:bg-dark-300 dark:shadow-dark-700 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
··· 78 79 80 81 + > 82 <span class="iconify lucide--info text-neutral-600 dark:text-neutral-400"></span> 83 </button> 84 + <Modal 85 + open={openInfo()} 86 + onClose={() => setOpenInfo(false)} 87 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-max max-w-[90vw] rounded-lg border-[0.5px] border-neutral-300 bg-white p-3 shadow-md sm:max-w-xl dark:border-neutral-700" 88 + > 89 + <div class="mb-2 flex items-center justify-between gap-4"> 90 + <p class="truncate font-semibold">{repo.did}</p> 91 + <button 92 + onclick={() => setOpenInfo(false)} 93 + class="flex shrink-0 items-center rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 active:bg-neutral-200 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 94 + > 95 + <span class="iconify lucide--x"></span> 96 + </button> 97 + </div> 98 + <div class="grid grid-cols-[auto_1fr] items-baseline gap-x-1 gap-y-0.5 text-sm"> 99 + <span class="font-medium">Head:</span> 100 + <span class="wrap-anywhere text-neutral-700 dark:text-neutral-300">{repo.head}</span> 101 + 102 + <Show when={TID.validate(repo.rev)}> 103 + <span class="font-medium">Rev:</span> 104 + <div class="flex gap-1"> 105 + <span class="text-neutral-700 dark:text-neutral-300">{repo.rev}</span> 106 + <span class="text-neutral-600 dark:text-neutral-400">ยท</span> 107 + <span class="text-neutral-600 dark:text-neutral-400"> 108 + {localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000)} 109 + </span> 110 + </div> 111 + </Show> 112 + 113 + <Show when={repo.active !== undefined}> 114 + <span class="font-medium">Active:</span> 115 + <span 116 + class={`iconify self-center ${ 117 + repo.active ? 118 + "lucide--check text-green-500 dark:text-green-400" 119 + : "lucide--x text-red-500 dark:text-red-400" 120 + }`} 121 + ></span> 122 + </Show> 123 + 124 + <Show when={repo.status}> 125 + <span class="font-medium">Status:</span> 126 + <span class="text-neutral-700 dark:text-neutral-300">{repo.status}</span> 127 + </Show> 128 + </div> 129 + </Modal> 130 + </div> 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 pt-2 pb-4"> 254 + <div class="flex flex-col items-center gap-1 pb-2"> 255 + <p>{repos()?.length} loaded</p> 256 + <Show when={cursor()}> 257 + <Button 258 + onClick={() => refetch()} 259 + disabled={response.loading} 260 + classList={{ "w-20 justify-center": true }} 261 + > 262 + <Show 263 + when={!response.loading} 264 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 265 + > 266 + Load more 267 + </Show> 268 + </Button> 269 + </Show> 270 + </div> 271 + </div>
+363 -315
src/views/stream/index.tsx
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 import { JSONValue } from "../../components/json"; 7 - import { StickyOverlay } from "../../components/sticky"; 8 import { TextInput } from "../../components/text-input"; 9 import { StreamStats, StreamStatsPanel } from "./stats"; 10 11 const LIMIT = 20; 12 - type Parameter = { name: string; param: string | string[] | undefined }; 13 14 - const StreamView = () => { 15 const [searchParams, setSearchParams] = useSearchParams(); 16 - const [parameters, setParameters] = createSignal<Parameter[]>([]); 17 - const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 18 - const [records, setRecords] = createSignal<Array<any>>([]); 19 const [connected, setConnected] = createSignal(false); 20 const [paused, setPaused] = createSignal(false); 21 const [notice, setNotice] = createSignal(""); 22 23 24 25 26 27 28 29 30 ··· 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 ··· 67 68 69 70 71 72 73 74 75 ··· 77 78 79 80 81 82 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 - 126 - 127 - 128 - 129 - 130 - 131 - 132 - 133 - 134 - 135 - 136 - 137 - 138 - 139 - 140 - 141 - 142 - 143 - 144 - 145 - 146 - 147 - 148 - 149 - 150 - 151 - 152 - 153 - 154 - 155 - 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 - 164 - 165 - 166 - 167 - 168 - 169 - 170 - 171 - 172 - 173 - 174 - 175 - 176 - 177 - 178 - 179 - 180 - 181 - 182 - 183 - 184 - 185 - 186 - 187 - 188 - 189 - 190 - 191 - 192 - 193 - 194 - 195 - 196 - 197 - 198 - 199 - 200 - 201 - 202 - 203 - 204 - 205 - 206 - 207 - 208 - 209 - 210 - 211 - 212 - 213 - 214 - 215 - 216 - 217 - 218 - 219 - 220 - 221 - 222 - 223 - 224 - 225 - 226 - 227 - 228 - 229 - 230 - 231 - 232 - 233 - 234 - 235 - 236 - 237 - 238 - 239 - 240 - 241 - 242 - 243 - 244 - 245 - 246 - 247 - 248 - 249 - 250 - 251 - 252 - 253 - 254 - 255 - 256 - 257 - 258 - 259 - 260 - 261 - 262 - 263 - 264 - 265 266 return ( 267 <> 268 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 269 - <div class="flex w-full flex-col items-center"> 270 <div class="flex gap-4 font-medium"> 271 - <A 272 - class="flex items-center gap-1 border-b-2" 273 - 274 - 275 - 276 - 277 - 278 - 279 - 280 - 281 - 282 - 283 284 - </A> 285 </div> 286 <Show when={!connected()}> 287 - <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 288 <label class="flex items-center justify-end gap-x-1"> 289 - <span class="min-w-20">Instance</span> 290 <TextInput 291 292 - 293 - 294 - 295 - 296 - 297 - 298 - 299 - 300 - 301 - 302 - 303 - 304 - 305 - 306 - 307 - 308 - 309 - 310 - 311 - 312 - 313 - 314 - 315 - 316 - 317 - 318 - 319 - 320 - 321 - 322 - 323 - 324 - 325 - 326 - 327 - 328 - 329 - 330 - 331 - 332 - 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - 345 - 346 - 347 - 348 - 349 - 350 </form> 351 </Show> 352 <Show when={connected()}> 353 - <StickyOverlay> 354 - <div class="flex w-full flex-col gap-2 p-1"> 355 - <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 356 - <div class="font-semibold">Parameters</div> 357 - <For each={parameters()}> 358 - {(param) => ( 359 - <Show when={param.param}> 360 - <div class="text-sm"> 361 - <div class="text-xs text-neutral-500 dark:text-neutral-400"> 362 - {param.name} 363 - </div> 364 - <div class="text-neutral-700 dark:text-neutral-300">{param.param}</div> 365 - </div> 366 - </Show> 367 - )} 368 - </For> 369 - </div> 370 - <StreamStatsPanel stats={stats()} currentTime={currentTime()} /> 371 - <div class="flex justify-end gap-2"> 372 - <button 373 - type="button" 374 - ontouchstart={(e) => { 375 - e.preventDefault(); 376 - requestAnimationFrame(() => togglePause()); 377 - }} 378 - onclick={togglePause} 379 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 380 - > 381 - {paused() ? "Resume" : "Pause"} 382 - </button> 383 - <button 384 - type="button" 385 - ontouchstart={(e) => { 386 - e.preventDefault(); 387 - requestAnimationFrame(() => disconnect()); 388 - }} 389 - onclick={disconnect} 390 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 391 - > 392 - Disconnect 393 - </button> 394 - </div> 395 </div> 396 - </StickyOverlay> 397 </Show> 398 <Show when={notice().length}> 399 <div class="text-red-500 dark:text-red-400">{notice()}</div> 400 </Show> 401 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:text-sm md:w-3xl"> 402 - <For each={records().toReversed()}> 403 - {(rec) => ( 404 - <div class="pb-2"> 405 - <JSONValue data={rec} repo={rec.did ?? rec.repo} hideBlobs /> 406 - </div> 407 - )} 408 - </For> 409 - </div> 410 </div> 411 </> 412 );
··· 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 import { Button } from "../../components/button"; 6 + import DidHoverCard from "../../components/hover-card/did"; 7 import { JSONValue } from "../../components/json"; 8 import { TextInput } from "../../components/text-input"; 9 + import { addToClipboard } from "../../utils/copy"; 10 + import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 11 import { StreamStats, StreamStatsPanel } from "./stats"; 12 13 const LIMIT = 20; 14 15 + const TYPE_COLORS: Record<string, string> = { 16 + create: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", 17 + update: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", 18 + delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", 19 + identity: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", 20 + account: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", 21 + sync: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", 22 + }; 23 + 24 + const StreamRecordItem = (props: { record: any; streamType: StreamType }) => { 25 + const [expanded, setExpanded] = createSignal(false); 26 + const config = () => STREAM_CONFIGS[props.streamType]; 27 + const info = () => config().parseRecord(props.record); 28 + 29 + const displayType = () => { 30 + const i = info(); 31 + return i.type === "commit" || i.type === "link" ? i.action : i.type; 32 + }; 33 + 34 + const copyRecord = (e: MouseEvent) => { 35 + e.stopPropagation(); 36 + addToClipboard(JSON.stringify(props.record, null, 2)); 37 + }; 38 + 39 + return ( 40 + <div class="flex flex-col gap-2"> 41 + <div class="flex items-start gap-1"> 42 + <button 43 + type="button" 44 + onclick={() => setExpanded(!expanded())} 45 + class="dark:hover:bg-dark-200 flex min-w-0 flex-1 items-start gap-2 rounded p-1 text-left hover:bg-neutral-200/70" 46 + > 47 + <span class="mt-0.5 shrink-0 text-neutral-400 dark:text-neutral-500"> 48 + {expanded() ? 49 + <span class="iconify lucide--chevron-down"></span> 50 + : <span class="iconify lucide--chevron-right"></span>} 51 + </span> 52 + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> 53 + <div class="flex items-center gap-x-1.5 sm:gap-x-2"> 54 + <span 55 + class={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${TYPE_COLORS[displayType()!] || "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300"}`} 56 + > 57 + {displayType()} 58 + </span> 59 + <Show when={info().collection && info().collection !== info().type}> 60 + <span class="min-w-0 truncate text-neutral-600 dark:text-neutral-300"> 61 + {info().collection} 62 + </span> 63 + </Show> 64 + <Show when={info().rkey}> 65 + <span class="truncate text-neutral-400 dark:text-neutral-500">{info().rkey}</span> 66 + </Show> 67 + </div> 68 + <div class="flex flex-col gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:flex-row sm:items-center dark:text-neutral-400"> 69 + <Show when={info().did}> 70 + <span class="w-fit" onclick={(e) => e.stopPropagation()}> 71 + <DidHoverCard newTab did={info().did!} /> 72 + </span> 73 + </Show> 74 + <Show when={info().time}> 75 + <span>{info().time}</span> 76 + </Show> 77 + </div> 78 + </div> 79 + </button> 80 + <Show when={expanded()}> 81 + <button 82 + type="button" 83 + onclick={copyRecord} 84 + class="flex size-6 shrink-0 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-600 active:bg-neutral-300 sm:size-7 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:active:bg-neutral-600" 85 + > 86 + <span class="iconify lucide--copy"></span> 87 + </button> 88 + </Show> 89 + </div> 90 + <Show when={expanded()}> 91 + <div class="ml-6.5"> 92 + <div class="w-full text-xs wrap-anywhere whitespace-pre-wrap md:w-2xl"> 93 + <JSONValue newTab data={props.record} repo={info().did ?? ""} hideBlobs /> 94 + </div> 95 + </div> 96 + </Show> 97 + </div> 98 + ); 99 + }; 100 + 101 + export const StreamView = () => { 102 const [searchParams, setSearchParams] = useSearchParams(); 103 + const streamType = getStreamType(useLocation().pathname); 104 + const config = () => STREAM_CONFIGS[streamType]; 105 + 106 + const [records, setRecords] = createSignal<any[]>([]); 107 const [connected, setConnected] = createSignal(false); 108 const [paused, setPaused] = createSignal(false); 109 const [notice, setNotice] = createSignal(""); 110 + const [parameters, setParameters] = createSignal<{ name: string; value?: string }[]>([]); 111 + const [stats, setStats] = createSignal<StreamStats>({ 112 + totalEvents: 0, 113 + eventsPerSecond: 0, 114 115 + collections: {}, 116 + }); 117 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 118 119 + let socket: WebSocket; 120 + let firehose: Firehose; 121 + let formRef!: HTMLFormElement; 122 123 + let rafId: number | null = null; 124 + let statsIntervalId: number | null = null; 125 + let statsUpdateIntervalId: number | null = null; 126 + let currentSecondEventCount = 0; 127 + let totalEventsCount = 0; 128 + let eventTypesMap: Record<string, number> = {}; 129 + let collectionsMap: Record<string, number> = {}; 130 131 + const addRecord = (record: any) => { 132 + currentSecondEventCount++; 133 + totalEventsCount++; 134 135 + const rawEventType = record.kind || record.$type || "unknown"; 136 + const eventType = rawEventType.includes("#") ? rawEventType.split("#").pop() : rawEventType; 137 + eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 138 139 + if (eventType !== "account" && eventType !== "identity") { 140 + const collection = 141 + record.commit?.collection || 142 + record.op?.path?.split("/")[0] || 143 + record.link?.source || 144 + "unknown"; 145 + collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 146 + } 147 148 149 ··· 155 156 157 158 + }; 159 160 + const disconnect = () => { 161 + if (!config().useFirehoseLib) socket?.close(); 162 + else firehose?.close(); 163 164 + if (rafId !== null) { 165 + cancelAnimationFrame(rafId); 166 + rafId = null; 167 168 169 170 171 172 173 + clearInterval(statsUpdateIntervalId); 174 + statsUpdateIntervalId = null; 175 + } 176 177 + pendingRecords = []; 178 + totalEventsCount = 0; 179 + eventTypesMap = {}; 180 + collectionsMap = {}; 181 + setConnected(false); 182 + setPaused(false); 183 + setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 184 + }; 185 186 + const connectStream = async (formData: FormData) => { 187 + setNotice(""); 188 + if (connected()) { 189 + disconnect(); 190 191 + } 192 + setRecords([]); 193 194 + const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 195 + const url = config().buildUrl(instance, formData); 196 197 + // Save all form fields to URL params 198 + const params: Record<string, string | undefined> = { instance }; 199 + config().fields.forEach((field) => { 200 + params[field.searchParam] = formData.get(field.name)?.toString(); 201 + }); 202 + setSearchParams(params); 203 204 + // Build parameters display 205 + setParameters([ 206 + { name: "Instance", value: instance }, 207 + ...config() 208 + .fields.filter((f) => f.type !== "checkbox") 209 + .map((f) => ({ name: f.label, value: formData.get(f.name)?.toString() })), 210 + ...config() 211 + .fields.filter((f) => f.type === "checkbox" && formData.get(f.name) === "on") 212 + .map((f) => ({ name: f.label, value: "on" })), 213 + ]); 214 215 + setConnected(true); 216 + const now = Date.now(); 217 + setCurrentTime(now); 218 219 + totalEventsCount = 0; 220 + eventTypesMap = {}; 221 + collectionsMap = {}; 222 223 224 ··· 234 235 236 237 + })); 238 + }, 50); 239 240 + statsIntervalId = window.setInterval(() => { 241 + setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 242 + currentSecondEventCount = 0; 243 + setCurrentTime(Date.now()); 244 + }, 1000); 245 246 + if (!config().useFirehoseLib) { 247 + socket = new WebSocket(url); 248 + socket.addEventListener("message", (event) => { 249 + const rec = JSON.parse(event.data); 250 + const isFilteredEvent = rec.kind === "account" || rec.kind === "identity"; 251 + if (!isFilteredEvent || streamType !== "jetstream" || searchParams.allEvents === "on") 252 + addRecord(rec); 253 + }); 254 + socket.addEventListener("error", () => { 255 256 + disconnect(); 257 + }); 258 + } else { 259 + const cursor = formData.get("cursor")?.toString(); 260 + firehose = new Firehose({ 261 + relay: url, 262 + cursor: cursor, 263 264 265 ··· 267 268 269 270 + }); 271 + firehose.on("commit", (commit) => { 272 + for (const op of commit.ops) { 273 + addRecord({ 274 + $type: commit.$type, 275 + repo: commit.repo, 276 + seq: commit.seq, 277 278 + rev: commit.rev, 279 + since: commit.since, 280 + op: op, 281 + }); 282 + } 283 + }); 284 + firehose.on("identity", (identity) => addRecord(identity)); 285 + firehose.on("account", (account) => addRecord(account)); 286 + firehose.on("sync", (sync) => { 287 + addRecord({ 288 + $type: sync.$type, 289 + did: sync.did, 290 + rev: sync.rev, 291 + seq: sync.seq, 292 + time: sync.time, 293 + }); 294 + }); 295 + firehose.start(); 296 + } 297 + }; 298 299 + onMount(() => { 300 + if (searchParams.instance) { 301 + const formData = new FormData(); 302 + formData.append("instance", searchParams.instance.toString()); 303 + config().fields.forEach((field) => { 304 + const value = searchParams[field.searchParam]; 305 + if (value) formData.append(field.name, value.toString()); 306 + }); 307 + connectStream(formData); 308 + } 309 + }); 310 311 + onCleanup(() => { 312 + socket?.close(); 313 + firehose?.close(); 314 + if (rafId !== null) cancelAnimationFrame(rafId); 315 + if (statsIntervalId !== null) clearInterval(statsIntervalId); 316 + if (statsUpdateIntervalId !== null) clearInterval(statsUpdateIntervalId); 317 + }); 318 319 return ( 320 <> 321 + <Title>{config().label} - PDSls</Title> 322 + <div class="flex w-full flex-col items-center gap-2"> 323 + {/* Tab Navigation */} 324 <div class="flex gap-4 font-medium"> 325 + <For each={STREAM_TYPES}> 326 + {(type) => ( 327 + <A 328 + class="flex items-center gap-1 border-b-2" 329 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 330 + href={`/${type}`} 331 + > 332 + {STREAM_CONFIGS[type].label} 333 + </A> 334 + )} 335 + </For> 336 + </div> 337 338 + {/* Stream Description */} 339 + <div class="w-full px-2 text-center"> 340 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{config().description}</p> 341 </div> 342 + 343 + {/* Connection Form */} 344 <Show when={!connected()}> 345 + <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 346 <label class="flex items-center justify-end gap-x-1"> 347 + <span class="min-w-21 select-none">Instance</span> 348 <TextInput 349 + name="instance" 350 + value={searchParams.instance ?? config().defaultInstance} 351 + class="grow" 352 + /> 353 + </label> 354 + 355 + <For each={config().fields}> 356 + {(field) => ( 357 + <label class="flex items-center justify-end gap-x-1"> 358 + <Show when={field.type === "checkbox"}> 359 + <input 360 + type="checkbox" 361 + name={field.name} 362 + id={field.name} 363 + checked={searchParams[field.searchParam] === "on"} 364 + /> 365 + </Show> 366 + <span class="min-w-21 select-none">{field.label}</span> 367 + <Show when={field.type === "textarea"}> 368 + <textarea 369 + name={field.name} 370 + spellcheck={false} 371 + placeholder={field.placeholder} 372 + value={(searchParams[field.searchParam] as string) ?? ""} 373 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 374 + /> 375 + </Show> 376 + <Show when={field.type === "text"}> 377 + <TextInput 378 + name={field.name} 379 + placeholder={field.placeholder} 380 + value={(searchParams[field.searchParam] as string) ?? ""} 381 + class="grow" 382 + /> 383 + </Show> 384 + </label> 385 + )} 386 + </For> 387 388 + <div class="flex justify-end gap-2"> 389 + <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 390 + </div> 391 </form> 392 </Show> 393 + 394 + {/* Connected State */} 395 <Show when={connected()}> 396 + <div class="flex w-full flex-col gap-2 p-2"> 397 + <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 398 + <div class="font-semibold">Parameters</div> 399 + <For each={parameters()}> 400 + {(param) => ( 401 + <Show when={param.value}> 402 + <div class="text-sm"> 403 + <div class="text-xs text-neutral-500 dark:text-neutral-400">{param.name}</div> 404 + <div class="text-neutral-700 dark:text-neutral-300">{param.value}</div> 405 + </div> 406 + </Show> 407 + )} 408 + </For> 409 + </div> 410 + <StreamStatsPanel 411 + stats={stats()} 412 + currentTime={currentTime()} 413 + streamType={streamType} 414 + showAllEvents={searchParams.allEvents === "on"} 415 + /> 416 + <div class="flex justify-end gap-2"> 417 + <Button 418 + ontouchstart={(e) => { 419 + e.preventDefault(); 420 + requestAnimationFrame(() => setPaused(!paused())); 421 + }} 422 + onClick={() => setPaused(!paused())} 423 + > 424 + {paused() ? "Resume" : "Pause"} 425 + </Button> 426 + <Button 427 + ontouchstart={(e) => { 428 + e.preventDefault(); 429 + requestAnimationFrame(() => disconnect()); 430 + }} 431 + onClick={disconnect} 432 + > 433 + Disconnect 434 + </Button> 435 </div> 436 + </div> 437 </Show> 438 + 439 + {/* Error Notice */} 440 <Show when={notice().length}> 441 <div class="text-red-500 dark:text-red-400">{notice()}</div> 442 </Show> 443 + 444 + {/* Records List */} 445 + <Show when={connected() || records().length > 0}> 446 + <div class="flex min-h-280 w-full flex-col gap-2 font-mono text-xs [overflow-anchor:auto] sm:text-sm"> 447 + <For each={records().toReversed()}> 448 + {(rec) => ( 449 + <div class="[overflow-anchor:none]"> 450 + <StreamRecordItem record={rec} streamType={streamType} /> 451 + </div> 452 + )} 453 + </For> 454 + <div class="h-px [overflow-anchor:auto]" /> 455 + </div> 456 + </Show> 457 </div> 458 </> 459 ); 460 + };
+221
src/views/stream/config.ts
···
··· 1 + import { localDateFromTimestamp } from "../../utils/date"; 2 + 3 + export type StreamType = "jetstream" | "firehose" | "spacedust"; 4 + 5 + export type FormField = { 6 + name: string; 7 + label: string; 8 + type: "text" | "textarea" | "checkbox"; 9 + placeholder?: string; 10 + searchParam: string; 11 + }; 12 + 13 + export type RecordInfo = { 14 + type: string; 15 + did?: string; 16 + collection?: string; 17 + rkey?: string; 18 + action?: string; 19 + time?: string; 20 + }; 21 + 22 + export type StreamConfig = { 23 + label: string; 24 + description: string; 25 + icon: string; 26 + defaultInstance: string; 27 + fields: FormField[]; 28 + useFirehoseLib: boolean; 29 + buildUrl: (instance: string, formData: FormData) => string; 30 + parseRecord: (record: any) => RecordInfo; 31 + showEventTypes: boolean; 32 + collectionsLabel: string; 33 + }; 34 + 35 + export const STREAM_CONFIGS: Record<StreamType, StreamConfig> = { 36 + jetstream: { 37 + label: "Jetstream", 38 + description: "A simplified event stream with support for collection and DID filtering.", 39 + icon: "lucide--radio-tower", 40 + defaultInstance: "wss://jetstream1.us-east.bsky.network/subscribe", 41 + useFirehoseLib: false, 42 + showEventTypes: true, 43 + collectionsLabel: "Top Collections", 44 + fields: [ 45 + { 46 + name: "collections", 47 + label: "Collections", 48 + type: "textarea", 49 + placeholder: "Comma-separated list of collections", 50 + searchParam: "collections", 51 + }, 52 + { 53 + name: "dids", 54 + label: "DIDs", 55 + type: "textarea", 56 + placeholder: "Comma-separated list of DIDs", 57 + searchParam: "dids", 58 + }, 59 + { 60 + name: "cursor", 61 + label: "Cursor", 62 + type: "text", 63 + placeholder: "Leave empty for live-tail", 64 + searchParam: "cursor", 65 + }, 66 + { 67 + name: "allEvents", 68 + label: "Show account and identity events", 69 + type: "checkbox", 70 + searchParam: "allEvents", 71 + }, 72 + ], 73 + buildUrl: (instance, formData) => { 74 + let url = instance + "?"; 75 + 76 + const collections = formData.get("collections")?.toString().split(","); 77 + collections?.forEach((c) => { 78 + if (c.trim().length) url += `wantedCollections=${c.trim()}&`; 79 + }); 80 + 81 + const dids = formData.get("dids")?.toString().split(","); 82 + dids?.forEach((d) => { 83 + if (d.trim().length) url += `wantedDids=${d.trim()}&`; 84 + }); 85 + 86 + const cursor = formData.get("cursor")?.toString(); 87 + if (cursor?.length) url += `cursor=${cursor}&`; 88 + 89 + return url.replace(/[&?]$/, ""); 90 + }, 91 + parseRecord: (rec) => { 92 + const collection = rec.commit?.collection || rec.kind; 93 + const rkey = rec.commit?.rkey; 94 + const action = rec.commit?.operation; 95 + const time = rec.time_us ? localDateFromTimestamp(rec.time_us / 1000) : undefined; 96 + return { type: rec.kind, did: rec.did, collection, rkey, action, time }; 97 + }, 98 + }, 99 + 100 + firehose: { 101 + label: "Firehose", 102 + description: "The raw event stream from a relay or PDS.", 103 + icon: "lucide--rss", 104 + defaultInstance: "wss://bsky.network", 105 + useFirehoseLib: true, 106 + showEventTypes: true, 107 + collectionsLabel: "Top Collections", 108 + fields: [ 109 + { 110 + name: "cursor", 111 + label: "Cursor", 112 + type: "text", 113 + placeholder: "Leave empty for live-tail", 114 + searchParam: "cursor", 115 + }, 116 + ], 117 + buildUrl: (instance, _formData) => { 118 + let url = instance; 119 + url = url.replace("/xrpc/com.atproto.sync.subscribeRepos", ""); 120 + if (!(url.startsWith("wss://") || url.startsWith("ws://"))) { 121 + url = "wss://" + url; 122 + } 123 + return url; 124 + }, 125 + parseRecord: (rec) => { 126 + const type = rec.$type?.split("#").pop() || rec.$type; 127 + const did = rec.repo ?? rec.did; 128 + const pathParts = rec.op?.path?.split("/") || []; 129 + const collection = pathParts[0]; 130 + const rkey = pathParts[1]; 131 + const time = rec.time ? localDateFromTimestamp(Date.parse(rec.time)) : undefined; 132 + return { type, did, collection, rkey, action: rec.op?.action, time }; 133 + }, 134 + }, 135 + 136 + spacedust: { 137 + label: "Spacedust", 138 + description: "A stream of links showing interactions across the network.", 139 + icon: "lucide--link", 140 + defaultInstance: "wss://spacedust.microcosm.blue/subscribe", 141 + useFirehoseLib: false, 142 + showEventTypes: false, 143 + collectionsLabel: "Top Sources", 144 + fields: [ 145 + { 146 + name: "sources", 147 + label: "Sources", 148 + type: "textarea", 149 + placeholder: "e.g. app.bsky.graph.follow:subject", 150 + searchParam: "sources", 151 + }, 152 + { 153 + name: "subjectDids", 154 + label: "Subject DIDs", 155 + type: "textarea", 156 + placeholder: "Comma-separated list of DIDs", 157 + searchParam: "subjectDids", 158 + }, 159 + { 160 + name: "subjects", 161 + label: "Subjects", 162 + type: "textarea", 163 + placeholder: "Comma-separated list of AT URIs", 164 + searchParam: "subjects", 165 + }, 166 + { 167 + name: "instant", 168 + label: "Instant mode (bypass 21s delay buffer)", 169 + type: "checkbox", 170 + searchParam: "instant", 171 + }, 172 + ], 173 + buildUrl: (instance, formData) => { 174 + let url = instance + "?"; 175 + 176 + const sources = formData.get("sources")?.toString().split(","); 177 + sources?.forEach((s) => { 178 + if (s.trim().length) url += `wantedSources=${s.trim()}&`; 179 + }); 180 + 181 + const subjectDids = formData.get("subjectDids")?.toString().split(","); 182 + subjectDids?.forEach((d) => { 183 + if (d.trim().length) url += `wantedSubjectDids=${d.trim()}&`; 184 + }); 185 + 186 + const subjects = formData.get("subjects")?.toString().split(","); 187 + subjects?.forEach((s) => { 188 + if (s.trim().length) url += `wantedSubjects=${encodeURIComponent(s.trim())}&`; 189 + }); 190 + 191 + const instant = formData.get("instant")?.toString(); 192 + if (instant === "on") url += `instant=true&`; 193 + 194 + return url.replace(/[&?]$/, ""); 195 + }, 196 + parseRecord: (rec) => { 197 + const source = rec.link?.source; 198 + const sourceRecord = rec.link?.source_record; 199 + const uriParts = sourceRecord?.replace("at://", "").split("/") || []; 200 + const did = uriParts[0]; 201 + const collection = uriParts[1]; 202 + const rkey = uriParts[2]; 203 + return { 204 + type: rec.kind, 205 + did, 206 + collection: source || collection, 207 + rkey, 208 + action: rec.link?.operation, 209 + time: undefined, 210 + }; 211 + }, 212 + }, 213 + }; 214 + 215 + export const STREAM_TYPES = Object.keys(STREAM_CONFIGS) as StreamType[]; 216 + 217 + export const getStreamType = (pathname: string): StreamType => { 218 + if (pathname === "/firehose") return "firehose"; 219 + if (pathname === "/spacedust") return "spacedust"; 220 + return "jetstream"; 221 + };
+14 -14
src/layout.tsx
··· 9 import { NavBar } from "./components/navbar.jsx"; 10 import { NotificationContainer } from "./components/notification.jsx"; 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 - import { Search, SearchButton, showSearch } from "./components/search.jsx"; 13 import { themeEvent } from "./components/theme.jsx"; 14 import { resolveHandle } from "./utils/api.js"; 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 </Show> 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 128 <header 129 - class={`dark:shadow-dark-700 mb-3 flex w-full items-center justify-between rounded-xl border-[0.5px] border-neutral-300 bg-neutral-50 bg-size-[95%] bg-right bg-no-repeat p-2 pl-3 shadow-xs [--header-bg:#fafafa] [--trans-blue:#5BCEFA90] [--trans-pink:#F5A9B890] [--trans-white:#FFFFFF90] dark:border-neutral-700 dark:bg-neutral-800 dark:[--header-bg:#262626] dark:[--trans-blue:#5BCEFAa0] dark:[--trans-pink:#F5A9B8a0] dark:[--trans-white:#FFFFFFa0] ${localStorage.getItem("hrt") === "true" ? "bg-[linear-gradient(to_left,transparent_10%,var(--header-bg)_85%),linear-gradient(to_bottom,var(--trans-blue)_0%,var(--trans-blue)_20%,var(--trans-pink)_20%,var(--trans-pink)_40%,var(--trans-white)_40%,var(--trans-white)_60%,var(--trans-pink)_60%,var(--trans-pink)_80%,var(--trans-blue)_80%,var(--trans-blue)_100%)]" : ""}`} 130 style={{ 131 "background-image": 132 props.params.repo && props.params.repo in headers ? ··· 149 /> 150 </Show> 151 </A> 152 - <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 px-1 py-0.5 dark:bg-neutral-800/60"> 153 - <SearchButton /> 154 <Show when={agent()}> 155 <RecordEditor create={true} scope="create" /> 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 164 165 ··· 170 </div> 171 </header> 172 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 173 - <Show when={showSearch() || location.pathname === "/"}> 174 - <Search /> 175 - </Show> 176 <Show when={props.params.pds}> 177 <NavBar params={props.params} /> 178 </Show>
··· 9 import { NavBar } from "./components/navbar.jsx"; 10 import { NotificationContainer } from "./components/notification.jsx"; 11 import { PermissionPromptContainer } from "./components/permission-prompt.jsx"; 12 + import { Search, SearchButton } from "./components/search.jsx"; 13 import { themeEvent } from "./components/theme.jsx"; 14 import { resolveHandle } from "./utils/api.js"; 15 import { plcDirectory } from "./views/settings.jsx"; ··· 126 </Show> 127 <div id="main" class="mx-auto mb-8 flex max-w-lg flex-col items-center p-3"> 128 <header 129 + class={`dark:shadow-dark-700 mb-3 flex h-13 w-full items-center justify-between rounded-xl border-[0.5px] border-neutral-300 bg-neutral-50 bg-size-[95%] bg-right bg-no-repeat p-2 pl-3 shadow-xs [--header-bg:#fafafa] [--trans-blue:#5BCEFA90] [--trans-pink:#F5A9B890] [--trans-white:#FFFFFF90] dark:border-neutral-700 dark:bg-neutral-800 dark:[--header-bg:#262626] dark:[--trans-blue:#5BCEFAa0] dark:[--trans-pink:#F5A9B8a0] dark:[--trans-white:#FFFFFFa0] ${localStorage.getItem("hrt") === "true" ? "bg-[linear-gradient(to_left,transparent_10%,var(--header-bg)_85%),linear-gradient(to_bottom,var(--trans-blue)_0%,var(--trans-blue)_20%,var(--trans-pink)_20%,var(--trans-pink)_40%,var(--trans-white)_40%,var(--trans-white)_60%,var(--trans-pink)_60%,var(--trans-pink)_80%,var(--trans-blue)_80%,var(--trans-blue)_100%)]" : ""}`} 130 style={{ 131 "background-image": 132 props.params.repo && props.params.repo in headers ? ··· 149 /> 150 </Show> 151 </A> 152 + <div class="relative flex items-center gap-0.5 rounded-lg bg-neutral-50/60 p-1 dark:bg-neutral-800/60"> 153 + <div class="mr-1"> 154 + <SearchButton /> 155 + </div> 156 <Show when={agent()}> 157 <RecordEditor create={true} scope="create" /> 158 + </Show> 159 + <AccountManager /> 160 + <MenuProvider> 161 + <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-md p-1.5"> 162 + <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 163 + <NavMenu href="/firehose" label="Firehose" icon="lucide--rss" /> 164 + <NavMenu href="/spacedust" label="Spacedust" icon="lucide--orbit" /> 165 166 167 ··· 172 </div> 173 </header> 174 <div class="flex w-full flex-col items-center gap-3 text-pretty"> 175 + <Search /> 176 <Show when={props.params.pds}> 177 <NavBar params={props.params} /> 178 </Show>
+26 -29
src/components/create/index.tsx
··· 92 93 94 95 96 97 ··· 265 266 267 268 269 270 ··· 330 331 332 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - 345 - 346 - 347 - 348 - 349 - 350 - 351 - 352 - 353 - 354 - 355 - 356 - 357 - 358 - 359 - 360 361 362 ··· 463 <button 464 class={ 465 hasPermission() ? 466 - `flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-lg" : "rounded-sm"}` 467 - : `flex items-center p-1.5 opacity-40 ${props.create ? "rounded-lg" : "rounded-sm"}` 468 } 469 onclick={() => { 470 if (hasPermission()) {
··· 92 93 94 95 + embed: { 96 + $type: "app.bsky.embed.external", 97 + external: { 98 + uri: "https://pds.ls", 99 + title: "PDSls", 100 + description: "Browse the public data on atproto", 101 + }, 102 103 104 ··· 272 273 274 275 + <div class="flex flex-wrap items-center gap-1 text-sm"> 276 + <span>at://</span> 277 + <select 278 + class="dark:bg-dark-100 max-w-40 truncate rounded-md border border-neutral-200 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 279 + name="repo" 280 + id="repo" 281 + > 282 283 284 ··· 344 345 346 347 + </Show> 348 + <div class="flex justify-between gap-2"> 349 + <div class="relative" ref={insertMenuRef}> 350 + <Button onClick={() => setOpenInsertMenu(!openInsertMenu())}> 351 + <span class="iconify lucide--plus"></span> 352 + <span>Add</span> 353 + </Button> 354 + <Show when={openInsertMenu()}> 355 + <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute bottom-full left-0 z-10 mb-1 flex w-40 flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 shadow-md dark:border-neutral-700"> 356 + <MenuItem 357 358 359 ··· 460 <button 461 class={ 462 hasPermission() ? 463 + `flex items-center p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600 ${props.create ? "rounded-md" : "rounded-sm"}` 464 + : `flex items-center p-1.5 opacity-40 ${props.create ? "rounded-md" : "rounded-sm"}` 465 } 466 onclick={() => { 467 if (hasPermission()) {
+338 -1
src/views/collection.tsx
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 - <span class="shrink-0 text-sm text-blue-500 dark:text-blue-400">{props.record.rkey}</span> 44 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 45 {props.record.cid} 46 </span>
··· 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 44 + {props.record.rkey} 45 + </span> 46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 47 {props.record.cid} 48 </span> 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 101 + 102 + 103 + 104 + 105 + 106 + 107 + 108 + 109 + 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + 126 + 127 + 128 + 129 + 130 + 131 + 132 + 133 + 134 + 135 + 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 265 + 266 + 267 + 268 + 269 + 270 + 271 + 272 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 299 + 300 + 301 + 302 + 303 + 304 + 305 + 306 + 307 + 308 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 316 + <Button 317 + onClick={deleteRecords} 318 + classList={{ 319 + "bg-blue-500! text-white! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400! border-none!": 320 + recreate(), 321 + "text-white! border-none! bg-red-500! hover:bg-red-600! active:bg-red-700!": 322 + !recreate(), 323 + }} 324 + > 325 + {recreate() ? "Recreate" : "Delete"} 326 + </Button> 327 + 328 + 329 + 330 + 331 + 332 + 333 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + 377 + <Button onClick={() => refetch()}>Load more</Button> 378 + </Show> 379 + <Show when={response.loading}> 380 + <div class="iconify lucide--loader-circle w-20 animate-spin text-lg" /> 381 + </Show> 382 + </Show> 383 + </div>
+4 -4
src/auth/login.tsx
··· 32 }; 33 34 return ( 35 - <div class="flex flex-col gap-y-2 px-1"> 36 <Show when={!scopeFlow.showScopeSelector()}> 37 <Show when={props.onCancel}> 38 - <div class="mb-1 flex items-center gap-2"> 39 <button 40 onclick={handleCancel} 41 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 45 <div class="font-semibold">Add account</div> 46 </div> 47 </Show> 48 - <form class="flex flex-col gap-2" onsubmit={(e) => e.preventDefault()}> 49 <label for="username" class="hidden"> 50 Add account 51 </label> ··· 69 </div> 70 <button 71 onclick={() => initiateLogin(loginInput())} 72 - class="grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 73 > 74 Continue 75 </button>
··· 32 }; 33 34 return ( 35 + <div class="flex flex-col gap-y-3"> 36 <Show when={!scopeFlow.showScopeSelector()}> 37 <Show when={props.onCancel}> 38 + <div class="flex items-center gap-2"> 39 <button 40 onclick={handleCancel} 41 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 45 <div class="font-semibold">Add account</div> 46 </div> 47 </Show> 48 + <form class="flex flex-col gap-3" onsubmit={(e) => e.preventDefault()}> 49 <label for="username" class="hidden"> 50 Add account 51 </label> ··· 69 </div> 70 <button 71 onclick={() => initiateLogin(loginInput())} 72 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 73 > 74 Continue 75 </button>
+36 -26
src/auth/scope-selector.tsx
··· 44 }; 45 46 return ( 47 - <div class="flex flex-col gap-y-2"> 48 - <div class="mb-1 flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 51 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 52 53 54 - 55 - 56 - 57 - 58 - 59 - 60 - 61 - 62 - 63 - 64 - 65 - 66 - 67 - 68 - 69 - 70 - 71 - 72 - 73 - 74 - 75 - 76 - 77 </div> 78 <button 79 onclick={handleConfirm} 80 - class="mt-2 grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 81 > 82 Continue 83 </button>
··· 44 }; 45 46 return ( 47 + <div class="flex flex-col gap-y-3"> 48 + <div class="flex items-center gap-2"> 49 <button 50 onclick={props.onCancel} 51 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 52 53 54 + </button> 55 + <div class="font-semibold">Select permissions</div> 56 + </div> 57 + <div class="flex flex-col px-1"> 58 + <For each={GRANULAR_SCOPES}> 59 + {(scope) => { 60 + const isSelected = () => selectedScopes().has(scope.id); 61 + const isDisabled = () => scope.id === "blob" && isBlobDisabled(); 62 + 63 + return ( 64 + <button 65 + onclick={() => !isDisabled() && toggleScope(scope.id)} 66 + disabled={isDisabled()} 67 + class="group flex items-center gap-3 py-2" 68 + classList={{ "opacity-50": isDisabled() }} 69 + > 70 + <div 71 + class="flex size-5 items-center justify-center rounded border-2" 72 + classList={{ 73 + "bg-blue-500 border-transparent group-hover:bg-blue-600 group-active:bg-blue-400": 74 + isSelected() && !isDisabled(), 75 + "border-neutral-400 dark:border-neutral-500 group-hover:border-neutral-500 dark:group-hover:border-neutral-400 group-hover:bg-neutral-100 dark:group-hover:bg-neutral-800": 76 + !isSelected() && !isDisabled(), 77 + "border-neutral-300 dark:border-neutral-600": isDisabled(), 78 + }} 79 + > 80 + {isSelected() && <span class="iconify lucide--check text-sm text-white"></span>} 81 + </div> 82 + <span>{scope.label}</span> 83 + </button> 84 + ); 85 + }} 86 + </For> 87 </div> 88 <button 89 onclick={handleConfirm} 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 > 92 Continue 93 </button>
+1181 -22
pnpm-lock.yaml
··· 1 2 3 4 5 6 7 8 ··· 31 32 33 34 35 36 ··· 38 39 40 41 - '@atcute/identity-resolver': 42 - specifier: ^1.2.2 43 - version: 1.2.2(@atcute/identity@1.1.3) 44 - '@atcute/leaflet': 45 - specifier: ^1.0.16 46 - version: 1.0.16 47 - '@atcute/lexicon-doc': 48 - specifier: ^2.0.6 49 - version: 2.0.6 50 51 52 ··· 90 91 92 93 94 95 96 97 98 99 ··· 130 131 132 133 134 135 136 ··· 182 183 184 185 - '@atcute/identity@1.1.3': 186 - resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 187 188 - '@atcute/leaflet@1.0.16': 189 - resolution: {integrity: sha512-YS0+93C+bG2AlB1M5Cvf8v7GMnP/67l5G+RUQQltXydqugcAEW6ZgasXN/ZlsTJJl5tdWBTS4HsRYwdS576SkA==} 190 191 - '@atcute/lexicon-doc@2.0.6': 192 - resolution: {integrity: sha512-iDYJkuom+tIw3zIvU1ggCEVFfReXKfOUtIhpY2kEg2kQeSfMB75F+8k1QOpeAQBetyWYmjsHqBuSUX9oQS6L1Q==} 193 194 195 ··· 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 ··· 821 822 823 824 825 826 827 828 ··· 852 853 854 855 856 857 858 859 ··· 913 914 915 916 917 918 919 920 ··· 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 ··· 1425 1426 1427 1428 1429 1430 ··· 1624 1625 1626 1627 - '@atcute/lexicons': 1.2.6 1628 - '@badrap/valita': 0.4.6 1629 1630 - '@atcute/leaflet@1.0.16': 1631 dependencies: 1632 - '@atcute/atproto': 3.1.10 1633 - '@atcute/lexicons': 1.2.6 1634 1635 - '@atcute/lexicon-doc@2.0.6': 1636 dependencies: 1637 - '@atcute/identity': 1.1.3
··· 1 2 3 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 7 + importers: 8 9 + .: 10 11 12 ··· 35 36 37 38 + '@atcute/identity-resolver': 39 + specifier: ^1.2.2 40 + version: 1.2.2(@atcute/identity@1.1.3) 41 + '@atcute/lexicon-doc': 42 + specifier: ^2.0.6 43 + version: 2.0.6 44 45 46 ··· 48 49 50 51 52 53 ··· 91 92 93 94 + version: 0.5.2 95 + '@solidjs/meta': 96 + specifier: ^0.29.4 97 + version: 0.29.4(solid-js@1.9.11) 98 + '@solidjs/router': 99 + specifier: ^0.15.4 100 + version: 0.15.4(solid-js@1.9.11) 101 + codemirror: 102 + specifier: ^6.0.2 103 + version: 6.0.2 104 + 105 + specifier: ^3.0.1 106 + version: 3.0.1 107 + solid-js: 108 + specifier: ^1.9.11 109 + version: 1.9.11 110 + devDependencies: 111 + '@iconify-json/lucide': 112 + specifier: ^1.2.86 113 114 115 116 + version: 1.2.1(tailwindcss@4.1.18) 117 + '@tailwindcss/vite': 118 + specifier: ^4.1.18 119 + version: 4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 120 + prettier: 121 + specifier: ^3.8.1 122 + version: 3.8.1 123 + prettier-plugin-organize-imports: 124 + specifier: ^4.3.0 125 + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) 126 + prettier-plugin-tailwindcss: 127 + specifier: ^0.7.2 128 + version: 0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1) 129 + tailwindcss: 130 + specifier: ^4.1.18 131 + version: 4.1.18 132 + 133 + 134 + version: 5.9.3 135 + vite: 136 + specifier: ^7.3.1 137 + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 138 + vite-plugin-solid: 139 + specifier: ^2.11.10 140 + version: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 141 142 + packages: 143 144 145 ··· 176 177 178 179 + '@atcute/identity@1.1.3': 180 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 181 182 + '@atcute/lexicon-doc@2.0.6': 183 + resolution: {integrity: sha512-iDYJkuom+tIw3zIvU1ggCEVFfReXKfOUtIhpY2kEg2kQeSfMB75F+8k1QOpeAQBetyWYmjsHqBuSUX9oQS6L1Q==} 184 185 186 ··· 232 233 234 235 236 237 238 239 ··· 718 719 720 721 + '@noble/secp256k1@3.0.0': 722 + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 723 724 + '@rollup/rollup-android-arm-eabi@4.56.0': 725 + resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} 726 + cpu: [arm] 727 + os: [android] 728 729 + '@rollup/rollup-android-arm64@4.56.0': 730 + resolution: {integrity: sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==} 731 + cpu: [arm64] 732 + os: [android] 733 734 + '@rollup/rollup-darwin-arm64@4.56.0': 735 + resolution: {integrity: sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==} 736 + cpu: [arm64] 737 + os: [darwin] 738 739 + '@rollup/rollup-darwin-x64@4.56.0': 740 + resolution: {integrity: sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==} 741 + cpu: [x64] 742 + os: [darwin] 743 744 + '@rollup/rollup-freebsd-arm64@4.56.0': 745 + resolution: {integrity: sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==} 746 + cpu: [arm64] 747 + os: [freebsd] 748 749 + '@rollup/rollup-freebsd-x64@4.56.0': 750 + resolution: {integrity: sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==} 751 + cpu: [x64] 752 + os: [freebsd] 753 754 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 755 + resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} 756 + cpu: [arm] 757 + os: [linux] 758 759 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 760 + resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} 761 + cpu: [arm] 762 + os: [linux] 763 764 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 765 + resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} 766 + cpu: [arm64] 767 + os: [linux] 768 769 + '@rollup/rollup-linux-arm64-musl@4.56.0': 770 + resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} 771 + cpu: [arm64] 772 + os: [linux] 773 774 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 775 + resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} 776 + cpu: [loong64] 777 + os: [linux] 778 779 + '@rollup/rollup-linux-loong64-musl@4.56.0': 780 + resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} 781 + cpu: [loong64] 782 + os: [linux] 783 784 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 785 + resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} 786 + cpu: [ppc64] 787 + os: [linux] 788 789 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 790 + resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} 791 + cpu: [ppc64] 792 + os: [linux] 793 794 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 795 + resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} 796 + cpu: [riscv64] 797 + os: [linux] 798 799 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 800 + resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} 801 + cpu: [riscv64] 802 + os: [linux] 803 804 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 805 + resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} 806 + cpu: [s390x] 807 + os: [linux] 808 809 + '@rollup/rollup-linux-x64-gnu@4.56.0': 810 + resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} 811 + cpu: [x64] 812 + os: [linux] 813 814 + '@rollup/rollup-linux-x64-musl@4.56.0': 815 + resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} 816 + cpu: [x64] 817 + os: [linux] 818 819 + '@rollup/rollup-openbsd-x64@4.56.0': 820 + resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} 821 + cpu: [x64] 822 + os: [openbsd] 823 824 + '@rollup/rollup-openharmony-arm64@4.56.0': 825 + resolution: {integrity: sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==} 826 + cpu: [arm64] 827 + os: [openharmony] 828 829 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 830 + resolution: {integrity: sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==} 831 + cpu: [arm64] 832 + os: [win32] 833 834 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 835 + resolution: {integrity: sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==} 836 + cpu: [ia32] 837 + os: [win32] 838 839 + '@rollup/rollup-win32-x64-gnu@4.56.0': 840 + resolution: {integrity: sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==} 841 + cpu: [x64] 842 + os: [win32] 843 844 + '@rollup/rollup-win32-x64-msvc@4.56.0': 845 + resolution: {integrity: sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==} 846 + cpu: [x64] 847 + os: [win32] 848 849 850 ··· 967 968 969 970 + '@types/estree@1.0.8': 971 + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 972 973 + '@types/node@25.0.10': 974 + resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} 975 976 + acorn@8.15.0: 977 + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 978 979 980 ··· 1004 1005 1006 1007 + bun-types@1.3.6: 1008 + resolution: {integrity: sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==} 1009 1010 + caniuse-lite@1.0.30001766: 1011 + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} 1012 1013 + codemirror@6.0.2: 1014 + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} 1015 1016 1017 ··· 1071 1072 1073 1074 + domutils@3.2.2: 1075 + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 1076 1077 + electron-to-chromium@1.5.278: 1078 + resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} 1079 1080 + enhanced-resolve@5.18.4: 1081 + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} 1082 1083 1084 ··· 1375 1376 1377 1378 + prettier-plugin-svelte: 1379 + optional: true 1380 1381 + prettier@3.8.1: 1382 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 1383 + engines: {node: '>=14'} 1384 + hasBin: true 1385 1386 + resolve-pkg-maps@1.0.0: 1387 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 1388 1389 + rollup@4.56.0: 1390 + resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} 1391 + engines: {node: '>=18.0.0', npm: '>=8.0.0'} 1392 + hasBin: true 1393 1394 1395 1396 1397 1398 1399 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 1400 + hasBin: true 1401 1402 + seroval-plugins@1.5.0: 1403 + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} 1404 + engines: {node: '>=10'} 1405 + peerDependencies: 1406 + seroval: ^1.0 1407 1408 + seroval@1.5.0: 1409 + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} 1410 + engines: {node: '>=10'} 1411 1412 + solid-js@1.9.11: 1413 + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} 1414 1415 + solid-refresh@0.6.3: 1416 + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} 1417 1418 1419 ··· 1615 1616 1617 1618 + '@atcute/lexicons': 1.2.6 1619 + '@badrap/valita': 0.4.6 1620 + 1621 + '@atcute/lexicon-doc@2.0.6': 1622 + dependencies: 1623 + '@atcute/identity': 1.1.3 1624 + 1625 + 1626 + 1627 + 1628 + 1629 + 1630 + 1631 + 1632 + 1633 + 1634 + 1635 + 1636 + 1637 + 1638 + 1639 + 1640 + 1641 + 1642 + 1643 + 1644 + 1645 + 1646 + 1647 + 1648 + 1649 + 1650 + 1651 + 1652 + 1653 + 1654 + 1655 + 1656 + 1657 + 1658 + 1659 + 1660 + 1661 + 1662 + 1663 + 1664 + 1665 + 1666 + 1667 + 1668 + 1669 + 1670 + 1671 + 1672 + 1673 + 1674 + 1675 + 1676 + 1677 + 1678 + 1679 + 1680 + 1681 + 1682 + 1683 + 1684 + 1685 + 1686 + 1687 + 1688 + 1689 + 1690 + 1691 + 1692 + 1693 + 1694 + 1695 + 1696 + 1697 + 1698 + 1699 + 1700 + 1701 + 1702 + 1703 + 1704 + 1705 + 1706 + 1707 + 1708 + 1709 + 1710 + 1711 + 1712 + 1713 + 1714 + 1715 + 1716 + 1717 + 1718 + 1719 + 1720 + 1721 + 1722 + 1723 + 1724 + 1725 + 1726 + 1727 + 1728 + 1729 + 1730 + 1731 + 1732 + 1733 + 1734 + 1735 + 1736 + 1737 + 1738 + 1739 + 1740 + 1741 + 1742 + 1743 + 1744 + 1745 + 1746 + 1747 + 1748 + 1749 + 1750 + 1751 + 1752 + 1753 + 1754 + 1755 + 1756 + 1757 + 1758 + 1759 + 1760 + 1761 + 1762 + 1763 + 1764 + 1765 + 1766 + 1767 + 1768 + 1769 + 1770 + 1771 + 1772 + 1773 + 1774 + 1775 + 1776 + 1777 + 1778 + 1779 + 1780 + 1781 + 1782 + 1783 + 1784 + 1785 + 1786 + 1787 + 1788 + 1789 + 1790 + 1791 + 1792 + 1793 + 1794 + 1795 + 1796 + 1797 + 1798 + 1799 + 1800 + 1801 + 1802 1803 1804 ··· 1998 1999 2000 2001 2002 + 2003 + 2004 + 2005 + 2006 + 2007 + 2008 + 2009 + 2010 + 2011 + 2012 + 2013 + 2014 + 2015 + 2016 + 2017 + 2018 + 2019 + 2020 + 2021 + 2022 + 2023 + 2024 + 2025 + 2026 + 2027 + 2028 + 2029 + 2030 + 2031 + 2032 + 2033 + 2034 + 2035 + 2036 + 2037 + 2038 + 2039 + 2040 + 2041 + 2042 + 2043 + 2044 + 2045 + 2046 + 2047 + 2048 + 2049 + 2050 + 2051 + 2052 + 2053 + 2054 + 2055 + 2056 + 2057 + 2058 + 2059 + 2060 + 2061 + 2062 + 2063 + 2064 + 2065 + 2066 + 2067 + 2068 + 2069 + 2070 + 2071 + 2072 + 2073 + 2074 + 2075 + 2076 + 2077 + 2078 + 2079 + 2080 + 2081 + 2082 + 2083 + 2084 + 2085 + 2086 + 2087 + 2088 + 2089 + 2090 + 2091 + 2092 + 2093 + 2094 + 2095 + 2096 + 2097 + 2098 + 2099 + 2100 + 2101 + 2102 + 2103 + 2104 + 2105 + 2106 + 2107 + 2108 + 2109 + 2110 + 2111 + 2112 + 2113 + 2114 + 2115 + 2116 + 2117 + 2118 + 2119 + '@noble/secp256k1@3.0.0': {} 2120 + 2121 + '@rollup/rollup-android-arm-eabi@4.56.0': 2122 + optional: true 2123 + 2124 + '@rollup/rollup-android-arm64@4.56.0': 2125 + optional: true 2126 + 2127 + '@rollup/rollup-darwin-arm64@4.56.0': 2128 + optional: true 2129 + 2130 + '@rollup/rollup-darwin-x64@4.56.0': 2131 + optional: true 2132 + 2133 + '@rollup/rollup-freebsd-arm64@4.56.0': 2134 + optional: true 2135 + 2136 + '@rollup/rollup-freebsd-x64@4.56.0': 2137 + optional: true 2138 + 2139 + '@rollup/rollup-linux-arm-gnueabihf@4.56.0': 2140 + optional: true 2141 + 2142 + '@rollup/rollup-linux-arm-musleabihf@4.56.0': 2143 + optional: true 2144 + 2145 + '@rollup/rollup-linux-arm64-gnu@4.56.0': 2146 + optional: true 2147 + 2148 + '@rollup/rollup-linux-arm64-musl@4.56.0': 2149 + optional: true 2150 + 2151 + '@rollup/rollup-linux-loong64-gnu@4.56.0': 2152 + optional: true 2153 + 2154 + '@rollup/rollup-linux-loong64-musl@4.56.0': 2155 + optional: true 2156 + 2157 + '@rollup/rollup-linux-ppc64-gnu@4.56.0': 2158 + optional: true 2159 + 2160 + '@rollup/rollup-linux-ppc64-musl@4.56.0': 2161 + optional: true 2162 + 2163 + '@rollup/rollup-linux-riscv64-gnu@4.56.0': 2164 + optional: true 2165 + 2166 + '@rollup/rollup-linux-riscv64-musl@4.56.0': 2167 + optional: true 2168 + 2169 + '@rollup/rollup-linux-s390x-gnu@4.56.0': 2170 + optional: true 2171 + 2172 + '@rollup/rollup-linux-x64-gnu@4.56.0': 2173 + optional: true 2174 + 2175 + '@rollup/rollup-linux-x64-musl@4.56.0': 2176 + optional: true 2177 + 2178 + '@rollup/rollup-openbsd-x64@4.56.0': 2179 + optional: true 2180 + 2181 + '@rollup/rollup-openharmony-arm64@4.56.0': 2182 + optional: true 2183 + 2184 + '@rollup/rollup-win32-arm64-msvc@4.56.0': 2185 + optional: true 2186 + 2187 + '@rollup/rollup-win32-ia32-msvc@4.56.0': 2188 + optional: true 2189 + 2190 + '@rollup/rollup-win32-x64-gnu@4.56.0': 2191 + optional: true 2192 + 2193 + '@rollup/rollup-win32-x64-msvc@4.56.0': 2194 + optional: true 2195 + 2196 + '@skyware/firehose@0.5.2': 2197 + 2198 + 2199 + '@atcute/cbor': 2.3.0 2200 + nanoevents: 9.1.0 2201 + 2202 + '@solidjs/meta@0.29.4(solid-js@1.9.11)': 2203 dependencies: 2204 + solid-js: 1.9.11 2205 2206 + '@solidjs/router@0.15.4(solid-js@1.9.11)': 2207 dependencies: 2208 + solid-js: 1.9.11 2209 + 2210 + '@standard-schema/spec@1.1.0': {} 2211 + 2212 + 2213 + 2214 + 2215 + 2216 + 2217 + 2218 + 2219 + 2220 + 2221 + 2222 + 2223 + 2224 + 2225 + 2226 + 2227 + 2228 + 2229 + 2230 + 2231 + 2232 + 2233 + 2234 + 2235 + 2236 + 2237 + 2238 + 2239 + 2240 + 2241 + 2242 + 2243 + 2244 + 2245 + 2246 + 2247 + 2248 + 2249 + 2250 + 2251 + 2252 + 2253 + 2254 + 2255 + 2256 + 2257 + 2258 + 2259 + 2260 + 2261 + 2262 + 2263 + 2264 + 2265 + 2266 + 2267 + 2268 + 2269 + 2270 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 2271 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 2272 + 2273 + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2))': 2274 + dependencies: 2275 + '@tailwindcss/node': 4.1.18 2276 + '@tailwindcss/oxide': 4.1.18 2277 + tailwindcss: 4.1.18 2278 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2279 + 2280 + '@types/babel__core@7.20.5': 2281 + dependencies: 2282 + 2283 + 2284 + 2285 + 2286 + 2287 + 2288 + 2289 + 2290 + 2291 + 2292 + 2293 + 2294 + 2295 + 2296 + 2297 + 2298 + 2299 + 2300 + 2301 + 2302 + 2303 + 2304 + 2305 + '@types/estree@1.0.8': {} 2306 + 2307 + '@types/node@25.0.10': 2308 + dependencies: 2309 + undici-types: 7.16.0 2310 + 2311 + 2312 + 2313 + 2314 + 2315 + 2316 + 2317 + 2318 + 2319 + html-entities: 2.3.3 2320 + parse5: 7.3.0 2321 + 2322 + babel-preset-solid@1.9.10(@babel/core@7.28.6)(solid-js@1.9.11): 2323 + dependencies: 2324 + '@babel/core': 7.28.6 2325 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.28.6) 2326 + optionalDependencies: 2327 + solid-js: 1.9.11 2328 + 2329 + baseline-browser-mapping@2.9.17: {} 2330 + 2331 + 2332 + 2333 + browserslist@4.28.1: 2334 + dependencies: 2335 + baseline-browser-mapping: 2.9.17 2336 + caniuse-lite: 1.0.30001766 2337 + electron-to-chromium: 1.5.278 2338 + node-releases: 2.0.27 2339 + update-browserslist-db: 1.2.3(browserslist@4.28.1) 2340 + 2341 + bun-types@1.3.6: 2342 + dependencies: 2343 + '@types/node': 25.0.10 2344 + 2345 + caniuse-lite@1.0.30001766: {} 2346 + 2347 + codemirror@6.0.2: 2348 + dependencies: 2349 + 2350 + 2351 + 2352 + 2353 + 2354 + 2355 + 2356 + 2357 + 2358 + 2359 + 2360 + 2361 + 2362 + 2363 + 2364 + 2365 + 2366 + 2367 + 2368 + 2369 + 2370 + 2371 + 2372 + 2373 + 2374 + 2375 + 2376 + 2377 + 2378 + 2379 + 2380 + 2381 + 2382 + 2383 + 2384 + 2385 + 2386 + 2387 + 2388 + 2389 + 2390 + 2391 + 2392 + 2393 + 2394 + 2395 + 2396 + 2397 + 2398 + 2399 + 2400 + 2401 + 2402 + 2403 + 2404 + 2405 + 2406 + 2407 + 2408 + 2409 + 2410 + 2411 + 2412 + domelementtype: 2.3.0 2413 + domhandler: 5.0.3 2414 + 2415 + electron-to-chromium@1.5.278: {} 2416 + 2417 + enhanced-resolve@5.18.4: 2418 + dependencies: 2419 + 2420 + 2421 + 2422 + 2423 + 2424 + 2425 + 2426 + 2427 + 2428 + 2429 + 2430 + 2431 + 2432 + 2433 + 2434 + 2435 + 2436 + 2437 + 2438 + 2439 + 2440 + 2441 + 2442 + 2443 + 2444 + 2445 + 2446 + 2447 + 2448 + 2449 + 2450 + 2451 + 2452 + 2453 + 2454 + 2455 + 2456 + 2457 + 2458 + 2459 + 2460 + 2461 + 2462 + 2463 + 2464 + 2465 + 2466 + 2467 + 2468 + 2469 + 2470 + 2471 + 2472 + 2473 + 2474 + 2475 + 2476 + 2477 + 2478 + 2479 + 2480 + 2481 + 2482 + 2483 + 2484 + 2485 + 2486 + 2487 + 2488 + 2489 + 2490 + 2491 + 2492 + 2493 + 2494 + 2495 + 2496 + 2497 + 2498 + 2499 + 2500 + 2501 + 2502 + 2503 + 2504 + 2505 + 2506 + 2507 + 2508 + 2509 + 2510 + 2511 + 2512 + 2513 + 2514 + 2515 + 2516 + 2517 + 2518 + 2519 + 2520 + 2521 + 2522 + 2523 + 2524 + 2525 + 2526 + 2527 + 2528 + 2529 + 2530 + 2531 + 2532 + 2533 + 2534 + 2535 + 2536 + 2537 + 2538 + 2539 + 2540 + 2541 + 2542 + 2543 + 2544 + 2545 + 2546 + 2547 + 2548 + 2549 + 2550 + 2551 + 2552 + 2553 + 2554 + 2555 + 2556 + 2557 + 2558 + 2559 + 2560 + 2561 + 2562 + 2563 + 2564 + 2565 + 2566 + 2567 + 2568 + 2569 + 2570 + 2571 + 2572 + 2573 + 2574 + 2575 + 2576 + 2577 + 2578 + 2579 + 2580 + 2581 + 2582 + 2583 + 2584 + 2585 + 2586 + 2587 + 2588 + 2589 + 2590 + 2591 + 2592 + 2593 + 2594 + 2595 + 2596 + 2597 + 2598 + 2599 + 2600 + 2601 + 2602 + 2603 + 2604 + 2605 + 2606 + 2607 + 2608 + 2609 + 2610 + 2611 + 2612 + 2613 + 2614 + 2615 + 2616 + 2617 + 2618 + 2619 + 2620 + 2621 + 2622 + 2623 + 2624 + 2625 + 2626 + 2627 + 2628 + 2629 + 2630 + 2631 + 2632 + 2633 + 2634 + 2635 + 2636 + 2637 + 2638 + 2639 + 2640 + 2641 + picocolors: 1.1.1 2642 + source-map-js: 1.2.1 2643 + 2644 + prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): 2645 + dependencies: 2646 + prettier: 3.8.1 2647 + typescript: 5.9.3 2648 + 2649 + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3))(prettier@3.8.1): 2650 + dependencies: 2651 + prettier: 3.8.1 2652 + optionalDependencies: 2653 + prettier-plugin-organize-imports: 4.3.0(prettier@3.8.1)(typescript@5.9.3) 2654 + 2655 + prettier@3.8.1: {} 2656 + 2657 + resolve-pkg-maps@1.0.0: 2658 + optional: true 2659 + 2660 + rollup@4.56.0: 2661 + dependencies: 2662 + '@types/estree': 1.0.8 2663 + optionalDependencies: 2664 + '@rollup/rollup-android-arm-eabi': 4.56.0 2665 + '@rollup/rollup-android-arm64': 4.56.0 2666 + '@rollup/rollup-darwin-arm64': 4.56.0 2667 + '@rollup/rollup-darwin-x64': 4.56.0 2668 + '@rollup/rollup-freebsd-arm64': 4.56.0 2669 + '@rollup/rollup-freebsd-x64': 4.56.0 2670 + '@rollup/rollup-linux-arm-gnueabihf': 4.56.0 2671 + '@rollup/rollup-linux-arm-musleabihf': 4.56.0 2672 + '@rollup/rollup-linux-arm64-gnu': 4.56.0 2673 + '@rollup/rollup-linux-arm64-musl': 4.56.0 2674 + '@rollup/rollup-linux-loong64-gnu': 4.56.0 2675 + '@rollup/rollup-linux-loong64-musl': 4.56.0 2676 + '@rollup/rollup-linux-ppc64-gnu': 4.56.0 2677 + '@rollup/rollup-linux-ppc64-musl': 4.56.0 2678 + '@rollup/rollup-linux-riscv64-gnu': 4.56.0 2679 + '@rollup/rollup-linux-riscv64-musl': 4.56.0 2680 + '@rollup/rollup-linux-s390x-gnu': 4.56.0 2681 + '@rollup/rollup-linux-x64-gnu': 4.56.0 2682 + '@rollup/rollup-linux-x64-musl': 4.56.0 2683 + '@rollup/rollup-openbsd-x64': 4.56.0 2684 + '@rollup/rollup-openharmony-arm64': 4.56.0 2685 + '@rollup/rollup-win32-arm64-msvc': 4.56.0 2686 + '@rollup/rollup-win32-ia32-msvc': 4.56.0 2687 + '@rollup/rollup-win32-x64-gnu': 4.56.0 2688 + '@rollup/rollup-win32-x64-msvc': 4.56.0 2689 + fsevents: 2.3.3 2690 + 2691 + sax@1.4.4: {} 2692 + 2693 + semver@6.3.1: {} 2694 + 2695 + seroval-plugins@1.5.0(seroval@1.5.0): 2696 + dependencies: 2697 + seroval: 1.5.0 2698 + 2699 + seroval@1.5.0: {} 2700 + 2701 + solid-js@1.9.11: 2702 + dependencies: 2703 + csstype: 3.2.3 2704 + seroval: 1.5.0 2705 + seroval-plugins: 1.5.0(seroval@1.5.0) 2706 + 2707 + solid-refresh@0.6.3(solid-js@1.9.11): 2708 + dependencies: 2709 + '@babel/generator': 7.28.6 2710 + '@babel/helper-module-imports': 7.28.6 2711 + '@babel/types': 7.28.6 2712 + solid-js: 1.9.11 2713 + transitivePeerDependencies: 2714 + - supports-color 2715 + 2716 + 2717 + 2718 + 2719 + 2720 + 2721 + 2722 + 2723 + 2724 + 2725 + 2726 + 2727 + 2728 + 2729 + 2730 + 2731 + 2732 + 2733 + 2734 + 2735 + 2736 + 2737 + 2738 + 2739 + 2740 + 2741 + 2742 + 2743 + 2744 + 2745 + 2746 + 2747 + 2748 + 2749 + 2750 + 2751 + 2752 + 2753 + 2754 + 2755 + 2756 + 2757 + 2758 + 2759 + 2760 + escalade: 3.2.0 2761 + picocolors: 1.1.1 2762 + 2763 + vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2764 + dependencies: 2765 + '@babel/core': 7.28.6 2766 + '@types/babel__core': 7.20.5 2767 + babel-preset-solid: 1.9.10(@babel/core@7.28.6)(solid-js@1.9.11) 2768 + merge-anything: 5.1.7 2769 + solid-js: 1.9.11 2770 + solid-refresh: 0.6.3(solid-js@1.9.11) 2771 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2772 + vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)) 2773 + transitivePeerDependencies: 2774 + - supports-color 2775 + 2776 + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2): 2777 + dependencies: 2778 + esbuild: 0.27.2 2779 + fdir: 6.5.0(picomatch@4.0.3) 2780 + picomatch: 4.0.3 2781 + postcss: 8.5.6 2782 + rollup: 4.56.0 2783 + tinyglobby: 0.2.15 2784 + optionalDependencies: 2785 + '@types/node': 25.0.10 2786 + fsevents: 2.3.3 2787 + jiti: 2.6.1 2788 + lightningcss: 1.30.2 2789 + tsx: 4.19.2 2790 + 2791 + vitefu@1.1.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)): 2792 + optionalDependencies: 2793 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2) 2794 + 2795 + w3c-keyname@2.2.8: {} 2796 +
-14
src/utils/templates.ts
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 - "blue.linkat.board": (uri) => ({ 41 - label: "Linkat", 42 - link: `https://linkat.blue/${uri.repo}`, 43 - }), 44 "sh.tangled.actor.profile": (uri) => ({ 45 label: "Tangled", 46 link: `https://tangled.org/${uri.repo}`, ··· 51 link: `https://tangled.org/${uri.repo}/${record.name}`, 52 icon: "i-tangled", 53 }), 54 - "pub.leaflet.document": (uri) => ({ 55 - label: "Leaflet", 56 - link: `https://leaflet.pub/p/${uri.repo}/${uri.rkey}`, 57 - icon: "iconify-color i-leaflet", 58 - }), 59 - "pub.leaflet.publication": (uri) => ({ 60 - label: "Leaflet", 61 - link: `https://leaflet.pub/lish/${uri.repo}/${uri.rkey}`, 62 - icon: "iconify-color i-leaflet", 63 - }), 64 };
··· 37 link: `https://pinksea.art/${uri.repo}`, 38 icon: "i-pinksea", 39 }), 40 "sh.tangled.actor.profile": (uri) => ({ 41 label: "Tangled", 42 link: `https://tangled.org/${uri.repo}`, ··· 47 link: `https://tangled.org/${uri.repo}/${record.name}`, 48 icon: "i-tangled", 49 }), 50 };
-12
src/utils/types/lexicons.ts
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 - import { 21 - PubLeafletComment, 22 - PubLeafletDocument, 23 - PubLeafletGraphSubscription, 24 - PubLeafletPublication, 25 - } from "@atcute/leaflet"; 26 import { 27 ShTangledActorProfile, 28 ShTangledFeedStar, ··· 85 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 86 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 87 "sh.tangled.knot": ShTangledKnot.mainSchema, 88 - 89 - // Leaflet 90 - "pub.leaflet.comment": PubLeafletComment.mainSchema, 91 - "pub.leaflet.document": PubLeafletDocument.mainSchema, 92 - "pub.leaflet.graph.subscription": PubLeafletGraphSubscription.mainSchema, 93 - "pub.leaflet.publication": PubLeafletPublication.mainSchema, 94 };
··· 17 AppBskyLabelerService, 18 ChatBskyActorDeclaration, 19 } from "@atcute/bluesky"; 20 import { 21 ShTangledActorProfile, 22 ShTangledFeedStar, ··· 79 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 80 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 81 "sh.tangled.knot": ShTangledKnot.mainSchema, 82 };
+1
.gitignore
··· 2 dist 3 .env 4 .DS_Store
··· 2 dist 3 .env 4 .DS_Store 5 + public/oauth-client-metadata.json
-13
public/oauth-client-metadata.json
··· 1 - { 2 - "client_id": "https://pdsls.dev/oauth-client-metadata.json", 3 - "client_name": "PDSls", 4 - "client_uri": "https://pdsls.dev", 5 - "logo_uri": "https://pdsls.dev/favicon.ico", 6 - "redirect_uris": ["https://pdsls.dev/"], 7 - "scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 8 - "grant_types": ["authorization_code", "refresh_token"], 9 - "response_types": ["code"], 10 - "token_endpoint_auth_method": "none", 11 - "application_type": "web", 12 - "dpop_bound_access_tokens": true 13 - }
···
+35
scripts/generate-oauth-metadata.js
···
··· 1 + import { mkdirSync, writeFileSync } from "fs"; 2 + import { dirname } from "path"; 3 + import { fileURLToPath } from "url"; 4 + 5 + const __filename = fileURLToPath(import.meta.url); 6 + const __dirname = dirname(__filename); 7 + 8 + const domain = process.env.APP_DOMAIN || "pdsls.dev"; 9 + const protocol = process.env.APP_PROTOCOL || "https"; 10 + const baseUrl = `${protocol}://${domain}`; 11 + 12 + const metadata = { 13 + client_id: `${baseUrl}/oauth-client-metadata.json`, 14 + client_name: "PDSls", 15 + client_uri: baseUrl, 16 + logo_uri: `${baseUrl}/favicon.ico`, 17 + redirect_uris: [`${baseUrl}/`], 18 + scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 19 + grant_types: ["authorization_code", "refresh_token"], 20 + response_types: ["code"], 21 + token_endpoint_auth_method: "none", 22 + application_type: "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + 26 + const outputPath = `${__dirname}/../public/oauth-client-metadata.json`; 27 + 28 + try { 29 + mkdirSync(dirname(outputPath), { recursive: true }); 30 + writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + "\n"); 31 + console.log(`Generated OAuth metadata for ${baseUrl}`); 32 + } catch (error) { 33 + console.error("Failed to generate OAuth metadata:", error); 34 + process.exit(1); 35 + }
public/avatar/bad-example.com.jpg

This is a binary file and will not be displayed.

public/avatar/futur.blue.jpg

This is a binary file and will not be displayed.

public/avatar/hailey.at.jpg

This is a binary file and will not be displayed.

public/avatar/jaz.sh.jpg

This is a binary file and will not be displayed.

public/avatar/jcsalterego.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/juli.ee.jpg

This is a binary file and will not be displayed.

public/avatar/mary.my.id.jpg

This is a binary file and will not be displayed.

public/avatar/retr0.id.jpg

This is a binary file and will not be displayed.

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.

+3 -1
src/components/button.tsx
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 children?: JSX.Element; 10 } 11 ··· 16 disabled={props.disabled ?? false} 17 class={ 18 props.class ?? 19 - "dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 20 } 21 classList={props.classList} 22 onClick={props.onClick} 23 > 24 {props.children} 25 </button>
··· 6 class?: string; 7 classList?: Record<string, boolean | undefined>; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 + ontouchstart?: (e: TouchEvent) => void; 10 children?: JSX.Element; 11 } 12 ··· 17 disabled={props.disabled ?? false} 18 class={ 19 props.class ?? 20 + "dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300" 21 } 22 classList={props.classList} 23 onClick={props.onClick} 24 + ontouchstart={props.ontouchstart} 25 > 26 {props.children} 27 </button>
+1 -1
src/components/text-input.tsx
··· 25 disabled={props.disabled} 26 required={props.required} 27 class={ 28 - "dark:bg-dark-100 rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 29 props.class 30 } 31 onInput={props.onInput}
··· 25 disabled={props.disabled} 26 required={props.required} 27 class={ 28 + "dark:bg-dark-100 rounded-md bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 29 props.class 30 } 31 onInput={props.onInput}
+4 -2
src/views/labels.tsx
··· 277 <Button 278 onClick={handleLoadMore} 279 disabled={loading()} 280 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-20 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 281 > 282 <Show 283 when={!loading()} 284 - fallback={<span class="iconify lucide--loader-circle animate-spin" />} 285 > 286 Load more 287 </Show>
··· 277 <Button 278 onClick={handleLoadMore} 279 disabled={loading()} 280 + classList={{ "w-20 justify-center": true }} 281 > 282 <Show 283 when={!loading()} 284 + fallback={ 285 + <span class="iconify lucide--loader-circle animate-spin text-base" /> 286 + } 287 > 288 Load more 289 </Show>
+20 -3
src/views/repo.tsx
··· 29 updateNotification, 30 } from "../components/notification.jsx"; 31 import Tooltip from "../components/tooltip.jsx"; 32 import { 33 didDocCache, 34 labelerCache, ··· 411 </ErrorBoundary> 412 </Show> 413 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 414 - <div class="flex flex-col pb-16 text-sm wrap-anywhere"> 415 <Show 416 when={Object.keys(nsids() ?? {}).length != 0} 417 fallback={<span class="mt-3 text-center text-base">No collections found.</span>} ··· 626 </Show> 627 628 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 629 - <div class="fixed bottom-12 z-10 w-full max-w-lg"> 630 <div 631 - class="dark:bg-dark-200 dark:shadow-dark-700 mx-3 flex cursor-text items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 shadow-md dark:border-neutral-700" 632 onClick={(e) => {
··· 29 updateNotification, 30 } from "../components/notification.jsx"; 31 import Tooltip from "../components/tooltip.jsx"; 32 + import { isTouchDevice } from "../layout.jsx"; 33 import { 34 didDocCache, 35 labelerCache, ··· 412 </ErrorBoundary> 413 </Show> 414 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 415 + <div 416 + class={`flex flex-col ${isTouchDevice ? "pb-12" : "pb-16"} text-sm wrap-anywhere`} 417 + > 418 <Show 419 when={Object.keys(nsids() ?? {}).length != 0} 420 fallback={<span class="mt-3 text-center text-base">No collections found.</span>} ··· 629 </Show> 630 631 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 632 + <div class={`fixed ${isTouchDevice ? "bottom-8" : "bottom-12"} z-10 w-full max-w-lg`}> 633 <div 634 + class="dark:bg-dark-200 dark:shadow-dark-700 mx-3 flex cursor-text items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 shadow-sm dark:border-neutral-700" 635 onClick={(e) => { 636 + const input = e.currentTarget.querySelector("input"); 637 + if (e.target !== input) input?.focus(); 638 + }} 639 + > 640 + <span class="iconify lucide--filter text-neutral-500 dark:text-neutral-400"></span> 641 + 642 + 643 + spellcheck={false} 644 + autocapitalize="off" 645 + autocomplete="off" 646 + class="grow py-2 select-none placeholder:text-sm focus:outline-none" 647 + name="filter" 648 + placeholder="Filter collections..." 649 + value={filter() ?? ""}
+1 -1
index.html
··· 6 <link rel="icon" href="/favicon.ico" /> 7 <meta property="og:title" content="PDSls" /> 8 <meta property="og:type" content="website" /> 9 - <meta property="og:url" content="https://pdsls.dev" /> 10 <meta property="og:description" content="Browse the public data on atproto" /> 11 <meta property="description" content="Browse the public data on atproto" /> 12 <link rel="manifest" href="/manifest.json" />
··· 6 <link rel="icon" href="/favicon.ico" /> 7 <meta property="og:title" content="PDSls" /> 8 <meta property="og:type" content="website" /> 9 + <meta property="og:url" content="https://pds.ls" /> 10 <meta property="og:description" content="Browse the public data on atproto" /> 11 <meta property="description" content="Browse the public data on atproto" /> 12 <link rel="manifest" href="/manifest.json" />
+9 -8
src/auth/account.tsx
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 - import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 26 setOpenManager, 27 setPendingPermissionEdit, 28 setSessions, 29 } from "./state.js"; 30 31 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { ··· 72 73 export const AccountManager = () => { 74 const [avatars, setAvatars] = createStore<Record<Did, string>>(); 75 - const [showingAddAccount, setShowingAddAccount] = createSignal(false); 76 77 const getThumbnailUrl = (avatarUrl: string) => { 78 return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); ··· 122 open={openManager()} 123 onClose={() => { 124 setOpenManager(false); 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> ··· 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> ··· 177 </button> 178 </Show> 179 180 - <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 181 - <Login onCancel={() => setShowingAddAccount(false)} /> 182 </Show> 183 184 <Show when={scopeFlow.showScopeSelector()}> ··· 189 onConfirm={scopeFlow.complete} 190 onCancel={() => { 191 scopeFlow.cancel(); 192 - setShowingAddAccount(false); 193 }} 194 /> 195 </Show>
··· 1 import { Did } from "@atcute/lexicons"; 2 import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 import { A } from "@solidjs/router"; 4 + import { createEffect, For, onMount, Show } from "solid-js"; 5 import { createStore, produce } from "solid-js/store"; 6 import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7 import { Modal } from "../components/modal.jsx"; ··· 26 setOpenManager, 27 setPendingPermissionEdit, 28 setSessions, 29 + setShowAddAccount, 30 + showAddAccount, 31 } from "./state.js"; 32 33 const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { ··· 74 75 export const AccountManager = () => { 76 const [avatars, setAvatars] = createStore<Record<Did, string>>(); 77 78 const getThumbnailUrl = (avatarUrl: string) => { 79 return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); ··· 123 open={openManager()} 124 onClose={() => { 125 setOpenManager(false); 126 + setShowAddAccount(false); 127 scopeFlow.cancel(); 128 }} 129 alignTop 130 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" 131 > 132 + <Show when={!scopeFlow.showScopeSelector() && !showAddAccount()}> 133 <div class="mb-2 px-1 font-semibold"> 134 <span>Switch account</span> 135 </div> ··· 170 </For> 171 </div> 172 <button 173 + onclick={() => setShowAddAccount(true)} 174 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" 175 > 176 <span class="iconify lucide--plus"></span> ··· 178 </button> 179 </Show> 180 181 + <Show when={showAddAccount() && !scopeFlow.showScopeSelector()}> 182 + <Login onCancel={() => setShowAddAccount(false)} /> 183 </Show> 184 185 <Show when={scopeFlow.showScopeSelector()}> ··· 190 onConfirm={scopeFlow.complete} 191 onCancel={() => { 192 scopeFlow.cancel(); 193 + setShowAddAccount(false); 194 }} 195 /> 196 </Show>
+3 -3
src/components/search.tsx
··· 348 </span> 349 <button 350 type="button" 351 - class="text-xs text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" 352 onClick={() => { 353 localStorage.removeItem(RECENT_SEARCHES_KEY); 354 setRecentSearches([]); ··· 390 </A> 391 <button 392 type="button" 393 - class="mr-1 flex items-center rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-neutral-300 dark:hover:bg-neutral-600" 394 onClick={() => { 395 removeRecentSearch(recent.path); 396 setRecentSearches(getRecentSearches()); 397 }} 398 > 399 - <span class="iconify lucide--x text-sm text-neutral-500 dark:text-neutral-400"></span> 400 </button> 401 </div> 402 );
··· 348 </span> 349 <button 350 type="button" 351 + class="text-xs not-hover:text-neutral-500 dark:not-hover:text-neutral-400" 352 onClick={() => { 353 localStorage.removeItem(RECENT_SEARCHES_KEY); 354 setRecentSearches([]); ··· 390 </A> 391 <button 392 type="button" 393 + class="flex items-center p-2.5 opacity-0 not-hover:text-neutral-500 group-hover:opacity-100 dark:not-hover:text-neutral-400" 394 onClick={() => { 395 removeRecentSearch(recent.path); 396 setRecentSearches(getRecentSearches()); 397 }} 398 > 399 + <span class="iconify lucide--x text-base"></span> 400 </button> 401 </div> 402 );
+11
src/workers/plc-validate.ts
···
··· 1 + import { processIndexedEntryLog } from "@atcute/did-plc"; 2 + 3 + self.onmessage = async (e: MessageEvent<{ did: string; logs: any }>) => { 4 + const { did, logs } = e.data; 5 + try { 6 + await processIndexedEntryLog(did as any, logs); 7 + self.postMessage({ valid: true }); 8 + } catch { 9 + self.postMessage({ valid: false }); 10 + } 11 + };