atmosphere explorer pds.ls
tool typescript atproto

Compare changes

Choose any two refs to compare.

+1757 -1038
+1 -1
src/components/hover-card/base.tsx
··· 107 107 <A 108 108 class={`text-blue-500 hover:underline active:underline dark:text-blue-400 ${props.labelClass || ""}`} 109 109 href={props.href!} 110 - target={props.newTab ? "_blank" : "_self"} 110 + target={props.newTab ? "_blank" : undefined} 111 111 > 112 112 {props.label} 113 113 </A>
+363 -315
src/views/stream/index.tsx
··· 3 3 import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 4 import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 5 import { Button } from "../../components/button"; 6 + import DidHoverCard from "../../components/hover-card/did"; 6 7 import { JSONValue } from "../../components/json"; 7 - import { StickyOverlay } from "../../components/sticky"; 8 8 import { TextInput } from "../../components/text-input"; 9 + import { addToClipboard } from "../../utils/copy"; 10 + import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 9 11 import { StreamStats, StreamStatsPanel } from "./stats"; 10 12 11 13 const LIMIT = 20; 12 - type Parameter = { name: string; param: string | string[] | undefined }; 13 14 14 - const StreamView = () => { 15 + const TYPE_COLORS: Record<string, string> = { 16 + create: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", 17 + update: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", 18 + delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", 19 + identity: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", 20 + account: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", 21 + sync: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", 22 + }; 23 + 24 + const StreamRecordItem = (props: { record: any; streamType: StreamType }) => { 25 + const [expanded, setExpanded] = createSignal(false); 26 + const config = () => STREAM_CONFIGS[props.streamType]; 27 + const info = () => config().parseRecord(props.record); 28 + 29 + const displayType = () => { 30 + const i = info(); 31 + return i.type === "commit" || i.type === "link" ? i.action : i.type; 32 + }; 33 + 34 + const copyRecord = (e: MouseEvent) => { 35 + e.stopPropagation(); 36 + addToClipboard(JSON.stringify(props.record, null, 2)); 37 + }; 38 + 39 + return ( 40 + <div class="flex flex-col gap-2"> 41 + <div class="flex items-start gap-1"> 42 + <button 43 + type="button" 44 + onclick={() => setExpanded(!expanded())} 45 + class="dark:hover:bg-dark-200 flex min-w-0 flex-1 items-start gap-2 rounded p-1 text-left hover:bg-neutral-200/70" 46 + > 47 + <span class="mt-0.5 shrink-0 text-neutral-400 dark:text-neutral-500"> 48 + {expanded() ? 49 + <span class="iconify lucide--chevron-down"></span> 50 + : <span class="iconify lucide--chevron-right"></span>} 51 + </span> 52 + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> 53 + <div class="flex items-center gap-x-1.5 sm:gap-x-2"> 54 + <span 55 + class={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${TYPE_COLORS[displayType()!] || "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300"}`} 56 + > 57 + {displayType()} 58 + </span> 59 + <Show when={info().collection && info().collection !== info().type}> 60 + <span class="min-w-0 truncate text-neutral-600 dark:text-neutral-300"> 61 + {info().collection} 62 + </span> 63 + </Show> 64 + <Show when={info().rkey}> 65 + <span class="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 = () => { 15 102 const [searchParams, setSearchParams] = useSearchParams(); 16 - const [parameters, setParameters] = createSignal<Parameter[]>([]); 17 - const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 18 - const [records, setRecords] = createSignal<Array<any>>([]); 103 + const streamType = getStreamType(useLocation().pathname); 104 + const config = () => STREAM_CONFIGS[streamType]; 105 + 106 + const [records, setRecords] = createSignal<any[]>([]); 19 107 const [connected, setConnected] = createSignal(false); 20 108 const [paused, setPaused] = createSignal(false); 21 109 const [notice, setNotice] = createSignal(""); 110 + const [parameters, setParameters] = createSignal<{ name: string; value?: string }[]>([]); 111 + const [stats, setStats] = createSignal<StreamStats>({ 112 + totalEvents: 0, 113 + eventsPerSecond: 0, 22 114 115 + collections: {}, 116 + }); 117 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 23 118 119 + let socket: WebSocket; 120 + let firehose: Firehose; 121 + let formRef!: HTMLFormElement; 24 122 123 + let rafId: number | null = null; 124 + let statsIntervalId: number | null = null; 125 + let statsUpdateIntervalId: number | null = null; 126 + let currentSecondEventCount = 0; 127 + let totalEventsCount = 0; 128 + let eventTypesMap: Record<string, number> = {}; 129 + let collectionsMap: Record<string, number> = {}; 25 130 131 + const addRecord = (record: any) => { 132 + currentSecondEventCount++; 133 + totalEventsCount++; 26 134 135 + const rawEventType = record.kind || record.$type || "unknown"; 136 + const eventType = rawEventType.includes("#") ? rawEventType.split("#").pop() : rawEventType; 137 + eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 27 138 139 + if (eventType !== "account" && eventType !== "identity") { 140 + const collection = 141 + record.commit?.collection || 142 + record.op?.path?.split("/")[0] || 143 + record.link?.source || 144 + "unknown"; 145 + collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 146 + } 28 147 29 148 30 149 ··· 36 155 37 156 38 157 158 + }; 39 159 160 + const disconnect = () => { 161 + if (!config().useFirehoseLib) socket?.close(); 162 + else firehose?.close(); 40 163 164 + if (rafId !== null) { 165 + cancelAnimationFrame(rafId); 166 + rafId = null; 41 167 42 168 43 169 44 170 45 171 46 172 173 + clearInterval(statsUpdateIntervalId); 174 + statsUpdateIntervalId = null; 175 + } 47 176 177 + pendingRecords = []; 178 + totalEventsCount = 0; 179 + eventTypesMap = {}; 180 + collectionsMap = {}; 181 + setConnected(false); 182 + setPaused(false); 183 + setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 184 + }; 48 185 186 + const connectStream = async (formData: FormData) => { 187 + setNotice(""); 188 + if (connected()) { 189 + disconnect(); 49 190 191 + } 192 + setRecords([]); 50 193 194 + const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 195 + const url = config().buildUrl(instance, formData); 51 196 197 + // Save all form fields to URL params 198 + const params: Record<string, string | undefined> = { instance }; 199 + config().fields.forEach((field) => { 200 + params[field.searchParam] = formData.get(field.name)?.toString(); 201 + }); 202 + setSearchParams(params); 52 203 204 + // Build parameters display 205 + setParameters([ 206 + { name: "Instance", value: instance }, 207 + ...config() 208 + .fields.filter((f) => f.type !== "checkbox") 209 + .map((f) => ({ name: f.label, value: formData.get(f.name)?.toString() })), 210 + ...config() 211 + .fields.filter((f) => f.type === "checkbox" && formData.get(f.name) === "on") 212 + .map((f) => ({ name: f.label, value: "on" })), 213 + ]); 53 214 215 + setConnected(true); 216 + const now = Date.now(); 217 + setCurrentTime(now); 54 218 219 + totalEventsCount = 0; 220 + eventTypesMap = {}; 221 + collectionsMap = {}; 55 222 56 223 57 224 ··· 67 234 68 235 69 236 237 + })); 238 + }, 50); 70 239 240 + statsIntervalId = window.setInterval(() => { 241 + setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 242 + currentSecondEventCount = 0; 243 + setCurrentTime(Date.now()); 244 + }, 1000); 71 245 246 + if (!config().useFirehoseLib) { 247 + socket = new WebSocket(url); 248 + socket.addEventListener("message", (event) => { 249 + const rec = JSON.parse(event.data); 250 + const isFilteredEvent = rec.kind === "account" || rec.kind === "identity"; 251 + if (!isFilteredEvent || streamType !== "jetstream" || searchParams.allEvents === "on") 252 + addRecord(rec); 253 + }); 254 + socket.addEventListener("error", () => { 72 255 256 + disconnect(); 257 + }); 258 + } else { 259 + const cursor = formData.get("cursor")?.toString(); 260 + firehose = new Firehose({ 261 + relay: url, 262 + cursor: cursor, 73 263 74 264 75 265 ··· 77 267 78 268 79 269 270 + }); 271 + firehose.on("commit", (commit) => { 272 + for (const op of commit.ops) { 273 + addRecord({ 274 + $type: commit.$type, 275 + repo: commit.repo, 276 + seq: commit.seq, 80 277 278 + rev: commit.rev, 279 + since: commit.since, 280 + op: op, 281 + }); 282 + } 283 + }); 284 + firehose.on("identity", (identity) => addRecord(identity)); 285 + firehose.on("account", (account) => addRecord(account)); 286 + firehose.on("sync", (sync) => { 287 + addRecord({ 288 + $type: sync.$type, 289 + did: sync.did, 290 + rev: sync.rev, 291 + seq: sync.seq, 292 + time: sync.time, 293 + }); 294 + }); 295 + firehose.start(); 296 + } 297 + }; 81 298 299 + onMount(() => { 300 + if (searchParams.instance) { 301 + const formData = new FormData(); 302 + formData.append("instance", searchParams.instance.toString()); 303 + config().fields.forEach((field) => { 304 + const value = searchParams[field.searchParam]; 305 + if (value) formData.append(field.name, value.toString()); 306 + }); 307 + connectStream(formData); 308 + } 309 + }); 82 310 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 - 126 - 127 - 128 - 129 - 130 - 131 - 132 - 133 - 134 - 135 - 136 - 137 - 138 - 139 - 140 - 141 - 142 - 143 - 144 - 145 - 146 - 147 - 148 - 149 - 150 - 151 - 152 - 153 - 154 - 155 - 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 - 164 - 165 - 166 - 167 - 168 - 169 - 170 - 171 - 172 - 173 - 174 - 175 - 176 - 177 - 178 - 179 - 180 - 181 - 182 - 183 - 184 - 185 - 186 - 187 - 188 - 189 - 190 - 191 - 192 - 193 - 194 - 195 - 196 - 197 - 198 - 199 - 200 - 201 - 202 - 203 - 204 - 205 - 206 - 207 - 208 - 209 - 210 - 211 - 212 - 213 - 214 - 215 - 216 - 217 - 218 - 219 - 220 - 221 - 222 - 223 - 224 - 225 - 226 - 227 - 228 - 229 - 230 - 231 - 232 - 233 - 234 - 235 - 236 - 237 - 238 - 239 - 240 - 241 - 242 - 243 - 244 - 245 - 246 - 247 - 248 - 249 - 250 - 251 - 252 - 253 - 254 - 255 - 256 - 257 - 258 - 259 - 260 - 261 - 262 - 263 - 264 - 311 + onCleanup(() => { 312 + socket?.close(); 313 + firehose?.close(); 314 + if (rafId !== null) cancelAnimationFrame(rafId); 315 + if (statsIntervalId !== null) clearInterval(statsIntervalId); 316 + if (statsUpdateIntervalId !== null) clearInterval(statsUpdateIntervalId); 317 + }); 265 318 266 319 return ( 267 320 <> 268 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 269 - <div class="flex w-full flex-col items-center"> 321 + <Title>{config().label} - PDSls</Title> 322 + <div class="flex w-full flex-col items-center gap-2"> 323 + {/* Tab Navigation */} 270 324 <div class="flex gap-4 font-medium"> 271 - <A 272 - class="flex items-center gap-1 border-b-2" 273 - 274 - 275 - 276 - 277 - 278 - 279 - 280 - 281 - 282 - 325 + <For each={STREAM_TYPES}> 326 + {(type) => ( 327 + <A 328 + class="flex items-center gap-1 border-b-2" 329 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 330 + href={`/${type}`} 331 + > 332 + {STREAM_CONFIGS[type].label} 333 + </A> 334 + )} 335 + </For> 336 + </div> 283 337 284 - </A> 338 + {/* Stream Description */} 339 + <div class="w-full px-2 text-center"> 340 + <p class="text-sm text-neutral-600 dark:text-neutral-400">{config().description}</p> 285 341 </div> 342 + 343 + {/* Connection Form */} 286 344 <Show when={!connected()}> 287 - <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 345 + <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 288 346 <label class="flex items-center justify-end gap-x-1"> 289 - <span class="min-w-20">Instance</span> 347 + <span class="min-w-21 select-none">Instance</span> 290 348 <TextInput 349 + name="instance" 350 + value={searchParams.instance ?? config().defaultInstance} 351 + class="grow" 352 + /> 353 + </label> 354 + 355 + <For each={config().fields}> 356 + {(field) => ( 357 + <label class="flex items-center justify-end gap-x-1"> 358 + <Show when={field.type === "checkbox"}> 359 + <input 360 + type="checkbox" 361 + name={field.name} 362 + id={field.name} 363 + checked={searchParams[field.searchParam] === "on"} 364 + /> 365 + </Show> 366 + <span class="min-w-21 select-none">{field.label}</span> 367 + <Show when={field.type === "textarea"}> 368 + <textarea 369 + name={field.name} 370 + spellcheck={false} 371 + placeholder={field.placeholder} 372 + value={(searchParams[field.searchParam] as string) ?? ""} 373 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 374 + /> 375 + </Show> 376 + <Show when={field.type === "text"}> 377 + <TextInput 378 + name={field.name} 379 + placeholder={field.placeholder} 380 + value={(searchParams[field.searchParam] as string) ?? ""} 381 + class="grow" 382 + /> 383 + </Show> 384 + </label> 385 + )} 386 + </For> 291 387 292 - 293 - 294 - 295 - 296 - 297 - 298 - 299 - 300 - 301 - 302 - 303 - 304 - 305 - 306 - 307 - 308 - 309 - 310 - 311 - 312 - 313 - 314 - 315 - 316 - 317 - 318 - 319 - 320 - 321 - 322 - 323 - 324 - 325 - 326 - 327 - 328 - 329 - 330 - 331 - 332 - 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - 345 - 346 - 347 - 348 - 349 - 388 + <div class="flex justify-end gap-2"> 389 + <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 390 + </div> 350 391 </form> 351 392 </Show> 393 + 394 + {/* Connected State */} 352 395 <Show when={connected()}> 353 - <StickyOverlay> 354 - <div class="flex w-full flex-col gap-2 p-1"> 355 - <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 356 - <div class="font-semibold">Parameters</div> 357 - <For each={parameters()}> 358 - {(param) => ( 359 - <Show when={param.param}> 360 - <div class="text-sm"> 361 - <div class="text-xs text-neutral-500 dark:text-neutral-400"> 362 - {param.name} 363 - </div> 364 - <div class="text-neutral-700 dark:text-neutral-300">{param.param}</div> 365 - </div> 366 - </Show> 367 - )} 368 - </For> 369 - </div> 370 - <StreamStatsPanel stats={stats()} currentTime={currentTime()} /> 371 - <div class="flex justify-end gap-2"> 372 - <button 373 - type="button" 374 - ontouchstart={(e) => { 375 - e.preventDefault(); 376 - requestAnimationFrame(() => togglePause()); 377 - }} 378 - onclick={togglePause} 379 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 380 - > 381 - {paused() ? "Resume" : "Pause"} 382 - </button> 383 - <button 384 - type="button" 385 - ontouchstart={(e) => { 386 - e.preventDefault(); 387 - requestAnimationFrame(() => disconnect()); 388 - }} 389 - onclick={disconnect} 390 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 391 - > 392 - Disconnect 393 - </button> 394 - </div> 396 + <div class="flex w-full flex-col gap-2 p-2"> 397 + <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 398 + <div class="font-semibold">Parameters</div> 399 + <For each={parameters()}> 400 + {(param) => ( 401 + <Show when={param.value}> 402 + <div class="text-sm"> 403 + <div class="text-xs text-neutral-500 dark:text-neutral-400">{param.name}</div> 404 + <div class="text-neutral-700 dark:text-neutral-300">{param.value}</div> 405 + </div> 406 + </Show> 407 + )} 408 + </For> 409 + </div> 410 + <StreamStatsPanel 411 + stats={stats()} 412 + currentTime={currentTime()} 413 + streamType={streamType} 414 + showAllEvents={searchParams.allEvents === "on"} 415 + /> 416 + <div class="flex justify-end gap-2"> 417 + <Button 418 + ontouchstart={(e) => { 419 + e.preventDefault(); 420 + requestAnimationFrame(() => setPaused(!paused())); 421 + }} 422 + onClick={() => setPaused(!paused())} 423 + > 424 + {paused() ? "Resume" : "Pause"} 425 + </Button> 426 + <Button 427 + ontouchstart={(e) => { 428 + e.preventDefault(); 429 + requestAnimationFrame(() => disconnect()); 430 + }} 431 + onClick={disconnect} 432 + > 433 + Disconnect 434 + </Button> 395 435 </div> 396 - </StickyOverlay> 436 + </div> 397 437 </Show> 438 + 439 + {/* Error Notice */} 398 440 <Show when={notice().length}> 399 441 <div class="text-red-500 dark:text-red-400">{notice()}</div> 400 442 </Show> 401 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-xs wrap-anywhere whitespace-pre-wrap sm:text-sm md:w-3xl"> 402 - <For each={records().toReversed()}> 403 - {(rec) => ( 404 - <div class="pb-2"> 405 - <JSONValue data={rec} repo={rec.did ?? rec.repo} hideBlobs /> 406 - </div> 407 - )} 408 - </For> 409 - </div> 443 + 444 + {/* Records List */} 445 + <Show when={connected() || records().length > 0}> 446 + <div class="flex min-h-280 w-full flex-col gap-2 font-mono text-xs [overflow-anchor:auto] sm:text-sm"> 447 + <For each={records().toReversed()}> 448 + {(rec) => ( 449 + <div class="[overflow-anchor:none]"> 450 + <StreamRecordItem record={rec} streamType={streamType} /> 451 + </div> 452 + )} 453 + </For> 454 + <div class="h-px [overflow-anchor:auto]" /> 455 + </div> 456 + </Show> 410 457 </div> 411 458 </> 412 459 ); 460 + };
+1 -1
src/index.tsx
··· 19 19 () => ( 20 20 <Router root={Layout}> 21 21 <Route path="/" component={Home} /> 22 - <Route path={["/jetstream", "/firehose"]} component={StreamView} /> 22 + <Route path={["/jetstream", "/firehose", "/spacedust"]} component={StreamView} /> 23 23 <Route path="/labels" component={LabelView} /> 24 24 <Route path="/car" component={CarView} /> 25 25 <Route path="/car/explore" component={ExploreToolView} />
+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 + };
+23 -6
src/views/stream/stats.tsx
··· 1 1 import { For, Show } from "solid-js"; 2 + import { STREAM_CONFIGS, StreamType } from "./config"; 2 3 3 4 export type StreamStats = { 4 5 connectedAt?: number; ··· 22 23 } 23 24 }; 24 25 25 - export const StreamStatsPanel = (props: { stats: StreamStats; currentTime: number }) => { 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]; 26 33 const uptime = () => (props.stats.connectedAt ? props.currentTime - props.stats.connectedAt : 0); 27 34 28 - const topCollections = () => 29 - 35 + const shouldShowEventTypes = () => { 36 + if (!config().showEventTypes) return false; 37 + if (props.streamType === "jetstream") return props.showAllEvents === true; 38 + return true; 39 + }; 30 40 41 + const topCollections = () => 42 + Object.entries(props.stats.collections) 43 + .sort(([, a], [, b]) => b - a) 31 44 32 45 33 46 ··· 60 73 </div> 61 74 </div> 62 75 63 - <Show when={topEventTypes().length > 0}> 76 + <Show when={topEventTypes().length > 0 && shouldShowEventTypes()}> 64 77 <div class="mt-2"> 65 78 <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Event Types</div> 66 79 <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> ··· 86 99 87 100 <Show when={topCollections().length > 0}> 88 101 <div class="mt-2"> 89 - <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Top Collections</div> 102 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400"> 103 + {config().collectionsLabel} 104 + </div> 90 105 <div class="grid grid-cols-[1fr_5rem_3rem] gap-x-1 gap-y-0.5 font-mono text-xs sm:gap-x-4"> 91 106 <For each={topCollections()}> 92 107 {([collection, count]) => { 93 108 const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 94 109 return ( 95 110 <> 96 - <span class="text-neutral-700 dark:text-neutral-300">{collection}</span> 111 + <span class="min-w-0 truncate text-neutral-700 dark:text-neutral-300"> 112 + {collection} 113 + </span> 97 114 <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 98 115 {count.toLocaleString()} 99 116 </span>
+7 -3
src/styles/index.css
··· 47 47 48 48 49 49 50 + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037c-.856 3.061-3.978 3.842-6.755 3.37c4.854.826 6.089 3.562 3.422 6.299c-5.065 5.196-7.28-1.304-7.847-2.97c-.104-.305-.152-.448-.153-.327c0-.121-.05.022-.153.327c-.568 1.666-2.782 8.166-7.847 2.97c-2.667-2.737-1.432-5.473 3.422-6.3c-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026'/%3E%3C/svg%3E"); 51 + } 50 52 53 + .i-raycast-light { 54 + --svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22228%22%20height%3D%22228%22%20viewBox%3D%220%200%20228%20228%22%20fill%3D%22none%22%3E%3Cg%20clip-path%3D%22url(%23a)%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22m227.987%20113.987-11.89%2011.903-45.11-45.11V56.987zM114.039%200l-11.89%2011.89%2045.097%2045.097h23.793zM88.521%2025.518%2076.618%2037.42l19.58%2019.566h23.792zm82.518%2082.53v23.794l19.501%2019.566%2011.903-11.89zm-6.859%2044.19%206.807-6.808h-88.47V56.987l-6.807%206.82L62.94%2051.1%2051.05%2062.99l12.758%2012.772-6.82%206.756v13.627L37.421%2076.566%2025.518%2088.47l31.469%2031.47v27.229L11.89%20102.097%200%20113.987l114.039%20114%2011.903-11.89-45.11-45.11h27.229l31.47%2031.482%2011.903-11.903-19.579-19.579h13.627l6.808-6.807%2012.771%2012.759%2011.891-11.891z%22%20fill%3D%22%23ff6363%22%2F%3E%3C%2Fg%3E%3Cdefs%3E%3CclipPath%20id%3D%22a%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M0%200h228v228H0z%22%2F%3E%3C%2FclipPath%3E%3C%2Fdefs%3E%3C%2Fsvg%3E"); 55 + } 51 56 52 - 53 - 54 - --svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M1.468%2010.977c-.55%201.061-.961%201.751-1.359%202.741a1.508%201.508%200%201%200%202.8%201.124l.227-.574v-.002c.28-.71.52-1.316.81-1.862.328-.018.702-.02%201.125-.023h.053c.77-.005%201.697-.01%202.497-.172s1.791-.545%202.229-1.57c.119-.278.239-.688.134-1.105h.151c.422%200%201.017.001%201.548-.143.62-.17%201.272-.569%201.558-1.41a1.52%201.52%200%200%200%20.034-.925l.084-.015.042-.007c.363-.063.849-.148%201.264-.304.404-.15%201.068-.488%201.267-1.262.113-.44.1-.908-.154-1.33a1.7%201.7%200%200%200-.36-.414c.112-.14.253-.333.35-.547.17-.371.257-.916-.089-1.45-.393-.604-1.066-.71-1.4-.737a6%206%200%200%200-.985.026%201.2%201.2%200%200%200-.156-.275c-.371-.496-.947-.538-1.272-.53-.655.018-1.167.31-1.538.61-.194.159-.657.806-.808.974%200-.603-.581-.91-.99-.973-.794-.123-1.285.388-1.742.973-.57.73-1.01%201.668-1.531%202.373-.18-.117-.393-.39-.733-.375-.56.026-.932.406-1.173.666-.419.452-.685%201.273-.867%201.885-.197.885-.332%201.258-.491%202.228a9.4%209.4%200%200%200-.144%201.677c-.109.213-.234.443-.381.728%22%20fill%3D%22%23639431%22%2F%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M4.714%204.78c.16.14.349.306.755.165.266-.093.61-.695.993-1.367.56-.982%201.205-2.114%201.816-2.02.738.114.693.523.658.837-.025.22-.044.394.216.387.264-.006.521-.317.82-.678.413-.498.904-1.092%201.602-1.11.492-.014.484.198.476.413-.005.138-.01.276.116.358.123.08.434.053.79.02.573-.052%201.265-.114%201.497.243.204.314-.056.626-.305.925-.21.254-.414.498-.321.726.076.186.231.291.383.394.25.168.491.33.361.834-.136.533-.96.677-1.732.812-.646.113-1.257.22-1.397.544-.088.203.058.297.222.403.195.127.415.27.292.633-.29.85-1.254.85-2.16.85-.777%200-1.51%200-1.735.537-.13.31.067.365.282.425.264.074.557.155.315.723-.464%201.087-2.195%201.096-3.78%201.105-.58.004-1.141.007-1.613.063a.18.18%200%200%200-.13.083c-.434.713-.742%201.496-1.07%202.332l-.221.559a.486.486%200%201%201-.903-.363c.373-.928.803-1.781%201.273-2.564.767-1.413%202.28-3.147%203.88-4.45%201.423-1.184%202.782-2.071%204.364-2.744.198-.084.139-.316-.068-.256-1.403.405-2.643%201.21-3.928%202.02-1.399.881-2.57%202.073-3.291%202.94-.127.153-.405.027-.365-.168.313-1.523.636-2.92%201.11-3.432.45-.485.603-.35.798-.18%22%20fill%3D%22%23d9ea72%22%2F%3E%3C%2Fsvg%3E"); 57 + .i-raycast-dark { 58 + --svg: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22228%22%20height%3D%22228%22%20viewBox%3D%220%200%20228%20228%22%20fill%3D%22none%22%3E%3Cg%20clip-path%3D%22url(%23a)%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M57%20147.207V171L-.052%20113.948l11.955-11.864zM80.793%20171H57l57.052%2057.052%2011.903-11.903zm135.317-45.11L228%20114%20114%200l-11.89%2011.89L147.155%2057h-27.229L88.482%2025.583%2076.58%2037.473l19.58%2019.579H82.53v88.469H171v-13.614l19.579%2019.579%2011.89-11.903L171%20108.048V80.819zM62.952%2051.049l-11.89%2011.903L63.82%2075.71l11.89-11.903zM164.193%20152.29l-11.852%2011.89%2012.759%2012.759%2011.903-11.891zM37.421%2076.58%2025.53%2088.47%2057%20119.951V96.145zM131.842%20171h-23.794l31.483%2031.469%2011.89-11.89z%22%20fill%3D%22%23ff6363%22%2F%3E%3C%2Fg%3E%3Cdefs%3E%3CclipPath%20id%3D%22a%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M0%200h228v228H0z%22%2F%3E%3C%2FclipPath%3E%3C%2Fdefs%3E%3C%2Fsvg%3E"); 55 59 } 56 60 57 61 @keyframes slideIn {
+206 -133
src/views/home.tsx
··· 1 1 import { A } from "@solidjs/router"; 2 - import { setOpenManager } from "../auth/state.js"; 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 + }; 3 11 4 12 export const Home = () => { 5 - return ( 6 - <div class="flex w-full flex-col gap-6 px-2 wrap-break-word"> 7 - <div class="flex flex-col gap-3"> 8 - 9 - 10 - 11 - 12 - 13 - 14 - 15 - 16 - 17 - 18 - 19 - 20 - 21 - 22 - 23 - 24 - 25 - 26 - 27 - 28 - </span> 29 - </a> 30 - <a 31 - href="https://constellation.microcosm.blue" 32 - target="_blank" 33 - class="group grid grid-cols-[auto_1fr] items-center gap-x-2.5 gap-y-0.5 hover:text-blue-500 dark:hover:text-blue-400" 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 - 66 - 67 - 68 - 69 - 70 - 71 - 72 - 73 - 74 - 75 - 76 - 77 - 78 - 79 - 80 - 81 - 82 - 83 - 84 - 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 + ); 85 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 + ]; 86 60 61 + const profiles = [...allExampleProfiles].sort(() => Math.random() - 0.5).slice(0, 3); 87 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> 88 82 89 - Raw repository event stream 90 - </span> 91 - </A> 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> 92 107 </div> 93 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> 94 126 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 - </section> 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> 129 198 </div> 130 199 131 - <div class="flex justify-center gap-2 text-sm text-neutral-600 dark:text-neutral-300"> 132 - <a 200 + <div class="flex justify-center gap-1.5 text-sm text-neutral-600 sm:gap-2 dark:text-neutral-300"> 201 + <FooterLink 133 202 href="https://juli.ee" 134 - target="_blank" 135 - 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 after:text-rose-400 after:transition-[width] after:duration-300 after:ease-out hover:after:w-full dark:after:text-rose-300" 203 + color="after:text-rose-400" 204 + darkColor="dark:after:text-rose-300" 136 205 > 137 206 <span class="iconify lucide--terminal text-rose-400 dark:text-rose-300"></span> 138 207 <span class="font-pecita">juliet</span> 139 - </a> 140 - โ€ข 141 - <a 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 142 216 href="https://bsky.app/profile/did:plc:6q5daed5gutiyerimlrnojnz" 143 - 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 after:text-[#0085ff] after:transition-[width] after:duration-300 after:ease-out hover:after:w-full" 144 - target="_blank" 217 + color="after:text-[#0085ff]" 145 218 > 146 219 <span class="simple-icons--bluesky iconify text-[#0085ff]"></span> 147 220 Bluesky 148 - </a> 221 + </FooterLink> 149 222 โ€ข 150 - <a 151 - href="https://tangled.org/@pdsls.dev/pdsls/" 152 - 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 after:text-black after:transition-[width] after:duration-300 after:ease-out hover:after:w-full dark:after:text-white" 153 - target="_blank" 223 + <FooterLink 224 + href="https://tangled.org/did:plc:6q5daed5gutiyerimlrnojnz/pdsls/" 225 + color="after:text-black" 226 + darkColor="dark:after:text-white" 154 227 > 155 228 <span class="iconify i-tangled text-black dark:text-white"></span> 156 229 Source 157 - </a> 230 + </FooterLink> 158 231 </div> 159 232 </div> 160 233 );
+90 -89
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, 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"; 1 8 2 9 3 10 ··· 16 23 17 24 18 25 26 + setOpenManager, 27 + setPendingPermissionEdit, 28 + setSessions, 29 + setShowAddAccount, 30 + showAddAccount, 31 + } from "./state.js"; 19 32 33 + const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 20 34 21 35 22 36 ··· 58 72 59 73 60 74 75 + export const AccountManager = () => { 76 + const [avatars, setAvatars] = createStore<Record<Did, string>>(); 61 77 78 + const getThumbnailUrl = (avatarUrl: string) => { 79 + return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); 62 80 63 81 64 82 ··· 102 120 103 121 104 122 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 - 123 + open={openManager()} 124 + onClose={() => { 125 + setOpenManager(false); 126 + setShowAddAccount(false); 126 127 scopeFlow.cancel(); 127 128 }} 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" 129 131 > 130 - <div class="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-88 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 131 - <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 132 - <div class="mb-2 px-1 font-semibold"> 133 - <span>Manage accounts</span> 134 - </div> 135 - <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 136 - <For each={Object.keys(sessions)}> 137 - {(did) => ( 138 - <div class="flex w-full items-center justify-between"> 139 - <A 140 - href={`/at://${did}`} 141 - onClick={() => setOpenManager(false)} 142 - class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 132 + <Show when={!scopeFlow.showScopeSelector() && !showAddAccount()}> 133 + <div class="mb-2 px-1 font-semibold"> 134 + <span>Switch account</span> 135 + </div> 136 + <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 137 + <For each={Object.keys(sessions)}> 138 + {(did) => ( 139 + <div class="flex w-full items-center justify-between"> 140 + <A 141 + href={`/at://${did}`} 142 + onClick={() => setOpenManager(false)} 143 + 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" 144 + > 145 + <Show 146 + when={avatars[did as Did]} 147 + fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 143 148 > 144 - <Show 145 - when={avatars[did as Did]} 146 - fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 147 - > 148 - <img 149 - src={getThumbnailUrl(avatars[did as Did])} 150 - class="size-6 rounded-full" 151 - /> 152 - </Show> 153 - </A> 154 - <button 155 - class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 156 - onclick={() => handleAccountClick(did as Did)} 157 - > 158 - <span class="truncate">{sessions[did]?.handle || did}</span> 159 - <Show when={did === agent()?.sub && sessions[did].signedIn}> 160 - <span class="iconify lucide--circle-check shrink-0 text-blue-500 dark:text-blue-400"></span> 161 - </Show> 162 - <Show when={!sessions[did].signedIn}> 163 - <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 164 - </Show> 165 - </button> 166 - <AccountDropdown 167 - did={did as Did} 168 - onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 169 - /> 170 - </div> 171 - )} 172 - </For> 173 - </div> 174 - <button 175 - onclick={() => setShowingAddAccount(true)} 176 - class="flex w-full items-center justify-center gap-2 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" 177 - > 178 - <span class="iconify lucide--plus"></span> 179 - <span>Add account</span> 180 - </button> 181 - </Show> 182 - 183 - <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 184 - <Login onCancel={() => setShowingAddAccount(false)} /> 185 - </Show> 186 - 187 - <Show when={scopeFlow.showScopeSelector()}> 188 - <ScopeSelector 189 - initialScopes={parseScopeString( 190 - sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 149 + <img src={getThumbnailUrl(avatars[did as Did])} class="size-6 rounded-full" /> 150 + </Show> 151 + </A> 152 + <button 153 + class="flex grow items-center justify-between gap-1 truncate rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 154 + onclick={() => handleAccountClick(did as Did)} 155 + > 156 + <span class="truncate">{sessions[did]?.handle || did}</span> 157 + <Show when={did === agent()?.sub && sessions[did].signedIn}> 158 + <span class="iconify lucide--circle-check shrink-0 text-blue-500 dark:text-blue-400"></span> 159 + </Show> 160 + <Show when={!sessions[did].signedIn}> 161 + <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 162 + </Show> 163 + </button> 164 + <AccountDropdown 165 + did={did as Did} 166 + onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 167 + /> 168 + </div> 191 169 )} 192 - onConfirm={scopeFlow.complete} 193 - onCancel={() => { 194 - scopeFlow.cancel(); 195 - setShowingAddAccount(false); 196 - }} 197 - /> 198 - </Show> 199 - </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> 177 + <span>Add account</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()}> 186 + <ScopeSelector 187 + initialScopes={parseScopeString( 188 + sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 189 + )} 190 + onConfirm={scopeFlow.complete} 191 + onCancel={() => { 192 + scopeFlow.cancel(); 193 + setShowAddAccount(false); 194 + }} 195 + /> 196 + </Show> 200 197 </Modal> 201 198 <button 202 199 onclick={() => setOpenManager(true)} 200 + class={`flex items-center rounded-md ${agent() && avatars[agent()!.sub] ? "p-1.25" : "p-1.5"} hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600`} 201 + > 202 + {agent() && avatars[agent()!.sub] ? 203 + <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" />
+10 -8
src/components/create/confirm-submit.tsx
··· 27 27 }; 28 28 29 29 return ( 30 - <div class="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[24rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 30 + <> 31 31 <div class="flex flex-col gap-3 text-sm"> 32 32 <h2 class="font-semibold">{props.isCreate ? "Create" : "Edit"} record</h2> 33 33 <div class="flex flex-col gap-1.5"> ··· 81 81 82 82 83 83 84 - 85 - 86 - 87 - 88 - 89 - 84 + <Button onClick={props.onClose}>Cancel</Button> 85 + <Button 86 + onClick={() => props.onConfirm(validate(), recreate())} 87 + classList={{ 88 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 89 + }} 90 + > 91 + {props.isCreate ? "Create" : "Edit"} 90 92 </Button> 91 93 </div> 92 94 </div> 93 - </div> 95 + </> 94 96 ); 95 97 };
+11 -9
src/components/create/file-upload.tsx
··· 50 50 }; 51 51 52 52 return ( 53 - <div class="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 53 + <> 54 54 <h2 class="mb-2 font-semibold">Upload blob</h2> 55 55 <div class="flex flex-col gap-2 text-sm"> 56 56 <div class="flex flex-col gap-1"> ··· 87 87 88 88 89 89 90 - 91 - 92 - 93 - 94 - 95 - 96 - 90 + <Show when={!uploading()}> 91 + <Button 92 + onClick={uploadBlob} 93 + classList={{ 94 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 95 + }} 96 + > 97 + Upload 98 + </Button> 97 99 </Show> 98 100 </div> 99 101 </div> 100 - </div> 102 + </> 101 103 ); 102 104 };
+11 -9
src/components/create/handle-input.tsx
··· 40 40 }; 41 41 42 42 return ( 43 - <div class="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 43 + <> 44 44 <h2 class="mb-2 font-semibold">Insert DID from handle</h2> 45 45 <form ref={handleFormRef} onSubmit={resolveDid} class="flex flex-col gap-2 text-sm"> 46 46 <div class="flex flex-col gap-1"> ··· 72 72 73 73 74 74 75 - 76 - 77 - 78 - 79 - 80 - 81 - 75 + <Show when={!resolving()}> 76 + <Button 77 + type="submit" 78 + classList={{ 79 + "bg-blue-500! text-white! border-none! hover:bg-blue-600! active:bg-blue-700! dark:bg-blue-600! dark:hover:bg-blue-500! dark:active:bg-blue-400!": true, 80 + }} 81 + > 82 + Insert 83 + </Button> 82 84 </Show> 83 85 </div> 84 86 </form> 85 - </div> 87 + </> 86 88 ); 87 89 };
+221 -205
src/components/create/index.tsx
··· 92 92 93 93 94 94 95 - 96 - 97 - 98 - 99 - 100 - 101 - 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 102 103 103 104 104 ··· 236 236 closeOnClick={false} 237 237 nonBlocking={isMinimized()} 238 238 alignTop 239 + contentClass={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700 ${isMaximized() ? "w-[calc(100%-1rem)] max-w-7xl h-[85vh]" : "w-[calc(100%-1rem)] max-w-3xl h-[65vh]"} ${isMinimized() ? "hidden" : ""}`} 239 240 > 240 - <div 241 - classList={{ 242 - "dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto flex flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-all duration-200 dark:border-neutral-700 starting:opacity-0": true, 243 - "w-[calc(100%-1rem)] max-w-3xl h-[65vh]": !isMaximized(), 244 - "w-[calc(100%-1rem)] max-w-7xl h-[85vh]": isMaximized(), 245 - hidden: isMinimized(), 246 - }} 247 - > 248 - <div class="mb-2 flex w-full justify-between text-base"> 249 - <div class="flex items-center gap-2"> 250 - <span class="font-semibold select-none"> 251 - {props.create ? "Creating" : "Editing"} record 252 - </span> 253 - </div> 254 - <div class="flex items-center gap-1"> 255 - <button 256 - type="button" 257 - onclick={() => setIsMinimized(true)} 258 - class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 259 - > 260 - <span class="iconify lucide--minus"></span> 261 - </button> 262 - <button 263 - type="button" 264 - onclick={() => setIsMaximized(!isMaximized())} 265 - class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 266 - > 267 - <span 268 - class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 269 - ></span> 270 - </button> 271 - <button 272 - id="close" 273 - onclick={() => setOpenDialog(false)} 274 - class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 241 + <div class="mb-2 flex w-full justify-between text-base"> 242 + <div class="flex items-center gap-2"> 243 + <span class="font-semibold select-none"> 244 + {props.create ? "Creating" : "Editing"} record 245 + </span> 246 + </div> 247 + <div class="flex items-center gap-1"> 248 + <button 249 + type="button" 250 + onclick={() => setIsMinimized(true)} 251 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 252 + > 253 + <span class="iconify lucide--minus"></span> 254 + </button> 255 + <button 256 + type="button" 257 + onclick={() => setIsMaximized(!isMaximized())} 258 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 259 + > 260 + <span 261 + class={`iconify ${isMaximized() ? "lucide--minimize-2" : "lucide--maximize-2"}`} 262 + ></span> 263 + </button> 264 + <button 265 + id="close" 266 + onclick={() => setOpenDialog(false)} 267 + class="flex items-center rounded-lg p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 268 + > 269 + <span class="iconify lucide--x"></span> 270 + </button> 271 + </div> 272 + </div> 273 + <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 274 + <Show when={props.create}> 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" 275 281 > 276 - <span class="iconify lucide--x"></span> 277 - </button> 282 + <For each={Object.keys(sessions)}> 283 + {(session) => ( 284 + <option value={session} selected={session === agent()?.sub}> 285 + {sessions[session].handle ?? session} 286 + </option> 287 + )} 288 + </For> 289 + </select> 290 + <span>/</span> 291 + <TextInput 292 + id="collection" 293 + name="collection" 294 + placeholder="Collection (default: $type)" 295 + class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 296 + onInput={(e) => { 297 + const value = e.currentTarget.value; 298 + if (!value || isNsid(value)) setCollectionError(""); 299 + else 300 + setCollectionError( 301 + "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", 302 + ); 303 + }} 304 + /> 305 + <span>/</span> 306 + <TextInput 307 + id="rkey" 308 + name="rkey" 309 + placeholder="Record key (default: TID)" 310 + class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 311 + onInput={(e) => { 312 + const value = e.currentTarget.value; 313 + if (!value || isRecordKey(value)) setRkeyError(""); 314 + else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); 315 + }} 316 + /> 278 317 </div> 279 - </div> 280 - <form ref={formRef} class="flex min-h-0 flex-1 flex-col gap-y-2"> 281 - <Show when={props.create}> 282 - <div class="flex flex-wrap items-center gap-1 text-sm"> 283 - <span>at://</span> 284 - <select 285 - class="dark:bg-dark-100 max-w-40 truncate rounded-lg border-[0.5px] border-neutral-300 bg-white px-1 py-1 select-none focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 286 - name="repo" 287 - id="repo" 288 - > 289 - <For each={Object.keys(sessions)}> 290 - {(session) => ( 291 - <option value={session} selected={session === agent()?.sub}> 292 - {sessions[session].handle ?? session} 293 - </option> 294 - )} 295 - </For> 296 - </select> 297 - <span>/</span> 298 - <TextInput 299 - id="collection" 300 - name="collection" 301 - placeholder="Collection (default: $type)" 302 - class={`w-40 placeholder:text-xs lg:w-52 ${collectionError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 303 - onInput={(e) => { 304 - const value = e.currentTarget.value; 305 - if (!value || isNsid(value)) setCollectionError(""); 306 - else 307 - setCollectionError( 308 - "Invalid collection: use reverse domain format (e.g. app.bsky.feed.post)", 309 - ); 310 - }} 311 - /> 312 - <span>/</span> 313 - <TextInput 314 - id="rkey" 315 - name="rkey" 316 - placeholder="Record key (default: TID)" 317 - class={`w-40 placeholder:text-xs lg:w-52 ${rkeyError() ? "border-red-500 focus:outline-red-500 dark:border-red-400 dark:focus:outline-red-400" : ""}`} 318 - onInput={(e) => { 319 - const value = e.currentTarget.value; 320 - if (!value || isRecordKey(value)) setRkeyError(""); 321 - else setRkeyError("Invalid record key: 1-512 chars, use a-z A-Z 0-9 . _ ~ : -"); 322 - }} 323 - /> 318 + <Show when={collectionError() || rkeyError()}> 319 + <div class="text-xs text-red-500 dark:text-red-400"> 320 + <div>{collectionError()}</div> 321 + <div>{rkeyError()}</div> 324 322 </div> 325 - <Show when={collectionError() || rkeyError()}> 326 - <div class="text-xs text-red-500 dark:text-red-400"> 327 - <div>{collectionError()}</div> 328 - <div>{rkeyError()}</div> 323 + </Show> 324 + </Show> 325 + <div class="min-h-0 flex-1"> 326 + <Suspense 327 + fallback={ 328 + <div class="flex h-full items-center justify-center"> 329 + <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 329 330 </div> 330 - </Show> 331 + } 332 + > 333 + <Editor 334 + content={JSON.stringify( 335 + !props.create ? props.record 336 + : params.rkey ? placeholder() 337 + : defaultPlaceholder(), 338 + null, 339 + 2, 340 + )} 341 + /> 342 + </Suspense> 343 + </div> 344 + <div class="flex flex-col gap-2"> 345 + <Show when={notice()}> 346 + <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 331 347 </Show> 332 - <div class="min-h-0 flex-1"> 333 - <Suspense 334 - fallback={ 335 - <div class="flex h-full items-center justify-center"> 336 - <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 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 + icon="lucide--id-card" 358 + label="Insert DID" 359 + onClick={insertDidFromHandle} 360 + /> 361 + <MenuItem 362 + icon="lucide--clock" 363 + label="Insert timestamp" 364 + onClick={insertTimestamp} 365 + /> 366 + <button 367 + type="button" 368 + class={ 369 + hasUserScope("blob") ? 370 + "flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 371 + : "flex items-center gap-2 rounded-md p-2 text-left text-xs opacity-40" 372 + } 373 + onClick={() => { 374 + if (hasUserScope("blob")) { 375 + setOpenInsertMenu(false); 376 + blobInput.click(); 377 + } 378 + }} 379 + > 380 + <span class="iconify lucide--upload shrink-0"></span> 381 + <span>Upload blob{hasUserScope("blob") ? "" : " (permission needed)"}</span> 382 + </button> 337 383 </div> 338 - } 339 - > 340 - <Editor 341 - content={JSON.stringify( 342 - !props.create ? props.record 343 - : params.rkey ? placeholder() 344 - : defaultPlaceholder(), 345 - null, 346 - 2, 347 - )} 384 + </Show> 385 + <input 386 + type="file" 387 + id="blob" 388 + class="sr-only" 389 + ref={blobInput} 390 + onChange={(e) => { 391 + if (e.target.files !== null) setOpenUpload(true); 392 + }} 348 393 /> 349 - </Suspense> 350 - </div> 351 - <div class="flex flex-col gap-2"> 352 - <Show when={notice()}> 353 - <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 354 - </Show> 355 - <div class="flex justify-between gap-2"> 356 - <div class="relative" ref={insertMenuRef}> 357 - <button 358 - type="button" 359 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 flex w-fit rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1.5 text-base shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 360 - onClick={() => setOpenInsertMenu(!openInsertMenu())} 361 - > 362 - <span class="iconify lucide--plus select-none"></span> 363 - </button> 364 - <Show when={openInsertMenu()}> 365 - <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"> 366 - <MenuItem 367 - icon="lucide--id-card" 368 - label="Insert DID" 369 - onClick={insertDidFromHandle} 370 - /> 371 - <MenuItem 372 - icon="lucide--clock" 373 - label="Insert timestamp" 374 - onClick={insertTimestamp} 375 - /> 376 - <button 377 - type="button" 378 - class={ 379 - hasUserScope("blob") ? 380 - "flex items-center gap-2 rounded-md p-2 text-left text-xs hover:bg-neutral-100 active:bg-neutral-200 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 381 - : "flex items-center gap-2 rounded-md p-2 text-left text-xs opacity-40" 382 - } 383 - onClick={() => { 384 - if (hasUserScope("blob")) { 385 - setOpenInsertMenu(false); 386 - blobInput.click(); 387 - } 388 - }} 389 - > 390 - <span class="iconify lucide--upload shrink-0"></span> 391 - <span>Upload blob{hasUserScope("blob") ? "" : " (permission needed)"}</span> 392 - </button> 393 - </div> 394 - </Show> 395 - <input 396 - type="file" 397 - id="blob" 398 - class="sr-only" 399 - ref={blobInput} 400 - onChange={(e) => { 401 - if (e.target.files !== null) setOpenUpload(true); 402 - }} 403 - /> 404 - </div> 405 - <Modal 406 - open={openUpload()} 394 + </div> 395 + <Modal 396 + open={openUpload()} 397 + onClose={() => setOpenUpload(false)} 398 + closeOnClick={false} 399 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 400 + > 401 + <FileUpload 402 + file={blobInput.files![0]} 403 + blobInput={blobInput} 407 404 onClose={() => setOpenUpload(false)} 408 - closeOnClick={false} 409 - > 410 - <FileUpload 411 - file={blobInput.files![0]} 412 - blobInput={blobInput} 413 - onClose={() => setOpenUpload(false)} 414 - /> 415 - </Modal> 416 - <Modal 417 - open={openHandleDialog()} 418 - onClose={() => setOpenHandleDialog(false)} 419 - closeOnClick={false} 420 - > 421 - <HandleInput onClose={() => setOpenHandleDialog(false)} /> 422 - </Modal> 423 - <Modal 424 - open={openConfirmDialog()} 405 + /> 406 + </Modal> 407 + <Modal 408 + open={openHandleDialog()} 409 + onClose={() => setOpenHandleDialog(false)} 410 + closeOnClick={false} 411 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[20rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 412 + > 413 + <HandleInput onClose={() => setOpenHandleDialog(false)} /> 414 + </Modal> 415 + <Modal 416 + open={openConfirmDialog()} 417 + onClose={() => setOpenConfirmDialog(false)} 418 + closeOnClick={false} 419 + contentClass="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-[24rem] rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md dark:border-neutral-700" 420 + > 421 + <ConfirmSubmit 422 + isCreate={props.create} 423 + onConfirm={(validate, recreate) => { 424 + if (props.create) { 425 + createRecord(validate); 426 + } else { 427 + editRecord(validate, recreate); 428 + } 429 + }} 425 430 onClose={() => setOpenConfirmDialog(false)} 426 - closeOnClick={false} 427 - > 428 - <ConfirmSubmit 429 - isCreate={props.create} 430 - onConfirm={(validate, recreate) => { 431 - if (props.create) { 432 - createRecord(validate); 433 - } else { 434 - editRecord(validate, recreate); 435 - } 436 - }} 437 - onClose={() => setOpenConfirmDialog(false)} 438 - /> 439 - </Modal> 440 - <div class="flex items-center justify-end gap-2"> 441 - <Button onClick={() => setOpenConfirmDialog(true)}> 442 - {props.create ? "Create..." : "Edit..."} 443 - </Button> 444 - </div> 431 + /> 432 + </Modal> 433 + <div class="flex items-center justify-end gap-2"> 434 + <Button onClick={() => setOpenConfirmDialog(true)}> 435 + {props.create ? "Create..." : "Edit..."} 436 + </Button> 445 437 </div> 446 438 </div> 447 - </form> 448 - </div> 439 + </div> 440 + </form> 449 441 </Modal> 450 442 <Show when={isMinimized() && openDialog()}> 451 443 <button 444 + 445 + 446 + 447 + 448 + 449 + 450 + 451 + 452 + 453 + 454 + 455 + 456 + 457 + 458 + 459 + 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()) {
+6 -1
src/components/modal.tsx
··· 6 6 closeOnClick?: boolean; 7 7 nonBlocking?: boolean; 8 8 alignTop?: boolean; 9 + contentClass?: string; 9 10 } 10 11 11 12 export const Modal = (props: ModalProps) => { ··· 54 55 } 55 56 }} 56 57 > 57 - {props.children} 58 + <div 59 + class={`transition-all starting:scale-95 starting:opacity-0 ${props.contentClass ?? ""}`} 60 + > 61 + {props.children} 62 + </div> 58 63 </div> 59 64 </Show> 60 65 );
+19 -15
src/components/permission-prompt.tsx
··· 27 27 }; 28 28 29 29 return ( 30 - <Modal open={requestedScope() !== null} onClose={() => setRequestedScope(null)}> 31 - <div class="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 transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 32 - <h2 class="mb-2 font-semibold">Permission required</h2> 33 - <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400"> 34 - You need the "{scopeLabel()}" permission to perform this action. 35 - </p> 36 - <div class="flex justify-end gap-2"> 37 - <Button onClick={() => setRequestedScope(null)}>Cancel</Button> 38 - <Button 39 - onClick={handleEditPermissions} 40 - class="dark:shadow-dark-700 rounded-lg bg-blue-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500 dark:active:bg-blue-400" 41 - > 42 - Edit permissions 43 - </Button> 44 - </div> 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> 45 49 </div> 46 50 </Modal> 47 51 );
+82 -19
src/views/collection.tsx
··· 37 37 38 38 39 39 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> 40 49 41 50 42 51 ··· 290 299 291 300 292 301 302 + /> 303 + </Show> 304 + </div> 305 + <Modal 306 + open={openDelete()} 307 + onClose={() => setOpenDelete(false)} 308 + 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" 309 + > 310 + <h2 class="mb-2 font-semibold"> 311 + {recreate() ? "Recreate" : "Delete"}{" "} 312 + {records.filter((r) => r.toDelete).length} records? 313 + </h2> 314 + <div class="flex justify-end gap-2"> 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 + </div> 328 + </Modal> 329 + </Show> 293 330 294 331 295 332 ··· 297 334 298 335 299 336 300 - /> 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> 301 382 </Show> 302 383 </div> 303 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 304 - <div class="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 transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 305 - <h2 class="mb-2 font-semibold"> 306 - {recreate() ? "Recreate" : "Delete"}{" "} 307 - {records.filter((r) => r.toDelete).length} records? 308 - </h2> 309 - <div class="flex justify-end gap-2"> 310 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 311 - <Button 312 - onClick={deleteRecords} 313 - class={`dark:shadow-dark-700 rounded-lg px-2 py-1.5 text-xs text-white shadow-xs select-none ${recreate() ? "bg-green-500 hover:bg-green-600 active:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 dark:active:bg-green-800" : "bg-red-500 hover:bg-red-600 active:bg-red-700"}`} 314 - > 315 - {recreate() ? "Recreate" : "Delete"} 316 - </Button> 317 - </div> 318 - </div> 319 - </Modal> 320 - </Show>
+183 -40
src/views/pds.tsx
··· 81 81 > 82 82 <span class="iconify lucide--info text-neutral-600 dark:text-neutral-400"></span> 83 83 </button> 84 - <Modal open={openInfo()} onClose={() => setOpenInfo(false)}> 85 - <div class="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto w-max max-w-[90vw] rounded-lg border-[0.5px] border-neutral-300 bg-white p-3 shadow-md transition-opacity duration-200 sm:max-w-xl dark:border-neutral-700 starting:opacity-0"> 86 - <div class="mb-2 flex items-center justify-between gap-4"> 87 - <p class="truncate font-semibold">{repo.did}</p> 88 - <button 89 - onclick={() => setOpenInfo(false)} 90 - class="flex shrink-0 items-center rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 active:bg-neutral-200 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200 dark:active:bg-neutral-600" 91 - > 92 - <span class="iconify lucide--x"></span> 93 - </button> 94 - </div> 95 - <div class="grid grid-cols-[auto_1fr] items-baseline gap-x-1 gap-y-0.5 text-sm"> 96 - <span class="font-medium">Head:</span> 97 - <span class="wrap-anywhere text-neutral-700 dark:text-neutral-300">{repo.head}</span> 98 - 99 - <Show when={TID.validate(repo.rev)}> 100 - <span class="font-medium">Rev:</span> 101 - <div class="flex gap-1"> 102 - <span class="text-neutral-700 dark:text-neutral-300">{repo.rev}</span> 103 - <span class="text-neutral-600 dark:text-neutral-400">ยท</span> 104 - <span class="text-neutral-600 dark:text-neutral-400"> 105 - {localDateFromTimestamp(TID.parse(repo.rev).timestamp / 1000)} 106 - </span> 107 - </div> 108 - </Show> 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> 109 101 110 - <Show when={repo.active !== undefined}> 111 - <span class="font-medium">Active:</span> 112 - <span 113 - class={`iconify self-center ${ 114 - repo.active ? 115 - "lucide--check text-green-500 dark:text-green-400" 116 - : "lucide--x text-red-500 dark:text-red-400" 117 - }`} 118 - ></span> 119 - </Show> 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> 120 112 121 - <Show when={repo.status}> 122 - <span class="font-medium">Status:</span> 123 - <span class="text-neutral-700 dark:text-neutral-300">{repo.status}</span> 124 - </Show> 125 - </div> 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> 126 128 </div> 127 129 </Modal> 128 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>
+16 -12
src/views/record.tsx
··· 418 418 > 419 419 <span class="iconify lucide--trash-2"></span> 420 420 </PermissionButton> 421 - <Modal open={openDelete()} onClose={() => setOpenDelete(false)}> 422 - <div class="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 transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 423 - <h2 class="mb-2 font-semibold">Delete this record?</h2> 424 - <div class="flex justify-end gap-2"> 425 - <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 426 - <Button 427 - onClick={deleteRecord} 428 - class="dark:shadow-dark-700 rounded-lg bg-red-500 px-2 py-1.5 text-xs text-white shadow-xs select-none hover:bg-red-400 active:bg-red-400" 429 - > 430 - Delete 431 - </Button> 432 - </div> 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> 433 437 </div> 434 438 </Modal> 435 439 </Show>
+9 -7
src/layout.tsx
··· 147 147 148 148 149 149 150 - 151 - 152 - 153 - 154 - 155 - 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" /> 156 158 </Show> 157 159 <AccountManager /> 158 160 <MenuProvider> 159 - <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-lg p-1.5"> 161 + <DropdownMenu icon="lucide--menu text-lg" buttonClass="rounded-md p-1.5"> 160 162 <NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" /> 161 163 <NavMenu href="/firehose" label="Firehose" icon="lucide--rss" /> 162 164 <NavMenu href="/spacedust" label="Spacedust" icon="lucide--orbit" />
+3 -9
src/utils/app-urls.ts
··· 3 3 export enum App { 4 4 Bluesky, 5 5 Tangled, 6 - Frontpage, 7 6 Pinksea, 8 - Linkat, 7 + Frontpage, 9 8 } 10 9 11 10 export const appName = { 12 11 [App.Bluesky]: "Bluesky", 13 12 [App.Tangled]: "Tangled", 14 - [App.Frontpage]: "Frontpage", 15 13 [App.Pinksea]: "Pinksea", 16 - [App.Linkat]: "Linkat", 14 + [App.Frontpage]: "Frontpage", 17 15 }; 18 16 19 17 export const appList: Record<AppUrl, App> = { ··· 22 20 "bsky.app": App.Bluesky, 23 21 "catsky.social": App.Bluesky, 24 22 "deer.aylac.top": App.Bluesky, 25 - "deer-social-ayla.pages.dev": App.Bluesky, 26 23 "deer.social": App.Bluesky, 27 24 "main.bsky.dev": App.Bluesky, 28 - "social.daniela.lol": App.Bluesky, 29 25 "witchsky.app": App.Bluesky, 30 26 "tangled.org": App.Tangled, 31 27 "frontpage.fyi": App.Frontpage, 32 28 "pinksea.art": App.Pinksea, 33 - "linkat.blue": App.Linkat, 34 29 }; 35 30 36 31 export const appHandleLink: Record<App, (url: string[]) => string> = { ··· 53 48 return `at://${user}/app.bsky.graph.follow/${rkey}`; 54 49 } 55 50 } else { 56 - return `at://${user}`; 51 + return `at://${user}/app.bsky.actor.profile/self`; 57 52 } 58 53 } else if (baseType === "starter-pack") { 59 54 return `at://${user}/app.bsky.graph.starterpack/${path[2]}`; ··· 106 101 107 102 return `at://${path[0]}`; 108 103 }, 109 - [App.Linkat]: (path) => `at://${path[0]}/blue.linkat.board/self`, 110 104 };
+4 -4
src/auth/login.tsx
··· 32 32 }; 33 33 34 34 return ( 35 - <div class="flex flex-col gap-y-2 px-1"> 35 + <div class="flex flex-col gap-y-3"> 36 36 <Show when={!scopeFlow.showScopeSelector()}> 37 37 <Show when={props.onCancel}> 38 - <div class="mb-1 flex items-center gap-2"> 38 + <div class="flex items-center gap-2"> 39 39 <button 40 40 onclick={handleCancel} 41 41 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" ··· 45 45 <div class="font-semibold">Add account</div> 46 46 </div> 47 47 </Show> 48 - <form class="flex flex-col gap-2" onsubmit={(e) => e.preventDefault()}> 48 + <form class="flex flex-col gap-3" onsubmit={(e) => e.preventDefault()}> 49 49 <label for="username" class="hidden"> 50 50 Add account 51 51 </label> ··· 69 69 </div> 70 70 <button 71 71 onclick={() => initiateLogin(loginInput())} 72 - class="grow rounded-lg border-[0.5px] border-neutral-300 bg-neutral-100 px-3 py-2 hover:bg-neutral-200 active:bg-neutral-300 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 72 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 73 73 > 74 74 Continue 75 75 </button>
+36 -26
src/auth/scope-selector.tsx
··· 44 44 }; 45 45 46 46 return ( 47 - <div class="flex flex-col gap-y-2"> 48 - <div class="mb-1 flex items-center gap-2"> 47 + <div class="flex flex-col gap-y-3"> 48 + <div class="flex items-center gap-2"> 49 49 <button 50 50 onclick={props.onCancel} 51 51 class="flex items-center rounded-md p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 52 52 53 53 54 - 55 - 56 - 57 - 58 - 59 - 60 - 61 - 62 - 63 - 64 - 65 - 66 - 67 - 68 - 69 - 70 - 71 - 72 - 73 - 74 - 75 - 76 - 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> 77 87 </div> 78 88 <button 79 89 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" 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" 81 91 > 82 92 Continue 83 93 </button>
-14
src/utils/templates.ts
··· 37 37 link: `https://pinksea.art/${uri.repo}`, 38 38 icon: "i-pinksea", 39 39 }), 40 - "blue.linkat.board": (uri) => ({ 41 - label: "Linkat", 42 - link: `https://linkat.blue/${uri.repo}`, 43 - }), 44 40 "sh.tangled.actor.profile": (uri) => ({ 45 41 label: "Tangled", 46 42 link: `https://tangled.org/${uri.repo}`, ··· 51 47 link: `https://tangled.org/${uri.repo}/${record.name}`, 52 48 icon: "i-tangled", 53 49 }), 54 - "pub.leaflet.document": (uri) => ({ 55 - label: "Leaflet", 56 - link: `https://leaflet.pub/p/${uri.repo}/${uri.rkey}`, 57 - icon: "iconify-color i-leaflet", 58 - }), 59 - "pub.leaflet.publication": (uri) => ({ 60 - label: "Leaflet", 61 - link: `https://leaflet.pub/lish/${uri.repo}/${uri.rkey}`, 62 - icon: "iconify-color i-leaflet", 63 - }), 64 50 };
-12
src/utils/types/lexicons.ts
··· 17 17 AppBskyLabelerService, 18 18 ChatBskyActorDeclaration, 19 19 } from "@atcute/bluesky"; 20 - import { 21 - PubLeafletComment, 22 - PubLeafletDocument, 23 - PubLeafletGraphSubscription, 24 - PubLeafletPublication, 25 - } from "@atcute/leaflet"; 26 20 import { 27 21 ShTangledActorProfile, 28 22 ShTangledFeedStar, ··· 85 79 "sh.tangled.repo.pull.status.merged": ShTangledRepoPullStatusMerged.mainSchema, 86 80 "sh.tangled.repo.pull.status.open": ShTangledRepoPullStatusOpen.mainSchema, 87 81 "sh.tangled.knot": ShTangledKnot.mainSchema, 88 - 89 - // Leaflet 90 - "pub.leaflet.comment": PubLeafletComment.mainSchema, 91 - "pub.leaflet.document": PubLeafletDocument.mainSchema, 92 - "pub.leaflet.graph.subscription": PubLeafletGraphSubscription.mainSchema, 93 - "pub.leaflet.publication": PubLeafletPublication.mainSchema, 94 82 };
+1
.gitignore
··· 2 2 dist 3 3 .env 4 4 .DS_Store 5 + public/oauth-client-metadata.json
-13
public/oauth-client-metadata.json
··· 1 - { 2 - "client_id": "https://pdsls.dev/oauth-client-metadata.json", 3 - "client_name": "PDSls", 4 - "client_uri": "https://pdsls.dev", 5 - "logo_uri": "https://pdsls.dev/favicon.ico", 6 - "redirect_uris": ["https://pdsls.dev/"], 7 - "scope": "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 8 - "grant_types": ["authorization_code", "refresh_token"], 9 - "response_types": ["code"], 10 - "token_endpoint_auth_method": "none", 11 - "application_type": "web", 12 - "dpop_bound_access_tokens": true 13 - }
+35
scripts/generate-oauth-metadata.js
··· 1 + import { mkdirSync, writeFileSync } from "fs"; 2 + import { dirname } from "path"; 3 + import { fileURLToPath } from "url"; 4 + 5 + const __filename = fileURLToPath(import.meta.url); 6 + const __dirname = dirname(__filename); 7 + 8 + const domain = process.env.APP_DOMAIN || "pdsls.dev"; 9 + const protocol = process.env.APP_PROTOCOL || "https"; 10 + const baseUrl = `${protocol}://${domain}`; 11 + 12 + const metadata = { 13 + client_id: `${baseUrl}/oauth-client-metadata.json`, 14 + client_name: "PDSls", 15 + client_uri: baseUrl, 16 + logo_uri: `${baseUrl}/favicon.ico`, 17 + redirect_uris: [`${baseUrl}/`], 18 + scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 19 + grant_types: ["authorization_code", "refresh_token"], 20 + response_types: ["code"], 21 + token_endpoint_auth_method: "none", 22 + application_type: "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + 26 + const outputPath = `${__dirname}/../public/oauth-client-metadata.json`; 27 + 28 + try { 29 + mkdirSync(dirname(outputPath), { recursive: true }); 30 + writeFileSync(outputPath, JSON.stringify(metadata, null, 2) + "\n"); 31 + console.log(`Generated OAuth metadata for ${baseUrl}`); 32 + } catch (error) { 33 + console.error("Failed to generate OAuth metadata:", error); 34 + process.exit(1); 35 + }
public/avatar/bad-example.com.jpg

This is a binary file and will not be displayed.

public/avatar/futur.blue.jpg

This is a binary file and will not be displayed.

public/avatar/hailey.at.jpg

This is a binary file and will not be displayed.

public/avatar/jaz.sh.jpg

This is a binary file and will not be displayed.

public/avatar/jcsalterego.bsky.social.jpg

This is a binary file and will not be displayed.

public/avatar/juli.ee.jpg

This is a binary file and will not be displayed.

public/avatar/mary.my.id.jpg

This is a binary file and will not be displayed.

public/avatar/retr0.id.jpg

This is a binary file and will not be displayed.

+21 -18
src/components/favicon.tsx
··· 1 - import { createSignal, JSX, Show } from "solid-js"; 1 + import { createSignal, JSX, Match, Show, Switch } from "solid-js"; 2 2 3 3 export const Favicon = (props: { 4 4 authority: string; ··· 8 8 const domain = () => props.authority.split(".").reverse().join("."); 9 9 10 10 const content = ( 11 - <> 12 - <Show when={!loaded()}> 13 - <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 14 - </Show> 15 - <img 16 - src={ 17 - ["bsky.app", "bsky.chat"].includes(domain()) ? 18 - "https://web-cdn.bsky.app/static/apple-touch-icon.png" 19 - : `https://${domain()}/favicon.ico` 20 - } 21 - alt="" 22 - class="h-4 w-4" 23 - classList={{ hidden: !loaded() }} 24 - onLoad={() => setLoaded(true)} 25 - onError={() => setLoaded(false)} 26 - /> 27 - </> 11 + <Switch> 12 + <Match when={domain() === "tangled.sh"}> 13 + <span class="iconify i-tangled size-4" /> 14 + </Match> 15 + <Match when={["bsky.app", "bsky.chat"].includes(domain())}> 16 + <img src="https://web-cdn.bsky.app/static/apple-touch-icon.png" class="size-4" /> 17 + </Match> 18 + <Match when={true}> 19 + <Show when={!loaded()}> 20 + <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 21 + </Show> 22 + <img 23 + src={`https://${domain()}/favicon.ico`} 24 + class="size-4" 25 + classList={{ hidden: !loaded() }} 26 + onLoad={() => setLoaded(true)} 27 + onError={() => setLoaded(false)} 28 + /> 29 + </Match> 30 + </Switch> 28 31 ); 29 32 30 33 return props.wrapper ?
+84
src/views/labels.tsx
··· 200 200 <> 201 201 <Title>Labels - PDSls</Title> 202 202 <div class="flex w-full flex-col items-center"> 203 + <div class="flex w-full flex-col gap-y-1 px-3 pb-3"> 204 + <h1 class="text-lg font-semibold">Labels</h1> 205 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 206 + Query labels applied by labelers to accounts and records. 207 + </p> 208 + </div> 203 209 <form 204 210 ref={formRef} 205 211 class="flex w-full max-w-3xl flex-col gap-y-3 px-3 pb-2" 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 + <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>
public/avatar/aylac.top.jpg

This is a binary file and will not be displayed.

public/avatar/computer.fish.jpg

This is a binary file and will not be displayed.

public/avatar/dreary.blacksky.app.jpg

This is a binary file and will not be displayed.

public/avatar/emilia.wtf.jpg

This is a binary file and will not be displayed.

public/avatar/futanari.observer.jpg

This is a binary file and will not be displayed.

public/avatar/mofu.run.jpg

This is a binary file and will not be displayed.

public/avatar/natalie.sh.jpg

This is a binary file and will not be displayed.

public/avatar/nekomimi.pet.jpg

This is a binary file and will not be displayed.

public/avatar/nullekko.moe.jpg

This is a binary file and will not be displayed.

public/avatar/paizuri.moe.jpg

This is a binary file and will not be displayed.

public/avatar/quilling.dev.jpg

This is a binary file and will not be displayed.

public/avatar/rainy.pet.jpg

This is a binary file and will not be displayed.

public/avatar/sapphic.moe.jpg

This is a binary file and will not be displayed.

public/avatar/blooym.dev.jpg

This is a binary file and will not be displayed.

public/avatar/isabelroses.com.jpg

This is a binary file and will not be displayed.

public/avatar/isuggest.selfce.st.jpg

This is a binary file and will not be displayed.

public/avatar/anyaustin.bsky.social.jpg

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

public/avatar/cwonus.org.jpg

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

public/avatar/olaren.dev.jpg

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

+2 -7
package.json
··· 10 10 "build": "vite build", 11 11 "serve": "vite preview" 12 12 }, 13 - "pnpm": { 14 - "overrides": { 15 - "seroval": "^1.4.1" 16 - } 17 - }, 18 13 "devDependencies": { 19 14 "@iconify-json/lucide": "^1.2.86", 20 15 "@iconify/tailwind4": "^1.2.1", 21 16 "@tailwindcss/vite": "^4.1.18", 22 - "prettier": "^3.8.0", 17 + "prettier": "^3.8.1", 23 18 "prettier-plugin-organize-imports": "^4.3.0", 24 19 "prettier-plugin-tailwindcss": "^0.7.2", 25 20 "tailwindcss": "^4.1.18", ··· 60 55 "@solidjs/router": "^0.15.4", 61 56 "codemirror": "^6.0.2", 62 57 "native-file-system-adapter": "^3.0.1", 63 - "solid-js": "^1.9.10" 58 + "solid-js": "^1.9.11" 64 59 }, 65 60 "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a" 66 61 }
+2 -2
src/components/backlinks.tsx
··· 46 46 }); 47 47 48 48 return ( 49 - <Show when={links()} fallback={<p class="px-3 py-2 text-neutral-500">Loadingโ€ฆ</p>}> 49 + <Show when={links()} fallback={<p class="px-3 py-2 text-center text-neutral-500">Loadingโ€ฆ</p>}> 50 50 <For each={links()!.linking_records}> 51 51 {({ did, collection, rkey }) => { 52 52 const timestamp = ··· 91 91 <div class="p-2"> 92 92 <Button 93 93 onClick={() => setMore(true)} 94 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-full items-center justify-center gap-1 rounded border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 94 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 w-full rounded-md border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 95 95 > 96 96 Load more 97 97 </Button>
+3 -1
src/components/button.tsx
··· 6 6 class?: string; 7 7 classList?: Record<string, boolean | undefined>; 8 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 + ontouchstart?: (e: TouchEvent) => void; 9 10 children?: JSX.Element; 10 11 } 11 12 ··· 16 17 disabled={props.disabled ?? false} 17 18 class={ 18 19 props.class ?? 19 - "dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 20 + "dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-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" 20 21 } 21 22 classList={props.classList} 22 23 onClick={props.onClick} 24 + ontouchstart={props.ontouchstart} 23 25 > 24 26 {props.children} 25 27 </button>
+1 -1
src/components/json.tsx
··· 292 292 <Show when={hide()}> 293 293 <button 294 294 onclick={() => setHide(false)} 295 - class="flex items-center gap-1 rounded-lg 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" 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 296 > 297 297 <span class="iconify lucide--image"></span> 298 298 <span class="font-sans">Show media</span>
+1 -1
src/components/notification.tsx
··· 87 87 </Show> 88 88 <Show when={notification.onCancel}> 89 89 <button 90 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 mt-1 rounded border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 90 + class="dark:hover:bg-dark-200 dark:active:bg-dark-100 dark:bg-dark-300 mt-1 rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700" 91 91 onClick={(e) => { 92 92 e.stopPropagation(); 93 93 notification.onCancel?.();
+1 -1
src/components/text-input.tsx
··· 25 25 disabled={props.disabled} 26 26 required={props.required} 27 27 class={ 28 - "dark:bg-dark-100 rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 28 + "dark:bg-dark-100 rounded-md bg-white px-2 py-1 outline-1 outline-neutral-200 select-none placeholder:text-sm focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400 " + 29 29 props.class 30 30 } 31 31 onInput={props.onInput}
+13 -5
src/views/blob.tsx
··· 50 50 <p> 51 51 {blobs()?.length} blob{(blobs()?.length ?? 0 > 1) ? "s" : ""} 52 52 </p> 53 - <Show when={!response.loading && cursor()}> 54 - <Button onClick={() => refetch()}>Load more</Button> 55 - </Show> 56 - <Show when={response.loading}> 57 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 53 + <Show when={cursor()}> 54 + <Button 55 + onClick={() => refetch()} 56 + disabled={response.loading} 57 + classList={{ "w-20 justify-center": true }} 58 + > 59 + <Show 60 + when={!response.loading} 61 + fallback={<span class="iconify lucide--loader-circle animate-spin text-base" />} 62 + > 63 + Load more 64 + </Show> 65 + </Button> 58 66 </Show> 59 67 </div> 60 68 </div>
+1 -1
src/views/car/shared.tsx
··· 123 123 </p> 124 124 <p class="text-xs text-neutral-500 dark:text-neutral-400">or</p> 125 125 </div> 126 - <label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 126 + <label class="dark:bg-dark-300 dark:hover:bg-dark-200 dark:active:bg-dark-100 flex items-center gap-1 rounded-md border border-neutral-300 bg-neutral-50 px-2.5 py-1.5 text-sm text-neutral-700 transition-colors select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:text-neutral-300"> 127 127 <input 128 128 type="file" 129 129 accept={isIOS ? undefined : ".car,application/vnd.ipld.car"}
+1 -1
src/views/car/explore.tsx
··· 531 531 }} 532 532 class="flex w-full items-baseline gap-1 text-left" 533 533 > 534 - <span class="shrink-0 text-sm text-blue-500 dark:text-blue-400"> 534 + <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 535 535 {entry.key} 536 536 </span> 537 537 <span class="truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl">
+28 -26
src/views/repo.tsx
··· 9 9 createSignal, 10 10 ErrorBoundary, 11 11 For, 12 - onMount, 13 12 Show, 14 13 Suspense, 15 14 } from "solid-js"; ··· 29 28 removeNotification, 30 29 updateNotification, 31 30 } from "../components/notification.jsx"; 32 - import { TextInput } from "../components/text-input.jsx"; 33 31 import Tooltip from "../components/tooltip.jsx"; 32 + import { isTouchDevice } from "../layout.jsx"; 34 33 import { 35 34 didDocCache, 36 - 35 + labelerCache, 37 36 38 37 39 38 ··· 53 52 const [didDoc, setDidDoc] = createSignal<DidDocument>(); 54 53 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 55 54 const [filter, setFilter] = createSignal<string>(); 56 - const [showFilter, setShowFilter] = createSignal(false); 57 55 const [validHandles, setValidHandles] = createStore<Record<string, boolean>>({}); 58 56 const [rotationKeys, setRotationKeys] = createSignal<Array<string>>([]); 59 57 let rpc: Client; ··· 328 326 </Show> 329 327 <MenuProvider> 330 328 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-sm p-1.5"> 331 - <Show 332 - when={!error() && (!location.hash || location.hash.startsWith("#collections"))} 333 - > 334 - <ActionMenu 335 - label="Filter collections" 336 - icon="lucide--filter" 337 - onClick={() => setShowFilter(!showFilter())} 338 - /> 339 - </Show> 340 329 <CopyMenu content={params.repo!} label="Copy DID" icon="lucide--copy" /> 341 330 <NavMenu 342 331 href={`/jetstream?dids=${params.repo}`} ··· 423 412 </ErrorBoundary> 424 413 </Show> 425 414 <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 426 - <Show when={showFilter()}> 427 - <TextInput 428 - name="filter" 429 - placeholder="Filter collections" 430 - onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 431 - class="grow" 432 - ref={(node) => { 433 - onMount(() => node.focus()); 434 - }} 435 - /> 436 - </Show> 437 415 <div 438 - class="flex flex-col text-sm wrap-anywhere" 439 - classList={{ "-mt-1": !showFilter() }} 416 + class={`flex flex-col ${isTouchDevice ? "pb-12" : "pb-16"} text-sm wrap-anywhere`} 440 417 > 441 418 <Show 442 419 when={Object.keys(nsids() ?? {}).length != 0} ··· 650 627 </div> 651 628 </div> 652 629 </Show> 630 + 631 + <Show when={nsids() && (!location.hash || location.hash.startsWith("#collections"))}> 632 + <div class={`fixed ${isTouchDevice ? "bottom-8" : "bottom-12"} z-10 w-full max-w-lg`}> 633 + <div 634 + class="dark:bg-dark-200 dark:shadow-dark-700 mx-3 flex cursor-text items-center gap-2 rounded-lg border border-neutral-200 bg-white px-3 shadow-sm dark:border-neutral-700" 635 + onClick={(e) => { 636 + const input = e.currentTarget.querySelector("input"); 637 + if (e.target !== input) input?.focus(); 638 + }} 639 + > 640 + <span class="iconify lucide--filter text-neutral-500 dark:text-neutral-400"></span> 641 + <input 642 + type="text" 643 + spellcheck={false} 644 + autocapitalize="off" 645 + autocomplete="off" 646 + class="grow py-2 select-none placeholder:text-sm focus:outline-none" 647 + name="filter" 648 + placeholder="Filter collections..." 649 + value={filter() ?? ""} 650 + onInput={(e) => setFilter(e.currentTarget.value.toLowerCase())} 651 + /> 652 + </div> 653 + </div> 654 + </Show> 653 655 </> 654 656 ); 655 657 };
+8 -2
src/utils/api.ts
··· 62 62 throw new Error("Not a valid DID identifier"); 63 63 } 64 64 65 - const doc = await didDocumentResolver().resolve(did); 66 - didDocCache[did] = doc; 65 + let doc: DidDocument; 66 + try { 67 + doc = await didDocumentResolver().resolve(did); 68 + didDocCache[did] = doc; 69 + } catch (e) { 70 + console.error(e); 71 + throw new Error("Error during did document resolution"); 72 + } 67 73 68 74 const pds = getPdsEndpoint(doc); 69 75 const labeler = getLabelerEndpoint(doc);
+1 -1
index.html
··· 6 6 <link rel="icon" href="/favicon.ico" /> 7 7 <meta property="og:title" content="PDSls" /> 8 8 <meta property="og:type" content="website" /> 9 - <meta property="og:url" content="https://pdsls.dev" /> 9 + <meta property="og:url" content="https://pds.ls" /> 10 10 <meta property="og:description" content="Browse the public data on atproto" /> 11 11 <meta property="description" content="Browse the public data on atproto" /> 12 12 <link rel="manifest" href="/manifest.json" />
+3 -3
src/components/navbar.tsx
··· 108 108 when={handle() !== props.params.repo} 109 109 fallback={<span class="truncate">{props.params.repo}</span>} 110 110 > 111 - <span class="shrink-0">{handle()}</span> 111 + <span class="max-w-full shrink-0 truncate">{handle()}</span> 112 112 <span class="truncate text-neutral-500 dark:text-neutral-400"> 113 113 ({props.params.repo}) 114 114 </span> ··· 125 125 when={handle() !== props.params.repo} 126 126 fallback={<span class="truncate">{props.params.repo}</span>} 127 127 > 128 - <span class="shrink-0">{handle()}</span> 128 + <span class="max-w-full shrink-0 truncate">{handle()}</span> 129 129 <span class="truncate">({props.params.repo})</span> 130 130 </Show> 131 131 </A> ··· 170 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 171 </Tooltip> 172 172 <div class="flex min-w-0 gap-1 py-0.5 font-medium"> 173 - <span class="shrink-0">{props.params.rkey}</span> 173 + <span>{props.params.rkey}</span> 174 174 <Show when={rkeyTimestamp()}> 175 175 <span class="truncate text-neutral-500 dark:text-neutral-400"> 176 176 ({localDateFromTimestamp(rkeyTimestamp()!)})
+1
src/auth/state.ts
··· 13 13 export const [agent, setAgent] = createSignal<OAuthUserAgent | undefined>(); 14 14 export const [sessions, setSessions] = createStore<Sessions>(); 15 15 export const [openManager, setOpenManager] = createSignal(false); 16 + export const [showAddAccount, setShowAddAccount] = createSignal(false); 16 17 export const [pendingPermissionEdit, setPendingPermissionEdit] = createSignal<string | null>(null);
+3 -3
src/components/search.tsx
··· 348 348 </span> 349 349 <button 350 350 type="button" 351 - class="text-xs text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" 351 + class="text-xs not-hover:text-neutral-500 dark:not-hover:text-neutral-400" 352 352 onClick={() => { 353 353 localStorage.removeItem(RECENT_SEARCHES_KEY); 354 354 setRecentSearches([]); ··· 390 390 </A> 391 391 <button 392 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" 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 394 onClick={() => { 395 395 removeRecentSearch(recent.path); 396 396 setRecentSearches(getRecentSearches()); 397 397 }} 398 398 > 399 - <span class="iconify lucide--x text-sm text-neutral-500 dark:text-neutral-400"></span> 399 + <span class="iconify lucide--x text-base"></span> 400 400 </button> 401 401 </div> 402 402 );
+13 -14
src/views/logs.tsx
··· 3 3 defs, 4 4 IndexedEntry, 5 5 IndexedEntryLog, 6 - processIndexedEntryLog, 7 6 } from "@atcute/did-plc"; 8 - import { createEffect, createResource, createSignal, For, Show } from "solid-js"; 7 + import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; 9 8 import { localDateFromTimestamp } from "../utils/date.js"; 10 9 import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 10 + import PlcValidateWorker from "../workers/plc-validate.ts?worker"; 11 11 import { plcDirectory } from "./settings.jsx"; 12 12 13 13 type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method"; ··· 32 32 return Array.from(groupBy(opHistory, (item) => item.orig)); 33 33 }; 34 34 35 - const validateLog = async (logs: IndexedEntryLog) => { 36 - try { 37 - await processIndexedEntryLog(props.did as any, logs); 38 - setValidLog(true); 39 - } catch (e) { 40 - console.error(e); 41 - setValidLog(false); 42 - } 43 - }; 44 - 45 35 const [plcOps] = 46 36 createResource<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>(fetchPlcLogs); 47 37 38 + let worker: Worker | undefined; 39 + onCleanup(() => worker?.terminate()); 40 + 48 41 createEffect(() => { 49 42 const logs = rawLogs(); 50 43 if (logs) { 51 44 setValidLog(undefined); 52 - // Defer validation to next tick to avoid blocking rendering 53 - setTimeout(() => validateLog(logs), 0); 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 }); 54 53 } 55 54 }); 56 55
+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 + };