csr tangled client in solid-js
15

Configure Feed

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

new repo

dawn (May 30, 2026, 9:32 PM +0300) 4d141e86 bc175275

+493 -3
+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.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=* 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.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",
+61
src/index.css
··· 3812 3812 } 3813 3813 } 3814 3814 3815 + /* Timeline Step styling */ 3816 + .untangled-timeline-step { 3817 + position: relative; 3818 + padding-left: 2.25rem; 3819 + } 3820 + 3821 + .untangled-timeline-step::before { 3822 + content: ''; 3823 + position: absolute; 3824 + left: 12px; 3825 + width: 1px; 3826 + background-color: #e5e7eb; /* gray-200 */ 3827 + } 3828 + 3829 + @media (prefers-color-scheme: dark) { 3830 + .untangled-timeline-step::before { 3831 + background-color: #374151; /* gray-700 */ 3832 + } 3833 + } 3834 + 3835 + .untangled-timeline-step-1::before { 3836 + top: 16px; 3837 + bottom: 0; 3838 + } 3839 + 3840 + .untangled-timeline-step-2::before { 3841 + top: 0; 3842 + height: 16px; 3843 + } 3844 + 3845 + .untangled-timeline-circle { 3846 + position: absolute; 3847 + left: 0; 3848 + top: 4px; 3849 + width: 25px; 3850 + height: 25px; 3851 + display: flex; 3852 + align-items: center; 3853 + justify-content: center; 3854 + } 3855 + 3856 + .untangled-timeline-circle-inner { 3857 + width: 25px; 3858 + height: 25px; 3859 + border-radius: 9999px; 3860 + display: flex; 3861 + align-items: center; 3862 + justify-content: center; 3863 + font-size: 0.875rem; /* text-sm */ 3864 + font-weight: 500; /* font-medium */ 3865 + background-color: #e5e7eb; /* bg-gray-200 */ 3866 + color: #1f2937; /* text-gray-800 */ 3867 + } 3868 + 3869 + @media (prefers-color-scheme: dark) { 3870 + .untangled-timeline-circle-inner { 3871 + background-color: #4b5563; /* bg-gray-600 */ 3872 + color: #f3f4f6; /* text-gray-100 */ 3873 + } 3874 + } 3875 + 3815 3876
+5 -1
src/layout.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { Bell, BookMarked, LogOut, RotateCcw, Save, Search, Settings, UserRound, Plus, LineSquiggle } from 'lucide-solid'; 2 + import { Bell, BookMarked, LogOut, RotateCcw, Save, Search, Settings, UserRound, Plus, LineSquiggle, BookPlus } from 'lucide-solid'; 3 3 import { A, useLocation, useNavigate } from '@solidjs/router'; 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; ··· 311 311 </summary> 312 312 <div class="untangled-topbar-menu-dropdown"> 313 313 <div class="untangled-topbar-menu-item-separator"> 314 + <A href="/repo/new" class="untangled-topbar-menu-item" onClick={closeMenu}> 315 + <BookPlus class="size-4" /> 316 + new repository 317 + </A> 314 318 <A href="/strings/new" class="untangled-topbar-menu-item" onClick={closeMenu}> 315 319 <LineSquiggle class="size-4" /> 316 320 new string
+110 -1
src/lib/api/repos.ts
··· 13 13 } from './appview'; 14 14 import { getBacklinksPage } from './backlinks'; 15 15 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 16 - import { getRpc, normalizeServiceUrl, createServiceAuthRpc } from './client'; 16 + import { getRpc, normalizeServiceUrl, createServiceAuthRpc, createAuthRpc } from './client'; 17 17 import { REPO_COLLECTION } from './constants'; 18 18 import { resolveActor } from './identity'; 19 19 import { 20 20 maxTimestamp, 21 21 parseAtUri, 22 22 timestampFromDate, 23 + type RecordCreationResult, 23 24 } from './records'; 24 25 25 26 export interface RepoContext { ··· 674 675 return 0; 675 676 } 676 677 }; 678 + 679 + export const listUserKnots = async (actorIdentifier: string | ResolvedActor): Promise<string[]> => { 680 + const actor = typeof actorIdentifier === 'string' ? await resolveActor(actorIdentifier) : actorIdentifier; 681 + const rpc = getRpc(actor.pds); 682 + const records: string[] = []; 683 + let cursor: string | undefined; 684 + 685 + try { 686 + do { 687 + const page = await ok( 688 + rpc.get('com.atproto.repo.listRecords', { 689 + params: { 690 + repo: actor.did, 691 + collection: 'sh.tangled.knot', 692 + limit: 100, 693 + cursor, 694 + reverse: true, 695 + }, 696 + }), 697 + ); 698 + 699 + for (const r of page.records) { 700 + const rkey = parseAtUri(r.uri).rkey; 701 + records.push(rkey); 702 + } 703 + cursor = page.cursor; 704 + } while (cursor); 705 + } catch (err) { 706 + console.error('Failed to list knots from PDS:', err); 707 + } 708 + 709 + return records; 710 + }; 711 + 712 + export const createRepo = async ( 713 + agent: OAuthUserAgent, 714 + input: { 715 + name: string; 716 + description?: string; 717 + defaultBranch: string; 718 + knot: string; 719 + }, 720 + ): Promise<RecordCreationResult> => { 721 + const repoName = input.name.trim(); 722 + const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; 723 + const rkey = cleanedName.toLowerCase(); 724 + 725 + const knotClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.create'); 726 + const createResult = (await ok( 727 + knotClient.post('sh.tangled.repo.create', { 728 + input: { 729 + rkey, 730 + name: rkey, 731 + defaultBranch: input.defaultBranch || 'main', 732 + }, 733 + }), 734 + )) as { repoDid?: string }; 735 + 736 + const repoDid = createResult.repoDid; 737 + if (!repoDid) { 738 + throw new Error('Knot failed to mint a repo DID.'); 739 + } 740 + 741 + const pdsRpc = createAuthRpc(agent); 742 + const record = { 743 + $type: 'sh.tangled.repo', 744 + name: cleanedName, 745 + knot: input.knot, 746 + repoDid: repoDid, 747 + description: input.description?.trim() || '', 748 + createdAt: new Date().toISOString(), 749 + }; 750 + 751 + try { 752 + const result = await ok( 753 + pdsRpc.post('com.atproto.repo.createRecord', { 754 + input: { 755 + repo: agent.sub, 756 + collection: REPO_COLLECTION, 757 + rkey: rkey, 758 + record, 759 + }, 760 + }), 761 + ); 762 + 763 + return { 764 + uri: result.uri, 765 + rkey: parseAtUri(result.uri).rkey, 766 + }; 767 + } catch (pdsErr) { 768 + try { 769 + const knotDeleteClient = await createServiceAuthRpc(agent, input.knot, 'sh.tangled.repo.delete'); 770 + await ok( 771 + knotDeleteClient.post('sh.tangled.repo.delete', { 772 + input: { 773 + did: agent.sub, 774 + name: rkey, 775 + rkey: rkey, 776 + }, 777 + as: null, 778 + }), 779 + ); 780 + } catch (deleteErr) { 781 + console.error('Failed to clean up repo on knot:', deleteErr); 782 + } 783 + throw pdsErr; 784 + } 785 + };
+1
src/pages/repo.tsx
··· 2 2 export { CommitPage, CommitsPage } from './repo/commits'; 3 3 export { IssuePage, IssuesPage, NewIssuePage } from './repo/issues'; 4 4 export { NewPullPage, PullPage, PullsPage } from './repo/pulls'; 5 + export { NewRepoPage } from './repo/new';
+313
src/pages/repo/new.tsx
··· 1 + import { A, useNavigate } from '@solidjs/router'; 2 + import { createQuery, useQueryClient } from '@tanstack/solid-query'; 3 + import { BookPlus, LoaderCircle } from 'lucide-solid'; 4 + import { For, Show, createEffect, createSignal, type Component } from 'solid-js'; 5 + 6 + import { Avatar } from '../../components/common'; 7 + import { createRepo, listUserKnots, listRepoRecords } from '../../lib/api/repos'; 8 + import { resolveActor } from '../../lib/api/identity'; 9 + import { useAuth } from '../../lib/auth'; 10 + import { getErrorMessage } from '../../lib/repo-utils'; 11 + 12 + export const validateRepoName = (name: string): string | null => { 13 + if (name.length === 0) { 14 + return 'repository name cannot be empty'; 15 + } 16 + if (name.length > 100) { 17 + return 'repository name must be 100 characters or fewer'; 18 + } 19 + if (name.includes('/') || name.includes('\\')) { 20 + return 'repository name contains invalid path characters'; 21 + } 22 + if (name.startsWith('.') || name.endsWith('.')) { 23 + return 'repository name contains invalid path sequence'; 24 + } 25 + if (name.includes('..')) { 26 + return 'repository name cannot contain sequential dots'; 27 + } 28 + const validChars = /^[a-zA-Z0-9\-_.]+$/; 29 + if (!validChars.test(name)) { 30 + return 'repository name can only contain alphanumeric characters, periods, hyphens, and underscores'; 31 + } 32 + if (name.toLowerCase() === 'self') { 33 + return 'repository name "self" is reserved'; 34 + } 35 + return null; 36 + }; 37 + 38 + export const NewRepoPage: Component = () => { 39 + const auth = useAuth(); 40 + const navigate = useNavigate(); 41 + const queryClient = useQueryClient(); 42 + 43 + const [name, setName] = createSignal(''); 44 + const [description, setDescription] = createSignal(''); 45 + const [branch, setBranch] = createSignal('main'); 46 + const [selectedKnot, setSelectedKnot] = createSignal(''); 47 + 48 + const [saving, setSaving] = createSignal(false); 49 + const [error, setError] = createSignal<string | null>(null); 50 + 51 + const viewerQuery = createQuery(() => ({ 52 + queryKey: ['viewer', auth.currentDid()], 53 + enabled: Boolean(auth.currentDid()), 54 + queryFn: async () => resolveActor(auth.currentDid()!), 55 + })); 56 + 57 + const knotsQuery = createQuery(() => ({ 58 + queryKey: ['knots', auth.currentDid()], 59 + enabled: Boolean(auth.currentDid()), 60 + queryFn: async () => listUserKnots(auth.currentDid()!), 61 + })); 62 + 63 + // Automatically select the knot if there is only one 64 + createEffect(() => { 65 + const knots = knotsQuery.data; 66 + if (knots && knots.length === 1) { 67 + setSelectedKnot(knots[0]); 68 + } 69 + }); 70 + 71 + const handleSubmit = async (e: SubmitEvent) => { 72 + e.preventDefault(); 73 + const agent = auth.agent(); 74 + if (!agent) return; 75 + 76 + const repoName = name().trim(); 77 + const nameError = validateRepoName(repoName); 78 + if (nameError) { 79 + setError(nameError); 80 + return; 81 + } 82 + 83 + const knot = selectedKnot(); 84 + if (!knot) { 85 + setError('please select a knot.'); 86 + return; 87 + } 88 + 89 + setSaving(true); 90 + setError(null); 91 + 92 + try { 93 + // First check if the repository already exists for this user 94 + const existingRecords = await queryClient.fetchQuery({ 95 + queryKey: ['profile-repos', auth.currentDid()], 96 + queryFn: () => listRepoRecords(auth.currentDid()!), 97 + }); 98 + 99 + const cleanedName = repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName; 100 + const rkey = cleanedName.toLowerCase(); 101 + const exists = existingRecords.some( 102 + (r) => r.rkey === rkey || (r.value.name && r.value.name.toLowerCase() === rkey) 103 + ); 104 + 105 + if (exists) { 106 + setError(`you already have a repository named "${cleanedName}".`); 107 + setSaving(false); 108 + return; 109 + } 110 + 111 + const result = await createRepo(agent, { 112 + name: repoName, 113 + description: description(), 114 + defaultBranch: branch() || 'main', 115 + knot: knot, 116 + }); 117 + 118 + // Invalidate repos query for the current user 119 + await queryClient.invalidateQueries({ 120 + queryKey: ['profile-repos', auth.currentDid()], 121 + }); 122 + 123 + // Navigate to the newly created repository 124 + const handle = viewerQuery.data?.handle || auth.currentDid(); 125 + navigate(`/${handle}/${result.rkey}`); 126 + } catch (err) { 127 + setError(getErrorMessage(err)); 128 + } finally { 129 + setSaving(false); 130 + } 131 + }; 132 + 133 + return ( 134 + <Show 135 + when={auth.currentDid()} 136 + fallback={ 137 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded p-6 relative flex gap-2 items-center dark:text-white"> 138 + please log in to create a new repository. 139 + </div> 140 + } 141 + > 142 + <div class="grid grid-cols-12 dark:text-white"> 143 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 144 + <h1 class="text-xl font-bold dark:text-white mb-1">create a new repository</h1> 145 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 146 + repositories contain a project's files and version history. all repositories are publicly accessible. 147 + </p> 148 + </div> 149 + 150 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10 border border-gray-200 dark:border-gray-700"> 151 + <form onSubmit={handleSubmit}> 152 + {error() && <p class="text-red-500 dark:text-red-400 text-sm mb-4">{error()}</p>} 153 + 154 + {/* Step 1 */} 155 + <div class="untangled-timeline-step untangled-timeline-step-1"> 156 + <div class="untangled-timeline-circle"> 157 + <div class="untangled-timeline-circle-inner"> 158 + 1 159 + </div> 160 + </div> 161 + 162 + <div class="flex-1 pb-12"> 163 + <h2 class="text-lg font-medium dark:text-white">general</h2> 164 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">basic repository information.</div> 165 + 166 + <div class="space-y-4"> 167 + {/* Repository Name */} 168 + <div> 169 + <label class="block text-sm font-bold dark:text-white mb-1"> 170 + REPOSITORY NAME 171 + </label> 172 + <div class="flex flex-col md:flex-row md:items-center w-full"> 173 + <div class="shrink-0 hidden md:flex items-center px-3 py-2 gap-1.5 text-sm text-gray-700 dark:text-gray-300 border border-r-0 border-gray-300 dark:border-gray-600 rounded-l bg-gray-50 dark:bg-gray-700 h-[38px]"> 174 + <Avatar did={auth.currentDid()!} size="size-5" /> 175 + <span>{viewerQuery.data?.handle || auth.currentDid()}</span> 176 + </div> 177 + <input 178 + type="text" 179 + id="name" 180 + name="name" 181 + value={name()} 182 + onInput={(e) => setName(e.currentTarget.value)} 183 + required 184 + class="flex-1 md:rounded-r md:rounded-l-none rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]" 185 + placeholder="repository-name" 186 + /> 187 + </div> 188 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 189 + choose a unique, descriptive name for your repository. use letters, numbers, and hyphens. 190 + </p> 191 + </div> 192 + 193 + {/* Description */} 194 + <div> 195 + <label for="description" class="block text-sm font-bold dark:text-white mb-1"> 196 + DESCRIPTION 197 + </label> 198 + <input 199 + type="text" 200 + id="description" 201 + name="description" 202 + value={description()} 203 + onInput={(e) => setDescription(e.currentTarget.value)} 204 + maxlength="140" 205 + class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]" 206 + placeholder="a brief description of your project..." 207 + /> 208 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 209 + optional. a short description to help others understand what your project does (max 140 characters). 210 + </p> 211 + </div> 212 + </div> 213 + </div> 214 + </div> 215 + 216 + {/* Step 2 */} 217 + <div class="untangled-timeline-step untangled-timeline-step-2"> 218 + <div class="untangled-timeline-circle"> 219 + <div class="untangled-timeline-circle-inner"> 220 + 2 221 + </div> 222 + </div> 223 + 224 + <div class="flex-1"> 225 + <h2 class="text-lg font-medium dark:text-white">configuration</h2> 226 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">repository settings and hosting.</div> 227 + 228 + <div class="space-y-4"> 229 + {/* Default Branch */} 230 + <div> 231 + <label for="branch" class="block text-sm font-bold dark:text-white mb-1"> 232 + DEFAULT BRANCH 233 + </label> 234 + <input 235 + type="text" 236 + id="branch" 237 + name="branch" 238 + value={branch()} 239 + onInput={(e) => setBranch(e.currentTarget.value)} 240 + required 241 + class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 h-[38px]" 242 + /> 243 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 244 + the primary branch where development happens. common choices are "main" or "master". 245 + </p> 246 + </div> 247 + 248 + {/* Knot Selection */} 249 + <div> 250 + <label class="block text-sm font-bold dark:text-white mb-1"> 251 + SELECT A KNOT 252 + </label> 253 + <div class="w-full space-y-2 py-1"> 254 + <Show 255 + when={!knotsQuery.isLoading} 256 + fallback={<div class="text-sm text-gray-500 dark:text-gray-400">loading knots...</div>} 257 + > 258 + <Show 259 + when={knotsQuery.data && knotsQuery.data.length > 0} 260 + fallback={<p class="dark:text-white">no knots available.</p>} 261 + > 262 + <For each={knotsQuery.data}> 263 + {(knot) => ( 264 + <div class="flex items-center"> 265 + <input 266 + type="radio" 267 + name="domain" 268 + value={knot} 269 + checked={selectedKnot() === knot} 270 + onChange={() => setSelectedKnot(knot)} 271 + class="mr-2 cursor-pointer" 272 + id={`domain-${knot}`} 273 + required 274 + /> 275 + <label for={`domain-${knot}`} class="dark:text-white lowercase cursor-pointer"> 276 + {knot} 277 + </label> 278 + </div> 279 + )} 280 + </For> 281 + </Show> 282 + </Show> 283 + </div> 284 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 285 + a knot hosts repository data and handles git operations. you can also{' '} 286 + <A href="/settings/knots" class="underline"> 287 + register your own knot 288 + </A> 289 + . 290 + </p> 291 + </div> 292 + </div> 293 + </div> 294 + </div> 295 + 296 + <div class="mt-8 flex justify-end"> 297 + <button 298 + type="submit" 299 + disabled={saving() || !selectedKnot()} 300 + class="btn-create flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 301 + > 302 + <Show when={saving()} fallback={<BookPlus class="w-4 h-4" />}> 303 + <LoaderCircle class="w-4 h-4 animate-spin" /> 304 + </Show> 305 + create repository 306 + </button> 307 + </div> 308 + </form> 309 + </div> 310 + </div> 311 + </Show> 312 + ); 313 + };
+2
src/routes.tsx
··· 10 10 IssuesPage, 11 11 NewIssuePage, 12 12 NewPullPage, 13 + NewRepoPage, 13 14 PullPage, 14 15 PullsPage, 15 16 RepoCodePage, ··· 31 32 <Route path="/strings/:actor/:rkey" component={StringPage} /> 32 33 <Route path="/strings/:actor/:rkey/edit" component={EditStringPage} /> 33 34 <Route path="/strings/:actor/:rkey/raw" component={RawStringPage} /> 35 + <Route path="/repo/new" component={NewRepoPage} /> 34 36 <Route path="/:owner/:repo" component={RepoLayout}> 35 37 <Route path="/" component={RepoCodePage} /> 36 38 <Route path="/issues/new" component={NewIssuePage} />