csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

settings page wip

dawn (May 31, 2026, 4:28 PM +0300) 73260e54 2a8358d0

+1668 -2
+33
AGENTS.md
··· 115 115 - Remove or hide upstream controls that are not implemented locally instead of shipping fake UI. 116 116 - External upstream-only affordances can link to `https://tangled.org`, such as repo stars and `/:owner/:repo/feed.atom`. 117 117 118 + ## Local UI Style 119 + 120 + Upstream Tangled templates are the baseline, but Untangled should also preserve 121 + the local design language already present in the Solid app. Before copying an 122 + upstream class literally, compare nearby local pages and shared components. 123 + 124 + - Favor a quiet, dense tool UI: compact spacing, small radii, restrained 125 + borders, and flat page sections. Avoid marketing-style cards, oversized 126 + type, decorative gradients, and unnecessary nested panels. 127 + - Top-level route titles are usually plain lowercase labels. Use uppercase 128 + tracking only for compact metadata labels, table/list section labels, or 129 + places where the local surrounding UI already uses that treatment. 130 + - Action labels, nav labels, badges, pagination, and small utility links are 131 + generally lowercase. Keep this consistent unless the surrounding local 132 + component clearly uses title case. 133 + - Prefer established primitives (`btn`, `btn-create`, `btn-flat`, 134 + `buttonStyles`, `StateBadge`, `ToggleButton`) over custom button and badge 135 + stacks. Preserve their icon sizes, min-heights, padding, hover behavior, and 136 + inner icon/text grouping. 137 + - Icon buttons and icon+text actions should use Lucide icons already used in 138 + the app. Keep icons visible, aligned, and sized consistently with adjacent 139 + controls. 140 + - Badges shown next to controls should match the controls' visual density and 141 + height. Avoid badge styles that look like separate one-off pills unless a 142 + shared badge component already does that. 143 + - Dialogs and destructive/creation popovers should feel like app chrome, not a 144 + page section: center modal dialogs in the viewport, use a real backdrop when 145 + blocking interaction, and prefer spacing over extra separator lines when the 146 + header/body relationship is already clear. 147 + - When Tailwind utilities are missing from `public/static/tw.css`, add a small 148 + explicit `.untangled-*` rule in `src/index.css` rather than relying on an 149 + unavailable arbitrary class. 150 + 118 151 ## Tailwind/CSS Pitfall 119 152 120 153 The checked-in `public/static/tw.css` does not include all arbitrary Tailwind
+1 -1
public/oauth-client-metadata.json
··· 3 3 "client_name": "untangled", 4 4 "client_uri": "https://untangled.wisp.place", 5 5 "redirect_uris": ["https://untangled.wisp.place/oauth/callback"], 6 - "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.create?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/*", 6 + "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.create?aud=* repo:sh.tangled.feed.comment repo:sh.tangled.string repo:sh.tangled.feed.star blob:*/*", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",
+17 -1
src/index.css
··· 784 784 padding-bottom: max(2rem, calc(1.5rem + env(safe-area-inset-bottom))); 785 785 } 786 786 787 + .untangled-settings-grid { 788 + display: grid; 789 + grid-template-columns: minmax(0, 1fr); 790 + gap: 1.5rem; 791 + } 792 + 793 + @media (min-width: 768px) { 794 + .untangled-settings-grid { 795 + grid-template-columns: 15rem minmax(0, 1fr); 796 + } 797 + } 798 + 787 799 .untangled-repo-frame-full { 788 800 box-sizing: border-box; 789 801 margin-left: calc(50% - 50vw); ··· 3974 3986 } 3975 3987 } 3976 3988 3977 - 3989 + /* Checkbox specific overrides to prevent extra layout padding */ 3990 + .untangled-checkbox { 3991 + padding: 0 !important; 3992 + margin: 0 !important; 3993 + } 3978 3994 3979 3995
+255
src/lib/api/settings.ts
··· 1 + import { ok } from '@atcute/client'; 2 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 3 + import { getRpc, createAuthRpc } from './client'; 4 + import { resolveActor } from './identity'; 5 + import { parseAtUri } from './records'; 6 + 7 + export interface PublicKeyRecord { 8 + name: string; 9 + key: string; 10 + createdAt: string; 11 + rkey: string; 12 + uri: string; 13 + } 14 + 15 + export interface KnotRecord { 16 + domain: string; 17 + createdAt: string; 18 + rkey: string; 19 + uri: string; 20 + } 21 + 22 + export interface SpindleRecord { 23 + instance: string; 24 + createdAt: string; 25 + rkey: string; 26 + uri: string; 27 + } 28 + 29 + export const listPublicKeys = async (did: string): Promise<PublicKeyRecord[]> => { 30 + const actor = await resolveActor(did); 31 + const rpc = getRpc(actor.pds); 32 + const keys: PublicKeyRecord[] = []; 33 + let cursor: string | undefined; 34 + 35 + try { 36 + do { 37 + const page = await ok( 38 + rpc.get('com.atproto.repo.listRecords', { 39 + params: { 40 + repo: actor.did, 41 + collection: 'sh.tangled.publicKey', 42 + limit: 100, 43 + cursor, 44 + reverse: true, 45 + }, 46 + }), 47 + ); 48 + 49 + for (const r of page.records) { 50 + const rkey = parseAtUri(r.uri).rkey; 51 + const val = r.value as any; 52 + keys.push({ 53 + name: val.name || '', 54 + key: val.key || '', 55 + createdAt: val.createdAt || '', 56 + rkey, 57 + uri: r.uri, 58 + }); 59 + } 60 + cursor = page.cursor; 61 + } while (cursor); 62 + } catch (err) { 63 + console.error('Failed to list public keys from PDS:', err); 64 + } 65 + 66 + return keys; 67 + }; 68 + 69 + export const addPublicKey = async ( 70 + agent: OAuthUserAgent, 71 + name: string, 72 + key: string, 73 + ): Promise<void> => { 74 + const rpc = createAuthRpc(agent); 75 + await ok( 76 + rpc.post('com.atproto.repo.createRecord', { 77 + input: { 78 + repo: agent.sub, 79 + collection: 'sh.tangled.publicKey', 80 + record: { 81 + $type: 'sh.tangled.publicKey', 82 + name, 83 + key, 84 + createdAt: new Date().toISOString(), 85 + }, 86 + }, 87 + }), 88 + ); 89 + }; 90 + 91 + export const deletePublicKey = async ( 92 + agent: OAuthUserAgent, 93 + rkey: string, 94 + ): Promise<void> => { 95 + const rpc = createAuthRpc(agent); 96 + await ok( 97 + rpc.post('com.atproto.repo.deleteRecord', { 98 + input: { 99 + repo: agent.sub, 100 + collection: 'sh.tangled.publicKey', 101 + rkey, 102 + }, 103 + }), 104 + ); 105 + }; 106 + 107 + export const listKnots = async (did: string): Promise<KnotRecord[]> => { 108 + const actor = await resolveActor(did); 109 + const rpc = getRpc(actor.pds); 110 + const knots: KnotRecord[] = []; 111 + let cursor: string | undefined; 112 + 113 + try { 114 + do { 115 + const page = await ok( 116 + rpc.get('com.atproto.repo.listRecords', { 117 + params: { 118 + repo: actor.did, 119 + collection: 'sh.tangled.knot', 120 + limit: 100, 121 + cursor, 122 + reverse: true, 123 + }, 124 + }), 125 + ); 126 + 127 + for (const r of page.records) { 128 + const rkey = parseAtUri(r.uri).rkey; 129 + const val = r.value as any; 130 + knots.push({ 131 + domain: rkey, 132 + createdAt: val.createdAt || '', 133 + rkey, 134 + uri: r.uri, 135 + }); 136 + } 137 + cursor = page.cursor; 138 + } while (cursor); 139 + } catch (err) { 140 + console.error('Failed to list knots from PDS:', err); 141 + } 142 + 143 + return knots; 144 + }; 145 + 146 + export const registerKnot = async ( 147 + agent: OAuthUserAgent, 148 + domain: string, 149 + ): Promise<void> => { 150 + const rpc = createAuthRpc(agent); 151 + await ok( 152 + rpc.post('com.atproto.repo.putRecord', { 153 + input: { 154 + repo: agent.sub, 155 + collection: 'sh.tangled.knot', 156 + rkey: domain, 157 + record: { 158 + $type: 'sh.tangled.knot', 159 + createdAt: new Date().toISOString(), 160 + }, 161 + }, 162 + }), 163 + ); 164 + }; 165 + 166 + export const unregisterKnot = async ( 167 + agent: OAuthUserAgent, 168 + domain: string, 169 + ): Promise<void> => { 170 + const rpc = createAuthRpc(agent); 171 + await ok( 172 + rpc.post('com.atproto.repo.deleteRecord', { 173 + input: { 174 + repo: agent.sub, 175 + collection: 'sh.tangled.knot', 176 + rkey: domain, 177 + }, 178 + }), 179 + ); 180 + }; 181 + 182 + export const listSpindles = async (did: string): Promise<SpindleRecord[]> => { 183 + const actor = await resolveActor(did); 184 + const rpc = getRpc(actor.pds); 185 + const spindles: SpindleRecord[] = []; 186 + let cursor: string | undefined; 187 + 188 + try { 189 + do { 190 + const page = await ok( 191 + rpc.get('com.atproto.repo.listRecords', { 192 + params: { 193 + repo: actor.did, 194 + collection: 'sh.tangled.spindle', 195 + limit: 100, 196 + cursor, 197 + reverse: true, 198 + }, 199 + }), 200 + ); 201 + 202 + for (const r of page.records) { 203 + const rkey = parseAtUri(r.uri).rkey; 204 + const val = r.value as any; 205 + spindles.push({ 206 + instance: rkey, 207 + createdAt: val.createdAt || '', 208 + rkey, 209 + uri: r.uri, 210 + }); 211 + } 212 + cursor = page.cursor; 213 + } while (cursor); 214 + } catch (err) { 215 + console.error('Failed to list spindles from PDS:', err); 216 + } 217 + 218 + return spindles; 219 + }; 220 + 221 + export const registerSpindle = async ( 222 + agent: OAuthUserAgent, 223 + instance: string, 224 + ): Promise<void> => { 225 + const rpc = createAuthRpc(agent); 226 + await ok( 227 + rpc.post('com.atproto.repo.putRecord', { 228 + input: { 229 + repo: agent.sub, 230 + collection: 'sh.tangled.spindle', 231 + rkey: instance, 232 + record: { 233 + $type: 'sh.tangled.spindle', 234 + createdAt: new Date().toISOString(), 235 + }, 236 + }, 237 + }), 238 + ); 239 + }; 240 + 241 + export const unregisterSpindle = async ( 242 + agent: OAuthUserAgent, 243 + instance: string, 244 + ): Promise<void> => { 245 + const rpc = createAuthRpc(agent); 246 + await ok( 247 + rpc.post('com.atproto.repo.deleteRecord', { 248 + input: { 249 + repo: agent.sub, 250 + collection: 'sh.tangled.spindle', 251 + rkey: instance, 252 + }, 253 + }), 254 + ); 255 + };
+1340
src/pages/settings.tsx
··· 1 + import { 2 + For, 3 + Show, 4 + createSignal, 5 + createEffect, 6 + createMemo, 7 + createResource, 8 + type Component, 9 + type JSX, 10 + } from 'solid-js'; 11 + import { Portal } from 'solid-js/web'; 12 + import { A, useNavigate, useParams, useLocation, type RouteSectionProps } from '@solidjs/router'; 13 + import { createQuery, useQueryClient } from '@tanstack/solid-query'; 14 + import { 15 + User, 16 + Key, 17 + Mail, 18 + Bell, 19 + HardDrive, 20 + Volleyball, 21 + Spool, 22 + Plus, 23 + Trash2, 24 + RotateCw, 25 + X, 26 + Book, 27 + ShieldCheck, 28 + BookMarked, 29 + LoaderCircle, 30 + Save, 31 + } from 'lucide-solid'; 32 + import { useAuth } from '../lib/auth'; 33 + import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils'; 34 + import { Avatar, inputStyles, textareaStyles } from '../components/common'; 35 + import { resolveActor } from '../lib/api/identity'; 36 + import { listRepoRecords } from '../lib/api/repos'; 37 + import { 38 + listPublicKeys, 39 + addPublicKey, 40 + deletePublicKey, 41 + listKnots, 42 + registerKnot, 43 + unregisterKnot, 44 + listSpindles, 45 + registerSpindle, 46 + unregisterSpindle, 47 + type PublicKeyRecord, 48 + } from '../lib/api/settings'; 49 + 50 + // Helper to compute SSH Fingerprint using SubtleCrypto 51 + export const getSshFingerprint = async (key: string): Promise<string> => { 52 + try { 53 + const parts = key.trim().split(/\s+/); 54 + if (parts.length < 2) return 'Invalid SSH Key format'; 55 + const base64Data = parts[1]; 56 + const binaryString = atob(base64Data); 57 + const bytes = new Uint8Array(binaryString.length); 58 + for (let i = 0; i < binaryString.length; i++) { 59 + bytes[i] = binaryString.charCodeAt(i); 60 + } 61 + const hashBuffer = await window.crypto.subtle.digest('SHA-256', bytes); 62 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 63 + const hashString = hashArray.map(b => String.fromCharCode(b)).join(''); 64 + const base64Hash = btoa(hashString).replace(/=+$/, ''); 65 + return `SHA256:${base64Hash}`; 66 + } catch (e) { 67 + console.error('Fingerprint generation failed:', e); 68 + return 'Error generating fingerprint'; 69 + } 70 + }; 71 + 72 + // Reusable Modal Component matching design system 73 + const Modal: Component<{ 74 + isOpen: boolean; 75 + onClose: () => void; 76 + title: string; 77 + children: JSX.Element; 78 + }> = (props) => { 79 + return ( 80 + <Show when={props.isOpen}> 81 + <Portal> 82 + <div 83 + class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" 84 + onClick={props.onClose} 85 + > 86 + <div 87 + class="bg-white dark:bg-gray-800 w-full max-w-md p-6 rounded border border-gray-200 dark:border-gray-700 shadow-xl text-gray-900 dark:text-gray-100 flex flex-col gap-5 animate-in fade-in zoom-in-95 duration-150" 88 + onClick={(event) => event.stopPropagation()} 89 + > 90 + <div class="flex justify-between items-center pb-1"> 91 + <h2 class="text-sm font-bold uppercase tracking-wider">{props.title}</h2> 92 + <button onClick={props.onClose} class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> 93 + <X class="size-5" /> 94 + </button> 95 + </div> 96 + <div>{props.children}</div> 97 + </div> 98 + </div> 99 + </Portal> 100 + </Show> 101 + ); 102 + }; 103 + 104 + // Sidebar active/inactive styling matching upstream template 105 + const UserSettingsTabs = [ 106 + { name: 'profile', label: 'profile', icon: User, path: '/settings/profile' }, 107 + { name: 'keys', label: 'keys', icon: Key, path: '/settings/keys' }, 108 + { name: 'emails', label: 'emails', icon: Mail, path: '/settings/emails' }, 109 + { name: 'notifications', label: 'notifications', icon: Bell, path: '/settings/notifications' }, 110 + { name: 'knots', label: 'knots', icon: Volleyball, path: '/settings/knots' }, 111 + { name: 'spindles', label: 'spindles', icon: Spool, path: '/settings/spindles' }, 112 + ]; 113 + 114 + const settingsButtonClass = 'btn flex items-center gap-2'; 115 + const settingsDangerButtonClass = 116 + 'btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group'; 117 + const settingsRegisterButtonClass = 118 + 'btn rounded flex items-center dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group'; 119 + const verifiedBadgeClass = 120 + 'min-h-8 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm'; 121 + const dashboardVerifiedBadgeClass = 122 + 'min-h-8 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm'; 123 + 124 + export const SettingsLayout: Component<RouteSectionProps> = (props) => { 125 + const auth = useAuth(); 126 + const location = useLocation(); 127 + 128 + const isTabActive = (tabName: string, path: string) => { 129 + if (tabName === 'knots' && location.pathname.startsWith('/settings/knots')) { 130 + return true; 131 + } 132 + if (tabName === 'spindles' && location.pathname.startsWith('/settings/spindles')) { 133 + return true; 134 + } 135 + return location.pathname === path; 136 + }; 137 + 138 + return ( 139 + <Show 140 + when={auth.currentDid()} 141 + fallback={ 142 + <div class="p-6 text-center"> 143 + <p class="text-xl font-bold dark:text-white mb-4">settings</p> 144 + <div class="bg-white dark:bg-gray-800 p-6 rounded max-w-md mx-auto border border-gray-200 dark:border-gray-700 text-gray-500"> 145 + Please sign in to access settings. 146 + </div> 147 + </div> 148 + } 149 + > 150 + <div class="p-6"> 151 + <h1 class="text-xl font-bold dark:text-white">settings</h1> 152 + </div> 153 + <div class="bg-white dark:bg-gray-800 p-6 rounded w-full mx-auto drop-shadow-sm dark:text-white"> 154 + <section class="untangled-settings-grid"> 155 + {/* Sidebar Navigation */} 156 + <div> 157 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 158 + <For each={UserSettingsTabs}> 159 + {(tab) => { 160 + const active = () => isTabActive(tab.name, tab.path); 161 + return ( 162 + <A 163 + href={tab.path} 164 + class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25" 165 + > 166 + <div 167 + class={`flex gap-3 items-center p-2 transition-colors ${ 168 + active() 169 + ? 'bg-white dark:bg-gray-700 drop-shadow-sm' 170 + : 'bg-gray-100 dark:bg-gray-800' 171 + }`} 172 + > 173 + <tab.icon class="size-4" /> 174 + {tab.label} 175 + </div> 176 + </A> 177 + ); 178 + }} 179 + </For> 180 + </div> 181 + </div> 182 + 183 + {/* Tab Content */} 184 + <div class="min-w-0 flex flex-col gap-6"> 185 + {props.children} 186 + </div> 187 + </section> 188 + </div> 189 + </Show> 190 + ); 191 + }; 192 + 193 + // Profile settings tab implementation 194 + export const ProfileSettingsTab: Component = () => { 195 + const auth = useAuth(); 196 + const [punchcardHideMine, setPunchcardHideMine] = createSignal(false); 197 + const [punchcardHideOthers, setPunchcardHideOthers] = createSignal(false); 198 + 199 + // Load punchcard preference from localStorage 200 + createEffect(() => { 201 + const did = auth.currentDid(); 202 + if (did) { 203 + const mine = localStorage.getItem(`untangled.punchcard.hideMine.${did}`) === 'true'; 204 + const others = localStorage.getItem(`untangled.punchcard.hideOthers.${did}`) === 'true'; 205 + setPunchcardHideMine(mine); 206 + setPunchcardHideOthers(others); 207 + } 208 + }); 209 + 210 + // Save punchcard preferences 211 + const handlePunchcardChange = (field: 'mine' | 'others', value: boolean) => { 212 + const did = auth.currentDid(); 213 + if (!did) return; 214 + if (field === 'mine') { 215 + setPunchcardHideMine(value); 216 + localStorage.setItem(`untangled.punchcard.hideMine.${did}`, String(value)); 217 + } else { 218 + setPunchcardHideOthers(value); 219 + localStorage.setItem(`untangled.punchcard.hideOthers.${did}`, String(value)); 220 + } 221 + }; 222 + 223 + const actorQuery = createQuery(() => ({ 224 + queryKey: ['actor', auth.currentDid()], 225 + queryFn: async () => resolveActor(auth.currentDid()!), 226 + enabled: Boolean(auth.currentDid()), 227 + })); 228 + 229 + return ( 230 + <div class="flex flex-col gap-6"> 231 + {/* Profile Info Section */} 232 + <div> 233 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">PROFILE</h2> 234 + <p class="text-gray-500 dark:text-gray-400 pb-2"> 235 + Your account information from your AT Protocol identity. 236 + </p> 237 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 238 + <div class="flex flex-col gap-1 p-4"> 239 + <span class="text-sm text-gray-500 dark:text-gray-400">Handle</span> 240 + <div class="flex items-center gap-2"> 241 + <span class="font-bold"> 242 + {actorQuery.isLoading ? 'Loading...' : actorQuery.data?.handle || auth.currentDid()} 243 + </span> 244 + </div> 245 + </div> 246 + <div class="flex flex-col gap-1 p-4"> 247 + <span class="text-sm text-gray-500 dark:text-gray-400">Decentralized Identifier (DID)</span> 248 + <span class="font-mono font-bold break-all">{auth.currentDid()}</span> 249 + </div> 250 + <div class="flex flex-col gap-1 p-4"> 251 + <span class="text-sm text-gray-500 dark:text-gray-400">Personal Data Server (PDS)</span> 252 + <span class="font-bold">{actorQuery.isLoading ? 'Loading...' : actorQuery.data?.pds || 'Unknown PDS'}</span> 253 + </div> 254 + </div> 255 + </div> 256 + 257 + {/* Punchcard Preferences Section */} 258 + <div> 259 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">PUNCHCARD</h2> 260 + <p class="text-gray-500 dark:text-gray-400 pb-2"> 261 + Configure punchcard visibility and preferences. 262 + </p> 263 + <div class="flex flex-col gap-1.5"> 264 + <div class="flex items-center gap-2"> 265 + <input 266 + type="checkbox" 267 + id="hideMine" 268 + checked={punchcardHideMine()} 269 + onChange={(e) => handlePunchcardChange('mine', e.currentTarget.checked)} 270 + class="my-0 py-0 rounded border-gray-300 dark:border-gray-600 text-blue-600 size-4 p-0 m-0 untangled-checkbox" 271 + /> 272 + <label for="hideMine" class="my-0 py-0 normal-case font-normal text-sm text-gray-700 dark:text-gray-300 cursor-pointer"> 273 + hide mine 274 + </label> 275 + </div> 276 + <div class="flex items-center gap-2"> 277 + <input 278 + type="checkbox" 279 + id="hideOthers" 280 + checked={punchcardHideOthers()} 281 + onChange={(e) => handlePunchcardChange('others', e.currentTarget.checked)} 282 + class="my-0 py-0 rounded border-gray-300 dark:border-gray-600 text-blue-600 size-4 p-0 m-0 untangled-checkbox" 283 + /> 284 + <label for="hideOthers" class="my-0 py-0 normal-case font-normal text-sm text-gray-700 dark:text-gray-300 cursor-pointer"> 285 + hide others from me 286 + </label> 287 + </div> 288 + </div> 289 + </div> 290 + </div> 291 + ); 292 + }; 293 + 294 + // SSH Keys settings tab implementation 295 + const SshKeyRow: Component<{ 296 + keyRecord: PublicKeyRecord; 297 + onDelete: () => void; 298 + }> = (props) => { 299 + const [fingerprint] = createResource(() => getSshFingerprint(props.keyRecord.key)); 300 + 301 + return ( 302 + <div class="flex items-center justify-between p-2"> 303 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 304 + <div class="flex items-center gap-2 text-gray-900 dark:text-gray-100"> 305 + <Key class="w-4 h-4 text-gray-500" /> 306 + <span class="font-bold break-all">{props.keyRecord.name}</span> 307 + </div> 308 + <span class="font-mono text-sm text-gray-500 dark:text-gray-400 break-all select-all"> 309 + {fingerprint.loading ? 'Calculating fingerprint...' : fingerprint()} 310 + </span> 311 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 312 + <span>added {formatRelativeTime(props.keyRecord.createdAt)}</span> 313 + </div> 314 + </div> 315 + <button 316 + class={`${settingsDangerButtonClass} shrink-0`} 317 + title="Delete key" 318 + onClick={() => { 319 + if (confirm(`Are you sure you want to delete the key "${props.keyRecord.name}"?`)) { 320 + props.onDelete(); 321 + } 322 + }} 323 + > 324 + <Trash2 class="w-5 h-5" /> 325 + <span class="hidden md:inline">delete</span> 326 + </button> 327 + </div> 328 + ); 329 + }; 330 + 331 + export const KeysSettingsTab: Component = () => { 332 + const auth = useAuth(); 333 + const queryClient = useQueryClient(); 334 + const [isOpen, setIsOpen] = createSignal(false); 335 + const [keyName, setKeyName] = createSignal(''); 336 + const [keyValue, setKeyValue] = createSignal(''); 337 + const [loading, setLoading] = createSignal(false); 338 + const [error, setError] = createSignal(''); 339 + 340 + const keysQuery = createQuery(() => ({ 341 + queryKey: ['settings', 'keys', auth.currentDid()], 342 + queryFn: async () => listPublicKeys(auth.currentDid()!), 343 + enabled: Boolean(auth.currentDid()), 344 + })); 345 + 346 + const handleAddKey = async (e: Event) => { 347 + e.preventDefault(); 348 + setError(''); 349 + setLoading(true); 350 + 351 + try { 352 + const agent = auth.agent(); 353 + if (!agent) throw new Error('Not authenticated.'); 354 + if (!keyValue().trim().startsWith('ssh-')) { 355 + throw new Error("Invalid key format. SSH public keys usually start with 'ssh-'."); 356 + } 357 + 358 + await addPublicKey(agent, keyName().trim(), keyValue().trim()); 359 + await queryClient.refetchQueries({ queryKey: ['settings', 'keys', auth.currentDid()] }); 360 + setKeyName(''); 361 + setKeyValue(''); 362 + setIsOpen(false); 363 + } catch (err: unknown) { 364 + console.error(err); 365 + setError(getErrorMessage(err)); 366 + } finally { 367 + setLoading(false); 368 + } 369 + }; 370 + 371 + const handleDeleteKey = async (rkey: string) => { 372 + try { 373 + const agent = auth.agent(); 374 + if (!agent) throw new Error('Not authenticated.'); 375 + 376 + await deletePublicKey(agent, rkey); 377 + await queryClient.refetchQueries({ queryKey: ['settings', 'keys', auth.currentDid()] }); 378 + } catch (err: any) { 379 + console.error(err); 380 + alert(err.message || 'Failed to delete SSH key.'); 381 + } 382 + }; 383 + 384 + return ( 385 + <div class="flex flex-col gap-6"> 386 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 387 + <div class="col-span-1 md:col-span-2"> 388 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">SSH KEYS</h2> 389 + <p class="text-gray-500 dark:text-gray-400"> 390 + SSH public keys added here will be broadcasted to knots that you are a member of, allowing you to push to repositories there. 391 + </p> 392 + </div> 393 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 394 + <button onClick={() => setIsOpen(true)} class={settingsButtonClass}> 395 + <Plus class="size-4" /> add key 396 + </button> 397 + </div> 398 + </div> 399 + 400 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 401 + <Show 402 + when={!keysQuery.isLoading} 403 + fallback={<div class="p-4 text-center text-gray-500">Loading SSH keys...</div>} 404 + > 405 + <For 406 + each={keysQuery.data} 407 + fallback={ 408 + <div class="flex items-center justify-center p-4 text-gray-500"> 409 + No keys added yet 410 + </div> 411 + } 412 + > 413 + {(k) => <SshKeyRow keyRecord={k} onDelete={() => handleDeleteKey(k.rkey)} />} 414 + </For> 415 + </Show> 416 + </div> 417 + 418 + <Modal isOpen={isOpen()} onClose={() => setIsOpen(false)} title="add ssh key"> 419 + <form onSubmit={handleAddKey} class="flex flex-col gap-3"> 420 + <p class="text-sm text-gray-500">SSH keys allow you to push to repositories in knots you're a member of.</p> 421 + <div class="flex flex-col gap-1"> 422 + <label class="text-sm text-gray-500 font-medium">Key name</label> 423 + <input 424 + type="text" 425 + placeholder="Key name" 426 + value={keyName()} 427 + onInput={(e) => setKeyName(e.currentTarget.value)} 428 + class={inputStyles()} 429 + required 430 + disabled={loading()} 431 + /> 432 + </div> 433 + <div class="flex flex-col gap-1"> 434 + <label class="text-sm text-gray-500 font-medium">Public key string</label> 435 + <textarea 436 + placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 437 + value={keyValue()} 438 + onInput={(e) => setKeyValue(e.currentTarget.value)} 439 + class={textareaStyles()} 440 + rows="5" 441 + required 442 + disabled={loading()} 443 + /> 444 + </div> 445 + 446 + <Show when={error()}> 447 + <p class="text-sm text-red-500">{error()}</p> 448 + </Show> 449 + 450 + <div class="flex gap-2 pt-2"> 451 + <button 452 + type="button" 453 + onClick={() => setIsOpen(false)} 454 + class={`${settingsDangerButtonClass} w-1/2 justify-center`} 455 + disabled={loading()} 456 + > 457 + <X class="size-4" /> cancel 458 + </button> 459 + <button 460 + type="submit" 461 + class={`${settingsButtonClass} w-1/2 justify-center`} 462 + disabled={loading()} 463 + > 464 + <Show when={loading()} fallback={<><Plus class="size-4" /> add</>}> 465 + <LoaderCircle class="size-4 animate-spin mr-1" /> adding... 466 + </Show> 467 + </button> 468 + </div> 469 + </form> 470 + </Modal> 471 + </div> 472 + ); 473 + }; 474 + 475 + // Emails settings tab implementation 476 + interface EmailItem { 477 + address: string; 478 + verified: boolean; 479 + primary: boolean; 480 + createdAt: string; 481 + } 482 + 483 + export const EmailsSettingsTab: Component = () => { 484 + const auth = useAuth(); 485 + const [emails, setEmails] = createSignal<EmailItem[]>([]); 486 + const [isOpen, setIsOpen] = createSignal(false); 487 + const [newEmail, setNewEmail] = createSignal(''); 488 + 489 + const storageKey = createMemo(() => { 490 + const did = auth.currentDid(); 491 + return did ? `untangled.emails.${did}` : null; 492 + }); 493 + 494 + // Load emails from localStorage 495 + createEffect(() => { 496 + const key = storageKey(); 497 + if (!key) return; 498 + const saved = localStorage.getItem(key); 499 + if (saved) { 500 + try { 501 + setEmails(JSON.parse(saved)); 502 + } catch { 503 + setEmails([]); 504 + } 505 + } else { 506 + // Prepopulate with a default primary email 507 + const resolved = actorQuery.data; 508 + const prepopulated = [ 509 + { 510 + address: `${resolved?.handle || 'user'}@tangled.sh`, 511 + verified: true, 512 + primary: true, 513 + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), 514 + }, 515 + ]; 516 + setEmails(prepopulated); 517 + localStorage.setItem(key, JSON.stringify(prepopulated)); 518 + } 519 + }); 520 + 521 + const actorQuery = createQuery(() => ({ 522 + queryKey: ['actor', auth.currentDid()], 523 + queryFn: async () => resolveActor(auth.currentDid()!), 524 + enabled: Boolean(auth.currentDid()), 525 + })); 526 + 527 + const saveEmails = (list: EmailItem[]) => { 528 + setEmails(list); 529 + const key = storageKey(); 530 + if (key) { 531 + localStorage.setItem(key, JSON.stringify(list)); 532 + } 533 + }; 534 + 535 + const handleAddEmail = (e: Event) => { 536 + e.preventDefault(); 537 + const address = newEmail().trim(); 538 + if (!address) return; 539 + if (emails().some((em) => em.address.toLowerCase() === address.toLowerCase())) { 540 + alert('Email address already added.'); 541 + return; 542 + } 543 + 544 + const updated = [ 545 + ...emails(), 546 + { 547 + address, 548 + verified: false, 549 + primary: false, 550 + createdAt: new Date().toISOString(), 551 + }, 552 + ]; 553 + saveEmails(updated); 554 + setNewEmail(''); 555 + setIsOpen(false); 556 + }; 557 + 558 + const handleDeleteEmail = (address: string) => { 559 + const list = emails(); 560 + const target = list.find((em) => em.address === address); 561 + if (target?.primary) { 562 + alert('You cannot delete your primary email.'); 563 + return; 564 + } 565 + 566 + if (confirm(`Are you sure you want to delete the email "${address}"?`)) { 567 + saveEmails(list.filter((em) => em.address !== address)); 568 + } 569 + }; 570 + 571 + const handleVerifyEmail = (address: string) => { 572 + const updated = emails().map((em) => { 573 + if (em.address === address) { 574 + return { ...em, verified: true }; 575 + } 576 + return em; 577 + }); 578 + saveEmails(updated); 579 + alert(`Email address ${address} successfully verified!`); 580 + }; 581 + 582 + const handleResendVerification = (address: string) => { 583 + alert(`Verification email resent to ${address}. Click "Verify" to simulate confirming the code.`); 584 + }; 585 + 586 + const handleSetPrimary = (address: string) => { 587 + const updated = emails().map((em) => { 588 + if (em.address === address) { 589 + return { ...em, primary: true }; 590 + } 591 + if (em.primary) { 592 + return { ...em, primary: false }; 593 + } 594 + return em; 595 + }); 596 + saveEmails(updated); 597 + }; 598 + 599 + return ( 600 + <div class="flex flex-col gap-6"> 601 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 602 + <div class="col-span-1 md:col-span-2"> 603 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">EMAIL ADDRESSES</h2> 604 + <p class="text-gray-500 dark:text-gray-400"> 605 + Commits authored using emails listed here will be associated with your Tangled profile. 606 + </p> 607 + </div> 608 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 609 + <button onClick={() => setIsOpen(true)} class={settingsButtonClass}> 610 + <Plus class="size-4" /> add email 611 + </button> 612 + </div> 613 + </div> 614 + 615 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 616 + <For 617 + each={emails()} 618 + fallback={ 619 + <div class="flex items-center justify-center p-4 text-gray-500"> 620 + No emails added yet 621 + </div> 622 + } 623 + > 624 + {(email) => ( 625 + <div class="flex items-center justify-between p-2"> 626 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[70%]"> 627 + <div class="flex items-center gap-2 text-gray-900 dark:text-gray-100 flex-wrap"> 628 + <Mail class="w-4 h-4 text-gray-500 shrink-0" /> 629 + <span class="font-bold break-all">{email.address}</span> 630 + <div class="inline-flex items-center gap-1 shrink-0"> 631 + <Show 632 + when={email.verified} 633 + fallback={ 634 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded"> 635 + unverified 636 + </span> 637 + } 638 + > 639 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded"> 640 + verified 641 + </span> 642 + </Show> 643 + <Show when={email.primary}> 644 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded"> 645 + primary 646 + </span> 647 + </Show> 648 + </div> 649 + </div> 650 + <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 651 + <span>added {formatRelativeTime(email.createdAt)}</span> 652 + </div> 653 + </div> 654 + <div class="flex gap-2 items-center shrink-0"> 655 + <Show when={!email.verified}> 656 + <button 657 + onClick={() => handleVerifyEmail(email.address)} 658 + class="btn text-sm px-2 py-1" 659 + title="Verify email address" 660 + > 661 + verify 662 + </button> 663 + <button 664 + onClick={() => handleResendVerification(email.address)} 665 + class="btn flex gap-2 text-sm px-2 py-1" 666 + > 667 + <RotateCw class="w-4 h-4" /> 668 + <span class="hidden md:inline">resend</span> 669 + </button> 670 + </Show> 671 + <Show when={!email.primary && email.verified}> 672 + <button 673 + onClick={() => handleSetPrimary(email.address)} 674 + class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 675 + > 676 + set as primary 677 + </button> 678 + </Show> 679 + <Show when={!email.primary}> 680 + <button 681 + onClick={() => handleDeleteEmail(email.address)} 682 + class={settingsDangerButtonClass} 683 + title="Delete email" 684 + > 685 + <Trash2 class="w-5 h-5" /> 686 + <span class="hidden md:inline">delete</span> 687 + </button> 688 + </Show> 689 + </div> 690 + </div> 691 + )} 692 + </For> 693 + </div> 694 + 695 + <Modal isOpen={isOpen()} onClose={() => setIsOpen(false)} title="add email"> 696 + <form onSubmit={handleAddEmail} class="flex flex-col gap-3"> 697 + <p class="text-sm text-gray-500">Commits using this email will be associated with your profile.</p> 698 + <div class="flex flex-col gap-1"> 699 + <label class="text-sm text-gray-500 font-medium">Email address</label> 700 + <input 701 + type="email" 702 + placeholder="your@email.com" 703 + value={newEmail()} 704 + onInput={(e) => setNewEmail(e.currentTarget.value)} 705 + class={inputStyles()} 706 + required 707 + /> 708 + </div> 709 + 710 + <div class="flex gap-2 pt-2"> 711 + <button 712 + type="button" 713 + onClick={() => setIsOpen(false)} 714 + class={`${settingsDangerButtonClass} w-1/2 justify-center`} 715 + > 716 + <X class="size-4" /> cancel 717 + </button> 718 + <button type="submit" class={`${settingsButtonClass} w-1/2 justify-center`}> 719 + <Plus class="size-4" /> add 720 + </button> 721 + </div> 722 + </form> 723 + </Modal> 724 + </div> 725 + ); 726 + }; 727 + 728 + // Notifications settings tab implementation 729 + export const NotificationsSettingsTab: Component = () => { 730 + const auth = useAuth(); 731 + const [starred, setStarred] = createSignal(true); 732 + const [newIssues, setNewIssues] = createSignal(true); 733 + const [issueComments, setIssueComments] = createSignal(true); 734 + const [issueClosed, setIssueClosed] = createSignal(true); 735 + const [newPulls, setNewPulls] = createSignal(true); 736 + const [pullComments, setPullComments] = createSignal(true); 737 + const [pullMerged, setPullMerged] = createSignal(true); 738 + const [followers, setFollowers] = createSignal(true); 739 + const [mentions, setMentions] = createSignal(true); 740 + const [emailNotif, setEmailNotif] = createSignal(false); 741 + const [successMsg, setSuccessMsg] = createSignal(''); 742 + 743 + const storageKey = createMemo(() => { 744 + const did = auth.currentDid(); 745 + return did ? `untangled.notifications.${did}` : null; 746 + }); 747 + 748 + createEffect(() => { 749 + const key = storageKey(); 750 + if (!key) return; 751 + const saved = localStorage.getItem(key); 752 + if (saved) { 753 + try { 754 + const val = JSON.parse(saved); 755 + setStarred(val.starred !== false); 756 + setNewIssues(val.newIssues !== false); 757 + setIssueComments(val.issueComments !== false); 758 + setIssueClosed(val.issueClosed !== false); 759 + setNewPulls(val.newPulls !== false); 760 + setPullComments(val.pullComments !== false); 761 + setPullMerged(val.pullMerged !== false); 762 + setFollowers(val.followers !== false); 763 + setMentions(val.mentions !== false); 764 + setEmailNotif(val.emailNotif === true); 765 + } catch { 766 + // use defaults 767 + } 768 + } 769 + }); 770 + 771 + const handleSave = (e: Event) => { 772 + e.preventDefault(); 773 + const key = storageKey(); 774 + if (!key) return; 775 + 776 + const prefs = { 777 + starred: starred(), 778 + newIssues: newIssues(), 779 + issueComments: issueComments(), 780 + issueClosed: issueClosed(), 781 + newPulls: newPulls(), 782 + pullComments: pullComments(), 783 + pullMerged: pullMerged(), 784 + followers: followers(), 785 + mentions: mentions(), 786 + emailNotif: emailNotif(), 787 + }; 788 + 789 + localStorage.setItem(key, JSON.stringify(prefs)); 790 + setSuccessMsg('Notification preferences saved successfully.'); 791 + setTimeout(() => setSuccessMsg(''), 4000); 792 + }; 793 + 794 + return ( 795 + <div class="flex flex-col gap-6"> 796 + <div> 797 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">NOTIFICATION PREFERENCES</h2> 798 + <p class="text-gray-500 dark:text-gray-400"> 799 + Choose which notifications you want to receive when activity happens on your repositories and profile. 800 + </p> 801 + </div> 802 + 803 + <form onSubmit={handleSave} class="flex flex-col gap-6"> 804 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 805 + {/* Preference rows matching upstream exactly */} 806 + {[ 807 + { label: 'Repository starred', desc: 'When someone stars your repository.', state: starred, set: setStarred }, 808 + { label: 'New issues', desc: 'When someone creates an issue on your repository.', state: newIssues, set: setNewIssues }, 809 + { label: 'Issue comments', desc: "When someone comments on an issue you're involved with.", state: issueComments, set: setIssueComments }, 810 + { label: 'Issue closed', desc: 'When an issue on your repository is closed.', state: issueClosed, set: setIssueClosed }, 811 + { label: 'New pull requests', desc: 'When someone creates a pull request on your repository.', state: newPulls, set: setNewPulls }, 812 + { label: 'Pull request comments', desc: "When someone comments on a pull request you're involved with.", state: pullComments, set: setPullComments }, 813 + { label: 'Pull request merged', desc: 'When your pull request is merged.', state: pullMerged, set: setPullMerged }, 814 + { label: 'New followers', desc: 'When someone follows you.', state: followers, set: setFollowers }, 815 + { label: 'Mentions', desc: 'When someone mentions you.', state: mentions, set: setMentions }, 816 + { label: 'Email notifications', desc: 'Receive notifications via email in addition to in-app notifications.', state: emailNotif, set: setEmailNotif }, 817 + ].map((pref) => ( 818 + <div class="flex items-center justify-between p-2"> 819 + <div class="flex flex-col gap-1 pr-4"> 820 + <span class="font-bold text-gray-900 dark:text-gray-100">{pref.label}</span> 821 + <span class="text-sm text-gray-500 dark:text-gray-400">{pref.desc}</span> 822 + </div> 823 + <input 824 + type="checkbox" 825 + checked={pref.state()} 826 + onChange={(e) => pref.set(e.currentTarget.checked)} 827 + class="my-0 py-0 rounded border-gray-300 dark:border-gray-600 text-blue-600 size-4 p-0 m-0 untangled-checkbox" 828 + /> 829 + </div> 830 + ))} 831 + </div> 832 + 833 + <div class="flex justify-between items-center pt-2"> 834 + <span class="text-sm text-green-500 font-semibold">{successMsg()}</span> 835 + <button type="submit" class="btn-create flex items-center gap-2 group"> 836 + <Save class="w-4 h-4" /> 837 + save 838 + </button> 839 + </div> 840 + </form> 841 + </div> 842 + ); 843 + }; 844 + 845 + // Knots settings tab implementation 846 + export const KnotsSettingsTab: Component = () => { 847 + const auth = useAuth(); 848 + const queryClient = useQueryClient(); 849 + const [domain, setDomain] = createSignal(''); 850 + const [loading, setLoading] = createSignal(false); 851 + const [error, setError] = createSignal(''); 852 + 853 + const knotsQuery = createQuery(() => ({ 854 + queryKey: ['settings', 'knots', auth.currentDid()], 855 + queryFn: async () => listKnots(auth.currentDid()!), 856 + enabled: Boolean(auth.currentDid()), 857 + })); 858 + 859 + const handleRegister = async (e: Event) => { 860 + e.preventDefault(); 861 + setError(''); 862 + setLoading(true); 863 + 864 + const knotDomain = domain().trim(); 865 + if (!knotDomain) return; 866 + 867 + try { 868 + const agent = auth.agent(); 869 + if (!agent) throw new Error('Not authenticated.'); 870 + await registerKnot(agent, knotDomain); 871 + await queryClient.refetchQueries({ queryKey: ['settings', 'knots', auth.currentDid()] }); 872 + setDomain(''); 873 + } catch (err: any) { 874 + console.error(err); 875 + setError(err.message || 'Failed to register knot.'); 876 + } finally { 877 + setLoading(false); 878 + } 879 + }; 880 + 881 + const handleDelete = async (knotDomain: string) => { 882 + if ( 883 + confirm( 884 + `Are you sure you want to delete the knot "${knotDomain}"? All git data will stay on the knot, but repositories on it will disappear from Tangled.` 885 + ) 886 + ) { 887 + try { 888 + const agent = auth.agent(); 889 + if (!agent) throw new Error('Not authenticated.'); 890 + await unregisterKnot(agent, knotDomain); 891 + await queryClient.refetchQueries({ queryKey: ['settings', 'knots', auth.currentDid()] }); 892 + } catch (err: any) { 893 + console.error(err); 894 + alert(err.message || 'Failed to delete knot.'); 895 + } 896 + } 897 + }; 898 + 899 + return ( 900 + <div class="flex flex-col gap-6"> 901 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 902 + <div class="col-span-1 md:col-span-2"> 903 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">KNOTS</h2> 904 + <p class="text-gray-500 dark:text-gray-400"> 905 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 906 + When creating a repository, you can choose a knot to store it on. 907 + </p> 908 + </div> 909 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 910 + <a 911 + class={settingsButtonClass} 912 + href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide" 913 + target="_blank" 914 + rel="noopener noreferrer" 915 + > 916 + <Book class="size-4" /> docs 917 + </a> 918 + </div> 919 + </div> 920 + 921 + {/* Your Knots List */} 922 + <section class="rounded w-full flex flex-col gap-2"> 923 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">YOUR KNOTS</h2> 924 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800"> 925 + <Show 926 + when={!knotsQuery.isLoading} 927 + fallback={<div class="p-4 text-center text-gray-500">Loading knots...</div>} 928 + > 929 + <For 930 + each={knotsQuery.data} 931 + fallback={ 932 + <div class="flex items-center justify-center p-4 text-gray-500"> 933 + No knots registered yet 934 + </div> 935 + } 936 + > 937 + {(knot) => ( 938 + <div class="flex items-center justify-between p-2"> 939 + <A 940 + href={`/settings/knots/${knot.domain}`} 941 + class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]" 942 + > 943 + <HardDrive class="w-4 h-4 text-gray-500 shrink-0" /> 944 + <span class="hover:underline text-gray-900 dark:text-gray-100 break-all"> 945 + {knot.domain} 946 + </span> 947 + <span class="text-gray-500 shrink-0"> 948 + {formatRelativeTime(knot.createdAt)} 949 + </span> 950 + </A> 951 + <div class="flex gap-2 items-center shrink-0"> 952 + <span class={verifiedBadgeClass}> 953 + <ShieldCheck class="w-4 h-4" /> verified 954 + </span> 955 + <button 956 + onClick={() => handleDelete(knot.domain)} 957 + class={settingsDangerButtonClass} 958 + title="Delete knot" 959 + > 960 + <Trash2 class="w-5 h-5" /> 961 + <span class="hidden md:inline">delete</span> 962 + </button> 963 + </div> 964 + </div> 965 + )} 966 + </For> 967 + </Show> 968 + </div> 969 + </section> 970 + 971 + {/* Register a knot */} 972 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 973 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">REGISTER A KNOT</h2> 974 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 975 + <form onSubmit={handleRegister} class="max-w-2xl mb-2"> 976 + <div class="flex gap-2"> 977 + <input 978 + type="text" 979 + placeholder="knot.example.com" 980 + value={domain()} 981 + onInput={(e) => setDomain(e.currentTarget.value)} 982 + class={inputStyles()} 983 + required 984 + disabled={loading()} 985 + /> 986 + <button type="submit" class={settingsRegisterButtonClass} disabled={loading()}> 987 + <span class={loading() ? 'hidden' : 'inline-flex items-center gap-2 px-4'}> 988 + <Plus class="w-4 h-4" /> 989 + register 990 + </span> 991 + <span class={loading() ? 'inline-flex items-center gap-2 px-4' : 'hidden'}> 992 + <LoaderCircle class="w-4 h-4 animate-spin" /> 993 + registering... 994 + </span> 995 + </button> 996 + </div> 997 + </form> 998 + <Show when={error()}> 999 + <p class="text-sm text-red-500 mt-1">{error()}</p> 1000 + </Show> 1001 + </section> 1002 + </div> 1003 + ); 1004 + }; 1005 + 1006 + // Knot Details Dashboard implementation 1007 + export const KnotDashboardTab: Component = () => { 1008 + const params = useParams(); 1009 + const auth = useAuth(); 1010 + const navigate = useNavigate(); 1011 + const queryClient = useQueryClient(); 1012 + 1013 + const reposQuery = createQuery(() => ({ 1014 + queryKey: ['repos', auth.currentDid()], 1015 + queryFn: async () => listRepoRecords(auth.currentDid()!), 1016 + enabled: Boolean(auth.currentDid()), 1017 + })); 1018 + 1019 + const knotRepos = createMemo(() => { 1020 + const list = reposQuery.data; 1021 + return list?.filter((r) => r.value.knot === params.domain) || []; 1022 + }); 1023 + 1024 + const actorQuery = createQuery(() => ({ 1025 + queryKey: ['actor', auth.currentDid()], 1026 + queryFn: async () => resolveActor(auth.currentDid()!), 1027 + enabled: Boolean(auth.currentDid()), 1028 + })); 1029 + 1030 + const handleDelete = async () => { 1031 + const knotDomain = params.domain || ''; 1032 + if (!knotDomain) return; 1033 + if ( 1034 + confirm( 1035 + `Are you sure you want to delete the knot "${knotDomain}"? All git data will stay on the knot, but repositories on it will disappear from Tangled.` 1036 + ) 1037 + ) { 1038 + try { 1039 + const agent = auth.agent(); 1040 + if (!agent) throw new Error('Not authenticated.'); 1041 + await unregisterKnot(agent, knotDomain); 1042 + await queryClient.refetchQueries({ queryKey: ['settings', 'knots', auth.currentDid()] }); 1043 + navigate('/settings/knots'); 1044 + } catch (err: any) { 1045 + console.error(err); 1046 + alert(err.message || 'Failed to delete knot.'); 1047 + } 1048 + } 1049 + }; 1050 + 1051 + return ( 1052 + <div class="flex flex-col gap-6"> 1053 + <div class="flex justify-between items-center flex-wrap gap-2"> 1054 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">KNOT &middot; {params.domain}</h2> 1055 + <div class="flex gap-2 items-center"> 1056 + <span class={dashboardVerifiedBadgeClass}> 1057 + <ShieldCheck class="w-4 h-4" /> verified 1058 + </span> 1059 + <button 1060 + onClick={handleDelete} 1061 + class={settingsDangerButtonClass} 1062 + > 1063 + <Trash2 class="w-5 h-5" /> 1064 + <span class="hidden md:inline">delete</span> 1065 + </button> 1066 + </div> 1067 + </div> 1068 + 1069 + <section class="bg-white dark:bg-gray-800 rounded relative w-full mx-auto border border-gray-200 dark:border-gray-700 p-4 dark:text-white flex flex-col gap-4"> 1070 + <h3 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700 pb-2 mb-2">KNOT MEMBERS</h3> 1071 + <div class="flex flex-col gap-4"> 1072 + <div> 1073 + <div class="flex justify-between items-center"> 1074 + <div class="flex items-center gap-2"> 1075 + <Avatar did={auth.currentDid() || ''} size="size-7" /> 1076 + <span class="font-semibold text-gray-900 dark:text-gray-100"> 1077 + {actorQuery.data?.handle || auth.currentDid()} 1078 + </span> 1079 + <span class="text-gray-500 text-xs">(Owner)</span> 1080 + </div> 1081 + </div> 1082 + <div class="ml-4 pl-4 pt-2 border-l border-gray-200 dark:border-gray-700 flex flex-col gap-2"> 1083 + <Show 1084 + when={!reposQuery.isLoading} 1085 + fallback={<p class="text-gray-500 text-sm">Loading repositories...</p>} 1086 + > 1087 + <For 1088 + each={knotRepos()} 1089 + fallback={ 1090 + <div class="text-gray-500 text-sm italic"> 1091 + No repositories configured yet. 1092 + </div> 1093 + } 1094 + > 1095 + {(repo) => ( 1096 + <div class="flex gap-2 items-center"> 1097 + <BookMarked class="size-4 text-gray-400" /> 1098 + <A href={`/${actorQuery.data?.handle || auth.currentDid()}/${repo.rkey}`} class="text-blue-600 dark:text-blue-400 hover:underline"> 1099 + {repo.value.name} 1100 + </A> 1101 + </div> 1102 + )} 1103 + </For> 1104 + </Show> 1105 + </div> 1106 + </div> 1107 + </div> 1108 + </section> 1109 + </div> 1110 + ); 1111 + }; 1112 + 1113 + // Spindles settings tab implementation 1114 + export const SpindlesSettingsTab: Component = () => { 1115 + const auth = useAuth(); 1116 + const queryClient = useQueryClient(); 1117 + const [instance, setInstance] = createSignal(''); 1118 + const [loading, setLoading] = createSignal(false); 1119 + const [error, setError] = createSignal(''); 1120 + 1121 + const spindlesQuery = createQuery(() => ({ 1122 + queryKey: ['settings', 'spindles', auth.currentDid()], 1123 + queryFn: async () => listSpindles(auth.currentDid()!), 1124 + enabled: Boolean(auth.currentDid()), 1125 + })); 1126 + 1127 + const handleRegister = async (e: Event) => { 1128 + e.preventDefault(); 1129 + setError(''); 1130 + setLoading(true); 1131 + 1132 + const spindleDomain = instance().trim(); 1133 + if (!spindleDomain) return; 1134 + 1135 + try { 1136 + const agent = auth.agent(); 1137 + if (!agent) throw new Error('Not authenticated.'); 1138 + await registerSpindle(agent, spindleDomain); 1139 + await queryClient.refetchQueries({ queryKey: ['settings', 'spindles', auth.currentDid()] }); 1140 + setInstance(''); 1141 + } catch (err: any) { 1142 + console.error(err); 1143 + setError(err.message || 'Failed to register spindle.'); 1144 + } finally { 1145 + setLoading(false); 1146 + } 1147 + }; 1148 + 1149 + const handleDelete = async (spindleDomain: string) => { 1150 + if (confirm(`Are you sure you want to delete the spindle "${spindleDomain}"?`)) { 1151 + try { 1152 + const agent = auth.agent(); 1153 + if (!agent) throw new Error('Not authenticated.'); 1154 + await unregisterSpindle(agent, spindleDomain); 1155 + await queryClient.refetchQueries({ queryKey: ['settings', 'spindles', auth.currentDid()] }); 1156 + } catch (err: any) { 1157 + console.error(err); 1158 + alert(err.message || 'Failed to delete spindle.'); 1159 + } 1160 + } 1161 + }; 1162 + 1163 + return ( 1164 + <div class="flex flex-col gap-6"> 1165 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 1166 + <div class="col-span-1 md:col-span-2"> 1167 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">SPINDLE</h2> 1168 + <p class="text-gray-500 dark:text-gray-400"> 1169 + Spindles are small CI runners. 1170 + </p> 1171 + </div> 1172 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 1173 + <a 1174 + class={settingsButtonClass} 1175 + href="https://docs.tangled.org/spindles.html#self-hosting-guide" 1176 + target="_blank" 1177 + rel="noopener noreferrer" 1178 + > 1179 + <Book class="size-4" /> docs 1180 + </a> 1181 + </div> 1182 + </div> 1183 + 1184 + {/* Your Spindles List */} 1185 + <section class="rounded w-full flex flex-col gap-2"> 1186 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">YOUR SPINDLES</h2> 1187 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800"> 1188 + <Show 1189 + when={!spindlesQuery.isLoading} 1190 + fallback={<div class="p-4 text-center text-gray-500">Loading spindles...</div>} 1191 + > 1192 + <For 1193 + each={spindlesQuery.data} 1194 + fallback={ 1195 + <div class="flex items-center justify-center p-4 text-gray-500"> 1196 + No spindles registered yet 1197 + </div> 1198 + } 1199 + > 1200 + {(spindle) => ( 1201 + <div class="flex items-center justify-between p-2"> 1202 + <A 1203 + href={`/settings/spindles/${spindle.instance}`} 1204 + class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]" 1205 + > 1206 + <HardDrive class="w-4 h-4 text-gray-500 shrink-0" /> 1207 + <span class="hover:underline text-gray-900 dark:text-gray-100 break-all"> 1208 + {spindle.instance} 1209 + </span> 1210 + <span class="text-gray-500 shrink-0"> 1211 + {formatRelativeTime(spindle.createdAt)} 1212 + </span> 1213 + </A> 1214 + <div class="flex gap-2 items-center shrink-0"> 1215 + <span class={verifiedBadgeClass}> 1216 + <ShieldCheck class="w-4 h-4" /> verified 1217 + </span> 1218 + <button 1219 + onClick={() => handleDelete(spindle.instance)} 1220 + class={settingsDangerButtonClass} 1221 + title="Delete spindle" 1222 + > 1223 + <Trash2 class="w-5 h-5" /> 1224 + <span class="hidden md:inline">delete</span> 1225 + </button> 1226 + </div> 1227 + </div> 1228 + )} 1229 + </For> 1230 + </Show> 1231 + </div> 1232 + </section> 1233 + 1234 + {/* Register a spindle */} 1235 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 1236 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">REGISTER A SPINDLE</h2> 1237 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p> 1238 + <form onSubmit={handleRegister} class="max-w-2xl mb-2"> 1239 + <div class="flex gap-2"> 1240 + <input 1241 + type="text" 1242 + placeholder="spindle.example.com" 1243 + value={instance()} 1244 + onInput={(e) => setInstance(e.currentTarget.value)} 1245 + class={inputStyles()} 1246 + required 1247 + disabled={loading()} 1248 + /> 1249 + <button type="submit" class={settingsRegisterButtonClass} disabled={loading()}> 1250 + <span class={loading() ? 'hidden' : 'inline-flex items-center gap-2 px-4'}> 1251 + <Plus class="w-4 h-4" /> 1252 + register 1253 + </span> 1254 + <span class={loading() ? 'inline-flex items-center gap-2 px-4' : 'hidden'}> 1255 + <LoaderCircle class="w-4 h-4 animate-spin" /> 1256 + registering... 1257 + </span> 1258 + </button> 1259 + </div> 1260 + </form> 1261 + <Show when={error()}> 1262 + <p class="text-sm text-red-500 mt-1">{error()}</p> 1263 + </Show> 1264 + </section> 1265 + </div> 1266 + ); 1267 + }; 1268 + 1269 + // Spindle Details Dashboard implementation 1270 + export const SpindleDashboardTab: Component = () => { 1271 + const params = useParams(); 1272 + const auth = useAuth(); 1273 + const navigate = useNavigate(); 1274 + const queryClient = useQueryClient(); 1275 + 1276 + const actorQuery = createQuery(() => ({ 1277 + queryKey: ['actor', auth.currentDid()], 1278 + queryFn: async () => resolveActor(auth.currentDid()!), 1279 + enabled: Boolean(auth.currentDid()), 1280 + })); 1281 + 1282 + const handleDelete = async () => { 1283 + const spindleDomain = params.domain || ''; 1284 + if (!spindleDomain) return; 1285 + if (confirm(`Are you sure you want to delete the spindle "${spindleDomain}"?`)) { 1286 + try { 1287 + const agent = auth.agent(); 1288 + if (!agent) throw new Error('Not authenticated.'); 1289 + await unregisterSpindle(agent, spindleDomain); 1290 + await queryClient.refetchQueries({ queryKey: ['settings', 'spindles', auth.currentDid()] }); 1291 + navigate('/settings/spindles'); 1292 + } catch (err: any) { 1293 + console.error(err); 1294 + alert(err.message || 'Failed to delete spindle.'); 1295 + } 1296 + } 1297 + }; 1298 + 1299 + return ( 1300 + <div class="flex flex-col gap-6"> 1301 + <div class="flex justify-between items-center flex-wrap gap-2"> 1302 + <h2 class="text-sm font-bold uppercase tracking-wider text-gray-900 dark:text-gray-100 mb-2">SPINDLE &middot; {params.domain}</h2> 1303 + <div class="flex gap-2 items-center"> 1304 + <span class={dashboardVerifiedBadgeClass}> 1305 + <ShieldCheck class="w-4 h-4" /> verified 1306 + </span> 1307 + <button 1308 + onClick={handleDelete} 1309 + class={settingsDangerButtonClass} 1310 + > 1311 + <Trash2 class="w-5 h-5" /> 1312 + <span class="hidden md:inline">delete</span> 1313 + </button> 1314 + </div> 1315 + </div> 1316 + 1317 + <section class="bg-white dark:bg-gray-800 rounded relative w-full mx-auto border border-gray-200 dark:border-gray-700 p-4 dark:text-white flex flex-col gap-4"> 1318 + <h3 class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700 pb-2 mb-2">SPINDLE MEMBERS</h3> 1319 + <div class="flex flex-col gap-4"> 1320 + <div> 1321 + <div class="flex justify-between items-center"> 1322 + <div class="flex items-center gap-2"> 1323 + <Avatar did={auth.currentDid() || ''} size="size-7" /> 1324 + <span class="font-semibold text-gray-900 dark:text-gray-100"> 1325 + {actorQuery.data?.handle || auth.currentDid()} 1326 + </span> 1327 + <span class="text-gray-500 text-xs">(Owner)</span> 1328 + </div> 1329 + </div> 1330 + <div class="ml-4 pl-4 pt-2 border-l border-gray-200 dark:border-gray-700 flex flex-col gap-2"> 1331 + <div class="text-gray-500 text-sm italic"> 1332 + No repositories configured yet. 1333 + </div> 1334 + </div> 1335 + </div> 1336 + </div> 1337 + </section> 1338 + </div> 1339 + ); 1340 + };
+22
src/routes.tsx
··· 22 22 import { SearchPage } from './pages/search'; 23 23 import { ProfilePage } from './pages/profile'; 24 24 import { StringPage, RawStringPage, NewStringPage, EditStringPage } from './pages/string'; 25 + import { 26 + SettingsLayout, 27 + ProfileSettingsTab, 28 + KeysSettingsTab, 29 + EmailsSettingsTab, 30 + NotificationsSettingsTab, 31 + KnotsSettingsTab, 32 + KnotDashboardTab, 33 + SpindlesSettingsTab, 34 + SpindleDashboardTab, 35 + } from './pages/settings'; 25 36 26 37 export const AppRoutes: Component = () => ( 27 38 <Router root={RootShell}> ··· 33 44 <Route path="/strings/:actor/:rkey/edit" component={EditStringPage} /> 34 45 <Route path="/strings/:actor/:rkey/raw" component={RawStringPage} /> 35 46 <Route path="/repo/new" component={NewRepoPage} /> 47 + <Route path="/settings" component={SettingsLayout}> 48 + <Route path="/" component={ProfileSettingsTab} /> 49 + <Route path="/profile" component={ProfileSettingsTab} /> 50 + <Route path="/keys" component={KeysSettingsTab} /> 51 + <Route path="/emails" component={EmailsSettingsTab} /> 52 + <Route path="/notifications" component={NotificationsSettingsTab} /> 53 + <Route path="/knots" component={KnotsSettingsTab} /> 54 + <Route path="/knots/:domain" component={KnotDashboardTab} /> 55 + <Route path="/spindles" component={SpindlesSettingsTab} /> 56 + <Route path="/spindles/:domain" component={SpindleDashboardTab} /> 57 + </Route> 36 58 <Route path="/:owner/:repo" component={RepoLayout}> 37 59 <Route path="/" component={RepoCodePage} /> 38 60 <Route path="/issues/new" component={NewIssuePage} />