atmosphere explorer
at main 433 lines 18 kB view raw
1import { ComAtprotoRepoApplyWrites, ComAtprotoRepoGetRecord } from "@atcute/atproto"; 2import { Client, simpleFetchHandler } from "@atcute/client"; 3import { $type, ActorIdentifier, InferXRPCBodyOutput } from "@atcute/lexicons"; 4import * as TID from "@atcute/tid"; 5import { Title } from "@solidjs/meta"; 6import { A, useBeforeLeave, useParams, useSearchParams } from "@solidjs/router"; 7import { createMemo, createResource, createSignal, For, onMount, Show } from "solid-js"; 8import { createStore } from "solid-js/store"; 9import { agent } from "../auth/state"; 10import { Button } from "../components/button.jsx"; 11import HoverCard from "../components/hover-card/base"; 12import { JSONType, JSONValue } from "../components/json.jsx"; 13import { Modal } from "../components/modal.jsx"; 14import { addNotification, removeNotification } from "../components/notification.jsx"; 15import { PermissionButton } from "../components/permission-button.jsx"; 16import { StickyOverlay } from "../components/sticky.jsx"; 17import { TextInput } from "../components/text-input.jsx"; 18import Tooltip from "../components/tooltip.jsx"; 19import { resolvePDS } from "../utils/api.js"; 20import { localDateFromTimestamp } from "../utils/date.js"; 21import { 22 clearCollectionCache, 23 getCollectionCache, 24 setCollectionCache, 25} from "../utils/route-cache.js"; 26 27interface AtprotoRecord { 28 rkey: string; 29 cid: string; 30 record: InferXRPCBodyOutput<ComAtprotoRepoGetRecord.mainSchema["output"]>; 31 timestamp: number | undefined; 32 toDelete: boolean; 33} 34 35const DEFAULT_LIMIT = 100; 36 37const RecordLink = (props: { record: AtprotoRecord }) => { 38 return ( 39 <HoverCard 40 class="flex w-full min-w-0 items-baseline rounded px-1 py-0.5" 41 trigger={ 42 <> 43 <span class="max-w-full shrink-0 truncate text-sm text-blue-500 dark:text-blue-400"> 44 {props.record.rkey} 45 </span> 46 <span class="ml-1 truncate text-xs text-neutral-500 dark:text-neutral-400" dir="rtl"> 47 {props.record.cid} 48 </span> 49 <Show when={props.record.timestamp && props.record.timestamp <= Date.now()}> 50 <span class="ml-1 shrink-0 text-xs"> 51 {localDateFromTimestamp(props.record.timestamp!)} 52 </span> 53 </Show> 54 </> 55 } 56 > 57 <JSONValue 58 data={props.record.record.value as JSONType} 59 repo={props.record.record.uri.split("/")[2]} 60 truncate 61 hideBlobs 62 /> 63 </HoverCard> 64 ); 65}; 66 67const CollectionView = () => { 68 const params = useParams(); 69 const [searchParams, setSearchParams] = useSearchParams(); 70 const [cursor, setCursor] = createSignal<string>(); 71 const [records, setRecords] = createStore<AtprotoRecord[]>([]); 72 const [filter, setFilter] = createSignal<string>(); 73 const [batchDelete, setBatchDelete] = createSignal(false); 74 const [lastSelected, setLastSelected] = createSignal<number>(); 75 const [reverse, setReverse] = createSignal(searchParams.reverse === "true"); 76 const limit = () => { 77 const limitParam = 78 Array.isArray(searchParams.limit) ? searchParams.limit[0] : searchParams.limit; 79 const paramLimit = parseInt(limitParam || ""); 80 return !isNaN(paramLimit) && paramLimit > 0 && paramLimit <= 100 ? paramLimit : DEFAULT_LIMIT; 81 }; 82 const [recreate, setRecreate] = createSignal(false); 83 const [openDelete, setOpenDelete] = createSignal(false); 84 const [restoredFromCache, setRestoredFromCache] = createSignal(false); 85 const did = params.repo; 86 let pds: string; 87 let rpc: Client; 88 89 const cacheKey = () => `${params.pds}/${params.repo}/${params.collection}`; 90 91 onMount(() => { 92 const cached = getCollectionCache(cacheKey()); 93 if (cached) { 94 setRecords(cached.records as AtprotoRecord[]); 95 setCursor(cached.cursor); 96 setReverse(cached.reverse); 97 setSearchParams({ 98 reverse: cached.reverse ? "true" : undefined, 99 limit: cached.limit !== DEFAULT_LIMIT ? cached.limit.toString() : undefined, 100 }); 101 setRestoredFromCache(true); 102 requestAnimationFrame(() => { 103 window.scrollTo(0, cached.scrollY); 104 }); 105 } 106 }); 107 108 useBeforeLeave((e) => { 109 const recordPathPrefix = `/at://${did}/${params.collection}/`; 110 const isNavigatingToRecord = typeof e.to === "string" && e.to.startsWith(recordPathPrefix); 111 112 if (isNavigatingToRecord && records.length > 0) { 113 setCollectionCache(cacheKey(), { 114 records: [...records], 115 cursor: cursor(), 116 scrollY: window.scrollY, 117 reverse: reverse(), 118 limit: limit(), 119 }); 120 } else { 121 clearCollectionCache(cacheKey()); 122 } 123 }); 124 125 const fetchRecords = async () => { 126 if (restoredFromCache() && records.length > 0 && !cursor()) { 127 setRestoredFromCache(false); 128 return records; 129 } 130 if (restoredFromCache()) setRestoredFromCache(false); 131 132 const isLoadMore = cursor() !== undefined; 133 134 if (!pds) pds = await resolvePDS(did!); 135 if (!rpc) rpc = new Client({ handler: simpleFetchHandler({ service: pds }) }); 136 const res = await rpc.get("com.atproto.repo.listRecords", { 137 params: { 138 repo: did as ActorIdentifier, 139 collection: params.collection as `${string}.${string}.${string}`, 140 limit: limit(), 141 cursor: cursor(), 142 reverse: reverse(), 143 }, 144 }); 145 if (!res.ok) throw new Error(res.data.error); 146 setCursor(res.data.records.length < limit() ? undefined : res.data.cursor); 147 const tmpRecords: AtprotoRecord[] = []; 148 res.data.records.forEach((record) => { 149 const rkey = record.uri.split("/").pop()!; 150 tmpRecords.push({ 151 rkey: rkey, 152 cid: record.cid, 153 record: record, 154 timestamp: TID.validate(rkey) ? TID.parse(rkey).timestamp / 1000 : undefined, 155 toDelete: false, 156 }); 157 }); 158 setRecords(isLoadMore ? records.concat(tmpRecords) : tmpRecords); 159 return res.data.records; 160 }; 161 162 const [response, { refetch }] = createResource(fetchRecords); 163 164 const filteredRecords = createMemo(() => 165 records.filter((rec) => 166 filter() ? JSON.stringify(rec.record.value).includes(filter()!) : true, 167 ), 168 ); 169 170 const deleteRecords = async () => { 171 const recsToDel = records.filter((record) => record.toDelete); 172 let writes: Array< 173 | $type.enforce<ComAtprotoRepoApplyWrites.Delete> 174 | $type.enforce<ComAtprotoRepoApplyWrites.Create> 175 > = []; 176 recsToDel.forEach((record) => { 177 writes.push({ 178 $type: "com.atproto.repo.applyWrites#delete", 179 collection: params.collection as `${string}.${string}.${string}`, 180 rkey: record.rkey, 181 }); 182 if (recreate()) { 183 writes.push({ 184 $type: "com.atproto.repo.applyWrites#create", 185 collection: params.collection as `${string}.${string}.${string}`, 186 rkey: record.rkey, 187 value: record.record.value, 188 }); 189 } 190 }); 191 192 const BATCHSIZE = 200; 193 rpc = new Client({ handler: agent()! }); 194 for (let i = 0; i < writes.length; i += BATCHSIZE) { 195 await rpc.post("com.atproto.repo.applyWrites", { 196 input: { 197 repo: agent()!.sub, 198 writes: writes.slice(i, i + BATCHSIZE), 199 }, 200 }); 201 } 202 const id = addNotification({ 203 message: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, 204 type: "success", 205 }); 206 setTimeout(() => removeNotification(id), 3000); 207 setBatchDelete(false); 208 setRecords([]); 209 setCursor(undefined); 210 setOpenDelete(false); 211 setRecreate(false); 212 clearCollectionCache(cacheKey()); 213 refetch(); 214 }; 215 216 const handleSelectionClick = (e: MouseEvent, index: number) => { 217 if (e.shiftKey && lastSelected() !== undefined) 218 setRecords( 219 { 220 from: lastSelected()! < index ? lastSelected() : index + 1, 221 to: index > lastSelected()! ? index - 1 : lastSelected(), 222 }, 223 "toDelete", 224 true, 225 ); 226 else setLastSelected(index); 227 }; 228 229 const selectAll = () => 230 setRecords( 231 records 232 .map((record, index) => 233 JSON.stringify(record.record.value).includes(filter() ?? "") ? index : undefined, 234 ) 235 .filter((i) => i !== undefined), 236 "toDelete", 237 true, 238 ); 239 240 return ( 241 <> 242 <Title>{params.collection} - PDSls</Title> 243 <Show when={records.length || response()}> 244 <div class="-mt-2 flex w-full flex-col items-center"> 245 <StickyOverlay> 246 <div class="flex w-full flex-col gap-2"> 247 <div class="flex items-center gap-1.5"> 248 <Show when={agent() && agent()?.sub === did}> 249 <div class="flex items-center"> 250 <PermissionButton 251 scope="delete" 252 tooltip={batchDelete() ? "Cancel" : "Manage"} 253 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 254 disabledClass="flex items-center rounded-md p-1.5 opacity-40" 255 onClick={() => { 256 setRecords({ from: 0, to: records.length - 1 }, "toDelete", false); 257 setLastSelected(undefined); 258 setBatchDelete(!batchDelete()); 259 }} 260 > 261 <span 262 class={`iconify ${batchDelete() ? "lucide--x" : "lucide--trash-2"} `} 263 ></span> 264 </PermissionButton> 265 <Show when={batchDelete()}> 266 <Tooltip 267 text="Select all" 268 children={ 269 <button 270 onclick={() => selectAll()} 271 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 272 > 273 <span class="iconify lucide--list-checks"></span> 274 </button> 275 } 276 /> 277 <PermissionButton 278 scope="create" 279 tooltip="Recreate" 280 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 281 disabledClass="flex items-center rounded-md p-1.5 opacity-40" 282 onClick={() => { 283 setRecreate(true); 284 setOpenDelete(true); 285 }} 286 > 287 <span class="iconify lucide--recycle text-green-500 dark:text-green-400"></span> 288 </PermissionButton> 289 <Tooltip 290 text="Delete" 291 children={ 292 <button 293 onclick={() => { 294 setRecreate(false); 295 setOpenDelete(true); 296 }} 297 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 298 > 299 <span class="iconify lucide--trash-2 text-red-500 dark:text-red-400"></span> 300 </button> 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> 330 <TextInput 331 name="Filter" 332 placeholder="Filter records" 333 onInput={(e) => setFilter(e.currentTarget.value)} 334 class="grow text-sm" 335 /> 336 <Tooltip text="Jetstream"> 337 <A 338 href={`/jetstream?collections=${params.collection}&dids=${params.repo}`} 339 class="flex items-center rounded-md p-1.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 340 > 341 <span class="iconify lucide--radio-tower"></span> 342 </A> 343 </Tooltip> 344 </div> 345 <Show when={records.length > 1}> 346 <div class="flex items-center justify-between gap-x-2"> 347 <Button 348 onClick={() => { 349 const newReverse = !reverse(); 350 setReverse(newReverse); 351 setSearchParams({ reverse: newReverse ? "true" : undefined }); 352 setCursor(undefined); 353 setRestoredFromCache(false); 354 clearCollectionCache(cacheKey()); 355 refetch(); 356 }} 357 classList={{ 358 "text-blue-500! dark:text-blue-400! border-blue-500! dark:border-blue-400!": 359 reverse(), 360 }} 361 > 362 <span 363 class={`iconify ${reverse() ? "lucide--arrow-down-wide-narrow" : "lucide--arrow-up-narrow-wide"}`} 364 ></span> 365 Reverse 366 </Button> 367 <div> 368 <Show when={batchDelete()}> 369 <span>{records.filter((rec) => rec.toDelete).length}</span> 370 <span>/</span> 371 </Show> 372 <span>{filter() ? filteredRecords().length : records.length} records</span> 373 </div> 374 <div class="flex w-20 items-center justify-end"> 375 <Show when={cursor()}> 376 <Show when={!response.loading}> 377 <Button onClick={() => refetch()}>Load more</Button> 378 </Show> 379 <Show when={response.loading}> 380 <div class="iconify lucide--loader-circle w-20 animate-spin text-lg" /> 381 </Show> 382 </Show> 383 </div> 384 </div> 385 </Show> 386 </div> 387 </StickyOverlay> 388 <div class="flex max-w-full flex-col px-2 font-mono"> 389 <For each={filteredRecords()}> 390 {(record, index) => { 391 const rounding = () => { 392 const recs = filteredRecords(); 393 const prevSelected = recs[index() - 1]?.toDelete; 394 const nextSelected = recs[index() + 1]?.toDelete; 395 return `${!prevSelected ? "rounded-t" : ""} ${!nextSelected ? "rounded-b" : ""}`; 396 }; 397 return ( 398 <> 399 <Show when={batchDelete()}> 400 <div 401 class={`select-none ${ 402 record.toDelete ? 403 `bg-blue-200 hover:bg-blue-300/80 active:bg-blue-300 dark:bg-blue-700/30 dark:hover:bg-blue-700/50 dark:active:bg-blue-700/70 ${rounding()}` 404 : "rounded hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 405 }`} 406 onclick={(e) => { 407 handleSelectionClick(e, index()); 408 setRecords(index(), "toDelete", !record.toDelete); 409 }} 410 > 411 <RecordLink record={record} /> 412 </div> 413 </Show> 414 <Show when={!batchDelete()}> 415 <A 416 href={`/at://${did}/${params.collection}/${record.rkey}`} 417 class="rounded select-none hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 418 > 419 <RecordLink record={record} /> 420 </A> 421 </Show> 422 </> 423 ); 424 }} 425 </For> 426 </div> 427 </div> 428 </Show> 429 </> 430 ); 431}; 432 433export { CollectionView };