this repo has no description
10
fork

Configure Feed

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

feat(account): add user profiles for reviewers

Split signed-in accounts into user and project profile paths so reviewers can manage their reviews without creating project listings, and keep non-home pages free of sky effects for better performance.

Made-with: Cursor

+705 -99
+157
assets/styles.css
··· 3525 3525 } 3526 3526 3527 3527 /* Hide the sky-effects toggle anywhere we explicitly disable effects (e.g. /explore). */ 3528 + body.explore-no-effects { 3529 + --nav-effects-strip: 0; 3530 + } 3528 3531 body.explore-no-effects #nav-effects-bar { 3529 3532 display: none !important; 3533 + } 3534 + body.explore-no-effects .reveal { 3535 + opacity: 1; 3536 + transform: none; 3537 + transition: none; 3530 3538 } 3531 3539 3532 3540 /* ---- Sign-in handle preview dropdown ---- */ ··· 5051 5059 gap: 1rem; 5052 5060 align-items: flex-start; 5053 5061 } 5062 + .profile-review-author-row { 5063 + display: flex; 5064 + gap: 0.65rem; 5065 + align-items: flex-start; 5066 + color: inherit; 5067 + text-decoration: none; 5068 + } 5069 + .profile-review-avatar { 5070 + display: inline-flex; 5071 + flex: 0 0 auto; 5072 + align-items: center; 5073 + justify-content: center; 5074 + width: 2.35rem; 5075 + height: 2.35rem; 5076 + overflow: hidden; 5077 + border-radius: 999px; 5078 + background: rgba(18, 26, 47, 0.1); 5079 + color: rgba(18, 26, 47, 0.68); 5080 + font-size: 0.9rem; 5081 + font-weight: 850; 5082 + } 5083 + .profile-review-avatar img { 5084 + width: 100%; 5085 + height: 100%; 5086 + object-fit: cover; 5087 + } 5054 5088 .profile-review-author, 5055 5089 .profile-review-date, 5056 5090 .profile-review-body, ··· 5133 5167 .dark-phase .profile-review-stars span { 5134 5168 color: rgba(255, 255, 255, 0.25); 5135 5169 } 5170 + .dark-phase .profile-review-avatar { 5171 + background: rgba(255, 255, 255, 0.12); 5172 + color: rgba(255, 255, 255, 0.72); 5173 + } 5136 5174 .dark-phase .profile-review-body-field textarea, 5137 5175 .dark-phase .profile-review-response-composer textarea { 5138 5176 background: rgba(255, 255, 255, 0.06); ··· 5213 5251 justify-content: space-between; 5214 5252 padding: 1.2rem; 5215 5253 } 5254 + .user-profile-settings { 5255 + display: grid; 5256 + grid-template-columns: minmax(0, 1fr) minmax(220px, 280px); 5257 + gap: 1.2rem; 5258 + align-items: start; 5259 + margin-bottom: 1rem; 5260 + padding: 1.2rem; 5261 + border-radius: 1.2rem; 5262 + } 5263 + .user-profile-preview { 5264 + display: flex; 5265 + gap: 1rem; 5266 + align-items: flex-start; 5267 + } 5268 + .user-profile-avatar, 5269 + .user-public-avatar { 5270 + display: inline-flex; 5271 + flex: 0 0 auto; 5272 + align-items: center; 5273 + justify-content: center; 5274 + overflow: hidden; 5275 + border-radius: 999px; 5276 + background: rgba(18, 26, 47, 0.1); 5277 + color: rgba(18, 26, 47, 0.7); 5278 + font-weight: 850; 5279 + } 5280 + .user-profile-avatar { 5281 + width: 4rem; 5282 + height: 4rem; 5283 + font-size: 1.3rem; 5284 + } 5285 + .user-public-avatar { 5286 + width: 6.5rem; 5287 + height: 6.5rem; 5288 + font-size: 2rem; 5289 + } 5290 + .user-profile-avatar img, 5291 + .user-public-avatar img { 5292 + width: 100%; 5293 + height: 100%; 5294 + object-fit: cover; 5295 + } 5296 + .user-profile-preview h2 { 5297 + margin: 0; 5298 + font-size: 1.15rem; 5299 + } 5300 + .user-profile-handle { 5301 + margin: 0.15rem 0 0.6rem; 5302 + color: rgba(18, 26, 47, 0.58); 5303 + } 5304 + .user-profile-bio { 5305 + margin-top: 0.7rem; 5306 + white-space: pre-wrap; 5307 + } 5308 + .user-profile-client-form { 5309 + display: flex; 5310 + flex-direction: column; 5311 + gap: 0.75rem; 5312 + } 5313 + .user-profile-client-form label span { 5314 + display: block; 5315 + margin-bottom: 0.35rem; 5316 + font-size: 0.82rem; 5317 + font-weight: 750; 5318 + color: rgba(18, 26, 47, 0.7); 5319 + } 5320 + .user-profile-client-form select { 5321 + width: 100%; 5322 + border: 1px solid rgba(18, 26, 47, 0.14); 5323 + border-radius: 0.8rem; 5324 + padding: 0.7rem 0.8rem; 5325 + background: rgba(255, 255, 255, 0.92); 5326 + color: inherit; 5327 + font: inherit; 5328 + } 5216 5329 .user-review-list { 5217 5330 display: flex; 5218 5331 flex-direction: column; ··· 5264 5377 border-color: rgba(160, 180, 255, 0.45); 5265 5378 } 5266 5379 .dark-phase .account-type-option span, 5380 + .dark-phase .user-profile-handle, 5381 + .dark-phase .user-profile-client-form label span, 5267 5382 .dark-phase .user-review-row-actions { 5268 5383 color: rgba(255, 255, 255, 0.6); 5269 5384 } 5385 + .dark-phase .user-profile-avatar, 5386 + .dark-phase .user-public-avatar { 5387 + background: rgba(255, 255, 255, 0.12); 5388 + color: rgba(255, 255, 255, 0.75); 5389 + } 5390 + .dark-phase .user-profile-client-form select { 5391 + background: rgba(255, 255, 255, 0.06); 5392 + border-color: rgba(255, 255, 255, 0.12); 5393 + } 5270 5394 @media (max-width: 640px) { 5271 5395 .account-type-options { 5272 5396 grid-template-columns: 1fr; 5273 5397 } 5274 5398 .account-reviews-empty { 5275 5399 align-items: flex-start; 5400 + flex-direction: column; 5401 + } 5402 + .user-profile-settings { 5403 + grid-template-columns: 1fr; 5404 + } 5405 + } 5406 + 5407 + .user-public-section { 5408 + min-height: 70vh; 5409 + padding: 8rem 0 4rem; 5410 + } 5411 + .user-public-card { 5412 + display: flex; 5413 + gap: 1.4rem; 5414 + align-items: flex-start; 5415 + margin-top: 1rem; 5416 + padding: 1.5rem; 5417 + border-radius: 1.4rem; 5418 + } 5419 + .user-public-body { 5420 + min-width: 0; 5421 + } 5422 + .user-public-client-link { 5423 + display: inline-flex; 5424 + margin-top: 1rem; 5425 + } 5426 + .user-public-client-link img { 5427 + width: 22px; 5428 + height: 22px; 5429 + border-radius: 0.4rem; 5430 + } 5431 + @media (max-width: 640px) { 5432 + .user-public-card { 5276 5433 flex-direction: column; 5277 5434 } 5278 5435 }
+19 -16
components/Nav.tsx
··· 22 22 * marketing nav) can omit it. */ 23 23 rememberedAccounts?: { did: string; handle: string }[]; 24 24 }; 25 + showEffects?: boolean; 25 26 } 26 27 27 - export default function Nav({ account }: NavProps = {}) { 28 + export default function Nav({ account, showEffects = false }: NavProps = {}) { 28 29 const t = useT(); 29 30 return ( 30 31 <> ··· 55 56 /> 56 57 </div> 57 58 )} 58 - <div class="nav-effects-bar" id="nav-effects-bar"> 59 - <label class="nav-sky-switch-label"> 60 - <span class="nav-sky-switch-text">{t.nav.effects}</span> 61 - <span class="nav-sky-switch"> 62 - <input 63 - type="checkbox" 64 - id="sky-effects-toggle" 65 - class="nav-sky-switch-input" 66 - defaultChecked 67 - aria-label={t.nav.effectsOn} 68 - /> 69 - <span class="nav-sky-switch-track" aria-hidden="true" /> 70 - </span> 71 - </label> 72 - </div> 59 + {showEffects && ( 60 + <div class="nav-effects-bar" id="nav-effects-bar"> 61 + <label class="nav-sky-switch-label"> 62 + <span class="nav-sky-switch-text">{t.nav.effects}</span> 63 + <span class="nav-sky-switch"> 64 + <input 65 + type="checkbox" 66 + id="sky-effects-toggle" 67 + class="nav-sky-switch-input" 68 + defaultChecked 69 + aria-label={t.nav.effectsOn} 70 + /> 71 + <span class="nav-sky-switch-track" aria-hidden="true" /> 72 + </span> 73 + </label> 74 + </div> 75 + )} 73 76 </> 74 77 ); 75 78 }
+39 -15
components/explore/ProfileReviewList.tsx
··· 6 6 export interface DisplayReview extends ReviewRow { 7 7 reviewerName: string | null; 8 8 reviewerHandle: string | null; 9 + reviewerAvatarUrl: string | null; 10 + reviewerProfileHref: string | null; 9 11 } 10 12 11 13 interface Props { ··· 64 66 {reviews.map((review) => ( 65 67 <article class="profile-review-card glass" key={review.id}> 66 68 <header class="profile-review-header"> 67 - <div> 68 - <p class="profile-review-author"> 69 - {review.reviewerName ?? review.reviewerHandle ?? 70 - copy.reviewerFallback} 71 - </p> 72 - {review.reviewerHandle && ( 73 - <p class="profile-review-handle"> 74 - @{review.reviewerHandle} 69 + <a 70 + class="profile-review-author-row" 71 + href={review.reviewerProfileHref ?? undefined} 72 + > 73 + <span class="profile-review-avatar" aria-hidden="true"> 74 + {review.reviewerAvatarUrl 75 + ? ( 76 + <img 77 + src={review.reviewerAvatarUrl} 78 + alt="" 79 + loading="lazy" 80 + decoding="async" 81 + /> 82 + ) 83 + : ( 84 + <span> 85 + {(review.reviewerName ?? review.reviewerHandle ?? 86 + copy.reviewerFallback).slice(0, 1).toUpperCase()} 87 + </span> 88 + )} 89 + </span> 90 + <div> 91 + <p class="profile-review-author"> 92 + {review.reviewerName ?? review.reviewerHandle ?? 93 + copy.reviewerFallback} 75 94 </p> 76 - )} 77 - <p class="profile-review-date"> 78 - {new Date(review.createdAt).toISOString().slice(0, 10)} 79 - {review.updatedAt > review.createdAt && ( 80 - <span>· {copy.edited}</span> 95 + {review.reviewerHandle && ( 96 + <p class="profile-review-handle"> 97 + @{review.reviewerHandle} 98 + </p> 81 99 )} 82 - </p> 83 - </div> 100 + <p class="profile-review-date"> 101 + {new Date(review.createdAt).toISOString().slice(0, 10)} 102 + {review.updatedAt > review.createdAt && ( 103 + <span>· {copy.edited}</span> 104 + )} 105 + </p> 106 + </div> 107 + </a> 84 108 <p 85 109 class="profile-review-stars" 86 110 aria-label={`${review.rating} stars`}
+11 -1
i18n/messages/en.tsx
··· 891 891 `You're signed in as @${handle}. Choose whether this account represents you as a person or a project you want listed in Explore.`, 892 892 userTitle: "I'm a user", 893 893 userBody: 894 - "Use your Bluesky profile for identity, write reviews, and manage your reviews. No registry profile is created.", 894 + "Create a user profile from Bluesky, write reviews, and manage your reviews.", 895 895 projectTitle: "I'm a project", 896 896 projectBody: 897 897 "Create and manage a public project profile in Explore with app links, screenshots, and developer details.", ··· 904 904 `Signed in as @${handle}. Your user profile comes from Bluesky; reviews are managed here.`, 905 905 empty: "You haven't reviewed any projects yet.", 906 906 explore: "Explore projects", 907 + viewProfile: "View public profile", 908 + clientLabel: "Open Bluesky links with", 909 + saveClient: "Save", 907 910 viewProject: "View project", 908 911 delete: "Delete review", 909 912 deleting: "Deleting…", 910 913 deleted: "Review deleted.", 911 914 error: "Couldn't update the review", 915 + }, 916 + 917 + userProfile: { 918 + backToExplore: "Back to Explore", 919 + notFoundTitle: "User not found", 920 + notFoundBody: "That user profile is not available.", 921 + openIn: (clientName: string): string => `Open in ${clientName}`, 912 922 }, 913 923 914 924 reviews: {
+2 -1
islands/SignInForm.tsx
··· 26 26 27 27 type PreviewResponse = PreviewSuccess | PreviewMiss; 28 28 29 - export default function SignInForm({ returnTo: _returnTo }: Props) { 29 + export default function SignInForm({ returnTo }: Props) { 30 30 const t = useT(); 31 31 const handle = useSignal(""); 32 32 const submitting = useSignal(false); ··· 123 123 onSubmit={onSubmit} 124 124 class="signin-form" 125 125 > 126 + {returnTo && <input type="hidden" name="next" value={returnTo} />} 126 127 <div class="signin-form-preview-wrap" ref={wrapRef}> 127 128 <label class="signin-form-label" for="signin-handle"> 128 129 {t.explore.create.signInLabel}
+78 -4
lib/account-types.ts
··· 4 4 * profile). The choice is local to this AppView. 5 5 */ 6 6 import { withDb } from "./db.ts"; 7 + import { DEFAULT_BSKY_CLIENT_ID, getBskyClient } from "./bsky-clients.ts"; 7 8 import { getProfileByDid } from "./registry.ts"; 8 9 9 10 export type AccountType = "user" | "project"; ··· 12 13 did: string; 13 14 handle: string; 14 15 displayName: string | null; 16 + bio: string | null; 17 + avatarCid: string | null; 18 + avatarMime: string | null; 19 + bskyClientId: string; 15 20 accountType: AccountType; 16 21 createdAt: number; 17 22 updatedAt: number; ··· 21 26 did: string; 22 27 handle: string; 23 28 display_name: string | null; 29 + bio: string | null; 30 + avatar_cid: string | null; 31 + avatar_mime: string | null; 32 + bsky_client_id: string | null; 24 33 account_type: string; 25 34 created_at: number; 26 35 updated_at: number; ··· 35 44 did: row.did, 36 45 handle: row.handle, 37 46 displayName: row.display_name, 47 + bio: row.bio, 48 + avatarCid: row.avatar_cid, 49 + avatarMime: row.avatar_mime, 50 + bskyClientId: getBskyClient(row.bsky_client_id).id, 38 51 accountType: normalizeAccountType(row.account_type), 39 52 createdAt: Number(row.created_at), 40 53 updatedAt: Number(row.updated_at), ··· 45 58 return await withDb(async (c) => { 46 59 const r = await c.execute({ 47 60 sql: ` 48 - SELECT did, handle, display_name, account_type, created_at, updated_at 61 + SELECT did, handle, display_name, avatar_cid, avatar_mime, 62 + bio, bsky_client_id, account_type, created_at, updated_at 49 63 FROM app_user 50 64 WHERE did = ? 51 65 LIMIT 1 ··· 57 71 }); 58 72 } 59 73 74 + export async function getAppUserByHandle( 75 + handle: string, 76 + ): Promise<AppUserRow | null> { 77 + return await withDb(async (c) => { 78 + const r = await c.execute({ 79 + sql: ` 80 + SELECT did, handle, display_name, avatar_cid, avatar_mime, 81 + bio, bsky_client_id, account_type, created_at, updated_at 82 + FROM app_user 83 + WHERE lower(handle) = lower(?) 84 + LIMIT 1 85 + `, 86 + args: [handle], 87 + }); 88 + const row = r.rows[0] as unknown as RawAppUserRow | undefined; 89 + return row ? rowToAppUser(row) : null; 90 + }); 91 + } 92 + 60 93 export async function setAppUserType(input: { 61 94 did: string; 62 95 handle: string; 63 96 displayName?: string | null; 97 + bio?: string | null; 98 + avatarCid?: string | null; 99 + avatarMime?: string | null; 100 + bskyClientId?: string | null; 64 101 accountType: AccountType; 65 102 }): Promise<AppUserRow> { 66 103 return await withDb(async (c) => { ··· 68 105 await c.execute({ 69 106 sql: ` 70 107 INSERT INTO app_user ( 71 - did, handle, display_name, account_type, created_at, updated_at 72 - ) VALUES (?, ?, ?, ?, ?, ?) 108 + did, handle, display_name, bio, avatar_cid, avatar_mime, 109 + bsky_client_id, 110 + account_type, created_at, updated_at 111 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 73 112 ON CONFLICT(did) DO UPDATE SET 74 113 handle = excluded.handle, 75 114 display_name = COALESCE(excluded.display_name, app_user.display_name), 115 + bio = COALESCE(excluded.bio, app_user.bio), 116 + avatar_cid = COALESCE(excluded.avatar_cid, app_user.avatar_cid), 117 + avatar_mime = COALESCE(excluded.avatar_mime, app_user.avatar_mime), 118 + bsky_client_id = excluded.bsky_client_id, 76 119 account_type = excluded.account_type, 77 120 updated_at = excluded.updated_at 78 121 `, ··· 80 123 input.did, 81 124 input.handle, 82 125 input.displayName ?? null, 126 + input.bio ?? null, 127 + input.avatarCid ?? null, 128 + input.avatarMime ?? null, 129 + getBskyClient(input.bskyClientId ?? DEFAULT_BSKY_CLIENT_ID).id, 83 130 input.accountType, 84 131 now, 85 132 now, ··· 95 142 did: string; 96 143 handle: string; 97 144 displayName?: string | null; 145 + bio?: string | null; 146 + avatarCid?: string | null; 147 + avatarMime?: string | null; 98 148 }): Promise<void> { 99 149 await withDb(async (c) => { 100 150 await c.execute({ 101 151 sql: ` 102 152 UPDATE app_user SET 103 153 handle = ?, 104 - display_name = ?, 154 + display_name = COALESCE(?, display_name), 155 + bio = COALESCE(?, bio), 156 + avatar_cid = COALESCE(?, avatar_cid), 157 + avatar_mime = COALESCE(?, avatar_mime), 105 158 updated_at = ? 106 159 WHERE did = ? 107 160 `, 108 161 args: [ 109 162 input.handle, 110 163 input.displayName?.trim() || null, 164 + input.bio?.trim() || null, 165 + input.avatarCid ?? null, 166 + input.avatarMime ?? null, 111 167 Date.now(), 112 168 input.did, 113 169 ], 170 + }); 171 + }); 172 + } 173 + 174 + export async function updateAppUserBskyClient( 175 + did: string, 176 + bskyClientId: string, 177 + ): Promise<void> { 178 + const client = getBskyClient(bskyClientId); 179 + await withDb(async (c) => { 180 + await c.execute({ 181 + sql: ` 182 + UPDATE app_user SET 183 + bsky_client_id = ?, 184 + updated_at = ? 185 + WHERE did = ? AND account_type = 'user' 186 + `, 187 + args: [client.id, Date.now(), did], 114 188 }); 115 189 }); 116 190 }
+26
lib/db.ts
··· 184 184 did TEXT PRIMARY KEY, 185 185 handle TEXT NOT NULL, 186 186 display_name TEXT, 187 + bio TEXT, 188 + avatar_cid TEXT, 189 + avatar_mime TEXT, 190 + bsky_client_id TEXT NOT NULL DEFAULT 'bluesky', 187 191 account_type TEXT NOT NULL, 188 192 created_at INTEGER NOT NULL, 189 193 updated_at INTEGER NOT NULL 190 194 )`, 195 + `CREATE INDEX IF NOT EXISTS app_user_handle ON app_user(handle)`, 191 196 `CREATE INDEX IF NOT EXISTS app_user_account_type ON app_user(account_type)`, 192 197 /** 193 198 * User reports against profiles. Anonymous reports carry a hashed IP ··· 416 421 table: "app_user", 417 422 column: "display_name", 418 423 ddl: "ALTER TABLE app_user ADD COLUMN display_name TEXT", 424 + }, 425 + { 426 + table: "app_user", 427 + column: "bio", 428 + ddl: "ALTER TABLE app_user ADD COLUMN bio TEXT", 429 + }, 430 + { 431 + table: "app_user", 432 + column: "avatar_cid", 433 + ddl: "ALTER TABLE app_user ADD COLUMN avatar_cid TEXT", 434 + }, 435 + { 436 + table: "app_user", 437 + column: "avatar_mime", 438 + ddl: "ALTER TABLE app_user ADD COLUMN avatar_mime TEXT", 439 + }, 440 + { 441 + table: "app_user", 442 + column: "bsky_client_id", 443 + ddl: 444 + "ALTER TABLE app_user ADD COLUMN bsky_client_id TEXT NOT NULL DEFAULT 'bluesky'", 419 445 }, 420 446 ]; 421 447 for (const m of additiveColumns) {
+10 -1
lib/oauth.ts
··· 97 97 did: string; 98 98 handle: string; 99 99 pdsUrl: string; 100 + returnTo?: string; 100 101 asNonce?: string; 101 102 } 102 103 ··· 236 237 237 238 export async function startLogin( 238 239 handleOrDid: string, 240 + returnTo?: string | null, 239 241 ): Promise<{ redirectUrl: string }> { 240 242 ensureConfigured(); 241 243 const id = await resolveIdentity(handleOrDid); ··· 254 256 did: id.did, 255 257 handle: id.handle, 256 258 pdsUrl: id.pdsUrl, 259 + returnTo: returnTo ?? undefined, 257 260 }; 258 261 await saveFlowState(flow); 259 262 ··· 332 335 did: string; 333 336 handle: string; 334 337 pdsUrl: string; 338 + returnTo?: string; 335 339 } 336 340 337 341 export async function completeCallback(params: { ··· 374 378 await saveSession(session); 375 379 await deleteFlowState(params.state); 376 380 377 - return { did: session.did, handle: session.handle, pdsUrl: session.pdsUrl }; 381 + return { 382 + did: session.did, 383 + handle: session.handle, 384 + pdsUrl: session.pdsUrl, 385 + returnTo: flow.returnTo, 386 + }; 378 387 } 379 388 380 389 interface TokenResponse {
+32 -36
routes/_app.tsx
··· 251 251 const { Component, state, url } = ctx; 252 252 const locale = state.locale; 253 253 const t = getMessages(locale); 254 - /** 255 - * The dynamic sky / sun / cloud parallax is intentionally disabled on 256 - * content-dense pages — it competes with reading. We force-apply 257 - * `sky-static` server-side and hide the user-facing toggle on: 258 - * - /explore and any sub-route 259 - * - /developer-resources (dense reference material) 260 - * - /admin and any sub-route (operator surface; effects add nothing) 261 - */ 262 - const effectsOff = url.pathname === "/explore" || 263 - url.pathname.startsWith("/explore/") || 264 - url.pathname === "/developer-resources" || 265 - url.pathname === "/admin" || 266 - url.pathname.startsWith("/admin/"); 267 - const htmlClass = effectsOff ? "sky-static" : undefined; 268 - const bodyClass = effectsOff ? "sky-bg explore-no-effects" : "sky-bg"; 254 + const effectsOn = url.pathname === "/"; 255 + const htmlClass = effectsOn ? undefined : "sky-static"; 256 + const bodyClass = effectsOn ? "sky-bg" : "sky-bg explore-no-effects"; 269 257 return ( 270 258 <html lang={locale} class={htmlClass}> 271 259 <head> ··· 287 275 <link rel="icon" href="/favicon.ico" sizes="any" /> 288 276 <link rel="icon" type="image/svg+xml" href="/union.svg" /> 289 277 <link rel="apple-touch-icon" href="/union.svg" /> 290 - <script 291 - dangerouslySetInnerHTML={{ 292 - __html: 293 - "(function(){try{var m=window.matchMedia&&window.matchMedia('(max-width: 768px)');if(m&&m.matches)document.documentElement.classList.add('sky-static');else if(localStorage.getItem('atmosphere-sky-effects')==='0')document.documentElement.classList.add('sky-static');}catch(e){}})();", 294 - }} 295 - /> 296 - <script 297 - src="https://unpkg.com/@lottiefiles/lottie-player@2.0.8/dist/lottie-player.js" 298 - defer 299 - /> 278 + {effectsOn && ( 279 + <script 280 + dangerouslySetInnerHTML={{ 281 + __html: 282 + "(function(){try{var m=window.matchMedia&&window.matchMedia('(max-width: 768px)');if(m&&m.matches)document.documentElement.classList.add('sky-static');else if(localStorage.getItem('atmosphere-sky-effects')==='0')document.documentElement.classList.add('sky-static');}catch(e){}})();", 283 + }} 284 + /> 285 + )} 286 + {effectsOn && ( 287 + <script 288 + src="https://unpkg.com/@lottiefiles/lottie-player@2.0.8/dist/lottie-player.js" 289 + defer 290 + /> 291 + )} 300 292 </head> 301 293 <body class={bodyClass}> 302 294 <I18nProvider locale={locale}> 303 295 <Component /> 304 296 </I18nProvider> 305 - <script 306 - dangerouslySetInnerHTML={{ 307 - __html: `window.__ATMOSPHERE_I18N__=${ 308 - JSON.stringify({ 309 - effectsOn: t.nav.effectsOn, 310 - effectsOff: t.nav.effectsOff, 311 - }) 312 - };`, 313 - }} 314 - /> 315 - <script dangerouslySetInnerHTML={{ __html: inlineScript }} /> 297 + {effectsOn && ( 298 + <> 299 + <script 300 + dangerouslySetInnerHTML={{ 301 + __html: `window.__ATMOSPHERE_I18N__=${ 302 + JSON.stringify({ 303 + effectsOn: t.nav.effectsOn, 304 + effectsOff: t.nav.effectsOff, 305 + }) 306 + };`, 307 + }} 308 + /> 309 + <script dangerouslySetInnerHTML={{ __html: inlineScript }} /> 310 + </> 311 + )} 316 312 </body> 317 313 </html> 318 314 );
+70 -3
routes/account/reviews.tsx
··· 5 5 import UserReviewRow from "../../islands/UserReviewRow.tsx"; 6 6 import { getMessages } from "../../i18n/mod.ts"; 7 7 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 8 - import { getEffectiveAccountType } from "../../lib/account-types.ts"; 8 + import { 9 + getAppUser, 10 + getEffectiveAccountType, 11 + } from "../../lib/account-types.ts"; 12 + import { BSKY_CLIENTS } from "../../lib/bsky-clients.ts"; 9 13 import { getProfileByDid } from "../../lib/registry.ts"; 10 14 import { listReviewsByReviewer, type ReviewRow } from "../../lib/reviews.ts"; 15 + 16 + function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 17 + const ext = mime === "image/png" 18 + ? "png" 19 + : mime === "image/webp" 20 + ? "webp" 21 + : "jpeg"; 22 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}`; 23 + } 11 24 12 25 interface ReviewWithTarget extends ReviewRow { 13 26 targetHandle: string; ··· 33 46 }); 34 47 } 35 48 36 - const reviews = await listReviewsByReviewer(user.did).catch(() => []); 49 + const [appUser, reviews] = await Promise.all([ 50 + getAppUser(user.did).catch(() => null), 51 + listReviewsByReviewer(user.did).catch(() => []), 52 + ]); 37 53 const enriched: ReviewWithTarget[] = await Promise.all( 38 54 reviews.map(async (review) => { 39 55 const target = await getProfileByDid(review.targetDid, { ··· 51 67 <AccountReviewsPage 52 68 account={buildAccountMenuProps(ctx.state)} 53 69 handle={user.handle} 70 + profile={appUser} 54 71 reviews={enriched} 55 72 t={getMessages(ctx.state.locale)} 56 73 />, ··· 61 78 interface AccountReviewsPageProps { 62 79 account: ReturnType<typeof buildAccountMenuProps>; 63 80 handle: string; 81 + profile: Awaited<ReturnType<typeof getAppUser>>; 64 82 reviews: ReviewWithTarget[]; 65 83 // deno-lint-ignore no-explicit-any 66 84 t: any; 67 85 } 68 86 69 87 function AccountReviewsPage( 70 - { account, handle, reviews, t }: AccountReviewsPageProps, 88 + { account, handle, profile, reviews, t }: AccountReviewsPageProps, 71 89 ) { 72 90 const copy = t.accountReviews; 91 + const avatarUrl = profile?.avatarCid && profile.avatarMime 92 + ? bskyCdnAvatarUrl(profile.did, profile.avatarCid, profile.avatarMime) 93 + : null; 94 + const displayName = profile?.displayName || handle; 73 95 return ( 74 96 <div id="page-top"> 75 97 <GlassClouds /> ··· 82 104 <h1 class="text-section">{copy.headline}</h1> 83 105 <p class="text-body mt-2">{copy.subhead(handle)}</p> 84 106 </header> 107 + 108 + <section class="glass user-profile-settings"> 109 + <div class="user-profile-preview"> 110 + <div class="user-profile-avatar"> 111 + {avatarUrl 112 + ? <img src={avatarUrl} alt="" loading="lazy" /> 113 + : <span>{displayName.slice(0, 1).toUpperCase()}</span>} 114 + </div> 115 + <div> 116 + <h2>{displayName}</h2> 117 + <p class="user-profile-handle">@{handle}</p> 118 + {profile?.bio && <p class="user-profile-bio">{profile.bio} 119 + </p>} 120 + <a 121 + class="profile-form-button-secondary" 122 + href={`/users/${encodeURIComponent(handle)}`} 123 + > 124 + {copy.viewProfile} 125 + </a> 126 + </div> 127 + </div> 128 + <form 129 + method="POST" 130 + action="/api/account/profile" 131 + class="user-profile-client-form" 132 + > 133 + <label> 134 + <span>{copy.clientLabel}</span> 135 + <select name="bskyClientId"> 136 + {BSKY_CLIENTS.map((client) => ( 137 + <option 138 + key={client.id} 139 + value={client.id} 140 + selected={profile?.bskyClientId === client.id} 141 + > 142 + {client.name} 143 + </option> 144 + ))} 145 + </select> 146 + </label> 147 + <button type="submit" class="profile-form-button-primary"> 148 + {copy.saveClient} 149 + </button> 150 + </form> 151 + </section> 85 152 86 153 {reviews.length === 0 87 154 ? (
+12 -3
routes/account/type.tsx
··· 20 20 }); 21 21 } 22 22 23 + const rawNext = ctx.url.searchParams.get("next"); 24 + const next = rawNext && rawNext.startsWith("/") && !rawNext.startsWith("//") 25 + ? rawNext 26 + : null; 27 + 23 28 const existingType = await getEffectiveAccountType(user.did).catch(() => 24 29 null 25 30 ); 26 31 if (existingType === "project") { 27 32 return new Response(null, { 28 33 status: 303, 29 - headers: { location: "/explore/manage" }, 34 + headers: { location: next ?? "/explore/manage" }, 30 35 }); 31 36 } 32 37 if (existingType === "user") { 33 38 return new Response(null, { 34 39 status: 303, 35 - headers: { location: "/account/reviews" }, 40 + headers: { location: next ?? "/account/reviews" }, 36 41 }); 37 42 } 38 43 ··· 40 45 <AccountTypePage 41 46 account={buildAccountMenuProps(ctx.state)} 42 47 handle={user.handle} 48 + next={next} 43 49 t={getMessages(ctx.state.locale)} 44 50 />, 45 51 ); ··· 49 55 interface AccountTypePageProps { 50 56 account: ReturnType<typeof buildAccountMenuProps>; 51 57 handle: string; 58 + next: string | null; 52 59 // deno-lint-ignore no-explicit-any 53 60 t: any; 54 61 } 55 62 56 - function AccountTypePage({ account, handle, t }: AccountTypePageProps) { 63 + function AccountTypePage({ account, handle, next, t }: AccountTypePageProps) { 57 64 const copy = t.accountType; 58 65 return ( 59 66 <div id="page-top"> ··· 70 77 <div class="account-type-options"> 71 78 <form method="POST" action="/api/account/type"> 72 79 <input type="hidden" name="accountType" value="user" /> 80 + {next && <input type="hidden" name="next" value={next} />} 73 81 <button type="submit" class="account-type-option"> 74 82 <strong>{copy.userTitle}</strong> 75 83 <span>{copy.userBody}</span> ··· 77 85 </form> 78 86 <form method="POST" action="/api/account/type"> 79 87 <input type="hidden" name="accountType" value="project" /> 88 + {next && <input type="hidden" name="next" value={next} />} 80 89 <button type="submit" class="account-type-option"> 81 90 <strong>{copy.projectTitle}</strong> 82 91 <span>{copy.projectBody}</span>
+39
routes/api/account/profile.ts
··· 1 + /** 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. 5 + */ 6 + import { define } from "../../../utils.ts"; 7 + import { 8 + getEffectiveAccountType, 9 + updateAppUserBskyClient, 10 + } from "../../../lib/account-types.ts"; 11 + import { BSKY_CLIENT_IDS } from "../../../lib/bsky-clients.ts"; 12 + 13 + export const handler = define.handlers({ 14 + async POST(ctx) { 15 + const user = ctx.state.user; 16 + if (!user) return new Response("not authenticated", { status: 401 }); 17 + const accountType = await getEffectiveAccountType(user.did).catch(() => 18 + null 19 + ); 20 + if (accountType !== "user") { 21 + return new Response("user account required", { status: 403 }); 22 + } 23 + 24 + const form = await ctx.req.formData().catch(() => null); 25 + const rawClient = form?.get("bskyClientId"); 26 + if ( 27 + typeof rawClient !== "string" || 28 + !BSKY_CLIENT_IDS.includes(rawClient as typeof BSKY_CLIENT_IDS[number]) 29 + ) { 30 + return new Response("invalid Bluesky client", { status: 400 }); 31 + } 32 + 33 + await updateAppUserBskyClient(user.did, rawClient); 34 + return new Response(null, { 35 + status: 303, 36 + headers: { location: "/account/reviews" }, 37 + }); 38 + }, 39 + });
+10 -2
routes/api/account/type.ts
··· 18 18 } 19 19 const form = await ctx.req.formData().catch(() => null); 20 20 const raw = form?.get("accountType"); 21 + const rawNext = form?.get("next"); 22 + const next = typeof rawNext === "string" && rawNext.startsWith("/") && 23 + !rawNext.startsWith("//") 24 + ? rawNext 25 + : null; 21 26 const accountType = raw === "project" || raw === "user" 22 27 ? raw as AccountType 23 28 : null; ··· 45 50 did: user.did, 46 51 handle: user.handle, 47 52 displayName: bskyProfile?.displayName ?? null, 53 + bio: bskyProfile?.description ?? null, 54 + avatarCid: bskyProfile?.avatar?.ref.$link ?? null, 55 + avatarMime: bskyProfile?.avatar?.mimeType ?? null, 48 56 accountType, 49 57 }); 50 58 ··· 52 60 status: 303, 53 61 headers: { 54 62 location: accountType === "project" 55 - ? "/explore/manage" 56 - : "/account/reviews", 63 + ? next ?? "/explore/manage" 64 + : next ?? "/account/reviews", 57 65 }, 58 66 }); 59 67 },
+34 -2
routes/explore/[handle].tsx
··· 31 31 import { resolveIdentity } from "../../lib/identity.ts"; 32 32 import { getBskyProfile } from "../../lib/pds.ts"; 33 33 34 + function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 35 + const ext = mime === "image/png" 36 + ? "png" 37 + : mime === "image/webp" 38 + ? "webp" 39 + : "jpeg"; 40 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}`; 41 + } 42 + 34 43 export const handler = define.handlers({ 35 44 async GET(ctx) { 36 45 const handle = decodeURIComponent(ctx.params.handle).toLowerCase(); ··· 146 155 targetId={profile.handle} 147 156 signedIn={!!signedInUser} 148 157 isOwner={isOwner} 149 - loginHref={`/oauth/login?next=${ 158 + loginHref={`/explore/create?next=${ 150 159 encodeURIComponent(`/explore/${profile.handle}`) 151 160 }`} 152 161 ownReview={ownReview ··· 251 260 ]); 252 261 let reviewerName = appUser?.displayName ?? profile?.name ?? null; 253 262 let reviewerHandle = appUser?.handle ?? profile?.handle ?? null; 254 - if (!reviewerName) { 263 + let reviewerAvatarUrl = appUser?.avatarCid && appUser.avatarMime 264 + ? bskyCdnAvatarUrl( 265 + review.reviewerDid, 266 + appUser.avatarCid, 267 + appUser.avatarMime, 268 + ) 269 + : profile?.avatarCid 270 + ? `/api/registry/avatar/${encodeURIComponent(review.reviewerDid)}` 271 + : null; 272 + if (!reviewerName || !reviewerAvatarUrl) { 255 273 const identity = await resolveIdentity(review.reviewerDid).catch(() => 256 274 null 257 275 ); ··· 262 280 : null; 263 281 reviewerName = bsky?.displayName ?? null; 264 282 reviewerHandle = reviewerHandle ?? identity?.handle ?? null; 283 + reviewerAvatarUrl = bsky?.avatar 284 + ? bskyCdnAvatarUrl( 285 + review.reviewerDid, 286 + bsky.avatar.ref.$link, 287 + bsky.avatar.mimeType, 288 + ) 289 + : reviewerAvatarUrl; 265 290 if (appUser && identity) { 266 291 await updateAppUserProfile({ 267 292 did: review.reviewerDid, 268 293 handle: identity.handle, 269 294 displayName: bsky?.displayName ?? null, 295 + bio: bsky?.description ?? null, 296 + avatarCid: bsky?.avatar?.ref.$link ?? null, 297 + avatarMime: bsky?.avatar?.mimeType ?? null, 270 298 }).catch(() => {}); 271 299 } 272 300 } ··· 274 302 ...review, 275 303 reviewerName, 276 304 reviewerHandle, 305 + reviewerAvatarUrl, 306 + reviewerProfileHref: appUser?.accountType === "user" && reviewerHandle 307 + ? `/users/${encodeURIComponent(reviewerHandle)}` 308 + : null, 277 309 }; 278 310 }), 279 311 );
+8 -4
routes/explore/create.tsx
··· 11 11 export default define.page(async function ExploreCreate(ctx) { 12 12 const t = getMessages(ctx.state.locale).explore; 13 13 const user = ctx.state.user; 14 + const rawNext = ctx.url.searchParams.get("next"); 15 + const next = rawNext && rawNext.startsWith("/") && !rawNext.startsWith("//") 16 + ? rawNext 17 + : null; 14 18 15 19 if (user) { 16 20 const accountType = await getEffectiveAccountType(user.did).catch(() => ··· 20 24 status: 303, 21 25 headers: { 22 26 location: accountType === "project" 23 - ? "/explore/manage" 27 + ? next ?? "/explore/manage" 24 28 : accountType === "user" 25 - ? "/account/reviews" 26 - : "/account/type", 29 + ? next ?? "/account/reviews" 30 + : `/account/type${next ? `?next=${encodeURIComponent(next)}` : ""}`, 27 31 }, 28 32 }) as unknown as preact.JSX.Element; 29 33 } ··· 56 60 }} 57 61 > 58 62 {isOAuthConfigured() 59 - ? <SignInForm /> 63 + ? <SignInForm returnTo={next ?? undefined} /> 60 64 : <p class="text-body">{t.create.configError}</p>} 61 65 </div> 62 66 </div>
+1 -1
routes/index.tsx
··· 16 16 <div id="page-top"> 17 17 <GlassClouds /> 18 18 <div class="content-layer"> 19 - <Nav /> 19 + <Nav showEffects /> 20 20 <Hero /> 21 21 <WhatIsAtmosphere /> 22 22 <OnePlace />
+12 -1
routes/oauth/callback.ts
··· 60 60 did: result.did, 61 61 handle: result.handle, 62 62 displayName: bskyProfile?.displayName ?? null, 63 + bio: bskyProfile?.description ?? null, 64 + avatarCid: bskyProfile?.avatar?.ref.$link ?? null, 65 + avatarMime: bskyProfile?.avatar?.mimeType ?? null, 63 66 }).catch(() => {}); 67 + const returnTo = result.returnTo && result.returnTo.startsWith("/") && 68 + !result.returnTo.startsWith("//") 69 + ? result.returnTo 70 + : null; 64 71 const headers = new Headers({ 65 - location: needsChoice ? "/account/type" : "/explore/manage", 72 + location: needsChoice 73 + ? `/account/type${ 74 + returnTo ? `?next=${encodeURIComponent(returnTo)}` : "" 75 + }` 76 + : returnTo ?? "/explore/manage", 66 77 }); 67 78 headers.append("set-cookie", sessionCookie); 68 79 headers.append("set-cookie", rememberedCookie);
+28 -9
routes/oauth/login.ts
··· 9 9 import { define } from "../../utils.ts"; 10 10 import { isOAuthConfigured, startLogin } from "../../lib/oauth.ts"; 11 11 12 - async function getHandle(req: Request, url: URL): Promise<string | null> { 12 + function safeNext(raw: string | null): string | null { 13 + if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return null; 14 + return raw; 15 + } 16 + 17 + async function getLoginInput( 18 + req: Request, 19 + url: URL, 20 + ): Promise<{ handle: string | null; next: string | null }> { 13 21 const fromQs = url.searchParams.get("handle"); 14 - if (fromQs) return fromQs.trim(); 22 + const nextFromQs = safeNext(url.searchParams.get("next")); 23 + if (fromQs) return { handle: fromQs.trim(), next: nextFromQs }; 15 24 const ct = (req.headers.get("content-type") ?? "").toLowerCase(); 16 25 if (ct.includes("application/json")) { 17 26 const body = await req.json().catch(() => null) as 18 - | { handle?: string } 27 + | { handle?: string; next?: string } 19 28 | null; 20 - return body?.handle?.trim() ?? null; 29 + return { 30 + handle: body?.handle?.trim() ?? null, 31 + next: safeNext(body?.next ?? null), 32 + }; 21 33 } 22 34 if ( 23 35 ct.includes("application/x-www-form-urlencoded") || 24 36 ct.includes("multipart/form-data") 25 37 ) { 26 38 const form = await req.formData().catch(() => null); 27 - if (!form) return null; 39 + if (!form) return { handle: null, next: nextFromQs }; 28 40 const v = form.get("handle"); 29 - return typeof v === "string" ? v.trim() : null; 41 + const next = form.get("next"); 42 + return { 43 + handle: typeof v === "string" ? v.trim() : null, 44 + next: safeNext(typeof next === "string" ? next : null) ?? nextFromQs, 45 + }; 30 46 } 31 - return null; 47 + return { handle: null, next: nextFromQs }; 32 48 } 33 49 34 50 async function handle(ctx: { req: Request; url: URL }): Promise<Response> { ··· 38 54 { status: 503 }, 39 55 ); 40 56 } 41 - const handleStr = await getHandle(ctx.req, ctx.url); 57 + const { handle: handleStr, next: returnTo } = await getLoginInput( 58 + ctx.req, 59 + ctx.url, 60 + ); 42 61 if (!handleStr) { 43 62 return new Response("missing handle", { status: 400 }); 44 63 } 45 64 try { 46 - const { redirectUrl } = await startLogin(handleStr); 65 + const { redirectUrl } = await startLogin(handleStr, returnTo); 47 66 return new Response(null, { 48 67 status: 303, 49 68 headers: { location: redirectUrl },
+117
routes/users/[handle].tsx
··· 1 + import { define } from "../../utils.ts"; 2 + import Nav from "../../components/Nav.tsx"; 3 + import GlassClouds from "../../components/GlassClouds.tsx"; 4 + import Footer from "../../components/Footer.tsx"; 5 + import { getMessages } from "../../i18n/mod.ts"; 6 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 7 + import { getAppUserByHandle } from "../../lib/account-types.ts"; 8 + import { getBskyClient } from "../../lib/bsky-clients.ts"; 9 + 10 + function bskyCdnAvatarUrl(did: string, cid: string, mime: string): string { 11 + const ext = mime === "image/png" 12 + ? "png" 13 + : mime === "image/webp" 14 + ? "webp" 15 + : "jpeg"; 16 + return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@${ext}`; 17 + } 18 + 19 + export const handler = define.handlers({ 20 + async GET(ctx) { 21 + const handle = decodeURIComponent(ctx.params.handle ?? "").trim() 22 + .toLowerCase(); 23 + const profile = handle 24 + ? await getAppUserByHandle(handle).catch(() => null) 25 + : null; 26 + return ctx.render( 27 + <UserProfilePage 28 + account={buildAccountMenuProps(ctx.state)} 29 + profile={profile?.accountType === "user" ? profile : null} 30 + t={getMessages(ctx.state.locale)} 31 + />, 32 + { status: profile?.accountType === "user" ? 200 : 404 }, 33 + ); 34 + }, 35 + }); 36 + 37 + interface UserProfilePageProps { 38 + account: ReturnType<typeof buildAccountMenuProps>; 39 + profile: Awaited<ReturnType<typeof getAppUserByHandle>>; 40 + // deno-lint-ignore no-explicit-any 41 + t: any; 42 + } 43 + 44 + function UserProfilePage({ account, profile, t }: UserProfilePageProps) { 45 + const copy = t.userProfile; 46 + if (!profile) { 47 + return ( 48 + <div id="page-top"> 49 + <GlassClouds /> 50 + <div class="content-layer"> 51 + <Nav account={account} showEffects={false} /> 52 + <section class="user-public-section"> 53 + <div class="container" style={{ maxWidth: "640px" }}> 54 + <div class="glass user-public-card"> 55 + <h1 class="text-section">{copy.notFoundTitle}</h1> 56 + <p class="text-body mt-2">{copy.notFoundBody}</p> 57 + </div> 58 + </div> 59 + </section> 60 + <Footer variant="compact" /> 61 + </div> 62 + </div> 63 + ); 64 + } 65 + 66 + const displayName = profile.displayName || profile.handle; 67 + const avatarUrl = profile.avatarCid && profile.avatarMime 68 + ? bskyCdnAvatarUrl(profile.did, profile.avatarCid, profile.avatarMime) 69 + : null; 70 + const client = getBskyClient(profile.bskyClientId); 71 + return ( 72 + <div id="page-top"> 73 + <GlassClouds /> 74 + <div class="content-layer"> 75 + <Nav account={account} showEffects={false} /> 76 + <section class="user-public-section"> 77 + <div class="container" style={{ maxWidth: "720px" }}> 78 + <p> 79 + <a href="/explore" class="text-link-button"> 80 + ← {copy.backToExplore} 81 + </a> 82 + </p> 83 + <div class="glass user-public-card"> 84 + <div class="user-public-avatar"> 85 + {avatarUrl 86 + ? <img src={avatarUrl} alt="" /> 87 + : <span>{displayName.slice(0, 1).toUpperCase()}</span>} 88 + </div> 89 + <div class="user-public-body"> 90 + <h1 class="text-section">{displayName}</h1> 91 + <p class="user-profile-handle">@{profile.handle}</p> 92 + {profile.bio && ( 93 + <p class="text-body user-profile-bio">{profile.bio}</p> 94 + )} 95 + <a 96 + class="profile-hero-action user-public-client-link" 97 + href={client.profileUrl(profile.handle)} 98 + target="_blank" 99 + rel="noopener noreferrer" 100 + > 101 + <span class="profile-hero-action-icon"> 102 + <img src={client.iconUrl} alt="" /> 103 + </span> 104 + <span>{copy.openIn(client.name)}</span> 105 + <span class="profile-hero-action-arrow" aria-hidden="true"> 106 + 107 + </span> 108 + </a> 109 + </div> 110 + </div> 111 + </div> 112 + </section> 113 + <Footer variant="compact" /> 114 + </div> 115 + </div> 116 + ); 117 + }