forked from pdsls.dev/pdsls
this repo has no description

done

juli.ee 855ab79f bc07dfe1

verified
Changed files
+275 -150
src
+4 -1
src/components/button.tsx
··· 1 1 import { JSX } from "solid-js"; 2 2 3 3 export interface ButtonProps { 4 + type?: "button" | "submit" | "reset" | "menu" | undefined; 5 + disabled?: boolean; 4 6 class?: string; 5 7 classList?: Record<string, boolean | undefined>; 6 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; ··· 10 12 export const Button = (props: ButtonProps) => { 11 13 return ( 12 14 <button 13 - type="button" 15 + type={props.type ?? "button"} 16 + disabled={props.disabled ?? false} 14 17 class={ 15 18 props.class ?? 16 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"
+4
src/components/dropdown.tsx
··· 89 89 ); 90 90 }; 91 91 92 + export const MenuSeparator = () => { 93 + return <div class="my-1 h-[0.5px] bg-neutral-300 dark:bg-neutral-600" />; 94 + }; 95 + 92 96 export const DropdownMenu = (props: { 93 97 icon: string; 94 98 buttonClass?: string;
+3 -2
src/layout.tsx
··· 4 4 import { createEffect, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 5 5 import { AccountManager } from "./components/account.jsx"; 6 6 import { RecordEditor } from "./components/create.jsx"; 7 - import { DropdownMenu, MenuProvider, NavMenu } from "./components/dropdown.jsx"; 7 + import { DropdownMenu, MenuProvider, MenuSeparator, NavMenu } from "./components/dropdown.jsx"; 8 8 import { agent } from "./components/login.jsx"; 9 9 import { NavBar } from "./components/navbar.jsx"; 10 10 import { NotificationContainer } from "./components/notification.jsx"; ··· 141 141 <DropdownMenu 142 142 icon="lucide--menu text-xl" 143 143 buttonClass="rounded-lg p-1" 144 - menuClass="top-8 p-3 text-sm" 144 + menuClass="top-11 p-3 text-sm" 145 145 > 146 146 <NavMenu href="/jetstream" label="Jetstream" /> 147 147 <NavMenu href="/firehose" label="Firehose" /> 148 148 <NavMenu href="/labels" label="Labels" /> 149 149 <NavMenu href="/settings" label="Settings" /> 150 + <MenuSeparator /> 150 151 <NavMenu 151 152 href="https://bsky.app/profile/did:plc:6q5daed5gutiyerimlrnojnz" 152 153 label="Bluesky"
+239 -139
src/views/labels.tsx
··· 1 1 import { ComAtprotoLabelDefs } from "@atcute/atproto"; 2 2 import { Client, CredentialManager } from "@atcute/client"; 3 + import { isAtprotoDid } from "@atcute/identity"; 4 + import { Handle } from "@atcute/lexicons"; 3 5 import { A, useSearchParams } from "@solidjs/router"; 4 - import { createSignal, For, onMount, Show } from "solid-js"; 6 + import { createMemo, createSignal, For, onMount, Show } from "solid-js"; 5 7 import { Button } from "../components/button.jsx"; 6 8 import { StickyOverlay } from "../components/sticky.jsx"; 7 9 import { TextInput } from "../components/text-input.jsx"; 8 - import { labelerCache, resolvePDS } from "../utils/api.js"; 10 + import { labelerCache, resolveHandle, resolvePDS } from "../utils/api.js"; 9 11 import { localDateFromTimestamp } from "../utils/date.js"; 10 12 13 + const LABELS_PER_PAGE = 50; 14 + 15 + const LabelCard = (props: { label: ComAtprotoLabelDefs.Label }) => { 16 + const label = props.label; 17 + 18 + return ( 19 + <div class="flex flex-col gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-3 dark:border-neutral-700 dark:bg-neutral-800"> 20 + <div class="flex flex-wrap items-center gap-x-2 gap-y-2"> 21 + <div class="inline-flex items-center gap-x-1 rounded-full bg-neutral-200 px-2 py-0.5 text-sm font-medium text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200"> 22 + <span class="iconify lucide--tag shrink-0" /> 23 + {label.val} 24 + </div> 25 + <Show when={label.neg}> 26 + <div class="inline-flex items-center gap-x-1 rounded-full border border-orange-400 bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700 dark:border-orange-600 dark:bg-orange-900/30 dark:text-orange-400"> 27 + <span class="iconify lucide--minus shrink-0 text-sm" /> 28 + <span>Negated</span> 29 + </div> 30 + </Show> 31 + <div class="flex flex-wrap gap-3 text-xs text-neutral-600 dark:text-neutral-400"> 32 + <div class="flex items-center gap-x-1"> 33 + <span class="iconify lucide--calendar shrink-0" /> 34 + <span>{localDateFromTimestamp(new Date(label.cts).getTime())}</span> 35 + </div> 36 + <Show when={label.exp}> 37 + {(exp) => ( 38 + <div class="flex items-center gap-x-1"> 39 + <span class="iconify lucide--clock-fading shrink-0" /> 40 + <span>e{localDateFromTimestamp(new Date(exp()).getTime())}</span> 41 + </div> 42 + )} 43 + </Show> 44 + </div> 45 + </div> 46 + 47 + <div class="flex flex-col gap-y-0.5"> 48 + <div class="text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400"> 49 + URI 50 + </div> 51 + <A 52 + href={`/at://${label.uri.replace("at://", "")}`} 53 + class="text-sm break-all text-blue-600 hover:underline dark:text-blue-400" 54 + > 55 + {label.uri} 56 + </A> 57 + </div> 58 + 59 + <Show when={label.cid}> 60 + <div class="flex flex-col gap-y-0.5"> 61 + <div class="text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400"> 62 + CID 63 + </div> 64 + <div class="text-sm break-all text-neutral-700 dark:text-neutral-300">{label.cid}</div> 65 + </div> 66 + </Show> 67 + </div> 68 + ); 69 + }; 70 + 11 71 export const LabelView = () => { 12 72 const [searchParams, setSearchParams] = useSearchParams(); 13 73 const [cursor, setCursor] = createSignal<string>(); 14 74 const [labels, setLabels] = createSignal<ComAtprotoLabelDefs.Label[]>([]); 15 - const [filter, setFilter] = createSignal<string>(); 16 - const [labelCount, setLabelCount] = createSignal(0); 75 + const [filter, setFilter] = createSignal(""); 17 76 const [loading, setLoading] = createSignal(false); 18 - let rpc: Client; 77 + const [error, setError] = createSignal<string>(); 78 + const [didInput, setDidInput] = createSignal(searchParams.did ?? ""); 79 + 80 + let rpc: Client | undefined; 19 81 let formRef!: HTMLFormElement; 20 82 83 + const filteredLabels = createMemo(() => { 84 + const filterValue = filter().trim().toLowerCase(); 85 + if (!filterValue) return labels(); 86 + return labels().filter((label) => label.val.toLowerCase().includes(filterValue)); 87 + }); 88 + 89 + const hasSearched = createMemo(() => Boolean(searchParams.uriPatterns)); 90 + 21 91 onMount(async () => { 22 - const formData = new FormData(); 23 - if (searchParams.did) formData.append("did", searchParams.did.toString()); 24 - if (searchParams.did) fetchLabels(formData); 92 + if (searchParams.did && searchParams.uriPatterns) { 93 + const formData = new FormData(); 94 + formData.append("did", searchParams.did.toString()); 95 + formData.append("uriPatterns", searchParams.uriPatterns.toString()); 96 + await fetchLabels(formData); 97 + } 25 98 }); 26 99 27 100 const fetchLabels = async (formData: FormData, reset?: boolean) => { 101 + let did = formData.get("did")?.toString()?.trim(); 102 + const uriPatterns = formData.get("uriPatterns")?.toString()?.trim(); 103 + 104 + if (!did || !uriPatterns) { 105 + setError("Please provide both DID and URI patterns"); 106 + return; 107 + } 108 + 28 109 if (reset) { 29 110 setLabels([]); 30 111 setCursor(undefined); 112 + setError(undefined); 31 113 } 32 114 33 - const did = formData.get("did")?.toString(); 34 - if (!did) return; 35 - await resolvePDS(did); 36 - rpc = new Client({ 37 - handler: new CredentialManager({ service: labelerCache[did] }), 38 - }); 115 + try { 116 + setLoading(true); 117 + setError(undefined); 39 118 40 - const uriPatterns = formData.get("uriPatterns")?.toString(); 41 - if (!uriPatterns) return; 119 + if (!isAtprotoDid(did)) did = await resolveHandle(did as Handle); 120 + await resolvePDS(did); 121 + if (!labelerCache[did]) throw new Error("Repository is not a labeler"); 122 + rpc = new Client({ 123 + handler: new CredentialManager({ service: labelerCache[did] }), 124 + }); 42 125 43 - setSearchParams({ 44 - did: formData.get("did")?.toString(), 45 - uriPatterns: formData.get("uriPatterns")?.toString(), 46 - }); 126 + setSearchParams({ did, uriPatterns }); 127 + setDidInput(did); 128 + 129 + const res = await rpc.get("com.atproto.label.queryLabels", { 130 + params: { 131 + uriPatterns: uriPatterns.split(",").map((p) => p.trim()), 132 + sources: [did as `did:${string}:${string}`], 133 + cursor: cursor(), 134 + }, 135 + }); 47 136 48 - setLoading(true); 49 - const res = await rpc.get("com.atproto.label.queryLabels", { 50 - params: { 51 - uriPatterns: uriPatterns.toString().trim().split(","), 52 - sources: [did as `did:${string}:${string}`], 53 - cursor: cursor(), 54 - }, 55 - }); 56 - setLoading(false); 57 - if (!res.ok) throw new Error(res.data.error); 58 - setCursor(res.data.labels.length < 50 ? undefined : res.data.cursor); 59 - setLabels(labels().concat(res.data.labels) ?? res.data.labels); 60 - return res.data.labels; 137 + if (!res.ok) throw new Error(res.data.error || "Failed to fetch labels"); 138 + 139 + const newLabels = res.data.labels || []; 140 + setCursor(newLabels.length < LABELS_PER_PAGE ? undefined : res.data.cursor); 141 + setLabels(reset ? newLabels : [...labels(), ...newLabels]); 142 + } catch (err) { 143 + setError(err instanceof Error ? err.message : "An error occurred"); 144 + console.error("Failed to fetch labels:", err); 145 + } finally { 146 + setLoading(false); 147 + } 61 148 }; 62 149 63 - const filterLabels = () => { 64 - const newFilter = labels().filter((label) => (filter() ? filter() === label.val : true)); 65 - setLabelCount(newFilter.length); 66 - return newFilter; 150 + const handleSearch = () => { 151 + fetchLabels(new FormData(formRef), true); 152 + }; 153 + 154 + const handleLoadMore = () => { 155 + fetchLabels(new FormData(formRef)); 67 156 }; 68 157 69 158 return ( 70 159 <div class="flex w-full flex-col items-center"> 71 - <form ref={formRef} class="flex w-full flex-col items-center gap-y-1 px-2"> 72 - <label class="flex w-full items-center gap-x-2 px-1"> 73 - <span class="">DID</span> 74 - <TextInput name="did" value={searchParams.did ?? ""} class="grow" /> 75 - </label> 76 - <label for="uriPatterns" class="ml-2 w-full text-sm"> 77 - URI Patterns (comma-separated) 78 - </label> 79 - <div class="flex w-full items-center gap-x-1 px-1"> 80 - <textarea 81 - id="uriPatterns" 82 - name="uriPatterns" 83 - spellcheck={false} 84 - rows={2} 85 - value={searchParams.uriPatterns ?? "*"} 86 - class="dark:bg-dark-100 dark:shadow-dark-700 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1 text-sm shadow-xs focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 87 - /> 88 - <div class="flex justify-center"> 89 - <Show when={!loading()}> 90 - <button 91 - type="button" 92 - onClick={() => fetchLabels(new FormData(formRef), true)} 93 - 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" 94 - > 95 - <span class="iconify lucide--search text-lg"></span> 96 - </button> 97 - </Show> 98 - <Show when={loading()}> 99 - <div class="m-1 flex items-center"> 100 - <span class="iconify lucide--loader-circle animate-spin text-lg"></span> 101 - </div> 102 - </Show> 160 + <form 161 + ref={formRef} 162 + class="flex w-full max-w-3xl flex-col gap-y-2 px-3 py-2" 163 + onSubmit={(e) => { 164 + e.preventDefault(); 165 + handleSearch(); 166 + }} 167 + > 168 + <div class="flex flex-col gap-y-1.5"> 169 + <label class="flex w-full flex-col gap-y-1"> 170 + <span class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 171 + Labeler DID/Handle 172 + </span> 173 + <TextInput 174 + name="did" 175 + value={didInput()} 176 + onInput={(e) => setDidInput(e.currentTarget.value)} 177 + placeholder="did:plc:..." 178 + class="w-full" 179 + /> 180 + </label> 181 + 182 + <label class="flex w-full flex-col gap-y-1"> 183 + <span class="text-sm font-medium text-neutral-700 dark:text-neutral-300"> 184 + URI Patterns (comma-separated) 185 + </span> 186 + <textarea 187 + id="uriPatterns" 188 + name="uriPatterns" 189 + spellcheck={false} 190 + rows={2} 191 + value={searchParams.uriPatterns ?? "*"} 192 + placeholder="at://did:web:example.com/app.bsky.feed.post/*" 193 + class="dark:bg-dark-100 dark:shadow-dark-700 grow rounded-lg border-[0.5px] border-neutral-300 bg-white px-2 py-1.5 text-sm shadow-xs focus:outline-[1px] focus:outline-neutral-600 dark:border-neutral-600 dark:focus:outline-neutral-400" 194 + /> 195 + </label> 196 + </div> 197 + 198 + <Button 199 + type="submit" 200 + disabled={loading()} 201 + class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-fit items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 202 + > 203 + <span class="iconify lucide--search" /> 204 + <span>Search Labels</span> 205 + </Button> 206 + 207 + <Show when={error()}> 208 + <div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300"> 209 + {error()} 103 210 </div> 104 - </div> 211 + </Show> 105 212 </form> 106 - <StickyOverlay> 107 - <TextInput 108 - placeholder="Filter by label" 109 - name="filter" 110 - onInput={(e) => setFilter(e.currentTarget.value)} 111 - class="w-full text-sm" 112 - /> 113 - <div class="flex items-center gap-x-2"> 114 - <Show when={labelCount() && labels().length}> 115 - <div> 116 - <span> 117 - {labelCount()} label{labelCount() > 1 ? "s" : ""} 118 - </span> 119 - </div> 120 - </Show> 121 - <Show when={cursor()}> 122 - <div class="flex h-8 w-22 items-center justify-center text-nowrap"> 123 - <Show when={!loading()}> 124 - <Button onClick={() => fetchLabels(new FormData(formRef))}>Load More</Button> 213 + 214 + <Show when={hasSearched()}> 215 + <StickyOverlay> 216 + <div class="flex w-full items-center gap-x-2"> 217 + <TextInput 218 + placeholder="Filter by label value" 219 + name="filter" 220 + value={filter()} 221 + onInput={(e) => setFilter(e.currentTarget.value)} 222 + class="min-w-0 grow text-sm" 223 + /> 224 + <div class="flex shrink-0 items-center gap-x-2 text-sm"> 225 + <Show when={labels().length > 0}> 226 + <span class="whitespace-nowrap text-neutral-600 dark:text-neutral-400"> 227 + {filteredLabels().length}/{labels().length} 228 + </span> 125 229 </Show> 126 - <Show when={loading()}> 127 - <div class="iconify lucide--loader-circle animate-spin text-xl" /> 230 + 231 + <Show when={cursor()}> 232 + <Button 233 + onClick={handleLoadMore} 234 + disabled={loading()} 235 + class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 w-20 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 236 + > 237 + <Show 238 + when={!loading()} 239 + fallback={<span class="iconify lucide--loader-circle animate-spin" />} 240 + > 241 + Load More 242 + </Show> 243 + </Button> 128 244 </Show> 129 245 </div> 246 + </div> 247 + </StickyOverlay> 248 + 249 + <div class="w-full max-w-3xl px-3 py-2"> 250 + <Show when={loading() && labels().length === 0}> 251 + <div class="flex flex-col items-center justify-center py-12 text-center"> 252 + <span class="iconify lucide--loader-circle mb-3 animate-spin text-4xl text-neutral-400" /> 253 + <p class="text-sm text-neutral-600 dark:text-neutral-400">Loading labels...</p> 254 + </div> 130 255 </Show> 131 - </div> 132 - </StickyOverlay> 133 - <Show when={labels().length}> 134 - <div class="flex flex-col gap-2 divide-y-[0.5px] divide-neutral-400 text-sm wrap-anywhere whitespace-pre-wrap dark:divide-neutral-600"> 135 - <For each={filterLabels()}> 136 - {(label) => ( 137 - <div class="flex items-center justify-between gap-2 pb-2"> 138 - <div class="flex flex-col"> 139 - <div class="flex items-center gap-x-2"> 140 - <div class="min-w-16 font-semibold">URI</div> 141 - <A 142 - href={`/at://${label.uri.replace("at://", "")}`} 143 - class="text-blue-400 hover:underline active:underline" 144 - > 145 - {label.uri} 146 - </A> 147 - </div> 148 - <Show when={label.cid}> 149 - <div class="flex items-center gap-x-2"> 150 - <div class="min-w-16 font-semibold">CID</div> 151 - {label.cid} 152 - </div> 153 - </Show> 154 - <div class="flex items-center gap-x-2"> 155 - <div class="min-w-16 font-semibold">Label</div> 156 - {label.val} 157 - </div> 158 - <div class="flex items-center gap-x-2"> 159 - <div class="min-w-16 font-semibold">Created</div> 160 - {localDateFromTimestamp(new Date(label.cts).getTime())} 161 - </div> 162 - <Show when={label.exp}> 163 - {(exp) => ( 164 - <div class="flex items-center gap-x-2"> 165 - <div class="min-w-16 font-semibold">Expires</div> 166 - {localDateFromTimestamp(new Date(exp()).getTime())} 167 - </div> 168 - )} 169 - </Show> 170 - </div> 171 - <Show when={label.neg}> 172 - <div class="iconify lucide--minus shrink-0 text-lg text-red-500 dark:text-red-400" /> 173 - </Show> 256 + 257 + <Show when={!loading() || labels().length > 0}> 258 + <Show when={filteredLabels().length > 0}> 259 + <div class="grid gap-2"> 260 + <For each={filteredLabels()}>{(label) => <LabelCard label={label} />}</For> 261 + </div> 262 + </Show> 263 + 264 + <Show when={labels().length > 0 && filteredLabels().length === 0}> 265 + <div class="flex flex-col items-center justify-center py-8 text-center"> 266 + <span class="iconify lucide--search-x mb-2 text-3xl text-neutral-400" /> 267 + <p class="text-sm text-neutral-600 dark:text-neutral-400"> 268 + No labels match your filter 269 + </p> 270 + </div> 271 + </Show> 272 + 273 + <Show when={labels().length === 0 && !loading()}> 274 + <div class="flex flex-col items-center justify-center py-8 text-center"> 275 + <span class="iconify lucide--inbox mb-2 text-3xl text-neutral-400" /> 276 + <p class="text-sm text-neutral-600 dark:text-neutral-400">No labels found</p> 174 277 </div> 175 - )} 176 - </For> 278 + </Show> 279 + </Show> 177 280 </div> 178 - </Show> 179 - <Show when={!labels().length && !loading() && searchParams.uriPatterns}> 180 - <div class="mt-2">No results</div> 181 281 </Show> 182 282 </div> 183 283 );
+8 -1
src/views/record.tsx
··· 9 9 import { Backlinks } from "../components/backlinks.jsx"; 10 10 import { Button } from "../components/button.jsx"; 11 11 import { RecordEditor, setPlaceholder } from "../components/create.jsx"; 12 - import { CopyMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 12 + import { 13 + CopyMenu, 14 + DropdownMenu, 15 + MenuProvider, 16 + MenuSeparator, 17 + NavMenu, 18 + } from "../components/dropdown.jsx"; 13 19 import { JSONValue } from "../components/json.jsx"; 14 20 import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; 15 21 import { agent } from "../components/login.jsx"; ··· 238 244 <Show when={record()?.cid}> 239 245 {(cid) => <CopyMenu content={cid()} label="Copy CID" icon="lucide--copy" />} 240 246 </Show> 247 + <MenuSeparator /> 241 248 <Show when={externalLink()}> 242 249 {(externalLink) => ( 243 250 <NavMenu
+17 -7
src/views/repo.tsx
··· 19 19 CopyMenu, 20 20 DropdownMenu, 21 21 MenuProvider, 22 + MenuSeparator, 22 23 NavMenu, 23 24 } from "../components/dropdown.jsx"; 24 25 import { setPDS } from "../components/navbar.jsx"; ··· 31 32 import Tooltip from "../components/tooltip.jsx"; 32 33 import { 33 34 didDocCache, 35 + labelerCache, 34 36 resolveHandle, 35 37 resolveLexiconAuthority, 36 38 resolvePDS, ··· 293 295 label="Jetstream" 294 296 icon="lucide--radio-tower" 295 297 /> 298 + <Show when={params.repo in labelerCache}> 299 + <NavMenu 300 + href={`/labels?did=${params.repo}&uriPatterns=*`} 301 + label="Labels" 302 + icon="lucide--tag" 303 + /> 304 + </Show> 305 + <Show when={error()?.length === 0 || error() === undefined}> 306 + <ActionMenu 307 + label="Export Repo" 308 + icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"} 309 + onClick={() => downloadRepo()} 310 + /> 311 + </Show> 312 + <MenuSeparator /> 296 313 <NavMenu 297 314 href={ 298 315 did.startsWith("did:plc") ? ··· 309 326 newTab 310 327 label="Audit Log" 311 328 icon="lucide--external-link" 312 - /> 313 - </Show> 314 - <Show when={error()?.length === 0 || error() === undefined}> 315 - <ActionMenu 316 - label="Export Repo" 317 - icon={downloading() ? "lucide--loader-circle animate-spin" : "lucide--download"} 318 - onClick={() => downloadRepo()} 319 329 /> 320 330 </Show> 321 331 </DropdownMenu>