forked from pdsls.dev/pdsls
atproto explorer

Compare changes

Choose any two refs to compare.

+1 -1
src/components/button.tsx
··· 13 13 type="button" 14 14 class={ 15 15 props.class ?? 16 - "dark:hover:bg-dark-200 dark:shadow-dark-800 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 font-semibold shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 16 + "dark:hover:bg-dark-200 dark:shadow-dark-800 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" 17 17 } 18 18 classList={props.classList} 19 19 onClick={props.onClick}
+139 -82
src/components/create.tsx
··· 1 1 import { Client } from "@atcute/client"; 2 2 import { remove } from "@mary/exif-rm"; 3 3 import { useNavigate, useParams } from "@solidjs/router"; 4 - import { createSignal, Show } from "solid-js"; 4 + import { createSignal, onCleanup, Show } from "solid-js"; 5 5 import { Editor, editorView } from "../components/editor.jsx"; 6 6 import { agent } from "../components/login.jsx"; 7 7 import { setNotif } from "../layout.jsx"; ··· 15 15 const params = useParams(); 16 16 const [openDialog, setOpenDialog] = createSignal(false); 17 17 const [notice, setNotice] = createSignal(""); 18 - const [uploading, setUploading] = createSignal(false); 18 + const [openUpload, setOpenUpload] = createSignal(false); 19 + let blobInput!: HTMLInputElement; 19 20 let formRef!: HTMLFormElement; 20 21 21 22 const placeholder = () => { ··· 125 126 } 126 127 }; 127 128 128 - const uploadBlob = async () => { 129 - setNotice(""); 130 - let blob: Blob; 129 + const FileUpload = (props: { file: File }) => { 130 + const [uploading, setUploading] = createSignal(false); 131 + const [error, setError] = createSignal(""); 131 132 132 - const file = (document.getElementById("blob") as HTMLInputElement)?.files?.[0]; 133 - if (!file) return; 133 + onCleanup(() => (blobInput.value = "")); 134 134 135 - const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 136 - (document.getElementById("mimetype") as HTMLInputElement).value = ""; 137 - if (mimetype) blob = new Blob([file], { type: mimetype }); 138 - else blob = file; 135 + const formatFileSize = (bytes: number) => { 136 + if (bytes === 0) return "0 Bytes"; 137 + const k = 1024; 138 + const sizes = ["Bytes", "KB", "MB", "GB"]; 139 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 140 + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; 141 + }; 142 + 143 + const uploadBlob = async () => { 144 + let blob: Blob; 145 + 146 + const mimetype = (document.getElementById("mimetype") as HTMLInputElement)?.value; 147 + (document.getElementById("mimetype") as HTMLInputElement).value = ""; 148 + if (mimetype) blob = new Blob([props.file], { type: mimetype }); 149 + else blob = props.file; 150 + 151 + if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 152 + const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 153 + if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); 154 + } 139 155 140 - if ((document.getElementById("exif-rm") as HTMLInputElement).checked) { 141 - const exifRemoved = remove(new Uint8Array(await blob.arrayBuffer())); 142 - if (exifRemoved !== null) blob = new Blob([exifRemoved], { type: blob.type }); 143 - } 156 + const rpc = new Client({ handler: agent()! }); 157 + setUploading(true); 158 + const res = await rpc.post("com.atproto.repo.uploadBlob", { 159 + input: blob, 160 + }); 161 + setUploading(false); 162 + if (!res.ok) { 163 + setError(res.data.error); 164 + return; 165 + } 166 + editorView.dispatch({ 167 + changes: { 168 + from: editorView.state.selection.main.head, 169 + insert: JSON.stringify(res.data.blob, null, 2), 170 + }, 171 + }); 172 + setOpenUpload(false); 173 + }; 144 174 145 - const rpc = new Client({ handler: agent()! }); 146 - setUploading(true); 147 - const res = await rpc.post("com.atproto.repo.uploadBlob", { 148 - input: blob, 149 - }); 150 - setUploading(false); 151 - (document.getElementById("blob") as HTMLInputElement).value = ""; 152 - if (!res.ok) { 153 - setNotice(res.data.error); 154 - return; 155 - } 156 - editorView.dispatch({ 157 - changes: { 158 - from: editorView.state.selection.main.head, 159 - insert: JSON.stringify(res.data.blob, null, 2), 160 - }, 161 - }); 175 + return ( 176 + <div class="dark:bg-dark-300 dark:shadow-dark-800 absolute top-70 left-[50%] w-[20rem] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 dark:border-neutral-700 starting:opacity-0"> 177 + <h2 class="mb-2 font-semibold">Upload blob</h2> 178 + <div class="flex flex-col gap-2 text-sm"> 179 + <div class="flex flex-col gap-1"> 180 + <p class="flex gap-1"> 181 + <span class="truncate">{props.file.name}</span> 182 + <span class="shrink-0 text-neutral-600 dark:text-neutral-400"> 183 + ({formatFileSize(props.file.size)}) 184 + </span> 185 + </p> 186 + </div> 187 + <div class="flex items-center gap-x-2"> 188 + <label for="mimetype" class="shrink-0 select-none"> 189 + MIME type 190 + </label> 191 + <TextInput id="mimetype" placeholder={props.file.type} /> 192 + </div> 193 + <div class="flex items-center gap-1"> 194 + <input id="exif-rm" type="checkbox" checked /> 195 + <label for="exif-rm" class="select-none"> 196 + Remove EXIF data 197 + </label> 198 + </div> 199 + <p class="text-xs text-neutral-600 dark:text-neutral-400"> 200 + Metadata will be pasted after the cursor 201 + </p> 202 + <Show when={error()}> 203 + <span class="text-red-500 dark:text-red-400">Error: {error()}</span> 204 + </Show> 205 + <div class="flex justify-between gap-2"> 206 + <Button onClick={() => setOpenUpload(false)}>Cancel</Button> 207 + <Show when={uploading()}> 208 + <div class="flex items-center gap-1"> 209 + <span class="iconify lucide--loader-circle animate-spin"></span> 210 + <span>Uploading</span> 211 + </div> 212 + </Show> 213 + <Show when={!uploading()}> 214 + <Button 215 + onClick={uploadBlob} 216 + class="dark:shadow-dark-800 flex items-center gap-1 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" 217 + > 218 + Upload 219 + </Button> 220 + </Show> 221 + </div> 222 + </div> 223 + </div> 224 + ); 162 225 }; 163 226 164 227 return ( ··· 180 243 </button> 181 244 </div> 182 245 <form ref={formRef} class="flex flex-col gap-y-2"> 183 - <div class="flex w-fit flex-col gap-y-1 text-xs sm:text-sm"> 246 + <div class="flex w-fit flex-col gap-y-1 text-sm"> 184 247 <Show when={props.create}> 185 248 <div class="flex items-center gap-x-2"> 186 249 <label for="collection" class="min-w-20 select-none"> ··· 189 252 <TextInput 190 253 id="collection" 191 254 name="collection" 192 - placeholder="Optional (default: record type)" 255 + placeholder="Optional (default: $type)" 193 256 class="w-[15rem]" 194 257 /> 195 258 </div> ··· 219 282 <option value="false">False</option> 220 283 </select> 221 284 </div> 222 - <div class="flex items-center gap-2"> 223 - <Show when={!uploading()}> 224 - <div class="dark:hover:bg-dark-200 dark:shadow-dark-800 dark:active:bg-dark-100 flex rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 text-xs font-semibold shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 225 - <input type="file" id="blob" class="sr-only" onChange={() => uploadBlob()} /> 226 - <label class="flex items-center gap-1 px-2 py-1.5 select-none" for="blob"> 227 - <span class="iconify lucide--upload text-sm"></span> 228 - Upload 229 - </label> 230 - </div> 231 - <p class="text-xs">Metadata will be pasted after the cursor</p> 232 - </Show> 233 - <Show when={uploading()}> 234 - <span class="iconify lucide--loader-circle animate-spin text-xl"></span> 235 - <p>Uploading...</p> 236 - </Show> 237 - </div> 238 - <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> 239 - <div class="flex items-center gap-x-2"> 240 - <label for="mimetype" class="min-w-20 select-none"> 241 - MIME type 242 - </label> 243 - <TextInput id="mimetype" placeholder="Optional" class="w-[15rem]" /> 244 - </div> 245 - <div class="flex items-center gap-1"> 246 - <input id="exif-rm" type="checkbox" checked /> 247 - <label for="exif-rm" class="select-none"> 248 - Remove EXIF data 249 - </label> 250 - </div> 251 - </div> 252 285 </div> 253 286 <Editor 254 287 content={JSON.stringify(props.create ? placeholder() : props.record, null, 2)} 255 288 /> 256 289 <div class="flex flex-col gap-2"> 257 290 <Show when={notice()}> 258 - <div class="text-red-500 dark:text-red-400">{notice()}</div> 291 + <div class="text-sm text-red-500 dark:text-red-400">{notice()}</div> 259 292 </Show> 260 - <div class="flex items-center justify-end gap-2"> 261 - <Show when={!props.create}> 262 - <div class="flex items-center gap-1"> 263 - <input id="recreate" name="recreate" type="checkbox" /> 264 - <label for="recreate" class="text-sm select-none"> 265 - Recreate record 266 - </label> 267 - </div> 268 - </Show> 269 - <Button 270 - onClick={() => 271 - props.create ? 272 - createRecord(new FormData(formRef)) 273 - : editRecord(new FormData(formRef)) 274 - } 293 + <div class="flex justify-between gap-2"> 294 + <div class="dark:hover:bg-dark-200 dark:shadow-dark-800 dark:active:bg-dark-100 flex w-fit rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 text-xs shadow-xs hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800"> 295 + <input 296 + type="file" 297 + id="blob" 298 + class="sr-only" 299 + ref={blobInput} 300 + onChange={(e) => { 301 + if (e.target.files !== null) setOpenUpload(true); 302 + }} 303 + /> 304 + <label class="flex items-center gap-1 px-2 py-1.5 select-none" for="blob"> 305 + <span class="iconify lucide--upload"></span> 306 + Upload 307 + </label> 308 + </div> 309 + <Modal 310 + open={openUpload()} 311 + onClose={() => setOpenUpload(false)} 312 + closeOnClick={false} 275 313 > 276 - {props.create ? "Create" : "Edit"} 277 - </Button> 314 + <FileUpload file={blobInput.files![0]} /> 315 + </Modal> 316 + <div class="flex items-center justify-end gap-2"> 317 + <Show when={!props.create}> 318 + <div class="flex items-center gap-1"> 319 + <input id="recreate" name="recreate" type="checkbox" /> 320 + <label for="recreate" class="text-sm select-none"> 321 + Recreate record 322 + </label> 323 + </div> 324 + </Show> 325 + <Button 326 + onClick={() => 327 + props.create ? 328 + createRecord(new FormData(formRef)) 329 + : editRecord(new FormData(formRef)) 330 + } 331 + > 332 + {props.create ? "Create" : "Edit"} 333 + </Button> 334 + </div> 278 335 </div> 279 336 </div> 280 337 </form>
+14 -20
src/components/navbar.tsx
··· 5 5 import Tooltip from "./tooltip"; 6 6 7 7 export const [pds, setPDS] = createSignal<string>(); 8 - export const [cid, setCID] = createSignal<string>(); 9 8 export const [isLabeler, setIsLabeler] = createSignal(false); 10 9 11 - const NavBar = (props: { params: Params }) => { 10 + export const NavBar = (props: { params: Params }) => { 12 11 const location = useLocation(); 13 12 const [handle, setHandle] = createSignal(props.params.repo); 14 13 const [showHandle, setShowHandle] = createSignal(localStorage.showHandle === "true"); ··· 45 44 </Show> 46 45 </Show> 47 46 </div> 48 - <MenuProvider> 49 - <DropdownMenu 50 - icon="lucide--copy text-base" 51 - buttonClass="rounded p-0.5" 52 - menuClass="top-6 p-2 text-xs" 53 - > 54 - <Show when={pds()}> 55 - <CopyMenu copyContent={pds()!} label="Copy PDS" /> 56 - </Show> 57 - <Show when={props.params.repo}> 47 + <Show when={props.params.repo}> 48 + <MenuProvider> 49 + <DropdownMenu 50 + icon="lucide--copy text-base" 51 + buttonClass="rounded p-0.5" 52 + menuClass="top-6 p-2 text-xs" 53 + > 54 + <Show when={pds()}> 55 + <CopyMenu copyContent={pds()!} label="Copy PDS" /> 56 + </Show> 58 57 <CopyMenu copyContent={props.params.repo} label="Copy DID" /> 59 58 <CopyMenu 60 59 copyContent={`at://${props.params.repo}${props.params.collection ? `/${props.params.collection}` : ""}${props.params.rkey ? `/${props.params.rkey}` : ""}`} 61 60 label="Copy AT URI" 62 61 /> 63 - </Show> 64 - <Show when={props.params.rkey && cid()}> 65 - <CopyMenu copyContent={cid()!} label="Copy CID" /> 66 - </Show> 67 - </DropdownMenu> 68 - </MenuProvider> 62 + </DropdownMenu> 63 + </MenuProvider> 64 + </Show> 69 65 </div> 70 66 <div class="flex flex-col flex-wrap"> 71 67 <Show when={props.params.repo}> ··· 147 143 </nav> 148 144 ); 149 145 }; 150 - 151 - export { NavBar };
+62 -6
src/components/search.tsx
··· 2 2 import { A, useLocation, useNavigate } from "@solidjs/router"; 3 3 import { createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 4 4 import { isTouchDevice } from "../layout"; 5 - import { appHandleLink, appList, AppUrl } from "../utils/app-urls"; 5 + import { appHandleLink, appList, appName, AppUrl } from "../utils/app-urls"; 6 6 import { createDebouncedValue } from "../utils/hooks/debounced"; 7 + import { Modal } from "./modal"; 7 8 8 9 export const [showSearch, setShowSearch] = createSignal(false); 9 10 ··· 67 68 input = input.trim().replace(/^@/, ""); 68 69 if (!input.length) return; 69 70 setShowSearch(false); 70 - if (input === "me" && localStorage.getItem("lastSignedIn") !== null) { 71 - navigate(`/at://${localStorage.getItem("lastSignedIn")}`); 72 - } else if (search()?.length) { 71 + if (search()?.length) { 73 72 navigate(`/at://${search()![0].did}`); 74 73 } else if (input.startsWith("https://") || input.startsWith("http://")) { 75 74 const hostLength = input.indexOf("/", 8); ··· 87 86 } else { 88 87 navigate(`/at://${input.replace("at://", "")}`); 89 88 } 90 - setShowSearch(false); 91 89 }; 92 90 93 91 return ( ··· 116 114 value={input() ?? ""} 117 115 onInput={(e) => setInput(e.currentTarget.value)} 118 116 /> 119 - <Show when={input()}> 117 + <Show when={input()} fallback={ListUrlsTooltip()}> 120 118 <button 121 119 type="button" 122 120 class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" ··· 146 144 </div> 147 145 </Show> 148 146 </form> 147 + ); 148 + }; 149 + 150 + const ListUrlsTooltip = () => { 151 + const [openList, setOpenList] = createSignal(false); 152 + 153 + let urls: Record<string, AppUrl[]> = {}; 154 + for (const [appUrl, appView] of Object.entries(appList)) { 155 + if (!urls[appView]) urls[appView] = [appUrl as AppUrl]; 156 + else urls[appView].push(appUrl as AppUrl); 157 + } 158 + 159 + return ( 160 + <> 161 + <Modal open={openList()} onClose={() => setOpenList(false)}> 162 + <div class="dark:bg-dark-300 dark:shadow-dark-800 absolute top-16 left-[50%] w-[22rem] -translate-x-1/2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 shadow-md transition-opacity duration-200 sm:w-[26rem] dark:border-neutral-700 starting:opacity-0"> 163 + <div class="mb-2 flex items-center gap-1 font-semibold"> 164 + <span class="iconify lucide--link"></span> 165 + <span>Supported URLs</span> 166 + </div> 167 + <div class="mb-2 text-sm text-neutral-600 dark:text-neutral-400"> 168 + Links that will be parsed automatically, as long as all the data necessary is on the 169 + URL. 170 + </div> 171 + <div class="flex flex-col gap-2 text-sm"> 172 + <For each={Object.entries(appName)}> 173 + {([appView, name]) => { 174 + return ( 175 + <div> 176 + <p class="font-semibold">{name}</p> 177 + <div class="grid grid-cols-2 gap-x-4 text-neutral-600 dark:text-neutral-400"> 178 + <For each={urls[appView]}> 179 + {(url) => ( 180 + <a 181 + href={`${url.startsWith("localhost:") ? "http://" : "https://"}${url}`} 182 + target="_blank" 183 + class="hover:underline active:underline" 184 + > 185 + {url} 186 + </a> 187 + )} 188 + </For> 189 + </div> 190 + </div> 191 + ); 192 + }} 193 + </For> 194 + </div> 195 + </div> 196 + </Modal> 197 + <button 198 + type="button" 199 + class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500" 200 + onClick={() => setOpenList(true)} 201 + > 202 + <span class="iconify lucide--help-circle"></span> 203 + </button> 204 + </> 149 205 ); 150 206 }; 151 207
+9
src/utils/app-urls.ts
··· 9 9 Linkat, 10 10 } 11 11 12 + export const appName = { 13 + [App.Bluesky]: "Bluesky", 14 + [App.Tangled]: "Tangled", 15 + [App.Whitewind]: "Whitewind", 16 + [App.Frontpage]: "Frontpage", 17 + [App.Pinksea]: "Pinksea", 18 + [App.Linkat]: "Linkat", 19 + }; 20 + 12 21 export const appList: Record<AppUrl, App> = { 13 22 "localhost:19006": App.Bluesky, 14 23 "blacksky.community": App.Bluesky,
+3 -3
src/views/collection.tsx
··· 41 41 42 42 return ( 43 43 <span 44 - class="relative flex w-full items-baseline rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 44 + class="relative flex w-full min-w-0 items-baseline rounded px-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 45 45 ref={rkeyRef} 46 46 onmouseover={() => setHover(true)} 47 47 onmouseleave={() => setHover(false)} ··· 267 267 <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 268 268 <Button 269 269 onClick={deleteRecords} 270 - class={`dark:shadow-dark-800 rounded-lg px-2 py-1.5 text-xs font-semibold text-neutral-200 shadow-xs select-none ${recreate() ? "bg-green-500 hover:bg-green-400 dark:bg-green-600 dark:hover:bg-green-500" : "bg-red-500 hover:bg-red-400 active:bg-red-400"}`} 270 + class={`dark:shadow-dark-800 rounded-lg px-2 py-1.5 text-xs text-white shadow-xs select-none ${recreate() ? "bg-green-500 hover:bg-green-400 dark:bg-green-600 dark:hover:bg-green-500" : "bg-red-500 hover:bg-red-400 active:bg-red-400"}`} 271 271 > 272 272 {recreate() ? "Recreate" : "Delete"} 273 273 </Button> ··· 301 301 }} 302 302 > 303 303 <span 304 - class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"} text-sm`} 304 + class={`iconify ${reverse() ? "lucide--rotate-ccw" : "lucide--rotate-cw"}`} 305 305 ></span> 306 306 Reverse 307 307 </Button>
+4 -6
src/views/labels.tsx
··· 68 68 initQuery(); 69 69 }} 70 70 > 71 - <div class="w-full"> 72 - <label for="patterns" class="ml-0.5 text-sm"> 73 - URI Patterns (comma-separated) 74 - </label> 75 - </div> 76 - <div class="flex w-full items-center gap-x-1"> 71 + <label for="patterns" class="ml-2 w-full text-sm"> 72 + URI Patterns (comma-separated) 73 + </label> 74 + <div class="flex w-full items-center gap-x-1 px-1"> 77 75 <textarea 78 76 id="patterns" 79 77 name="patterns"
+1
src/views/logs.tsx
··· 37 37 classList={{ 38 38 "flex items-center rounded-full p-1.5": true, 39 39 "bg-neutral-700 dark:bg-neutral-200": activePlcEvent() === props.event, 40 + "hover:bg-neutral-200 dark:hover:bg-neutral-700": activePlcEvent() !== props.event, 40 41 }} 41 42 onclick={() => setActivePlcEvent(activePlcEvent() === props.event ? undefined : props.event)} 42 43 >
+133 -43
src/views/pds.tsx
··· 2 2 import { Client, CredentialManager } from "@atcute/client"; 3 3 import { InferXRPCBodyOutput } from "@atcute/lexicons"; 4 4 import * as TID from "@atcute/tid"; 5 - import { A, useParams } from "@solidjs/router"; 5 + import { A, useLocation, useParams } from "@solidjs/router"; 6 6 import { createResource, createSignal, For, Show } from "solid-js"; 7 7 import { Button } from "../components/button"; 8 8 import { Modal } from "../components/modal"; 9 9 import { setPDS } from "../components/navbar"; 10 10 import Tooltip from "../components/tooltip"; 11 + import { addToClipboard } from "../utils/copy"; 11 12 import { localDateFromTimestamp } from "../utils/date"; 12 13 13 14 const LIMIT = 1000; 14 15 15 16 const PdsView = () => { 16 17 const params = useParams(); 18 + const location = useLocation(); 17 19 const [version, setVersion] = createSignal<string>(); 18 20 const [serverInfos, setServerInfos] = 19 21 createSignal<InferXRPCBodyOutput<ComAtprotoServerDescribeServer.mainSchema["output"]>>(); ··· 28 30 setVersion((res.data as any).version); 29 31 }; 30 32 33 + const describeServer = async () => { 34 + const res = await rpc.get("com.atproto.server.describeServer"); 35 + if (!res.ok) console.error(res.data.error); 36 + else setServerInfos(res.data); 37 + }; 38 + 31 39 const fetchRepos = async () => { 32 40 getVersion(); 33 - const describeRes = await rpc.get("com.atproto.server.describeServer"); 34 - if (!describeRes.ok) console.error(describeRes.data.error); 35 - else setServerInfos(describeRes.data); 41 + describeServer(); 36 42 const res = await rpc.get("com.atproto.sync.listRepos", { 37 43 params: { limit: LIMIT, cursor: cursor() }, 38 44 }); ··· 58 64 </A> 59 65 <Show when={!repo.active}> 60 66 <Tooltip text={repo.status ?? "Unknown status"}> 61 - <span class="iconify lucide--unplug"></span> 67 + <span class="iconify lucide--unplug text-red-500 dark:text-red-400"></span> 62 68 </Tooltip> 63 69 </Show> 64 70 <button ··· 103 109 ); 104 110 }; 105 111 112 + const Tab = (props: { tab: "repos" | "info"; label: string }) => ( 113 + <div class="flex items-center gap-0.5"> 114 + <A 115 + classList={{ 116 + "flex items-center gap-1 border-b-2": true, 117 + "border-transparent hover:border-neutral-400 dark:hover:border-neutral-600": 118 + (!!location.hash && location.hash !== `#${props.tab}`) || 119 + (!location.hash && props.tab !== "repos"), 120 + }} 121 + href={`/${params.pds}#${props.tab}`} 122 + > 123 + {props.label} 124 + </A> 125 + </div> 126 + ); 127 + 106 128 return ( 107 129 <Show when={repos() || response()}> 108 - <div class="flex w-full flex-col px-2"> 109 - <Show when={version()}> 110 - {(version) => ( 111 - <div class="flex items-baseline gap-x-1"> 112 - <span class="font-semibold">Version</span> 113 - <span class="truncate text-sm">{version()}</span> 130 + <div class="flex w-full flex-col"> 131 + <div class="dark:shadow-dark-800 dark:bg-dark-300 mb-2 flex w-full justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-sm shadow-xs dark:border-neutral-700"> 132 + <div class="flex gap-3"> 133 + <Tab tab="repos" label="Repositories" /> 134 + <Tab tab="info" label="Info" /> 135 + </div> 136 + <div class="flex gap-1"> 137 + <Tooltip text="Copy PDS"> 138 + <button 139 + onClick={() => addToClipboard(params.pds)} 140 + class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 141 + > 142 + <span class="iconify lucide--copy"></span> 143 + </button> 144 + </Tooltip> 145 + <Tooltip text="Firehose"> 146 + <A 147 + href={`/firehose?instance=wss://${params.pds}`} 148 + class="flex items-center rounded-lg p-1 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 149 + > 150 + <span class="iconify lucide--radio-tower"></span> 151 + </A> 152 + </Tooltip> 153 + </div> 154 + </div> 155 + <div class="flex flex-col gap-1 px-2"> 156 + <Show when={!location.hash || location.hash === "#repos"}> 157 + <div class="flex flex-col divide-y-[0.5px] divide-neutral-300 dark:divide-neutral-700"> 158 + <For each={repos()}>{(repo) => <RepoCard {...repo} />}</For> 114 159 </div> 115 - )} 116 - </Show> 117 - <Show when={serverInfos()}> 118 - {(server) => ( 119 - <> 120 - <Show when={server().inviteCodeRequired}> 121 - <span class="font-semibold">Invite Code Required</span> 122 - </Show> 123 - <Show when={server().phoneVerificationRequired}> 124 - <span class="font-semibold">Phone Verification Required</span> 125 - </Show> 126 - <Show when={server().availableUserDomains.length}> 127 - <div class="flex flex-col"> 128 - <span class="font-semibold">Available User Domains</span> 129 - <For each={server().availableUserDomains}> 130 - {(domain) => <span class="text-sm wrap-anywhere">{domain}</span>} 131 - </For> 160 + </Show> 161 + <Show when={location.hash === "#info"}> 162 + <Show when={version()}> 163 + {(version) => ( 164 + <div class="flex items-baseline gap-x-1"> 165 + <span class="font-semibold">Version</span> 166 + <span class="truncate text-sm">{version()}</span> 132 167 </div> 133 - </Show> 134 - </> 135 - )} 136 - </Show> 137 - <p class="w-full font-semibold">{repos()?.length} Repositories</p> 138 - <div class="flex flex-col divide-y-[0.5px] divide-neutral-300 dark:divide-neutral-700"> 139 - <For each={repos()}>{(repo) => <RepoCard {...repo} />}</For> 168 + )} 169 + </Show> 170 + <Show when={serverInfos()}> 171 + {(server) => ( 172 + <> 173 + <div class="flex items-baseline gap-x-1"> 174 + <span class="font-semibold">DID</span> 175 + <span class="truncate text-sm">{server().did}</span> 176 + </div> 177 + <Show when={server().inviteCodeRequired}> 178 + <span class="font-semibold">Invite Code Required</span> 179 + </Show> 180 + <Show when={server().phoneVerificationRequired}> 181 + <span class="font-semibold">Phone Verification Required</span> 182 + </Show> 183 + <Show when={server().availableUserDomains.length}> 184 + <div class="flex flex-col"> 185 + <span class="font-semibold">Available User Domains</span> 186 + <For each={server().availableUserDomains}> 187 + {(domain) => <span class="text-sm wrap-anywhere">{domain}</span>} 188 + </For> 189 + </div> 190 + </Show> 191 + <Show when={server().links?.privacyPolicy}> 192 + <div class="flex flex-col"> 193 + <span class="font-semibold">Privacy Policy</span> 194 + <a 195 + href={server().links?.privacyPolicy} 196 + class="text-sm hover:underline" 197 + target="_blank" 198 + > 199 + {server().links?.privacyPolicy} 200 + </a> 201 + </div> 202 + </Show> 203 + <Show when={server().links?.termsOfService}> 204 + <div class="flex flex-col"> 205 + <span class="font-semibold">Terms of Service</span> 206 + <a 207 + href={server().links?.termsOfService} 208 + class="text-sm hover:underline" 209 + target="_blank" 210 + > 211 + {server().links?.termsOfService} 212 + </a> 213 + </div> 214 + </Show> 215 + <Show when={server().contact?.email}> 216 + <div class="flex flex-col"> 217 + <span class="font-semibold">Contact</span> 218 + <a href={`mailto:${server().contact?.email}`} class="text-sm hover:underline"> 219 + {server().contact?.email} 220 + </a> 221 + </div> 222 + </Show> 223 + </> 224 + )} 225 + </Show> 226 + </Show> 140 227 </div> 141 228 </div> 142 - <Show when={cursor()}> 143 - <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-3"> 144 - <Show when={!response.loading}> 145 - <Button onClick={() => refetch()}>Load More</Button> 146 - </Show> 147 - <Show when={response.loading}> 148 - <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 149 - </Show> 229 + <Show when={!location.hash || location.hash === "#repos"}> 230 + <div class="dark:bg-dark-500 fixed bottom-0 z-5 flex w-screen justify-center bg-neutral-100 py-2"> 231 + <div class="flex flex-col items-center gap-1 pb-2"> 232 + <p>{repos()?.length} loaded</p> 233 + <Show when={!response.loading && cursor()}> 234 + <Button onClick={() => refetch()}>Load More</Button> 235 + </Show> 236 + <Show when={response.loading}> 237 + <span class="iconify lucide--loader-circle animate-spin py-3.5 text-xl"></span> 238 + </Show> 239 + </div> 150 240 </div> 151 241 </Show> 152 242 </Show>
+6 -5
src/views/record.tsx
··· 10 10 import { JSONValue } from "../components/json.jsx"; 11 11 import { agent } from "../components/login.jsx"; 12 12 import { Modal } from "../components/modal.jsx"; 13 - import { pds, setCID } from "../components/navbar.jsx"; 13 + import { pds } from "../components/navbar.jsx"; 14 14 import Tooltip from "../components/tooltip.jsx"; 15 15 import { setNotif } from "../layout.jsx"; 16 16 import { didDocCache, resolveLexiconAuthority, resolvePDS } from "../utils/api.js"; ··· 34 34 let rpc: Client; 35 35 36 36 const fetchRecord = async () => { 37 - setCID(undefined); 38 37 setValidRecord(undefined); 39 38 setValidSchema(undefined); 40 39 setLexiconUri(undefined); ··· 52 51 setNotice(res.data.error); 53 52 throw new Error(res.data.error); 54 53 } 55 - setCID(res.data.cid); 56 54 setExternalLink(checkUri(res.data.uri, res.data.value)); 57 55 resolveLexicon(params.collection as Nsid); 58 56 verify(res.data); ··· 179 177 <Button onClick={() => setOpenDelete(false)}>Cancel</Button> 180 178 <Button 181 179 onClick={deleteRecord} 182 - class="dark:shadow-dark-800 rounded-lg bg-red-500 px-2 py-1.5 text-xs font-semibold text-neutral-200 shadow-xs select-none hover:bg-red-400 active:bg-red-400" 180 + class="dark:shadow-dark-800 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" 183 181 > 184 182 Delete 185 183 </Button> ··· 198 196 label="Copy record" 199 197 icon="lucide--copy" 200 198 /> 199 + <Show when={record()?.cid}> 200 + {(cid) => <CopyMenu copyContent={cid()} label="Copy CID" icon="lucide--copy" />} 201 + </Show> 201 202 <Show when={externalLink()}> 202 203 {(externalLink) => ( 203 204 <NavMenu ··· 286 287 <div> 287 288 <div class="flex items-center gap-1"> 288 289 <span class="iconify lucide--scroll-text"></span> 289 - <p class="font-semibold">Lexicon document</p> 290 + <p class="font-semibold">Lexicon schema</p> 290 291 </div> 291 292 <div class="truncate text-xs"> 292 293 <A