atproto explorer pdsls.dev
atproto tool
1import { Did } from "@atcute/lexicons"; 2import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3import { A } from "@solidjs/router"; 4import { createSignal, For, onMount, Show } from "solid-js"; 5import { createStore, produce } from "solid-js/store"; 6import { ActionMenu, DropdownMenu, MenuProvider, NavMenu } from "../components/dropdown.jsx"; 7import { Modal } from "../components/modal.jsx"; 8import { Login } from "./login.jsx"; 9import { useOAuthScopeFlow } from "./scope-flow.js"; 10import { ScopeSelector } from "./scope-selector.jsx"; 11import { parseScopeString } from "./scope-utils.js"; 12import { 13 getAvatar, 14 loadHandleForSession, 15 loadSessionsFromStorage, 16 resumeSession, 17 retrieveSession, 18 saveSessionToStorage, 19} from "./session-manager.js"; 20import { agent, sessions, setAgent, setSessions } from "./state.js"; 21 22const AccountDropdown = (props: { did: Did; onEditPermissions: (did: Did) => void }) => { 23 const removeSession = async (did: Did) => { 24 const currentSession = agent()?.sub; 25 try { 26 const session = await getSession(did, { allowStale: true }); 27 const agent = new OAuthUserAgent(session); 28 await agent.signOut(); 29 } catch { 30 deleteStoredSession(did); 31 } 32 setSessions( 33 produce((accs) => { 34 delete accs[did]; 35 }), 36 ); 37 saveSessionToStorage(sessions); 38 if (currentSession === did) setAgent(undefined); 39 }; 40 41 return ( 42 <MenuProvider> 43 <DropdownMenu icon="lucide--ellipsis" buttonClass="rounded-md p-2"> 44 <NavMenu href={`/at://${props.did}`} label="Go to repo" icon="lucide--user-round" /> 45 <ActionMenu 46 icon="lucide--settings" 47 label="Edit permissions" 48 onClick={() => props.onEditPermissions(props.did)} 49 /> 50 <ActionMenu 51 icon="lucide--x" 52 label="Remove account" 53 onClick={() => removeSession(props.did)} 54 /> 55 </DropdownMenu> 56 </MenuProvider> 57 ); 58}; 59 60export const AccountManager = () => { 61 const [openManager, setOpenManager] = createSignal(false); 62 const [avatars, setAvatars] = createStore<Record<Did, string>>(); 63 const [showingAddAccount, setShowingAddAccount] = createSignal(false); 64 65 const getThumbnailUrl = (avatarUrl: string) => { 66 return avatarUrl.replace("img/avatar/", "img/avatar_thumbnail/"); 67 }; 68 69 const scopeFlow = useOAuthScopeFlow({ 70 beforeRedirect: (account) => resumeSession(account as Did), 71 }); 72 73 const handleAccountClick = async (did: Did) => { 74 try { 75 await resumeSession(did); 76 } catch { 77 scopeFlow.initiate(did); 78 } 79 }; 80 81 onMount(async () => { 82 try { 83 await retrieveSession(); 84 } catch {} 85 86 const storedSessions = loadSessionsFromStorage(); 87 if (storedSessions) { 88 const sessionDids = Object.keys(storedSessions) as Did[]; 89 sessionDids.forEach(async (did) => { 90 await loadHandleForSession(did, storedSessions); 91 }); 92 sessionDids.forEach(async (did) => { 93 const avatar = await getAvatar(did); 94 if (avatar) setAvatars(did, avatar); 95 }); 96 } 97 }); 98 99 return ( 100 <> 101 <Modal 102 open={openManager()} 103 onClose={() => { 104 setOpenManager(false); 105 setShowingAddAccount(false); 106 scopeFlow.cancel(); 107 }} 108 > 109 <div class="dark:bg-dark-300 dark:shadow-dark-700 absolute top-18 left-[50%] w-88 -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"> 110 <Show when={!scopeFlow.showScopeSelector() && !showingAddAccount()}> 111 <div class="mb-2 px-1 font-semibold"> 112 <span>Manage accounts</span> 113 </div> 114 <div class="mb-3 max-h-80 overflow-y-auto md:max-h-100"> 115 <For each={Object.keys(sessions)}> 116 {(did) => ( 117 <div class="flex w-full items-center justify-between"> 118 <A 119 href={`/at://${did}`} 120 onClick={() => setOpenManager(false)} 121 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" 122 > 123 <Show 124 when={avatars[did as Did]} 125 fallback={<span class="iconify lucide--user-round m-0.5 size-5"></span>} 126 > 127 <img 128 src={getThumbnailUrl(avatars[did as Did])} 129 class="size-6 rounded-full" 130 /> 131 </Show> 132 </A> 133 <button 134 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" 135 onclick={() => handleAccountClick(did as Did)} 136 > 137 <span class="truncate">{sessions[did]?.handle || did}</span> 138 <Show when={did === agent()?.sub && sessions[did].signedIn}> 139 <span class="iconify lucide--check shrink-0 text-green-500 dark:text-green-400"></span> 140 </Show> 141 <Show when={!sessions[did].signedIn}> 142 <span class="iconify lucide--circle-alert shrink-0 text-red-500 dark:text-red-400"></span> 143 </Show> 144 </button> 145 <AccountDropdown 146 did={did as Did} 147 onEditPermissions={(accountDid) => scopeFlow.initiateWithRedirect(accountDid)} 148 /> 149 </div> 150 )} 151 </For> 152 </div> 153 <button 154 onclick={() => setShowingAddAccount(true)} 155 class="flex w-full items-center justify-center gap-2 rounded-md border-[0.5px] border-neutral-300 bg-white px-3 py-2 hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-600 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600" 156 > 157 <span class="iconify lucide--user-plus"></span> 158 <span>Add account</span> 159 </button> 160 </Show> 161 162 <Show when={showingAddAccount() && !scopeFlow.showScopeSelector()}> 163 <Login onCancel={() => setShowingAddAccount(false)} /> 164 </Show> 165 166 <Show when={scopeFlow.showScopeSelector()}> 167 <ScopeSelector 168 initialScopes={parseScopeString( 169 sessions[scopeFlow.pendingAccount()]?.grantedScopes || "", 170 )} 171 onConfirm={scopeFlow.complete} 172 onCancel={() => { 173 scopeFlow.cancel(); 174 setShowingAddAccount(false); 175 }} 176 /> 177 </Show> 178 </div> 179 </Modal> 180 <button 181 onclick={() => setOpenManager(true)} 182 class={`flex items-center rounded-lg ${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`} 183 > 184 {agent() && avatars[agent()!.sub] ? 185 <img src={getThumbnailUrl(avatars[agent()!.sub])} class="size-5 rounded-full" /> 186 : <span class="iconify lucide--circle-user-round text-lg"></span>} 187 </button> 188 </> 189 ); 190};