csr tangled client in solid-js
15

Configure Feed

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

strings support

dawn (May 30, 2026, 9:04 PM +0300) bc175275 fda08d08

+1304 -15
+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 blob:*/*", 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:*/*", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",
+129
src/components/repo.tsx
··· 15 15 Star, 16 16 CircleDot, 17 17 GitPullRequest, 18 + LoaderCircle, 19 + MessageSquarePlus, 18 20 } from 'lucide-solid'; 21 + import { useAuth } from '../lib/auth'; 22 + import { resolveActor } from '../lib/api/identity'; 19 23 import { marked } from 'marked'; 20 24 import { A, useNavigate } from '@solidjs/router'; 21 25 import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; ··· 1544 1548 }; 1545 1549 1546 1550 export { CodeView, DiffView, PullDiffView } from './code-view'; 1551 + 1552 + export interface GenericComment { 1553 + uri: string; 1554 + rkey: string; 1555 + author: { did: string; handle: string }; 1556 + value: { 1557 + body: string; 1558 + createdAt: string; 1559 + replyTo?: any; 1560 + }; 1561 + } 1562 + 1563 + export interface GenericCommentThread { 1564 + item: GenericComment; 1565 + replies: GenericComment[]; 1566 + } 1567 + 1568 + export const CommentThreadsSection: Component<{ 1569 + threads: GenericCommentThread[]; 1570 + authorDid?: string; 1571 + title?: string; 1572 + }> = (props) => { 1573 + return ( 1574 + <div class="mt-6 space-y-4"> 1575 + <Show when={props.title}> 1576 + <h3 class="text-lg font-bold border-b border-gray-200 dark:border-gray-700 pb-2 dark:text-white mb-4"> 1577 + {props.title} 1578 + </h3> 1579 + </Show> 1580 + <div class="space-y-4"> 1581 + <For each={props.threads}> 1582 + {(thread) => ( 1583 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 1584 + <ThreadCommentView 1585 + id={`comment-${thread.item.rkey}`} 1586 + author={thread.item.author} 1587 + createdAt={thread.item.value.createdAt} 1588 + markdown={thread.item.value.body} 1589 + isAuthor={props.authorDid ? thread.item.author.did === props.authorDid : false} 1590 + class="rounded px-6 py-4 bg-white dark:bg-gray-800" 1591 + /> 1592 + <Show when={thread.replies.length > 0}> 1593 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 1594 + <For each={thread.replies}> 1595 + {(reply) => ( 1596 + <div class="-ml-4"> 1597 + <ThreadCommentView 1598 + id={`comment-${reply.rkey}`} 1599 + author={reply.author} 1600 + createdAt={reply.value.createdAt} 1601 + markdown={reply.value.body} 1602 + isAuthor={props.authorDid ? reply.author.did === props.authorDid : false} 1603 + isReply={true} 1604 + class="py-4 pr-4" 1605 + /> 1606 + </div> 1607 + )} 1608 + </For> 1609 + </div> 1610 + </Show> 1611 + </div> 1612 + )} 1613 + </For> 1614 + </div> 1615 + </div> 1616 + ); 1617 + }; 1618 + 1619 + export const CommentComposer: Component<{ 1620 + value: string; 1621 + onValueChange: (val: string) => void; 1622 + onSubmit: (event: SubmitEvent) => void; 1623 + working?: boolean; 1624 + submitLabel?: string; 1625 + placeholder?: string; 1626 + error?: string | null; 1627 + }> = (props) => { 1628 + const auth = useAuth(); 1629 + const viewerQuery = createQuery(() => ({ 1630 + queryKey: ['viewer', auth.currentDid()], 1631 + enabled: Boolean(auth.currentDid()), 1632 + queryFn: async () => resolveActor(auth.currentDid()!), 1633 + })); 1634 + 1635 + return ( 1636 + <form onSubmit={props.onSubmit} class="group/form w-full mt-4 flex flex-col"> 1637 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full border border-gray-200 dark:border-gray-700"> 1638 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 1639 + <Show when={viewerQuery.data} fallback={<div class="h-6" />}> 1640 + {(viewer) => ( 1641 + <A href={`/${viewer().handle || viewer().did}`} class="flex items-center gap-1 hover:underline text-gray-500 dark:text-gray-400 w-fit"> 1642 + <Avatar did={viewer().did} size="size-6" /> 1643 + <span>{viewer().handle || viewer().did}</span> 1644 + </A> 1645 + )} 1646 + </Show> 1647 + </div> 1648 + <textarea 1649 + rows={5} 1650 + placeholder={props.placeholder ?? 'Add to the discussion. Markdown is supported.'} 1651 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 dark:text-white" 1652 + required 1653 + value={props.value} 1654 + onInput={(event) => props.onValueChange(event.currentTarget.value)} 1655 + /> 1656 + <Show when={props.error}> 1657 + <div id="comment-error" class="error text-sm text-red-500 mt-1">{props.error}</div> 1658 + </Show> 1659 + </div> 1660 + <div class="flex gap-2 mt-2"> 1661 + <button 1662 + id="comment-button" 1663 + type="submit" 1664 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline" 1665 + disabled={props.working || !props.value.trim()} 1666 + > 1667 + <Show when={props.working} fallback={<MessageSquarePlus class="w-4 h-4" />}> 1668 + <LoaderCircle class="w-4 h-4 animate-spin" /> 1669 + </Show> 1670 + <span>{props.submitLabel ?? 'Comment'}</span> 1671 + </button> 1672 + </div> 1673 + </form> 1674 + ); 1675 + }; 1547 1676 1548 1677 1549 1678
+6 -3
src/index.css
··· 3784 3784 3785 3785 /* Align solid bar with the vertical replies thread line */ 3786 3786 .-ml-4.untangled-comment-highlight::before, 3787 - .-ml-4 > .untangled-comment-highlight::before { 3787 + .-ml-4 > .untangled-comment-highlight::before, 3788 + .untangled-thread-reply.untangled-comment-highlight::before { 3788 3789 left: calc(1rem - 2px); 3789 3790 border-radius: 0; 3790 3791 } 3791 3792 3792 3793 .-ml-4.untangled-comment-highlight, 3793 - .-ml-4 > .untangled-comment-highlight { 3794 + .-ml-4 > .untangled-comment-highlight, 3795 + .untangled-thread-reply.untangled-comment-highlight { 3794 3796 background-color: transparent !important; 3795 3797 background-image: linear-gradient(to right, transparent calc(1rem - 2px), rgb(254 240 138 / 0.15) calc(1rem - 2px)) !important; 3796 3798 } ··· 3803 3805 background-color: #ca8a04; 3804 3806 } 3805 3807 .-ml-4.untangled-comment-highlight, 3806 - .-ml-4 > .untangled-comment-highlight { 3808 + .-ml-4 > .untangled-comment-highlight, 3809 + .untangled-thread-reply.untangled-comment-highlight { 3807 3810 background-color: transparent !important; 3808 3811 background-image: linear-gradient(to right, transparent calc(1rem - 2px), rgb(161 98 7 / 0.1) calc(1rem - 2px)) !important; 3809 3812 }
+65 -11
src/layout.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { Bell, BookMarked, LogOut, RotateCcw, Save, Search, Settings, UserRound } from 'lucide-solid'; 2 + import { Bell, BookMarked, LogOut, RotateCcw, Save, Search, Settings, UserRound, Plus, LineSquiggle } 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'; ··· 40 40 const [draftRouteKnotRequestsThroughAppview, setDraftRouteKnotRequestsThroughAppview] = createSignal( 41 41 settings.routeKnotRequestsThroughAppview(), 42 42 ); 43 + const [draftForcePds, setDraftForcePds] = createSignal(settings.forcePds()); 43 44 const [error, setError] = createSignal<string | null>(null); 44 45 const [saved, setSaved] = createSignal(false); 45 46 46 47 createEffect(() => { 47 48 setDraftAppviewUrl(settings.appviewUrl()); 48 49 setDraftRouteKnotRequestsThroughAppview(settings.routeKnotRequestsThroughAppview()); 50 + setDraftForcePds(settings.forcePds()); 49 51 }); 50 52 51 53 const reloadAppviewBackedQueries = () => { ··· 58 60 try { 59 61 settings.setAppviewUrl(draftAppviewUrl()); 60 62 settings.setRouteKnotRequestsThroughAppview(draftRouteKnotRequestsThroughAppview()); 63 + settings.setForcePds(draftForcePds()); 61 64 setError(null); 62 65 setSaved(true); 63 66 reloadAppviewBackedQueries(); ··· 71 74 settings.resetAppviewSettings(); 72 75 setDraftAppviewUrl(settings.defaultAppviewUrl); 73 76 setDraftRouteKnotRequestsThroughAppview(false); 77 + setDraftForcePds(false); 74 78 setError(null); 75 79 setSaved(true); 76 80 reloadAppviewBackedQueries(); ··· 114 118 <span>route knot requests through appview</span> 115 119 </label> 116 120 121 + <label class="flex items-center gap-3 text-sm text-gray-700 dark:text-gray-200"> 122 + <input 123 + type="checkbox" 124 + checked={draftForcePds()} 125 + onChange={(event) => { 126 + setDraftForcePds(event.currentTarget.checked); 127 + setSaved(false); 128 + setError(null); 129 + }} 130 + class="size-4 rounded border-gray-300 dark:border-gray-600" 131 + /> 132 + <span>force requests through PDS (bypass appview)</span> 133 + </label> 134 + 117 135 <div class="flex items-center justify-between gap-3"> 118 136 <button type="button" class={buttonStyles()} onClick={onReset}> 119 137 <RotateCcw class="size-4" /> ··· 252 270 <BookMarked class="size-4" /> 253 271 repositories 254 272 </A> 273 + <A href={`/${handle()}?tab=strings`} class="untangled-topbar-menu-item" onClick={closeMenu}> 274 + <LineSquiggle class="size-4" /> 275 + strings 276 + </A> 255 277 </> 256 278 )} 257 279 </Show> ··· 275 297 ); 276 298 }; 277 299 300 + const NewMenu: Component = () => { 301 + const closeMenu = (e: MouseEvent) => { 302 + const details = (e.currentTarget as HTMLElement).closest('details'); 303 + if (details) details.open = false; 304 + }; 305 + 306 + return ( 307 + <details data-topbar-menu class="untangled-topbar-menu"> 308 + <summary class="untangled-topbar-menu-summary btn text-sm no-underline hover:no-underline flex items-center gap-2 group border border-gray-200 dark:border-gray-700 rounded-sm px-3 py-1.5 bg-white dark:bg-gray-800 cursor-pointer"> 309 + <Plus class="size-4" /> 310 + <span class="hidden md:inline">new</span> 311 + </summary> 312 + <div class="untangled-topbar-menu-dropdown"> 313 + <div class="untangled-topbar-menu-item-separator"> 314 + <A href="/strings/new" class="untangled-topbar-menu-item" onClick={closeMenu}> 315 + <LineSquiggle class="size-4" /> 316 + new string 317 + </A> 318 + </div> 319 + </div> 320 + </details> 321 + ); 322 + }; 323 + 278 324 const TopbarSearch: Component = () => { 279 325 const navigate = useNavigate(); 280 326 const location = useLocation(); ··· 384 430 when={auth.currentDid()} 385 431 fallback={<LoginMenu />} 386 432 > 433 + <NewMenu /> 387 434 <NotificationsMenu /> 388 435 <UserProfileMenu /> 389 436 </Show> ··· 393 440 ); 394 441 }; 395 442 396 - export const RootShell: Component<{ children?: JSX.Element }> = (props) => ( 397 - <div class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white"> 398 - <header class="untangled-safe-header w-full shadow-sm dark:text-white bg-white dark:bg-gray-800"> 399 - <Topbar /> 400 - </header> 443 + export const RootShell: Component<{ children?: JSX.Element }> = (props) => { 444 + const location = useLocation(); 445 + const isRaw = () => location.pathname.endsWith('/raw'); 401 446 402 - <div class="flex-grow"> 403 - <div class="untangled-shell mx-auto flex flex-col gap-4">{props.children}</div> 404 - </div> 405 - </div> 406 - ); 447 + return ( 448 + <Show when={!isRaw()} fallback={<>{props.children}</>}> 449 + <div class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white"> 450 + <header class="untangled-safe-header w-full shadow-sm dark:text-white bg-white dark:bg-gray-800"> 451 + <Topbar /> 452 + </header> 453 + 454 + <div class="flex-grow"> 455 + <div class="untangled-shell mx-auto flex flex-col gap-4">{props.children}</div> 456 + </div> 457 + </div> 458 + </Show> 459 + ); 460 + };
+18
src/lib/api.ts
··· 67 67 export { 68 68 searchRecords, 69 69 } from './api/search'; 70 + export { 71 + getString, 72 + listStringRecords, 73 + deleteString, 74 + updateString, 75 + createString, 76 + listStringComments, 77 + createStringComment, 78 + getStringStarSummary, 79 + createStringStar, 80 + deleteStringStar, 81 + } from './api/strings'; 70 82 71 83 export type { 72 84 HydratedRecord, ··· 74 86 RecordCreationResult, 75 87 } from './api/records'; 76 88 export type { 89 + StringRecord, 90 + StringComment, 91 + StringStarSummary, 92 + } from './api/strings'; 93 + export type { 94 + 77 95 BlobResponse, 78 96 BranchEntry, 79 97 BranchResponse,
+5
src/lib/api/appview.ts
··· 4 4 import { 5 5 getRouteKnotRequestsThroughAppview, 6 6 getTangledAppviewService, 7 + getForcePds, 7 8 } from '../settings'; 8 9 9 10 export interface AppviewRecordView<T> { ··· 144 145 }; 145 146 146 147 export const appviewFallback = async <T>(primary: () => Promise<T>, fallback: () => Promise<T>): Promise<T> => { 148 + if (getForcePds()) { 149 + return fallback(); 150 + } 151 + 147 152 try { 148 153 return await primary(); 149 154 } catch (cause) {
+1
src/lib/api/constants.ts
··· 21 21 export const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment'; 22 22 export const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status'; 23 23 export const FEED_COMMENT_COLLECTION: Nsid = 'sh.tangled.feed.comment'; 24 + export const STRING_COLLECTION: Nsid = 'sh.tangled.string'; 24 25
+335
src/lib/api/strings.ts
··· 1 + import { ok } from '@atcute/client'; 2 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 3 + import type { ResolvedActor } from '@atcute/identity-resolver'; 4 + import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax'; 5 + import { ShTangledString, ShTangledFeedStar } from '@atcute/tangled'; 6 + 7 + import { 8 + appviewFallback, 9 + listAllAppviewRecords, 10 + getDistinctAppviewRecordCount, 11 + type AppviewRecordView, 12 + } from './appview'; 13 + import { getRpc } from './client'; 14 + import { STRING_COLLECTION, STAR_COLLECTION, FEED_COMMENT_COLLECTION } from './constants'; 15 + import { resolveActor, createAuthRpc } from './identity'; 16 + import { parseAtUri, type RecordCreationResult } from './records'; 17 + import { appviewRecordToHydrated } from './hydration'; 18 + 19 + export interface StringRecord { 20 + uri: ResourceUri; 21 + cid: string; 22 + rkey: string; 23 + value: ShTangledString.Main; 24 + } 25 + 26 + export interface StringComment { 27 + uri: ResourceUri; 28 + cid?: string; 29 + rkey: string; 30 + author: ResolvedActor; 31 + value: { 32 + $type: string; 33 + body: string; 34 + createdAt: string; 35 + subject: { uri: string; cid: string }; 36 + replyTo?: { uri: string; cid: string }; 37 + }; 38 + } 39 + 40 + export interface StringStarSummary { 41 + count: number; 42 + isStarred: boolean; 43 + currentUserStarRkey?: string; 44 + } 45 + 46 + const fetchAllStringRecords = async ( 47 + actor: ResolvedActor, 48 + collection: Nsid, 49 + ): Promise<Array<{ uri: ResourceUri; cid: string; value: unknown }>> => { 50 + const rpc = getRpc(actor.pds); 51 + const records: Array<{ uri: ResourceUri; cid: string; value: unknown }> = []; 52 + let cursor: string | undefined; 53 + 54 + do { 55 + const page = await ok( 56 + rpc.get('com.atproto.repo.listRecords', { 57 + params: { 58 + repo: actor.did, 59 + collection, 60 + limit: 100, 61 + cursor, 62 + reverse: true, 63 + }, 64 + }), 65 + ); 66 + 67 + records.push(...page.records); 68 + cursor = page.cursor; 69 + } while (cursor); 70 + 71 + return records; 72 + }; 73 + 74 + const listStringRecordsFromAppview = async (owner: string | ResolvedActor): Promise<StringRecord[]> => { 75 + const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 76 + const records = await listAllAppviewRecords<ShTangledString.Main>('sh.tangled.string.listStrings', actor.did); 77 + 78 + return records.map((record) => ({ 79 + uri: record.uri, 80 + cid: record.cid ?? '', 81 + rkey: parseAtUri(record.uri).rkey, 82 + value: record.value, 83 + })); 84 + }; 85 + 86 + const listStringRecordsFromPds = async (owner: string | ResolvedActor): Promise<StringRecord[]> => { 87 + const actor = typeof owner === 'string' ? await resolveActor(owner) : owner; 88 + const records = await fetchAllStringRecords(actor, STRING_COLLECTION); 89 + 90 + return records.map((record) => ({ 91 + uri: record.uri, 92 + cid: record.cid, 93 + rkey: parseAtUri(record.uri).rkey, 94 + value: record.value as ShTangledString.Main, 95 + })); 96 + }; 97 + 98 + export const listStringRecords = async (owner: string | ResolvedActor): Promise<StringRecord[]> => 99 + appviewFallback( 100 + () => listStringRecordsFromAppview(owner), 101 + () => listStringRecordsFromPds(owner), 102 + ); 103 + 104 + export const getString = async ( 105 + ownerIdentifier: string, 106 + rkey: string, 107 + ): Promise<{ owner: ResolvedActor; record: StringRecord }> => { 108 + const owner = await resolveActor(ownerIdentifier); 109 + const response = await ok( 110 + getRpc(owner.pds).get('com.atproto.repo.getRecord', { 111 + params: { 112 + repo: owner.did, 113 + collection: STRING_COLLECTION, 114 + rkey, 115 + }, 116 + }), 117 + ); 118 + 119 + const record: StringRecord = { 120 + uri: response.uri, 121 + cid: response.cid ?? '', 122 + rkey, 123 + value: response.value as ShTangledString.Main, 124 + }; 125 + return { owner, record }; 126 + }; 127 + 128 + export const deleteString = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 129 + const rpc = createAuthRpc(agent); 130 + await ok( 131 + rpc.post('com.atproto.repo.deleteRecord', { 132 + input: { 133 + repo: agent.sub, 134 + collection: STRING_COLLECTION, 135 + rkey, 136 + }, 137 + }), 138 + ); 139 + }; 140 + 141 + export const updateString = async ( 142 + agent: OAuthUserAgent, 143 + rkey: string, 144 + input: { filename: string; description?: string; contents: string }, 145 + swapRecord?: string, 146 + ): Promise<RecordCreationResult> => { 147 + const rpc = createAuthRpc(agent); 148 + const record: ShTangledString.Main = { 149 + $type: 'sh.tangled.string', 150 + filename: input.filename.trim(), 151 + description: input.description?.trim() || '', 152 + contents: input.contents, 153 + createdAt: new Date().toISOString(), 154 + }; 155 + 156 + const result = await ok( 157 + rpc.post('com.atproto.repo.putRecord', { 158 + input: { 159 + repo: agent.sub, 160 + collection: STRING_COLLECTION, 161 + rkey, 162 + record, 163 + swapRecord: swapRecord || undefined, 164 + }, 165 + }), 166 + ); 167 + 168 + return { 169 + uri: result.uri, 170 + rkey: parseAtUri(result.uri).rkey, 171 + }; 172 + }; 173 + 174 + export const createString = async ( 175 + agent: OAuthUserAgent, 176 + input: { filename: string; description?: string; contents: string }, 177 + ): Promise<RecordCreationResult> => { 178 + const rpc = createAuthRpc(agent); 179 + const record: ShTangledString.Main = { 180 + $type: 'sh.tangled.string', 181 + filename: input.filename.trim(), 182 + description: input.description?.trim() || '', 183 + contents: input.contents, 184 + createdAt: new Date().toISOString(), 185 + }; 186 + 187 + const result = await ok( 188 + rpc.post('com.atproto.repo.createRecord', { 189 + input: { 190 + repo: agent.sub, 191 + collection: STRING_COLLECTION, 192 + record, 193 + }, 194 + }), 195 + ); 196 + 197 + return { 198 + uri: result.uri, 199 + rkey: parseAtUri(result.uri).rkey, 200 + }; 201 + }; 202 + 203 + 204 + export const listStringComments = async (stringUri: ResourceUri): Promise<StringComment[]> => { 205 + const records = await listAllAppviewRecords<any>( 206 + 'sh.tangled.feed.listComments', 207 + stringUri, 208 + ); 209 + const comments = await Promise.all(records.map(async (record) => { 210 + const hydrated = await appviewRecordToHydrated<any>(record); 211 + const val = hydrated.value; 212 + const bodyText = (val && typeof val === 'object' && val.body && typeof val.body === 'object' && 'text' in val.body) 213 + ? val.body.text 214 + : (val && typeof val === 'object' && typeof val.body === 'string' ? val.body : ''); 215 + return { 216 + ...hydrated, 217 + value: { 218 + $type: 'sh.tangled.feed.comment', 219 + body: bodyText, 220 + createdAt: (val && typeof val === 'object' && val.createdAt) || new Date().toISOString(), 221 + subject: (val && typeof val === 'object' && val.subject) || { uri: stringUri, cid: '' }, 222 + replyTo: val && typeof val === 'object' ? val.replyTo : undefined, 223 + } 224 + }; 225 + })); 226 + comments.sort((a, b) => new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()); 227 + return comments; 228 + }; 229 + 230 + export const createStringComment = async ( 231 + agent: OAuthUserAgent, 232 + stringUri: ResourceUri, 233 + stringCid: string, 234 + body: string, 235 + replyTo?: { uri: ResourceUri; cid: string }, 236 + ): Promise<RecordCreationResult> => { 237 + const rpc = createAuthRpc(agent); 238 + const record = { 239 + $type: 'sh.tangled.feed.comment', 240 + subject: { 241 + uri: stringUri, 242 + cid: stringCid, 243 + }, 244 + body: { 245 + $type: 'sh.tangled.markup.markdown', 246 + text: body.trim(), 247 + }, 248 + createdAt: new Date().toISOString(), 249 + ...(replyTo ? { 250 + replyTo: { 251 + uri: replyTo.uri, 252 + cid: replyTo.cid, 253 + } 254 + } : {}), 255 + }; 256 + 257 + const result = await ok( 258 + rpc.post('com.atproto.repo.createRecord', { 259 + input: { 260 + repo: agent.sub, 261 + collection: FEED_COMMENT_COLLECTION, 262 + record, 263 + }, 264 + }), 265 + ); 266 + 267 + return { 268 + uri: result.uri, 269 + rkey: parseAtUri(result.uri).rkey, 270 + }; 271 + }; 272 + 273 + export const getStringStarSummary = async ( 274 + stringUri: ResourceUri, 275 + viewerDid?: Did | null, 276 + ): Promise<StringStarSummary> => { 277 + const count = await getDistinctAppviewRecordCount('sh.tangled.feed.countStars', stringUri); 278 + let currentUserStar: AppviewRecordView<ShTangledFeedStar.Main> | undefined; 279 + 280 + if (viewerDid) { 281 + const stars = await listAllAppviewRecords<ShTangledFeedStar.Main>('sh.tangled.feed.listStarsBy', viewerDid); 282 + currentUserStar = stars.find((star) => { 283 + const subject = star.value.subject as { uri?: unknown }; 284 + return subject?.uri === stringUri; 285 + }); 286 + } 287 + 288 + return { 289 + count, 290 + isStarred: !!currentUserStar, 291 + currentUserStarRkey: currentUserStar ? parseAtUri(currentUserStar.uri).rkey : undefined, 292 + }; 293 + }; 294 + 295 + export const createStringStar = async ( 296 + agent: OAuthUserAgent, 297 + stringUri: ResourceUri, 298 + ): Promise<RecordCreationResult> => { 299 + const rpc = createAuthRpc(agent); 300 + const record: ShTangledFeedStar.Main = { 301 + $type: 'sh.tangled.feed.star', 302 + subject: { 303 + $type: 'sh.tangled.feed.star#string', 304 + uri: stringUri, 305 + }, 306 + createdAt: new Date().toISOString(), 307 + }; 308 + const result = await ok( 309 + rpc.post('com.atproto.repo.createRecord', { 310 + input: { 311 + repo: agent.sub, 312 + collection: STAR_COLLECTION, 313 + record, 314 + }, 315 + }), 316 + ); 317 + 318 + return { 319 + uri: result.uri, 320 + rkey: parseAtUri(result.uri).rkey, 321 + }; 322 + }; 323 + 324 + export const deleteStringStar = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 325 + const rpc = createAuthRpc(agent); 326 + await ok( 327 + rpc.post('com.atproto.repo.deleteRecord', { 328 + input: { 329 + repo: agent.sub, 330 + collection: STAR_COLLECTION, 331 + rkey, 332 + }, 333 + }), 334 + ); 335 + };
+25
src/lib/settings.tsx
··· 2 2 3 3 const APPVIEW_URL_KEY = 'untangled.settings.appviewUrl'; 4 4 const ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY = 'untangled.settings.routeKnotRequestsThroughAppview'; 5 + const FORCE_PDS_KEY = 'untangled.settings.forcePds'; 5 6 6 7 export const DEFAULT_TANGLED_APPVIEW_SERVICE = 7 8 import.meta.env.VITE_TANGLED_APPVIEW_SERVICE || 'https://bobbin.klbr.net'; ··· 46 47 return localStorage.getItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY) === 'true'; 47 48 }; 48 49 50 + const readStoredForcePds = (): boolean => { 51 + if (typeof localStorage === 'undefined') { 52 + return false; 53 + } 54 + 55 + return localStorage.getItem(FORCE_PDS_KEY) === 'true'; 56 + }; 57 + 49 58 let currentAppviewUrl = readStoredAppviewUrl(); 50 59 let currentRouteKnotRequestsThroughAppview = readStoredRouteKnotRequestsThroughAppview(); 60 + let currentForcePds = readStoredForcePds(); 51 61 52 62 export const getTangledAppviewService = (): string => currentAppviewUrl; 53 63 export const getRouteKnotRequestsThroughAppview = (): boolean => currentRouteKnotRequestsThroughAppview; 64 + export const getForcePds = (): boolean => currentForcePds; 54 65 55 66 interface AppSettingsContextValue { 56 67 appviewUrl: () => string; 57 68 routeKnotRequestsThroughAppview: () => boolean; 69 + forcePds: () => boolean; 58 70 defaultAppviewUrl: string; 59 71 setAppviewUrl: (url: string) => void; 60 72 setRouteKnotRequestsThroughAppview: (enabled: boolean) => void; 73 + setForcePds: (enabled: boolean) => void; 61 74 resetAppviewUrl: () => void; 62 75 resetAppviewSettings: () => void; 63 76 } ··· 69 82 const [routeKnotRequestsThroughAppview, setRouteKnotRequestsThroughAppviewSignal] = createSignal( 70 83 currentRouteKnotRequestsThroughAppview, 71 84 ); 85 + const [forcePds, setForcePdsSignal] = createSignal(currentForcePds); 72 86 const defaultAppviewUrl = normalizeAppviewUrl(DEFAULT_TANGLED_APPVIEW_SERVICE); 73 87 74 88 const setAppviewUrl = (url: string) => { ··· 84 98 setRouteKnotRequestsThroughAppviewSignal(enabled); 85 99 }; 86 100 101 + const setForcePds = (enabled: boolean) => { 102 + currentForcePds = enabled; 103 + localStorage.setItem(FORCE_PDS_KEY, String(enabled)); 104 + setForcePdsSignal(enabled); 105 + }; 106 + 87 107 const resetAppviewUrl = () => { 88 108 currentAppviewUrl = defaultAppviewUrl; 89 109 localStorage.removeItem(APPVIEW_URL_KEY); ··· 95 115 currentRouteKnotRequestsThroughAppview = false; 96 116 localStorage.removeItem(ROUTE_KNOT_REQUESTS_THROUGH_APPVIEW_KEY); 97 117 setRouteKnotRequestsThroughAppviewSignal(false); 118 + currentForcePds = false; 119 + localStorage.removeItem(FORCE_PDS_KEY); 120 + setForcePdsSignal(false); 98 121 }; 99 122 100 123 return ( ··· 102 125 value={{ 103 126 appviewUrl, 104 127 routeKnotRequestsThroughAppview, 128 + forcePds, 105 129 defaultAppviewUrl, 106 130 setAppviewUrl, 107 131 setRouteKnotRequestsThroughAppview, 132 + setForcePds, 108 133 resetAppviewUrl, 109 134 resetAppviewSettings, 110 135 }}
+66
src/pages/profile.tsx
··· 17 17 X, 18 18 Check, 19 19 Pencil, 20 + LineSquiggle, 20 21 } from 'lucide-solid'; 21 22 import { A, useParams, useSearchParams, useNavigate } from '@solidjs/router'; 22 23 import { createQuery, useQueryClient } from '@tanstack/solid-query'; ··· 39 40 } from '../lib/api/graph'; 40 41 import { resolveActor, getActorProfile, resolveAvatarUrl, putActorProfile } from '../lib/api/identity'; 41 42 import { listRepoRecords, getRepoByDid, type RepoContext } from '../lib/api/repos'; 43 + import { listStringRecords } from '../lib/api/strings'; 42 44 import { useAuth } from '../lib/auth'; 43 45 import { listAllAppviewRecords } from '../lib/api/appview'; 44 46 import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils'; ··· 48 50 activeTab: string; 49 51 reposCount: number; 50 52 starsCount: number; 53 + stringsCount: number; 51 54 reposLoading?: boolean; 52 55 starsLoading?: boolean; 56 + stringsLoading?: boolean; 53 57 }> = (props) => { 54 58 const tabs = [ 55 59 { id: 'overview', label: 'overview', icon: () => <GalleryVertical class="w-4 h-4 mr-2" /> }, 56 60 { id: 'repos', label: 'repos', icon: () => <BookMarked class="w-4 h-4 mr-2" />, count: () => props.reposCount, loading: () => props.reposLoading }, 57 61 { id: 'starred', label: 'starred', icon: () => <Star class="w-4 h-4 mr-2" />, count: () => props.starsCount, loading: () => props.starsLoading }, 62 + { id: 'strings', label: 'strings', icon: () => <LineSquiggle class="w-4 h-4 mr-2" />, count: () => props.stringsCount, loading: () => props.stringsLoading }, 58 63 ]; 59 64 60 65 return ( ··· 1042 1047 queryFn: () => listRepoRecords(actorQuery.data!), 1043 1048 })); 1044 1049 1050 + const stringsQuery = createQuery(() => ({ 1051 + queryKey: ['profile-strings', actorQuery.data?.did], 1052 + enabled: !!actorQuery.data, 1053 + queryFn: () => listStringRecords(actorQuery.data!), 1054 + })); 1055 + 1045 1056 const starsQuery = createQuery(() => ({ 1046 1057 queryKey: ['profile-stars', actorQuery.data?.did], 1047 1058 enabled: !!actorQuery.data, ··· 1297 1308 activeTab={activeTab()} 1298 1309 reposCount={reposQuery.data?.length ?? 0} 1299 1310 starsCount={starsQuery.data?.length ?? 0} 1311 + stringsCount={stringsQuery.data?.length ?? 0} 1300 1312 reposLoading={reposQuery.isLoading} 1301 1313 starsLoading={starsQuery.isLoading} 1314 + stringsLoading={stringsQuery.isLoading} 1302 1315 /> 1303 1316 1304 1317 <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white shadow-sm"> ··· 1502 1515 showOwner={true} 1503 1516 /> 1504 1517 )} 1518 + </For> 1519 + </div> 1520 + </Show> 1521 + </div> 1522 + </Match> 1523 + 1524 + {/* Strings Tab */} 1525 + <Match when={activeTab() === 'strings'}> 1526 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 1527 + <Show 1528 + when={!stringsQuery.isLoading} 1529 + fallback={ 1530 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 1531 + <For each={Array.from({ length: 3 })}> 1532 + {() => <RepoCardSkeleton />} 1533 + </For> 1534 + </div> 1535 + } 1536 + > 1537 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 1538 + <For 1539 + each={stringsQuery.data ?? []} 1540 + fallback={ 1541 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1542 + <span>This user does not have any strings yet.</span> 1543 + </div> 1544 + } 1545 + > 1546 + {(item) => { 1547 + const lineCount = () => item.value.contents.split('\n').length; 1548 + const timeAgo = () => formatRelativeTime(item.value.createdAt); 1549 + return ( 1550 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 1551 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 1552 + <div class="font-medium dark:text-white flex gap-2 items-center"> 1553 + <A href={`/strings/${resolvedActor().handle || resolvedActor().did}/${item.rkey}`} class="hover:underline"> 1554 + {item.value.filename} 1555 + </A> 1556 + </div> 1557 + {item.value.description && ( 1558 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 1559 + {item.value.description} 1560 + </div> 1561 + )} 1562 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 1563 + <span>{lineCount()} line{lineCount() !== 1 ? 's' : ''}</span> 1564 + <span class="select-none [&:before]:content-['·']"></span> 1565 + <span>{timeAgo()}</span> 1566 + </div> 1567 + </div> 1568 + </div> 1569 + ); 1570 + }} 1505 1571 </For> 1506 1572 </div> 1507 1573 </Show>
+9
src/pages/search.tsx
··· 10 10 Tag, 11 11 UserRound, 12 12 X, 13 + LineSquiggle, 13 14 } from 'lucide-solid'; 14 15 import { A, useNavigate, useSearchParams } from '@solidjs/router'; 15 16 import { createQuery } from '@tanstack/solid-query'; ··· 133 134 </Match> 134 135 <Match when={props.hit.nsid === 'sh.tangled.actor.profile'}> 135 136 <UserRound class="w-4 h-4 mr-1.5 shrink-0" /> 137 + </Match> 138 + <Match when={props.hit.nsid === 'sh.tangled.string'}> 139 + <LineSquiggle class="w-4 h-4 mr-1.5 shrink-0" /> 136 140 </Match> 137 141 <Match when={props.hit.nsid === 'sh.tangled.label.definition'}> 138 142 <Tag class="w-4 h-4 mr-1.5 shrink-0" /> ··· 340 344 <Match when={props.hit.nsid === 'sh.tangled.actor.profile'}> 341 345 <A href={`/${props.hit.author.handle}`} class="truncate min-w-0"> 342 346 {props.hit.author.handle} 347 + </A> 348 + </Match> 349 + <Match when={props.hit.nsid === 'sh.tangled.string'}> 350 + <A href={`/strings/${props.hit.author.handle || props.hit.author.did}/${props.hit.rkey}`} class="truncate min-w-0"> 351 + {props.hit.author.handle || props.hit.author.did}/{title()} 343 352 </A> 344 353 </Match> 345 354 <Match when={props.hit.nsid === 'sh.tangled.repo.issue.comment' || props.hit.nsid === 'sh.tangled.repo.pull.comment' || props.hit.nsid === 'sh.tangled.feed.comment'}>
+639
src/pages/string.tsx
··· 1 + import { A, useParams, useNavigate } from '@solidjs/router'; 2 + import { createQuery, useQueryClient } from '@tanstack/solid-query'; 3 + import { Show, Switch, Match, type Component, createSignal, createMemo, createEffect, onCleanup } from 'solid-js'; 4 + import type { ResourceUri } from '@atcute/lexicons/syntax'; 5 + import { Avatar, ErrorState, LoadingState, inputStyles, textareaStyles } from '../components/common'; 6 + import { CodeView, CommentComposer, CommentThreadsSection } from '../components/repo'; 7 + import { 8 + getString, 9 + deleteString, 10 + updateString, 11 + createString, 12 + listStringComments, 13 + createStringComment, 14 + getStringStarSummary, 15 + createStringStar, 16 + deleteStringStar, 17 + type StringComment, 18 + } from '../lib/api/strings'; 19 + import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils'; 20 + import { useAuth } from '../lib/auth'; 21 + import { Pencil, Trash2, Star, LoaderCircle, ArrowUp, X } from 'lucide-solid'; 22 + import clsx from 'clsx'; 23 + 24 + interface StringCommentThread { 25 + item: StringComment; 26 + replies: StringComment[]; 27 + } 28 + 29 + const buildStringCommentThreads = (comments: StringComment[]): StringCommentThread[] => { 30 + const byUri = new Map(comments.map((comment) => [comment.uri, comment])); 31 + const topLevel: StringComment[] = []; 32 + const repliesByRoot = new Map<string, StringComment[]>(); 33 + 34 + const findRoot = (comment: StringComment): StringComment | null => { 35 + let current = comment; 36 + const visited = new Set<string>([comment.uri]); 37 + while (true) { 38 + const replyToUri = typeof current.value.replyTo === 'string' 39 + ? current.value.replyTo 40 + : (current.value.replyTo as { uri?: string } | undefined)?.uri; 41 + if (!replyToUri) { 42 + return current; 43 + } 44 + const parent = byUri.get(replyToUri as ResourceUri); 45 + if (!parent) { 46 + return current; 47 + } 48 + if (visited.has(parent.uri)) { 49 + return current === comment ? null : current; 50 + } 51 + visited.add(parent.uri); 52 + current = parent; 53 + } 54 + }; 55 + 56 + for (const comment of comments) { 57 + const root = findRoot(comment); 58 + if (!root || root.uri === comment.uri) { 59 + topLevel.push(comment); 60 + } else { 61 + repliesByRoot.set(root.uri, [...(repliesByRoot.get(root.uri) ?? []), comment]); 62 + } 63 + } 64 + 65 + return topLevel.map((item) => ({ 66 + item, 67 + replies: repliesByRoot.get(item.uri) ?? [], 68 + })); 69 + }; 70 + 71 + export const StringPage: Component = () => { 72 + const params = useParams<{ actor: string; rkey: string }>(); 73 + const auth = useAuth(); 74 + const navigate = useNavigate(); 75 + const queryClient = useQueryClient(); 76 + 77 + // Deleting Signal 78 + const [deleting, setDeleting] = createSignal(false); 79 + 80 + // Star working Signal 81 + const [starWorking, setStarWorking] = createSignal(false); 82 + 83 + // Comment signals 84 + const [commentBody, setCommentBody] = createSignal(''); 85 + const [commentWorking, setCommentWorking] = createSignal(false); 86 + const [commentError, setCommentError] = createSignal<string | null>(null); 87 + 88 + const stringQuery = createQuery(() => ({ 89 + queryKey: ['string-detail', params.actor, params.rkey], 90 + queryFn: () => getString(params.actor, params.rkey), 91 + })); 92 + 93 + const starSummaryQuery = createQuery(() => ({ 94 + queryKey: ['string-star-summary', stringQuery.data?.record.uri], 95 + enabled: !!stringQuery.data?.record.uri, 96 + queryFn: () => getStringStarSummary(stringQuery.data!.record.uri, auth.currentDid()), 97 + })); 98 + 99 + const commentsQuery = createQuery(() => ({ 100 + queryKey: ['string-comments', stringQuery.data?.record.uri], 101 + enabled: !!stringQuery.data?.record.uri, 102 + queryFn: () => listStringComments(stringQuery.data!.record.uri), 103 + })); 104 + 105 + const commentThreads = createMemo(() => { 106 + const comments = commentsQuery.data ?? []; 107 + return buildStringCommentThreads(comments); 108 + }); 109 + 110 + const handleDelete = async () => { 111 + const agent = auth.agent(); 112 + const data = stringQuery.data; 113 + if (!agent || !data) return; 114 + 115 + if (!confirm(`are you sure you want to delete the string "${data.record.value.filename}"?`)) { 116 + return; 117 + } 118 + 119 + setDeleting(true); 120 + try { 121 + await deleteString(agent, params.rkey); 122 + const ownerId = data.owner.handle || data.owner.did; 123 + navigate(`/${ownerId}?tab=strings`); 124 + } catch (err) { 125 + alert(getErrorMessage(err)); 126 + } finally { 127 + setDeleting(false); 128 + } 129 + }; 130 + 131 + const handleStarToggle = async () => { 132 + const agent = auth.agent(); 133 + const summary = starSummaryQuery.data; 134 + const record = stringQuery.data?.record; 135 + if (!agent || !summary || !record || starWorking()) return; 136 + 137 + setStarWorking(true); 138 + try { 139 + if (summary.isStarred && summary.currentUserStarRkey) { 140 + await deleteStringStar(agent, summary.currentUserStarRkey); 141 + } else { 142 + await createStringStar(agent, record.uri); 143 + } 144 + await queryClient.invalidateQueries({ queryKey: ['string-star-summary', record.uri] }); 145 + } catch (err) { 146 + console.error('failed to toggle star', err); 147 + } finally { 148 + setStarWorking(false); 149 + } 150 + }; 151 + 152 + const handleCommentSubmit = async (e: SubmitEvent) => { 153 + e.preventDefault(); 154 + const agent = auth.agent(); 155 + const record = stringQuery.data?.record; 156 + if (!agent || !record || !commentBody().trim() || commentWorking()) return; 157 + 158 + setCommentWorking(true); 159 + setCommentError(null); 160 + try { 161 + await createStringComment(agent, record.uri, record.cid, commentBody()); 162 + setCommentBody(''); 163 + await queryClient.invalidateQueries({ queryKey: ['string-comments', record.uri] }); 164 + } catch (err) { 165 + setCommentError(getErrorMessage(err)); 166 + } finally { 167 + setCommentWorking(false); 168 + } 169 + }; 170 + 171 + const starBusy = () => starWorking() || starSummaryQuery.isLoading; 172 + 173 + return ( 174 + <Switch> 175 + <Match when={stringQuery.isLoading}> 176 + <LoadingState label="loading string..." /> 177 + </Match> 178 + <Match when={stringQuery.error}> 179 + <div class="p-6 bg-white dark:bg-gray-800 rounded shadow-sm"> 180 + <ErrorState message={getErrorMessage(stringQuery.error).toLowerCase()} /> 181 + </div> 182 + </Match> 183 + <Match when={stringQuery.data}> 184 + {(data) => { 185 + const { owner, record } = data(); 186 + const lineCount = () => record.value.contents.split('\n').length; 187 + const byteCount = () => new TextEncoder().encode(record.value.contents).length; 188 + const formattedSize = () => { 189 + const bytes = byteCount(); 190 + if (bytes < 1024) return `${bytes} B`; 191 + const kb = bytes / 1024; 192 + if (kb < 1024) return `${kb.toFixed(1)} KB`; 193 + return `${(kb / 1024).toFixed(1)} MB`; 194 + }; 195 + const timeAgo = () => formatRelativeTime(record.value.createdAt); 196 + 197 + const [rawUrl, setRawUrl] = createSignal<string>(); 198 + createEffect(() => { 199 + const webBlob = new Blob([record.value.contents], { type: 'text/plain;charset=utf-8' }); 200 + const url = URL.createObjectURL(webBlob); 201 + setRawUrl(url); 202 + onCleanup(() => URL.revokeObjectURL(url)); 203 + }); 204 + 205 + return ( 206 + <div class="flex flex-col gap-4"> 207 + <section id="string-header" class="mb-2 py-2 px-4 dark:text-white"> 208 + <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 209 + <div class="flex flex-col gap-2"> 210 + <div class="flex items-center gap-2 flex-wrap"> 211 + <A href={`/${owner.handle || owner.did}`} class="flex items-center gap-1 hover:underline text-black dark:text-white font-medium"> 212 + <Avatar did={owner.did} size="size-6" /> 213 + <span>{owner.handle || owner.did}</span> 214 + </A> 215 + <span class="select-none text-gray-400">/</span> 216 + <A href={`/strings/${owner.handle || owner.did}/${record.rkey}`} class="font-bold hover:underline text-black dark:text-white"> 217 + {record.value.filename} 218 + </A> 219 + </div> 220 + <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 221 + <Show when={record.value.description} fallback={<span class="italic">this string has no description</span>}> 222 + {record.value.description} 223 + </Show> 224 + </span> 225 + </div> 226 + 227 + <div class="w-full sm:w-fit flex items-center gap-2 z-auto"> 228 + <Show when={auth.currentDid() === owner.did}> 229 + <A 230 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group border border-gray-200 dark:border-gray-700 rounded-sm px-3 py-1.5 bg-white dark:bg-gray-800" 231 + href={`/strings/${owner.handle || owner.did}/${record.rkey}/edit`} 232 + > 233 + <Pencil class="w-4 h-4" /> 234 + <span class="hidden md:inline">edit</span> 235 + </A> 236 + <button 237 + class="btn text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-2 group border border-gray-200 dark:border-gray-700 rounded-sm px-3 py-1.5 bg-white dark:bg-gray-800" 238 + onClick={handleDelete} 239 + disabled={deleting()} 240 + > 241 + <Show when={deleting()} fallback={<Trash2 class="w-4 h-4" />}> 242 + <LoaderCircle class="w-4 h-4 animate-spin" /> 243 + </Show> 244 + <span class="hidden md:inline">delete</span> 245 + </button> 246 + </Show> 247 + <Show when={starSummaryQuery.data}> 248 + <div class="btn-group w-full sm:w-auto flex"> 249 + <button 250 + class="btn-group-item active flex-1 gap-1 group flex items-center justify-center" 251 + disabled={starBusy()} 252 + onClick={handleStarToggle} 253 + > 254 + <Show when={starWorking()} fallback={ 255 + <Star class={clsx("w-4 h-4 shrink-0", starSummaryQuery.data?.isStarred ? "fill-current text-yellow-500" : "")} /> 256 + }> 257 + <LoaderCircle class="w-4 h-4 shrink-0 animate-spin" /> 258 + </Show> 259 + <span>{starSummaryQuery.data?.isStarred ? 'unstar' : 'star'}</span> 260 + </button> 261 + <span class="btn-group-item cursor-default flex items-center justify-center px-3"> 262 + {starSummaryQuery.data?.count ?? 0} 263 + </span> 264 + </div> 265 + </Show> 266 + </div> 267 + </div> 268 + </section> 269 + 270 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white shadow-sm"> 271 + <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 272 + <span> 273 + {record.value.filename} 274 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 275 + <span> 276 + {timeAgo()} 277 + </span> 278 + </span> 279 + <div> 280 + <span>{lineCount()} lines</span> 281 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 282 + <span>{formattedSize()}</span> 283 + <Show when={rawUrl()}> 284 + <span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span> 285 + <a href={rawUrl()!} target="_blank" rel="noreferrer" class="hover:underline">view raw</a> 286 + </Show> 287 + </div> 288 + </div> 289 + <div class="overflow-x-auto overflow-y-hidden relative"> 290 + <CodeView id="blob-contents" text={record.value.contents} wrap={false} path={record.value.filename} /> 291 + </div> 292 + </section> 293 + 294 + {/* Comments section */} 295 + <div class="flex flex-col gap-4 mt-4"> 296 + <Show when={commentsQuery.data}> 297 + <CommentThreadsSection 298 + threads={commentThreads()} 299 + authorDid={owner.did} 300 + /> 301 + </Show> 302 + 303 + <Show when={auth.agent()}> 304 + <CommentComposer 305 + value={commentBody()} 306 + onValueChange={setCommentBody} 307 + onSubmit={handleCommentSubmit} 308 + working={commentWorking()} 309 + error={commentError()} 310 + placeholder="add to the discussion. markdown is supported." 311 + submitLabel="comment" 312 + /> 313 + </Show> 314 + </div> 315 + </div> 316 + ); 317 + }} 318 + </Match> 319 + </Switch> 320 + ); 321 + }; 322 + 323 + export const NewStringPage: Component = () => { 324 + const auth = useAuth(); 325 + const navigate = useNavigate(); 326 + const queryClient = useQueryClient(); 327 + 328 + const [filename, setFilename] = createSignal(''); 329 + const [description, setDescription] = createSignal(''); 330 + const [contents, setContents] = createSignal(''); 331 + const [saving, setSaving] = createSignal(false); 332 + const [saveError, setSaveError] = createSignal<string | null>(null); 333 + 334 + const lines = () => { 335 + const val = contents(); 336 + return val === '' ? 0 : val.split('\n').length; 337 + }; 338 + const bytes = () => { 339 + return new TextEncoder().encode(contents()).length; 340 + }; 341 + 342 + const handlePublish = async (e: SubmitEvent) => { 343 + e.preventDefault(); 344 + const agent = auth.agent(); 345 + if (!agent) return; 346 + 347 + setSaving(true); 348 + setSaveError(null); 349 + try { 350 + const result = await createString(agent, { 351 + filename: filename(), 352 + description: description(), 353 + contents: contents(), 354 + }); 355 + await queryClient.invalidateQueries({ queryKey: ['profile-strings', auth.currentDid()] }); 356 + const ownerId = auth.currentDid(); 357 + navigate(`/strings/${ownerId}/${result.rkey}`); 358 + } catch (err) { 359 + setSaveError(getErrorMessage(err)); 360 + } finally { 361 + setSaving(false); 362 + } 363 + }; 364 + 365 + return ( 366 + <Show 367 + when={auth.currentDid()} 368 + fallback={ 369 + <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"> 370 + please log in to create a new string. 371 + </div> 372 + } 373 + > 374 + <div class="px-6 py-2 mb-4"> 375 + <p class="text-xl font-bold dark:text-white mb-1">create a new string</p> 376 + <p class="text-gray-600 dark:text-gray-400 mb-1">store and share code snippets with ease.</p> 377 + </div> 378 + 379 + <form 380 + onSubmit={handlePublish} 381 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded border border-gray-200 dark:border-gray-700" 382 + > 383 + {saveError() && <p class="text-red-500 dark:text-red-400 text-sm mb-2">{saveError()}</p>} 384 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 385 + <input 386 + type="text" 387 + id="filename" 388 + name="filename" 389 + placeholder="filename" 390 + required 391 + value={filename()} 392 + onInput={(e) => setFilename(e.currentTarget.value)} 393 + class={clsx(inputStyles(), 'md:max-w-64')} 394 + /> 395 + <input 396 + type="text" 397 + id="description" 398 + name="description" 399 + value={description()} 400 + onInput={(e) => setDescription(e.currentTarget.value)} 401 + placeholder="description ..." 402 + class={clsx(inputStyles(), 'flex-1')} 403 + /> 404 + </div> 405 + <textarea 406 + name="content" 407 + id="content-textarea" 408 + wrap="off" 409 + class={clsx(textareaStyles(), 'font-mono text-sm')} 410 + rows={20} 411 + spellcheck={false} 412 + placeholder="paste your string here!" 413 + value={contents()} 414 + onInput={(e) => setContents(e.currentTarget.value)} 415 + required 416 + /> 417 + <div class="flex justify-between items-center"> 418 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 419 + <span id="line-count">{lines()} line{lines() !== 1 ? 's' : ''}</span> 420 + <span class="select-none px-1 [&:before]:content-['·']"></span> 421 + <span id="byte-count">{bytes()} byte{bytes() !== 1 ? 's' : ''}</span> 422 + </div> 423 + <div id="actions" class="flex gap-2 items-center"> 424 + <button 425 + type="submit" 426 + id="new-button" 427 + class="w-fit btn-create group inline-flex items-center gap-2 text-white" 428 + disabled={saving()} 429 + > 430 + <span class="inline-flex items-center gap-2"> 431 + <ArrowUp class="w-4 h-4" /> 432 + publish 433 + </span> 434 + <Show when={saving()}> 435 + <span class="pl-2 inline-block"> 436 + <LoaderCircle class="w-4 h-4 animate-spin" /> 437 + </span> 438 + </Show> 439 + </button> 440 + </div> 441 + </div> 442 + </form> 443 + </Show> 444 + ); 445 + }; 446 + 447 + export const EditStringPage: Component = () => { 448 + const params = useParams<{ actor: string; rkey: string }>(); 449 + const auth = useAuth(); 450 + const navigate = useNavigate(); 451 + const queryClient = useQueryClient(); 452 + 453 + const [filename, setFilename] = createSignal(''); 454 + const [description, setDescription] = createSignal(''); 455 + const [contents, setContents] = createSignal(''); 456 + const [saving, setSaving] = createSignal(false); 457 + const [saveError, setSaveError] = createSignal<string | null>(null); 458 + 459 + const stringQuery = createQuery(() => ({ 460 + queryKey: ['string-detail', params.actor, params.rkey], 461 + queryFn: () => getString(params.actor, params.rkey), 462 + })); 463 + 464 + createEffect(() => { 465 + const data = stringQuery.data; 466 + if (data) { 467 + setFilename(data.record.value.filename); 468 + setDescription(data.record.value.description ?? ''); 469 + setContents(data.record.value.contents); 470 + } 471 + }); 472 + 473 + const isOwner = () => { 474 + const data = stringQuery.data; 475 + return data && auth.currentDid() === data.owner.did; 476 + }; 477 + 478 + const lines = () => { 479 + const val = contents(); 480 + return val === '' ? 0 : val.split('\n').length; 481 + }; 482 + const bytes = () => { 483 + return new TextEncoder().encode(contents()).length; 484 + }; 485 + 486 + const handleSave = async (e: SubmitEvent) => { 487 + e.preventDefault(); 488 + const agent = auth.agent(); 489 + const data = stringQuery.data; 490 + if (!agent || !data) return; 491 + 492 + setSaving(true); 493 + setSaveError(null); 494 + try { 495 + await updateString( 496 + agent, 497 + params.rkey, 498 + { 499 + filename: filename(), 500 + description: description(), 501 + contents: contents(), 502 + }, 503 + data.record.cid, 504 + ); 505 + await queryClient.invalidateQueries({ queryKey: ['string-detail', params.actor, params.rkey] }); 506 + navigate(`/strings/${params.actor}/${params.rkey}`); 507 + } catch (err) { 508 + setSaveError(getErrorMessage(err)); 509 + } finally { 510 + setSaving(false); 511 + } 512 + }; 513 + 514 + return ( 515 + <Switch> 516 + <Match when={stringQuery.isLoading}> 517 + <LoadingState label="loading string..." /> 518 + </Match> 519 + <Match when={stringQuery.error}> 520 + <div class="p-6 bg-white dark:bg-gray-800 rounded shadow-sm"> 521 + <ErrorState message={getErrorMessage(stringQuery.error).toLowerCase()} /> 522 + </div> 523 + </Match> 524 + <Match when={stringQuery.data && !isOwner()}> 525 + <div class="p-6 bg-white dark:bg-gray-800 rounded shadow-sm"> 526 + <ErrorState message="you are not authorized to edit this string." /> 527 + </div> 528 + </Match> 529 + <Match when={stringQuery.data && isOwner()}> 530 + <div> 531 + <div class="px-6 py-2 mb-4"> 532 + <p class="text-xl font-bold dark:text-white">edit string</p> 533 + </div> 534 + 535 + <form 536 + onSubmit={handleSave} 537 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded border border-gray-200 dark:border-gray-700" 538 + > 539 + {saveError() && <p class="text-red-500 dark:text-red-400 text-sm mb-2">{saveError()}</p>} 540 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 541 + <input 542 + type="text" 543 + id="filename" 544 + name="filename" 545 + placeholder="filename" 546 + required 547 + value={filename()} 548 + onInput={(e) => setFilename(e.currentTarget.value)} 549 + class={clsx(inputStyles(), 'md:max-w-64')} 550 + /> 551 + <input 552 + type="text" 553 + id="description" 554 + name="description" 555 + value={description()} 556 + onInput={(e) => setDescription(e.currentTarget.value)} 557 + placeholder="description ..." 558 + class={clsx(inputStyles(), 'flex-1')} 559 + /> 560 + </div> 561 + <textarea 562 + name="content" 563 + id="content-textarea" 564 + wrap="off" 565 + class={clsx(textareaStyles(), 'font-mono text-sm')} 566 + rows={20} 567 + spellcheck={false} 568 + placeholder="paste your string here!" 569 + value={contents()} 570 + onInput={(e) => setContents(e.currentTarget.value)} 571 + required 572 + /> 573 + <div class="flex justify-between items-center"> 574 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 575 + <span id="line-count">{lines()} line{lines() !== 1 ? 's' : ''}</span> 576 + <span class="select-none px-1 [&:before]:content-['·']"></span> 577 + <span id="byte-count">{bytes()} byte{bytes() !== 1 ? 's' : ''}</span> 578 + </div> 579 + <div id="actions" class="flex gap-2 items-center"> 580 + <A 581 + class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 582 + href={`/strings/${params.actor}/${params.rkey}`} 583 + > 584 + <X class="size-4" /> 585 + <span class="hidden md:inline">cancel</span> 586 + </A> 587 + <button 588 + type="submit" 589 + id="new-button" 590 + class="w-fit btn-create group inline-flex items-center gap-2 text-white" 591 + disabled={saving()} 592 + > 593 + <span class="inline-flex items-center gap-2"> 594 + <ArrowUp class="w-4 h-4" /> 595 + publish 596 + </span> 597 + <Show when={saving()}> 598 + <span class="pl-2 inline-block"> 599 + <LoaderCircle class="w-4 h-4 animate-spin" /> 600 + </span> 601 + </Show> 602 + </button> 603 + </div> 604 + </div> 605 + </form> 606 + </div> 607 + </Match> 608 + </Switch> 609 + ); 610 + }; 611 + 612 + export const RawStringPage: Component = () => { 613 + const params = useParams<{ actor: string; rkey: string }>(); 614 + 615 + const stringQuery = createQuery(() => ({ 616 + queryKey: ['string-detail', params.actor, params.rkey], 617 + queryFn: () => getString(params.actor, params.rkey), 618 + })); 619 + 620 + return ( 621 + <Switch> 622 + <Match when={stringQuery.isLoading}> 623 + <div class="p-4 font-mono text-sm">loading raw content...</div> 624 + </Match> 625 + <Match when={stringQuery.error}> 626 + <div class="p-4 font-mono text-sm text-red-500"> 627 + failed to load: {getErrorMessage(stringQuery.error).toLowerCase()} 628 + </div> 629 + </Match> 630 + <Match when={stringQuery.data}> 631 + {(data) => ( 632 + <pre style="white-space: pre-wrap; word-wrap: break-word; font-family: monospace;" class="p-4 bg-white dark:bg-gray-950 dark:text-white"> 633 + {data().record.value.contents} 634 + </pre> 635 + )} 636 + </Match> 637 + </Switch> 638 + ); 639 + };
+5
src/routes.tsx
··· 20 20 import { OAuthCallbackPage } from './pages/oauth-callback'; 21 21 import { SearchPage } from './pages/search'; 22 22 import { ProfilePage } from './pages/profile'; 23 + import { StringPage, RawStringPage, NewStringPage, EditStringPage } from './pages/string'; 23 24 24 25 export const AppRoutes: Component = () => ( 25 26 <Router root={RootShell}> 26 27 <Route path="/" component={HomePage} /> 27 28 <Route path="/search" component={SearchPage} /> 28 29 <Route path="/oauth/callback" component={OAuthCallbackPage} /> 30 + <Route path="/strings/new" component={NewStringPage} /> 31 + <Route path="/strings/:actor/:rkey" component={StringPage} /> 32 + <Route path="/strings/:actor/:rkey/edit" component={EditStringPage} /> 33 + <Route path="/strings/:actor/:rkey/raw" component={RawStringPage} /> 29 34 <Route path="/:owner/:repo" component={RepoLayout}> 30 35 <Route path="/" component={RepoCodePage} /> 31 36 <Route path="/issues/new" component={NewIssuePage} />