👁️
5
fork

Configure Feed

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

add profiles, overhaul profile route

+665 -228
+2 -1
.claude/settings.local.json
··· 62 62 "Bash(wc:*)", 63 63 "Bash(ls:*)", 64 64 "Bash(npm run screenshot:wireframe:*)", 65 - "Bash(curl:*)" 65 + "Bash(curl:*)", 66 + "Bash(npm run build:lexicons:*)" 66 67 ], 67 68 "deny": [], 68 69 "ask": []
+7 -3
CLAUDE.md
··· 35 35 npm run typecheck # Check TypeScript types 36 36 37 37 # Typelex (schema generation) 38 - npm run build:typelex # Compile lexicons from typelex/*.tsp to lexicons/ 38 + npm run build:typelex # Compile lexicons from typelex/*.tsp to lexicons/ 39 + npm run build:lexicons # Generate TS types from lexicons/ to src/lib/lexicons/ 39 40 ``` 40 41 41 42 ## Architecture ··· 66 67 ### Typelex/Lexicons 67 68 68 69 - **Source**: `typelex/*.tsp` - TypeSpec definitions for AT Protocol lexicons 69 - - **Generated**: `lexicons/com/deckbelcher/**/*.json` - Compiled lexicon schemas 70 - - Run `npm run build:typelex` after modifying `.tsp` files 70 + - **Generated JSON**: `lexicons/com/deckbelcher/**/*.json` - Compiled lexicon schemas 71 + - **Generated TS**: `src/lib/lexicons/` - TypeScript types from lexicons 72 + - After modifying `.tsp` files, run both: 73 + 1. `npm run build:typelex` - compiles TypeSpec → JSON lexicons 74 + 2. `npm run build:lexicons` - generates TypeScript types from JSON lexicons 71 75 - Lexicons follow AT Protocol conventions (used for ATProto/Bluesky integrations) 72 76 73 77 ### Styling
+4 -19
lexicons/com/deckbelcher/actor/profile.json
··· 8 8 "record": { 9 9 "type": "object", 10 10 "properties": { 11 - "displayName": { 12 - "type": "string", 13 - "maxLength": 640, 14 - "maxGraphemes": 64, 15 - "description": "User's display name." 16 - }, 17 - "description": { 18 - "type": "string", 19 - "maxLength": 2560, 20 - "maxGraphemes": 256, 21 - "description": "Free-form profile description." 22 - }, 23 - "descriptionFacets": { 24 - "type": "array", 25 - "items": { 26 - "type": "ref", 27 - "ref": "com.deckbelcher.richtext.facet" 28 - }, 29 - "description": "Annotations of text in the profile description (mentions, URLs, hashtags, etc)." 11 + "bio": { 12 + "type": "ref", 13 + "ref": "com.deckbelcher.richtext#document", 14 + "description": "Profile bio/description as a rich text document." 30 15 }, 31 16 "pronouns": { 32 17 "type": "string",
+208
src/components/profile/ProfileHeader.tsx
··· 1 + import { ExternalLink, Pencil } from "lucide-react"; 2 + import { useCallback, useId, useMemo, useState } from "react"; 3 + import { ProseMirrorEditor } from "@/components/richtext/ProseMirrorEditor"; 4 + import { RichtextRenderer } from "@/components/richtext/RichtextRenderer"; 5 + import { schema } from "@/components/richtext/schema"; 6 + import type { Profile } from "@/lib/profile-queries"; 7 + import { lexiconToTree, treeToLexicon } from "@/lib/richtext-convert"; 8 + import { type PMDocJSON, useProseMirror } from "@/lib/useProseMirror"; 9 + 10 + interface ProfileHeaderProps { 11 + profile: Profile | null; 12 + handle: string | null; 13 + did: string; 14 + isOwner: boolean; 15 + onUpdate: (profile: Profile) => void; 16 + isSaving: boolean; 17 + } 18 + 19 + export function ProfileHeader({ 20 + profile, 21 + handle, 22 + did, 23 + isOwner, 24 + onUpdate, 25 + isSaving, 26 + }: ProfileHeaderProps) { 27 + const pronounsId = useId(); 28 + const [isEditing, setIsEditing] = useState(false); 29 + const [editedPronouns, setEditedPronouns] = useState(profile?.pronouns ?? ""); 30 + 31 + const displayHandle = handle ? `@${handle}` : did; 32 + 33 + // Convert lexicon to PM tree for editing 34 + const initialPMDoc = useMemo(() => { 35 + if (!profile?.bio) return undefined; 36 + return lexiconToTree(profile.bio).toJSON(); 37 + }, [profile?.bio]); 38 + 39 + const handleSaveBio = useCallback( 40 + (pmDocJSON: PMDocJSON) => { 41 + const pmNode = schema.nodeFromJSON(pmDocJSON); 42 + const lexicon = treeToLexicon(pmNode); 43 + onUpdate({ 44 + bio: lexicon, 45 + pronouns: editedPronouns.trim() || undefined, 46 + createdAt: profile?.createdAt ?? new Date().toISOString(), 47 + }); 48 + }, 49 + [onUpdate, editedPronouns, profile?.createdAt], 50 + ); 51 + 52 + const { doc, onChange, isDirty } = useProseMirror({ 53 + initialDoc: initialPMDoc, 54 + onSave: handleSaveBio, 55 + saveDebounceMs: 1500, 56 + }); 57 + 58 + const handleDone = () => { 59 + // Save pronouns if changed 60 + if (editedPronouns.trim() !== (profile?.pronouns ?? "")) { 61 + onUpdate({ 62 + bio: profile?.bio, 63 + pronouns: editedPronouns.trim() || undefined, 64 + createdAt: profile?.createdAt ?? new Date().toISOString(), 65 + }); 66 + } 67 + setIsEditing(false); 68 + }; 69 + 70 + const handleStartEdit = () => { 71 + setEditedPronouns(profile?.pronouns ?? ""); 72 + setIsEditing(true); 73 + }; 74 + 75 + const hasContent = 76 + profile?.bio?.content?.some((block) => { 77 + if ("text" in block && block.text) return true; 78 + if ("items" in block && block.items.length > 0) return true; 79 + return false; 80 + }) ?? false; 81 + 82 + const handleUrl = handle ? `https://${handle}` : null; 83 + 84 + if (isEditing) { 85 + return ( 86 + <div className="mb-8 space-y-4"> 87 + {/* Handle (not editable) */} 88 + <div className="flex items-center gap-3"> 89 + <h1 90 + className="text-3xl font-semibold text-gray-900 dark:text-white" 91 + style={{ fontVariationSettings: "'MONO' 0.5, 'CASL' 0.3" }} 92 + > 93 + {displayHandle} 94 + </h1> 95 + {handleUrl && ( 96 + <a 97 + href={handleUrl} 98 + target="_blank" 99 + rel="noopener noreferrer" 100 + className="text-gray-400 hover:text-cyan-500 dark:text-gray-500 dark:hover:text-cyan-400 transition-colors" 101 + title={`Visit ${handleUrl}`} 102 + > 103 + <ExternalLink className="w-6 h-6" /> 104 + </a> 105 + )} 106 + </div> 107 + 108 + {/* Pronouns input */} 109 + <div> 110 + <label 111 + htmlFor={pronounsId} 112 + className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" 113 + > 114 + Pronouns 115 + </label> 116 + <input 117 + id={pronounsId} 118 + type="text" 119 + value={editedPronouns} 120 + onChange={(e) => setEditedPronouns(e.target.value)} 121 + placeholder="e.g. she/her, they/them" 122 + maxLength={20} 123 + className="px-3 py-2 w-full max-w-xs bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:border-cyan-500" 124 + /> 125 + </div> 126 + 127 + {/* Bio editor */} 128 + <div> 129 + <span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> 130 + Bio 131 + </span> 132 + <ProseMirrorEditor 133 + defaultValue={doc} 134 + onChange={onChange} 135 + placeholder="Write something about yourself..." 136 + /> 137 + </div> 138 + 139 + {/* Save status and done button */} 140 + <div className="flex items-center justify-between"> 141 + <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 142 + {isSaving && <span>Saving...</span>} 143 + {!isSaving && isDirty && <span>Unsaved changes</span>} 144 + {!isSaving && !isDirty && hasContent && <span>Saved</span>} 145 + </div> 146 + <button 147 + type="button" 148 + onClick={handleDone} 149 + className="px-4 py-2 text-sm font-medium rounded-lg bg-cyan-600 hover:bg-cyan-700 text-white" 150 + > 151 + Done 152 + </button> 153 + </div> 154 + </div> 155 + ); 156 + } 157 + 158 + return ( 159 + <div className="mb-8 space-y-2"> 160 + {/* Handle row */} 161 + <div className="flex items-center gap-3"> 162 + <h1 163 + className="text-3xl font-semibold text-gray-900 dark:text-white" 164 + style={{ fontVariationSettings: "'MONO' 0.5, 'CASL' 0.3" }} 165 + > 166 + {displayHandle} 167 + </h1> 168 + {handleUrl && ( 169 + <a 170 + href={handleUrl} 171 + target="_blank" 172 + rel="noopener noreferrer" 173 + className="text-gray-400 hover:text-cyan-500 dark:text-gray-500 dark:hover:text-cyan-400 transition-colors" 174 + title={`Visit ${handleUrl}`} 175 + > 176 + <ExternalLink className="w-6 h-6" /> 177 + </a> 178 + )} 179 + </div> 180 + {/* Pronouns */} 181 + {profile?.pronouns && ( 182 + <p className="text-sm text-gray-500 dark:text-gray-400"> 183 + {profile.pronouns} 184 + </p> 185 + )} 186 + 187 + {/* Bio */} 188 + {hasContent && profile?.bio && ( 189 + <RichtextRenderer 190 + doc={profile.bio} 191 + className="text-gray-700 dark:text-gray-300" 192 + /> 193 + )} 194 + 195 + {/* Edit button for owner */} 196 + {isOwner && ( 197 + <button 198 + type="button" 199 + onClick={handleStartEdit} 200 + className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 201 + > 202 + <Pencil className="w-4 h-4" /> 203 + {hasContent || profile?.pronouns ? "Edit profile" : "Add bio"} 204 + </button> 205 + )} 206 + </div> 207 + ); 208 + }
+63
src/components/profile/ProfileLayout.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + import { Link } from "@tanstack/react-router"; 4 + import { ProfileHeader } from "@/components/profile/ProfileHeader"; 5 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 6 + import { 7 + getProfileQueryOptions, 8 + useUpdateProfileMutation, 9 + } from "@/lib/profile-queries"; 10 + import { useAuth } from "@/lib/useAuth"; 11 + 12 + interface ProfileLayoutProps { 13 + did: string; 14 + children: React.ReactNode; 15 + } 16 + 17 + export function ProfileLayout({ did, children }: ProfileLayoutProps) { 18 + const { session } = useAuth(); 19 + const { data: didDocument } = useQuery(didDocumentQueryOptions(did as Did)); 20 + const { data: profileData } = useQuery(getProfileQueryOptions(did as Did)); 21 + const updateProfileMutation = useUpdateProfileMutation(); 22 + 23 + const handle = extractHandle(didDocument ?? null); 24 + const isOwner = session?.info.sub === did; 25 + 26 + return ( 27 + <div className="min-h-screen bg-white dark:bg-slate-900"> 28 + <div className="max-w-7xl mx-auto px-6 py-16"> 29 + {/* Profile Header */} 30 + <ProfileHeader 31 + profile={profileData?.profile ?? null} 32 + handle={handle} 33 + did={did} 34 + isOwner={isOwner} 35 + onUpdate={(profile) => updateProfileMutation.mutate(profile)} 36 + isSaving={updateProfileMutation.isPending} 37 + /> 38 + 39 + {/* Tab Navigation */} 40 + <nav className="flex border-b border-gray-200 dark:border-slate-700 mb-6"> 41 + <Link 42 + to="/profile/$did" 43 + params={{ did }} 44 + activeOptions={{ exact: true }} 45 + className="px-4 py-2 font-medium text-sm border-b-2 transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 border-transparent hover:border-gray-300 dark:hover:border-gray-600 [&.active]:border-cyan-500 [&.active]:text-cyan-600 dark:[&.active]:text-cyan-400" 46 + > 47 + Decks 48 + </Link> 49 + <Link 50 + to="/profile/$did/lists" 51 + params={{ did }} 52 + className="px-4 py-2 font-medium text-sm border-b-2 transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 border-transparent hover:border-gray-300 dark:hover:border-gray-600 [&.active]:border-cyan-500 [&.active]:text-cyan-600 dark:[&.active]:text-cyan-400" 53 + > 54 + Lists 55 + </Link> 56 + </nav> 57 + 58 + {/* Tab content */} 59 + {children} 60 + </div> 61 + </div> 62 + ); 63 + }
+28 -1
src/lib/atproto-client.ts
··· 13 13 } from "@atcute/lexicons/validations"; 14 14 import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 15 15 import { 16 + ComDeckbelcherActorProfile, 16 17 ComDeckbelcherCollectionList, 17 18 ComDeckbelcherDeckList, 18 19 ComDeckbelcherSocialLike, ··· 80 81 81 82 export type Result<T, E = Error> = 82 83 | { success: true; data: T } 83 - | { success: false; error: E }; 84 + | { success: false; error: E; status?: number }; 84 85 85 86 export interface RecordResponse<T> { 86 87 uri: AtUri; ··· 121 122 error.message || 122 123 `Failed to fetch ${collection}: ${response.statusText}`, 123 124 ), 125 + status: response.status, 124 126 }; 125 127 } 126 128 ··· 543 545 const rkey = await hashToRkey(subject.ref); 544 546 return deleteRecord(agent, rkey, ComDeckbelcherSocialLike.mainSchema); 545 547 } 548 + 549 + // ============================================================================ 550 + // Profile Records 551 + // ============================================================================ 552 + 553 + export type ProfileRecordResponse = 554 + RecordResponse<ComDeckbelcherActorProfile.Main>; 555 + 556 + const PROFILE_RKEY = asRkey("self"); 557 + 558 + export function getProfileRecord(did: Did) { 559 + return getRecord(did, PROFILE_RKEY, ComDeckbelcherActorProfile.mainSchema); 560 + } 561 + 562 + export function upsertProfileRecord( 563 + agent: OAuthUserAgent, 564 + record: ComDeckbelcherActorProfile.Main, 565 + ) { 566 + return upsertRecord( 567 + agent, 568 + PROFILE_RKEY, 569 + record, 570 + ComDeckbelcherActorProfile.mainSchema, 571 + ); 572 + }
+6 -30
src/lib/lexicons/types/com/deckbelcher/actor/profile.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import type {} from "@atcute/lexicons/ambient"; 4 - import * as ComDeckbelcherRichtextFacet from "../richtext/facet.js"; 4 + import * as ComDeckbelcherRichtext from "../richtext.js"; 5 5 6 6 const _mainSchema = /*#__PURE__*/ v.record( 7 7 /*#__PURE__*/ v.literal("self"), 8 8 /*#__PURE__*/ v.object({ 9 9 $type: /*#__PURE__*/ v.literal("com.deckbelcher.actor.profile"), 10 10 /** 11 - * Timestamp when the profile was created. 12 - */ 13 - createdAt: /*#__PURE__*/ v.datetimeString(), 14 - /** 15 - * Free-form profile description. 16 - * @maxLength 2560 17 - * @maxGraphemes 256 18 - */ 19 - description: /*#__PURE__*/ v.optional( 20 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 21 - /*#__PURE__*/ v.stringLength(0, 2560), 22 - /*#__PURE__*/ v.stringGraphemes(0, 256), 23 - ]), 24 - ), 25 - /** 26 - * Annotations of text in the profile description (mentions, URLs, hashtags, etc). 11 + * Profile bio/description as a rich text document. 27 12 */ 28 - get descriptionFacets() { 29 - return /*#__PURE__*/ v.optional( 30 - /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 31 - ); 13 + get bio() { 14 + return /*#__PURE__*/ v.optional(ComDeckbelcherRichtext.documentSchema); 32 15 }, 33 16 /** 34 - * User's display name. 35 - * @maxLength 640 36 - * @maxGraphemes 64 17 + * Timestamp when the profile was created. 37 18 */ 38 - displayName: /*#__PURE__*/ v.optional( 39 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 40 - /*#__PURE__*/ v.stringLength(0, 640), 41 - /*#__PURE__*/ v.stringGraphemes(0, 64), 42 - ]), 43 - ), 19 + createdAt: /*#__PURE__*/ v.datetimeString(), 44 20 /** 45 21 * Free-form pronouns text. 46 22 * @maxLength 200
+114
src/lib/profile-queries.ts
··· 1 + /** 2 + * TanStack Query integration for profile operations 3 + * Provides query options and mutations with optimistic updates 4 + */ 5 + 6 + import type { Did } from "@atcute/lexicons"; 7 + import { queryOptions, useQueryClient } from "@tanstack/react-query"; 8 + import { getProfileRecord, upsertProfileRecord } from "./atproto-client"; 9 + import type { 10 + ComDeckbelcherActorProfile, 11 + ComDeckbelcherRichtext, 12 + } from "./lexicons/index"; 13 + import { optimisticRecord, runOptimistic } from "./optimistic-utils"; 14 + import { useAuth } from "./useAuth"; 15 + import { useMutationWithToast } from "./useMutationWithToast"; 16 + 17 + export interface Profile { 18 + bio?: ComDeckbelcherRichtext.Document; 19 + pronouns?: string; 20 + createdAt: string; 21 + } 22 + 23 + export interface ProfileRecord { 24 + profile: Profile; 25 + cid: string; 26 + } 27 + 28 + /** 29 + * Query options for fetching a profile 30 + * Returns null if profile doesn't exist (new user) 31 + */ 32 + export const getProfileQueryOptions = (did: Did) => 33 + queryOptions({ 34 + queryKey: ["profile", did] as const, 35 + queryFn: async (): Promise<ProfileRecord | null> => { 36 + const result = await getProfileRecord(did); 37 + if (!result.success) { 38 + // Profile doesn't exist yet - return null (not an error) 39 + // ATProto returns 400 for RecordNotFound 40 + if (result.status === 400) { 41 + return null; 42 + } 43 + throw result.error; 44 + } 45 + return { 46 + profile: result.data.value, 47 + cid: result.data.cid, 48 + }; 49 + }, 50 + staleTime: 60 * 1000, // 1 minute 51 + }); 52 + 53 + /** 54 + * Mutation for creating/updating a profile 55 + * Uses upsert since rkey is always "self" 56 + */ 57 + export function useUpdateProfileMutation() { 58 + const { agent, session } = useAuth(); 59 + const queryClient = useQueryClient(); 60 + 61 + return useMutationWithToast({ 62 + mutationFn: async (profile: Profile) => { 63 + if (!agent || !session) { 64 + throw new Error("Must be authenticated to update profile"); 65 + } 66 + 67 + const record: ComDeckbelcherActorProfile.Main = { 68 + $type: "com.deckbelcher.actor.profile", 69 + bio: profile.bio, 70 + pronouns: profile.pronouns, 71 + createdAt: profile.createdAt, 72 + }; 73 + 74 + const result = await upsertProfileRecord(agent, record); 75 + 76 + if (!result.success) { 77 + throw result.error; 78 + } 79 + 80 + return result.data; 81 + }, 82 + onMutate: async (newProfile) => { 83 + if (!session) return { rollback: () => {} }; 84 + 85 + const rollback = await runOptimistic([ 86 + optimisticRecord<ProfileRecord | null>( 87 + queryClient, 88 + ["profile", session.info.sub], 89 + (old) => ({ 90 + profile: newProfile, 91 + cid: old?.cid ?? "", 92 + }), 93 + ), 94 + ]); 95 + return { rollback }; 96 + }, 97 + onSuccess: (data, newProfile) => { 98 + if (!session) return; 99 + 100 + queryClient.setQueryData<ProfileRecord>(["profile", session.info.sub], { 101 + profile: newProfile, 102 + cid: data.cid, 103 + }); 104 + }, 105 + onError: (_err, _newProfile, context) => { 106 + context?.rollback(); 107 + if (session) { 108 + queryClient.invalidateQueries({ 109 + queryKey: ["profile", session.info.sub], 110 + }); 111 + } 112 + }, 113 + }); 114 + }
+21
src/routeTree.gen.ts
··· 20 20 import { Route as DeckNewRouteImport } from './routes/deck/new' 21 21 import { Route as CardIdRouteImport } from './routes/card/$id' 22 22 import { Route as ProfileDidIndexRouteImport } from './routes/profile/$did/index' 23 + import { Route as ProfileDidListsRouteImport } from './routes/profile/$did/lists' 23 24 import { Route as ProfileDidListRkeyRouteImport } from './routes/profile/$did/list/$rkey' 24 25 import { Route as ProfileDidDeckRkeyRouteImport } from './routes/profile/$did/deck/$rkey' 25 26 import { Route as ProfileDidListRkeyIndexRouteImport } from './routes/profile/$did/list/$rkey/index' ··· 80 81 const ProfileDidIndexRoute = ProfileDidIndexRouteImport.update({ 81 82 id: '/profile/$did/', 82 83 path: '/profile/$did/', 84 + getParentRoute: () => rootRouteImport, 85 + } as any) 86 + const ProfileDidListsRoute = ProfileDidListsRouteImport.update({ 87 + id: '/profile/$did/lists', 88 + path: '/profile/$did/lists', 83 89 getParentRoute: () => rootRouteImport, 84 90 } as any) 85 91 const ProfileDidListRkeyRoute = ProfileDidListRkeyRouteImport.update({ ··· 125 131 '/oauth/callback': typeof OauthCallbackRoute 126 132 '/u/$handle': typeof UHandleRoute 127 133 '/cards': typeof CardsIndexRoute 134 + '/profile/$did/lists': typeof ProfileDidListsRoute 128 135 '/profile/$did': typeof ProfileDidIndexRoute 129 136 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren 130 137 '/profile/$did/list/$rkey': typeof ProfileDidListRkeyRouteWithChildren ··· 144 151 '/oauth/callback': typeof OauthCallbackRoute 145 152 '/u/$handle': typeof UHandleRoute 146 153 '/cards': typeof CardsIndexRoute 154 + '/profile/$did/lists': typeof ProfileDidListsRoute 147 155 '/profile/$did': typeof ProfileDidIndexRoute 148 156 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 149 157 '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute ··· 162 170 '/oauth/callback': typeof OauthCallbackRoute 163 171 '/u/$handle': typeof UHandleRoute 164 172 '/cards/': typeof CardsIndexRoute 173 + '/profile/$did/lists': typeof ProfileDidListsRoute 165 174 '/profile/$did/': typeof ProfileDidIndexRoute 166 175 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren 167 176 '/profile/$did/list/$rkey': typeof ProfileDidListRkeyRouteWithChildren ··· 183 192 | '/oauth/callback' 184 193 | '/u/$handle' 185 194 | '/cards' 195 + | '/profile/$did/lists' 186 196 | '/profile/$did' 187 197 | '/profile/$did/deck/$rkey' 188 198 | '/profile/$did/list/$rkey' ··· 202 212 | '/oauth/callback' 203 213 | '/u/$handle' 204 214 | '/cards' 215 + | '/profile/$did/lists' 205 216 | '/profile/$did' 206 217 | '/profile/$did/deck/$rkey/bulk-edit' 207 218 | '/profile/$did/deck/$rkey/play' ··· 219 230 | '/oauth/callback' 220 231 | '/u/$handle' 221 232 | '/cards/' 233 + | '/profile/$did/lists' 222 234 | '/profile/$did/' 223 235 | '/profile/$did/deck/$rkey' 224 236 | '/profile/$did/list/$rkey' ··· 237 249 OauthCallbackRoute: typeof OauthCallbackRoute 238 250 UHandleRoute: typeof UHandleRoute 239 251 CardsIndexRoute: typeof CardsIndexRoute 252 + ProfileDidListsRoute: typeof ProfileDidListsRoute 240 253 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 241 254 ProfileDidDeckRkeyRoute: typeof ProfileDidDeckRkeyRouteWithChildren 242 255 ProfileDidListRkeyRoute: typeof ProfileDidListRkeyRouteWithChildren ··· 321 334 preLoaderRoute: typeof ProfileDidIndexRouteImport 322 335 parentRoute: typeof rootRouteImport 323 336 } 337 + '/profile/$did/lists': { 338 + id: '/profile/$did/lists' 339 + path: '/profile/$did/lists' 340 + fullPath: '/profile/$did/lists' 341 + preLoaderRoute: typeof ProfileDidListsRouteImport 342 + parentRoute: typeof rootRouteImport 343 + } 324 344 '/profile/$did/list/$rkey': { 325 345 id: '/profile/$did/list/$rkey' 326 346 path: '/profile/$did/list/$rkey' ··· 415 435 OauthCallbackRoute: OauthCallbackRoute, 416 436 UHandleRoute: UHandleRoute, 417 437 CardsIndexRoute: CardsIndexRoute, 438 + ProfileDidListsRoute: ProfileDidListsRoute, 418 439 ProfileDidIndexRoute: ProfileDidIndexRoute, 419 440 ProfileDidDeckRkeyRoute: ProfileDidDeckRkeyRouteWithChildren, 420 441 ProfileDidListRkeyRoute: ProfileDidListRkeyRouteWithChildren,
+115 -162
src/routes/profile/$did/index.tsx
··· 1 1 import type { Did } from "@atcute/lexicons"; 2 - import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 + import { useInfiniteQuery } from "@tanstack/react-query"; 3 3 import { createFileRoute, Link } from "@tanstack/react-router"; 4 4 import { Plus } from "lucide-react"; 5 5 import { useMemo } from "react"; 6 6 import { DeckPreview } from "@/components/DeckPreview"; 7 - import { ListPreview } from "@/components/ListPreview"; 8 - import { 9 - listUserCollectionListsQueryOptions, 10 - useCreateCollectionListMutation, 11 - } from "@/lib/collection-list-queries"; 7 + import { ProfileLayout } from "@/components/profile/ProfileLayout"; 12 8 import { 13 9 type DeckListRecord, 14 10 listUserDecksQueryOptions, 15 11 } from "@/lib/deck-queries"; 16 12 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 17 13 import { formatDisplayName } from "@/lib/format-utils"; 14 + import { getProfileQueryOptions } from "@/lib/profile-queries"; 18 15 import { useAuth } from "@/lib/useAuth"; 19 16 20 17 type SortOption = "updated-desc" | "updated-asc" | "name-asc" | "name-desc"; 21 18 22 - interface ProfileSearch { 19 + interface DecksSearch { 23 20 sort?: SortOption; 24 21 format?: string; 25 22 } 26 23 27 24 export const Route = createFileRoute("/profile/$did/")({ 28 - component: ProfilePage, 29 - validateSearch: (search: Record<string, unknown>): ProfileSearch => ({ 25 + component: DecksTab, 26 + validateSearch: (search: Record<string, unknown>): DecksSearch => ({ 30 27 sort: (search.sort as SortOption) || undefined, 31 28 format: (search.format as string) || undefined, 32 29 }), 33 30 loader: async ({ context, params }) => { 34 - // Prefetch deck list, collection lists, and DID document during SSR 35 - const [, , didDocument] = await Promise.all([ 31 + const [, didDocument] = await Promise.all([ 36 32 context.queryClient.ensureInfiniteQueryData( 37 33 listUserDecksQueryOptions(params.did as Did), 38 34 ), 39 - context.queryClient.ensureInfiniteQueryData( 40 - listUserCollectionListsQueryOptions(params.did as Did), 41 - ), 42 35 context.queryClient.ensureQueryData( 43 36 didDocumentQueryOptions(params.did as Did), 37 + ), 38 + context.queryClient.ensureQueryData( 39 + getProfileQueryOptions(params.did as Did), 44 40 ), 45 41 ]); 46 42 return { handle: extractHandle(didDocument) }; ··· 90 86 { value: "name-desc", label: "Name Z-A" }, 91 87 ]; 92 88 93 - function ProfilePage() { 89 + function DecksTab() { 94 90 const { did } = Route.useParams(); 95 91 const search = Route.useSearch(); 96 92 const navigate = Route.useNavigate(); 97 93 const { session } = useAuth(); 98 - const { data: decksData, isLoading: decksLoading } = useInfiniteQuery( 94 + const { data: decksData, isLoading } = useInfiniteQuery( 99 95 listUserDecksQueryOptions(did as Did), 100 96 ); 101 - const { data: listsData, isLoading: listsLoading } = useInfiniteQuery( 102 - listUserCollectionListsQueryOptions(did as Did), 103 - ); 104 - const { data: didDocument } = useQuery(didDocumentQueryOptions(did as Did)); 105 - const createListMutation = useCreateCollectionListMutation(); 106 97 107 - const handle = extractHandle(didDocument ?? null); 108 98 const isOwner = session?.info.sub === did; 109 99 const decks = decksData?.pages.flatMap((p) => p.records) ?? []; 110 - const lists = listsData?.pages.flatMap((p) => p.records) ?? []; 111 100 112 - // Get unique formats for filter dropdown 101 + return ( 102 + <ProfileLayout did={did}> 103 + <DecksContent 104 + did={did} 105 + decks={decks} 106 + isLoading={isLoading} 107 + isOwner={isOwner} 108 + search={search} 109 + navigate={navigate} 110 + /> 111 + </ProfileLayout> 112 + ); 113 + } 114 + 115 + interface DecksContentProps { 116 + did: string; 117 + decks: DeckListRecord[]; 118 + isLoading: boolean; 119 + isOwner: boolean; 120 + search: DecksSearch; 121 + navigate: ReturnType<typeof Route.useNavigate>; 122 + } 123 + 124 + function DecksContent({ 125 + did, 126 + decks, 127 + isLoading, 128 + isOwner, 129 + search, 130 + navigate, 131 + }: DecksContentProps) { 113 132 const availableFormats = useMemo(() => { 114 133 const formats = new Set<string>(); 115 134 for (const record of decks) { ··· 120 139 return Array.from(formats).sort(); 121 140 }, [decks]); 122 141 123 - // Filter and sort 124 142 const filteredAndSorted = useMemo(() => { 125 143 let records = decks; 126 144 127 - // Filter by format 128 145 if (search.format) { 129 146 records = records.filter((r) => r.value.format === search.format); 130 147 } 131 148 132 - // Sort 133 149 return sortDecks(records, search.sort); 134 150 }, [decks, search.format, search.sort]); 135 151 136 152 const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => { 137 153 const value = e.target.value as SortOption | ""; 138 154 navigate({ 139 - search: (prev) => ({ 155 + search: (prev: DecksSearch) => ({ 140 156 ...prev, 141 157 sort: value || undefined, 142 158 }), ··· 147 163 const handleFormatChange = (e: React.ChangeEvent<HTMLSelectElement>) => { 148 164 const value = e.target.value; 149 165 navigate({ 150 - search: (prev) => ({ 166 + search: (prev: DecksSearch) => ({ 151 167 ...prev, 152 168 format: value || undefined, 153 169 }), ··· 158 174 const hasActiveFilters = search.format != null; 159 175 160 176 return ( 161 - <div className="min-h-screen bg-white dark:bg-slate-900"> 162 - <div className="max-w-7xl mx-auto px-6 py-16"> 163 - <div className="mb-8"> 164 - <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2 font-display"> 165 - Decklists 166 - </h1> 167 - <p className="text-gray-600 dark:text-gray-400"> 168 - {handle ? `@${handle}` : did} 169 - </p> 170 - </div> 171 - 172 - {/* Sort and filter controls - only show if there are decks */} 177 + <section> 178 + {/* Sort and filter controls */} 179 + <div className="flex flex-wrap items-center justify-between gap-4 mb-6"> 173 180 {decks.length > 0 && ( 174 - <div className="flex flex-wrap gap-4 mb-6"> 181 + <div className="flex flex-wrap gap-4"> 175 182 <select 176 183 value={search.sort ?? "updated-desc"} 177 184 onChange={handleSortChange} ··· 200 207 )} 201 208 </div> 202 209 )} 210 + {isOwner && ( 211 + <Link 212 + to="/deck/new" 213 + className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white font-medium rounded-lg transition-colors" 214 + > 215 + <Plus className="w-4 h-4" /> 216 + New Deck 217 + </Link> 218 + )} 219 + </div> 203 220 204 - {/* Decks Section */} 205 - <section className="mb-12"> 206 - <div className="flex items-center justify-between mb-4"> 207 - <h2 className="text-2xl font-bold text-gray-900 dark:text-white"> 208 - Decks 209 - </h2> 210 - {isOwner && ( 211 - <Link 212 - to="/deck/new" 213 - className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white font-medium rounded-lg transition-colors" 214 - > 215 - <Plus className="w-4 h-4" /> 216 - New Deck 217 - </Link> 218 - )} 219 - </div> 220 - {decksLoading ? ( 221 - <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 222 - <p className="text-gray-600 dark:text-gray-400"> 223 - Loading decklists... 224 - </p> 225 - </div> 226 - ) : decks.length === 0 ? ( 227 - <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 228 - <p className="text-gray-600 dark:text-gray-400 mb-4"> 229 - {isOwner ? "No decklists yet" : "No decklists"} 230 - </p> 231 - {isOwner && ( 232 - <Link 233 - to="/deck/new" 234 - className="inline-block px-6 py-3 bg-cyan-600 hover:bg-cyan-700 text-white font-medium rounded-lg transition-colors" 235 - > 236 - Create Your First Deck 237 - </Link> 238 - )} 239 - </div> 240 - ) : filteredAndSorted.length === 0 ? ( 241 - <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 242 - <p className="text-gray-600 dark:text-gray-400 mb-4"> 243 - No decks match your filters 244 - </p> 245 - {hasActiveFilters && ( 246 - <button 247 - type="button" 248 - onClick={() => 249 - navigate({ search: { sort: search.sort }, replace: true }) 250 - } 251 - className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300 font-medium" 252 - > 253 - Clear filters 254 - </button> 255 - )} 256 - </div> 257 - ) : ( 258 - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 259 - {filteredAndSorted.map((record) => { 260 - const rkey = record.uri.split("/").pop(); 261 - if (!rkey) return null; 262 - 263 - return ( 264 - <DeckPreview 265 - key={record.uri} 266 - did={did as Did} 267 - rkey={rkey} 268 - deck={record.value} 269 - /> 270 - ); 271 - })} 272 - </div> 221 + {isLoading ? ( 222 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 223 + <p className="text-gray-600 dark:text-gray-400"> 224 + Loading decklists... 225 + </p> 226 + </div> 227 + ) : decks.length === 0 ? ( 228 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 229 + <p className="text-gray-600 dark:text-gray-400 mb-4"> 230 + {isOwner ? "No decklists yet" : "No decklists"} 231 + </p> 232 + {isOwner && ( 233 + <Link 234 + to="/deck/new" 235 + className="inline-block px-6 py-3 bg-cyan-600 hover:bg-cyan-700 text-white font-medium rounded-lg transition-colors" 236 + > 237 + Create Your First Deck 238 + </Link> 239 + )} 240 + </div> 241 + ) : filteredAndSorted.length === 0 ? ( 242 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 243 + <p className="text-gray-600 dark:text-gray-400 mb-4"> 244 + No decks match your filters 245 + </p> 246 + {hasActiveFilters && ( 247 + <button 248 + type="button" 249 + onClick={() => 250 + navigate({ 251 + search: { sort: search.sort }, 252 + replace: true, 253 + }) 254 + } 255 + className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300 font-medium" 256 + > 257 + Clear filters 258 + </button> 273 259 )} 274 - </section> 260 + </div> 261 + ) : ( 262 + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 263 + {filteredAndSorted.map((record) => { 264 + const rkey = record.uri.split("/").pop(); 265 + if (!rkey) return null; 275 266 276 - {/* Lists Section */} 277 - <section> 278 - <div className="flex items-center justify-between mb-4"> 279 - <h2 className="text-2xl font-bold text-gray-900 dark:text-white"> 280 - Lists 281 - </h2> 282 - {isOwner && ( 283 - <button 284 - type="button" 285 - onClick={() => createListMutation.mutate({ name: "New List" })} 286 - disabled={createListMutation.isPending} 287 - className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white font-medium rounded-lg transition-colors" 288 - > 289 - <Plus className="w-4 h-4" /> 290 - New List 291 - </button> 292 - )} 293 - </div> 294 - {listsLoading ? ( 295 - <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 296 - <p className="text-gray-600 dark:text-gray-400"> 297 - Loading lists... 298 - </p> 299 - </div> 300 - ) : lists.length === 0 ? ( 301 - <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 302 - <p className="text-gray-600 dark:text-gray-400"> 303 - {isOwner ? "No lists yet" : "No lists"} 304 - </p> 305 - </div> 306 - ) : ( 307 - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 308 - {lists.map((record) => { 309 - const rkey = record.uri.split("/").pop(); 310 - if (!rkey) return null; 311 - 312 - return ( 313 - <ListPreview 314 - key={record.uri} 315 - did={did as Did} 316 - rkey={rkey} 317 - list={record.value} 318 - /> 319 - ); 320 - })} 321 - </div> 322 - )} 323 - </section> 324 - </div> 325 - </div> 267 + return ( 268 + <DeckPreview 269 + key={record.uri} 270 + did={did as Did} 271 + rkey={rkey} 272 + deck={record.value} 273 + /> 274 + ); 275 + })} 276 + </div> 277 + )} 278 + </section> 326 279 ); 327 280 }
+95
src/routes/profile/$did/lists.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useInfiniteQuery } from "@tanstack/react-query"; 3 + import { createFileRoute } from "@tanstack/react-router"; 4 + import { Plus } from "lucide-react"; 5 + import { ListPreview } from "@/components/ListPreview"; 6 + import { ProfileLayout } from "@/components/profile/ProfileLayout"; 7 + import { 8 + listUserCollectionListsQueryOptions, 9 + useCreateCollectionListMutation, 10 + } from "@/lib/collection-list-queries"; 11 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 12 + import { getProfileQueryOptions } from "@/lib/profile-queries"; 13 + import { useAuth } from "@/lib/useAuth"; 14 + 15 + export const Route = createFileRoute("/profile/$did/lists")({ 16 + component: ListsTab, 17 + loader: async ({ context, params }) => { 18 + const [, didDocument] = await Promise.all([ 19 + context.queryClient.ensureInfiniteQueryData( 20 + listUserCollectionListsQueryOptions(params.did as Did), 21 + ), 22 + context.queryClient.ensureQueryData( 23 + didDocumentQueryOptions(params.did as Did), 24 + ), 25 + context.queryClient.ensureQueryData( 26 + getProfileQueryOptions(params.did as Did), 27 + ), 28 + ]); 29 + return { handle: extractHandle(didDocument) }; 30 + }, 31 + head: ({ loaderData }) => { 32 + const display = loaderData?.handle ? `@${loaderData.handle}` : "Profile"; 33 + return { meta: [{ title: `${display} - Lists | DeckBelcher` }] }; 34 + }, 35 + }); 36 + 37 + function ListsTab() { 38 + const { did } = Route.useParams(); 39 + const { session } = useAuth(); 40 + const { data: listsData, isLoading } = useInfiniteQuery( 41 + listUserCollectionListsQueryOptions(did as Did), 42 + ); 43 + const createListMutation = useCreateCollectionListMutation(); 44 + 45 + const isOwner = session?.info.sub === did; 46 + const lists = listsData?.pages.flatMap((p) => p.records) ?? []; 47 + 48 + return ( 49 + <ProfileLayout did={did}> 50 + <section> 51 + <div className="flex justify-end mb-6"> 52 + {isOwner && ( 53 + <button 54 + type="button" 55 + onClick={() => createListMutation.mutate({ name: "New List" })} 56 + disabled={createListMutation.isPending} 57 + className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-cyan-600/50 text-white font-medium rounded-lg transition-colors" 58 + > 59 + <Plus className="w-4 h-4" /> 60 + New List 61 + </button> 62 + )} 63 + </div> 64 + 65 + {isLoading ? ( 66 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 67 + <p className="text-gray-600 dark:text-gray-400">Loading lists...</p> 68 + </div> 69 + ) : lists.length === 0 ? ( 70 + <div className="text-center py-8 bg-gray-50 dark:bg-slate-800 rounded-lg"> 71 + <p className="text-gray-600 dark:text-gray-400"> 72 + {isOwner ? "No lists yet" : "No lists"} 73 + </p> 74 + </div> 75 + ) : ( 76 + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 77 + {lists.map((record) => { 78 + const rkey = record.uri.split("/").pop(); 79 + if (!rkey) return null; 80 + 81 + return ( 82 + <ListPreview 83 + key={record.uri} 84 + did={did as Did} 85 + rkey={rkey} 86 + list={record.value} 87 + /> 88 + ); 89 + })} 90 + </div> 91 + )} 92 + </section> 93 + </ProfileLayout> 94 + ); 95 + }
+2 -12
typelex/main.tsp
··· 11 11 /** A DeckBelcher user profile. */ 12 12 @rec("literal:self") 13 13 model Main { 14 - /** User's display name. */ 15 - @maxGraphemes(64) 16 - @maxLength(640) 17 - displayName?: string; 18 - 19 - /** Free-form profile description. */ 20 - @maxGraphemes(256) 21 - @maxLength(2560) 22 - description?: string; 23 - 24 - /** Annotations of text in the profile description (mentions, URLs, hashtags, etc). */ 25 - descriptionFacets?: com.deckbelcher.richtext.facet.Main[]; 14 + /** Profile bio/description as a rich text document. */ 15 + bio?: com.deckbelcher.richtext.Document; 26 16 27 17 /** Free-form pronouns text. */ 28 18 @maxGraphemes(20)