this repo has no description
0
fork

Configure Feed

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

feat(profile): support editing user profiles

Made-with: Cursor

+279 -10
+17
assets/styles.css
··· 5559 5559 font-size: clamp(1.15rem, 2vw, 1.45rem); 5560 5560 } 5561 5561 .user-profile-settings { 5562 + position: relative; 5562 5563 display: grid; 5563 5564 grid-template-columns: minmax(0, 1fr) minmax(220px, 280px); 5564 5565 gap: 1.2rem; ··· 5566 5567 margin-bottom: 1rem; 5567 5568 padding: 1.2rem; 5568 5569 border-radius: 1.2rem; 5570 + } 5571 + .user-profile-settings:has(.user-profile-client-form.is-modal-open) { 5572 + z-index: 300; 5573 + } 5574 + .user-profile-client-form.is-modal-open .modal-backdrop { 5575 + z-index: 1000; 5569 5576 } 5570 5577 .user-profile-preview { 5571 5578 display: flex; ··· 5621 5628 .user-profile-client-form { 5622 5629 display: flex; 5623 5630 flex-direction: column; 5631 + gap: 0.75rem; 5632 + } 5633 + .user-profile-bio-input { 5634 + min-height: 6rem; 5635 + resize: vertical; 5636 + } 5637 + .user-profile-save-row { 5638 + display: flex; 5639 + flex-wrap: wrap; 5640 + align-items: center; 5624 5641 gap: 0.75rem; 5625 5642 } 5626 5643 .user-bsky-picker-label {
+7
i18n/messages/en.tsx
··· 939 939 empty: "You haven't reviewed any projects yet.", 940 940 explore: "Explore projects", 941 941 viewProfile: "View public profile", 942 + nameLabel: "Display name", 943 + namePlaceholder: "Your name", 944 + bioLabel: "Bio", 945 + bioPlaceholder: "A short note about you", 942 946 clientLabel: "Bluesky button", 943 947 displayBskyButton: "Display Bluesky button", 944 948 configureBskyClient: "Choose Bluesky client", 945 949 saveClient: "Save", 950 + saving: "Saving…", 951 + saved: "Profile saved.", 952 + saveError: "Couldn't save your profile.", 946 953 cancel: "Cancel", 947 954 done: "Done", 948 955 viewProject: "View project",
+95 -4
islands/UserBskyClientPicker.tsx
··· 2 2 import { BSKY_CLIENTS, getBskyClient } from "../lib/bsky-clients.ts"; 3 3 4 4 interface Props { 5 + displayName: string; 6 + bio: string; 5 7 selectedClientId: string | null; 6 8 visible: boolean; 9 + nameLabel: string; 10 + namePlaceholder: string; 11 + bioLabel: string; 12 + bioPlaceholder: string; 7 13 label: string; 8 14 displayLabel: string; 9 15 settingsLabel: string; 10 16 saveLabel: string; 17 + savingLabel: string; 18 + savedLabel: string; 19 + errorLabel: string; 11 20 cancelLabel: string; 12 21 doneLabel: string; 13 22 } 14 23 15 24 export default function UserBskyClientPicker( 16 25 { 26 + displayName: initialDisplayName, 27 + bio: initialBio, 17 28 selectedClientId, 18 29 visible, 30 + nameLabel, 31 + namePlaceholder, 32 + bioLabel, 33 + bioPlaceholder, 19 34 label, 20 35 displayLabel, 21 36 settingsLabel, 22 37 saveLabel, 38 + savingLabel, 39 + savedLabel, 40 + errorLabel, 23 41 cancelLabel, 24 42 doneLabel, 25 43 }: Props, 26 44 ) { 45 + const displayName = useSignal(initialDisplayName); 46 + const bio = useSignal(initialBio); 27 47 const selected = useSignal(getBskyClient(selectedClientId).id); 28 48 const draftSelected = useSignal(selected.value); 29 49 const buttonVisible = useSignal(visible); 30 50 const modalOpen = useSignal(false); 51 + const submitting = useSignal(false); 52 + const message = useSignal<{ kind: "ok" | "error"; text: string } | null>( 53 + null, 54 + ); 31 55 32 56 const active = getBskyClient(selected.value); 57 + const onSubmit = async (event: Event) => { 58 + event.preventDefault(); 59 + submitting.value = true; 60 + message.value = null; 61 + const form = event.currentTarget as HTMLFormElement; 62 + try { 63 + const response = await fetch(form.action, { 64 + method: "POST", 65 + body: new FormData(form), 66 + }); 67 + if (!response.ok) { 68 + const text = await response.text().catch(() => ""); 69 + throw new Error(text || errorLabel); 70 + } 71 + message.value = { kind: "ok", text: savedLabel }; 72 + } catch (err) { 73 + message.value = { 74 + kind: "error", 75 + text: err instanceof Error ? err.message : errorLabel, 76 + }; 77 + } finally { 78 + submitting.value = false; 79 + } 80 + }; 33 81 34 82 return ( 35 83 <form 36 84 method="POST" 37 85 action="/api/account/profile" 38 - class="user-profile-client-form" 86 + class={`user-profile-client-form ${ 87 + modalOpen.value ? "is-modal-open" : "" 88 + }`} 89 + onSubmit={onSubmit} 39 90 > 91 + <label class="profile-form-field"> 92 + <span class="user-bsky-picker-label">{nameLabel}</span> 93 + <input 94 + type="text" 95 + name="displayName" 96 + value={displayName.value} 97 + maxLength={60} 98 + required 99 + placeholder={namePlaceholder} 100 + class="profile-form-input" 101 + onInput={(event) => 102 + displayName.value = (event.currentTarget as HTMLInputElement).value} 103 + /> 104 + </label> 105 + <label class="profile-form-field"> 106 + <span class="user-bsky-picker-label">{bioLabel}</span> 107 + <textarea 108 + name="bio" 109 + value={bio.value} 110 + maxLength={500} 111 + placeholder={bioPlaceholder} 112 + class="profile-form-input user-profile-bio-input" 113 + onInput={(event) => 114 + bio.value = (event.currentTarget as HTMLTextAreaElement).value} 115 + /> 116 + </label> 40 117 <input type="hidden" name="bskyClientId" value={selected.value} /> 41 118 <input type="hidden" name="bskyButtonVisible" value="0" /> 42 119 <label class="user-bsky-picker-label" id="user-bsky-picker-label"> ··· 172 249 </div> 173 250 </div> 174 251 )} 175 - <button type="submit" class="profile-form-button-primary"> 176 - {saveLabel} 177 - </button> 252 + <div class="user-profile-save-row"> 253 + <button 254 + type="submit" 255 + class="profile-form-button-primary" 256 + disabled={submitting.value} 257 + > 258 + {submitting.value ? savingLabel : saveLabel} 259 + </button> 260 + {message.value && ( 261 + <span 262 + class={`profile-form-status profile-form-status--${message.value.kind}`} 263 + role="status" 264 + > 265 + {message.value.text} 266 + </span> 267 + )} 268 + </div> 178 269 </form> 179 270 ); 180 271 }
+1 -1
lexicons/com/atmosphereaccount/registry/fullPermissions.json
··· 5 5 "main": { 6 6 "type": "permission-set", 7 7 "title": "Atmosphere Account", 8 - "detail": "Manage your Atmosphere profile, reviews, and updates.", 8 + "detail": "Manage your Atmosphere explore profile, reviews, and updates.", 9 9 "permissions": [ 10 10 { 11 11 "type": "permission",
+31
lib/account-types.ts
··· 196 196 }); 197 197 } 198 198 199 + export async function updateAppUserSettings(input: { 200 + did: string; 201 + displayName: string; 202 + bio: string; 203 + bskyClientId: string; 204 + bskyButtonVisible: boolean; 205 + }): Promise<void> { 206 + const client = getBskyClient(input.bskyClientId); 207 + await withDb(async (c) => { 208 + await c.execute({ 209 + sql: ` 210 + UPDATE app_user SET 211 + display_name = ?, 212 + bio = ?, 213 + bsky_client_id = ?, 214 + bsky_button_visible = ?, 215 + updated_at = ? 216 + WHERE did = ? AND account_type = 'user' 217 + `, 218 + args: [ 219 + input.displayName.trim(), 220 + input.bio.trim(), 221 + client.id, 222 + input.bskyButtonVisible ? 1 : 0, 223 + Date.now(), 224 + input.did, 225 + ], 226 + }); 227 + }); 228 + } 229 + 199 230 /** 200 231 * Existing published registry profiles predate account types. Treat those 201 232 * DIDs as projects so old project accounts do not get forced through the
+22
lib/pds.ts
··· 43 43 return await res.json() as PutRecordResult; 44 44 } 45 45 46 + export async function getProfileRecord( 47 + did: string, 48 + pdsUrl: string, 49 + ): Promise<ProfileRecord | null> { 50 + const url = new URL( 51 + `${pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.repo.getRecord`, 52 + ); 53 + url.searchParams.set("repo", did); 54 + url.searchParams.set("collection", PROFILE_NSID); 55 + url.searchParams.set("rkey", "self"); 56 + const res = await authedFetch(did, url.toString()); 57 + if (res.status === 404) return null; 58 + if (!res.ok) { 59 + const text = await res.text(); 60 + throw new Error(`getRecord failed: HTTP ${res.status}: ${text}`); 61 + } 62 + const json = await res.json() as { value?: unknown }; 63 + return json.value && typeof json.value === "object" 64 + ? json.value as ProfileRecord 65 + : null; 66 + } 67 + 46 68 export async function putReviewRecord( 47 69 did: string, 48 70 pdsUrl: string,
+9
routes/account/reviews.tsx
··· 126 126 </div> 127 127 </div> 128 128 <UserBskyClientPicker 129 + displayName={displayName} 130 + bio={profile?.bio ?? ""} 129 131 selectedClientId={profile?.bskyClientId ?? null} 130 132 visible={profile?.bskyButtonVisible ?? true} 133 + nameLabel={copy.nameLabel} 134 + namePlaceholder={copy.namePlaceholder} 135 + bioLabel={copy.bioLabel} 136 + bioPlaceholder={copy.bioPlaceholder} 131 137 label={copy.clientLabel} 132 138 displayLabel={copy.displayBskyButton} 133 139 settingsLabel={copy.configureBskyClient} 134 140 saveLabel={copy.saveClient} 141 + savingLabel={copy.saving} 142 + savedLabel={copy.saved} 143 + errorLabel={copy.saveError} 135 144 cancelLabel={copy.cancel} 136 145 doneLabel={copy.done} 137 146 />
+97 -5
routes/api/account/profile.ts
··· 1 1 /** 2 2 * Update settings for the signed-in user's profile. 3 - * Bluesky-sourced name/bio/avatar are refreshed on sign-in; this endpoint 4 - * only stores local presentation choices such as preferred Bluesky client. 3 + * User accounts can edit their local/public display fields and Bluesky 4 + * button preferences from /account/reviews. 5 5 */ 6 6 import { define } from "../../../utils.ts"; 7 7 import { 8 + getAppUser, 8 9 getEffectiveAccountType, 9 - updateAppUserBskyClient, 10 + updateAppUserSettings, 10 11 } from "../../../lib/account-types.ts"; 11 12 import { BSKY_CLIENT_IDS } from "../../../lib/bsky-clients.ts"; 13 + import { loadSession } from "../../../lib/oauth.ts"; 14 + import { getProfileRecord, putProfileRecord } from "../../../lib/pds.ts"; 15 + import { type ProfileRecord, validateProfile } from "../../../lib/lexicons.ts"; 16 + import { getProfileByDid, upsertProfile } from "../../../lib/registry.ts"; 12 17 13 18 export const handler = define.handlers({ 14 19 async POST(ctx) { ··· 22 27 } 23 28 24 29 const form = await ctx.req.formData().catch(() => null); 30 + const displayName = String(form?.get("displayName") ?? "").trim(); 31 + const bio = String(form?.get("bio") ?? "").trim(); 25 32 const rawClient = form?.get("bskyClientId"); 26 - const visible = form?.getAll("bskyButtonVisible").includes("1"); 33 + const visible = form?.getAll("bskyButtonVisible").includes("1") ?? false; 34 + if (!displayName || displayName.length > 60) { 35 + return new Response("display name must be 1-60 characters", { 36 + status: 400, 37 + }); 38 + } 39 + if (bio.length > 500) { 40 + return new Response("bio must be 500 characters or fewer", { 41 + status: 400, 42 + }); 43 + } 27 44 if ( 28 45 typeof rawClient !== "string" || 29 46 !BSKY_CLIENT_IDS.includes(rawClient as typeof BSKY_CLIENT_IDS[number]) ··· 31 48 return new Response("invalid Bluesky client", { status: 400 }); 32 49 } 33 50 34 - await updateAppUserBskyClient(user.did, rawClient, visible); 51 + const [session, appUser, existingProfile] = await Promise.all([ 52 + loadSession(user.did), 53 + getAppUser(user.did), 54 + getProfileByDid(user.did, { profileType: "user" }).catch(() => null), 55 + ]); 56 + if (!session || !appUser) { 57 + return new Response("session not found", { status: 401 }); 58 + } 59 + 60 + const existingRecord = await getProfileRecord(user.did, session.pdsUrl) 61 + .catch((err) => { 62 + const message = err instanceof Error ? err.message : String(err); 63 + return new Response(`getRecord failed: ${message}`, { status: 502 }); 64 + }); 65 + if (existingRecord instanceof Response) return existingRecord; 66 + const createdAt = existingRecord?.createdAt ?? 67 + (existingProfile 68 + ? new Date(existingProfile.createdAt).toISOString() 69 + : new Date().toISOString()); 70 + const draft: ProfileRecord = { 71 + profileType: "user", 72 + name: displayName, 73 + description: bio, 74 + avatar: existingRecord?.avatar, 75 + createdAt, 76 + }; 77 + const validation = validateProfile(draft); 78 + if (!validation.ok || !validation.value) { 79 + return new Response(`invalid profile: ${validation.error}`, { 80 + status: 400, 81 + }); 82 + } 83 + 84 + const put = await putProfileRecord( 85 + user.did, 86 + session.pdsUrl, 87 + validation.value, 88 + ) 89 + .catch((err) => { 90 + const message = err instanceof Error ? err.message : String(err); 91 + return new Response(`putRecord failed: ${message}`, { status: 502 }); 92 + }); 93 + if (put instanceof Response) return put; 94 + 95 + await Promise.all([ 96 + updateAppUserSettings({ 97 + did: user.did, 98 + displayName, 99 + bio, 100 + bskyClientId: rawClient, 101 + bskyButtonVisible: visible, 102 + }), 103 + upsertProfile({ 104 + did: user.did, 105 + handle: user.handle, 106 + profileType: validation.value.profileType, 107 + name: validation.value.name, 108 + description: validation.value.description, 109 + mainLink: null, 110 + iosLink: null, 111 + androidLink: null, 112 + categories: [], 113 + subcategories: [], 114 + links: [], 115 + screenshots: [], 116 + avatarCid: validation.value.avatar?.ref.$link ?? null, 117 + avatarMime: validation.value.avatar?.mimeType ?? null, 118 + iconCid: null, 119 + iconMime: null, 120 + pdsUrl: session.pdsUrl, 121 + recordCid: put.cid, 122 + recordRev: put.commit?.rev ?? put.cid, 123 + createdAt: Date.parse(validation.value.createdAt) || Date.now(), 124 + }), 125 + ]); 126 + 35 127 return new Response(null, { 36 128 status: 303, 37 129 headers: { location: "/account/reviews" },