Atproto AMA app
0
fork

Configure Feed

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

wip: avatars

+201 -34
+60
src/api/profile.ts
··· 1 + "use server"; 2 + 3 + import { cache } from "@solidjs/router"; 4 + 5 + export interface BlueskyProfile { 6 + avatar?: string; 7 + displayName?: string; 8 + description?: string; 9 + handle: string; 10 + } 11 + 12 + /** 13 + * Fetch a user's profile from Bluesky's public API 14 + * No authentication required - uses the public API endpoint 15 + */ 16 + export const getProfileByHandle = cache(async (handle: string): Promise<BlueskyProfile | null> => { 17 + try { 18 + const response = await fetch( 19 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}` 20 + ); 21 + 22 + if (!response.ok) { 23 + console.warn(`Failed to fetch profile for ${handle}: ${response.status}`); 24 + return null; 25 + } 26 + 27 + const data = await response.json(); 28 + 29 + return { 30 + avatar: data.avatar, 31 + displayName: data.displayName, 32 + description: data.description, 33 + handle: data.handle, 34 + }; 35 + } catch (error) { 36 + console.error(`Error fetching profile for ${handle}:`, error); 37 + return null; 38 + } 39 + }, "profile") 40 + 41 + /** 42 + * Fetch multiple profiles in parallel 43 + */ 44 + export async function getProfilesByHandles(handles: string[]): Promise<Map<string, BlueskyProfile>> { 45 + const uniqueHandles = [...new Set(handles)]; 46 + const profiles = await Promise.all( 47 + uniqueHandles.map(async (handle) => { 48 + const profile = await getProfileByHandle(handle); 49 + return [handle, profile] as const; 50 + }) 51 + ); 52 + 53 + const map = new Map<string, BlueskyProfile>(); 54 + for (const [handle, profile] of profiles) { 55 + if (profile) { 56 + map.set(handle, profile); 57 + } 58 + } 59 + return map; 60 + }
+61 -6
src/app.tsx
··· 1 1 // @refresh reload 2 2 import { Router } from "@solidjs/router"; 3 3 import { FileRoutes } from "@solidjs/start/router"; 4 - import { Suspense } from "solid-js"; 4 + import { Suspense, createResource, Show } from "solid-js"; 5 5 import "./app.css"; 6 + import { getUser } from "~/api"; 7 + import { getProfileByHandle } from "~/api/profile"; 8 + import { Avatar } from "~/components/Avatar"; 9 + import { logout } from "~/api"; 10 + 11 + function Header() { 12 + const [user] = createResource(() => getUser()); 13 + const [profile] = createResource( 14 + () => user()?.handle, 15 + (handle) => getProfileByHandle(handle) 16 + ); 17 + 18 + return ( 19 + <header class="border-b bg-white"> 20 + <div class="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> 21 + <a href="/" class="text-xl font-bold text-gray-900 hover:text-gray-700"> 22 + Askimut 23 + </a> 24 + 25 + <Show 26 + when={user()} 27 + fallback={ 28 + <a 29 + href="/login" 30 + class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm font-medium" 31 + > 32 + Login 33 + </a> 34 + } 35 + > 36 + <div class="flex items-center gap-3"> 37 + <a 38 + href={`/${user()?.handle}/answers`} 39 + class="flex items-center gap-2 hover:opacity-80" 40 + > 41 + <Avatar 42 + src={profile()?.avatar} 43 + handle={user()?.handle || ""} 44 + size="md" 45 + /> 46 + <span class="text-sm text-gray-600">@{user()?.handle}</span> 47 + </a> 48 + <form action={logout} method="post"> 49 + <button 50 + type="submit" 51 + class="px-3 py-1 text-sm text-gray-500 hover:text-gray-700" 52 + > 53 + Logout 54 + </button> 55 + </form> 56 + </div> 57 + </Show> 58 + </div> 59 + </header> 60 + ); 61 + } 6 62 7 63 export default function App() { 8 64 return ( 9 65 <Router 10 - root={props => ( 11 - <> 12 - <a href="/">Index</a> 13 - <a href="/about">About</a> 66 + root={(props) => ( 67 + <div class="min-h-screen bg-gray-50"> 68 + <Header /> 14 69 <Suspense>{props.children}</Suspense> 15 - </> 70 + </div> 16 71 )} 17 72 > 18 73 <FileRoutes />
+43
src/components/Avatar.tsx
··· 1 + import { Show } from "solid-js"; 2 + 3 + export type AvatarProps = { 4 + src?: string; 5 + handle: string; 6 + size?: "sm" | "md" | "lg"; 7 + class?: string; 8 + }; 9 + 10 + const sizeClasses = { 11 + sm: "w-6 h-6 text-xs", 12 + md: "w-8 h-8 text-sm", 13 + lg: "w-12 h-12 text-base", 14 + }; 15 + 16 + function getInitials(handle: string): string { 17 + // Remove @ prefix if present and get first two chars 18 + const clean = handle.replace(/^@/, ""); 19 + return clean.slice(0, 2).toUpperCase(); 20 + } 21 + 22 + export function Avatar(props: AvatarProps) { 23 + const size = () => props.size || "md"; 24 + 25 + return ( 26 + <Show 27 + when={props.src} 28 + fallback={ 29 + <div 30 + class={`${sizeClasses[size()]} rounded-full bg-gray-200 flex items-center justify-center font-medium text-gray-600 ${props.class || ""}`} 31 + > 32 + {getInitials(props.handle)} 33 + </div> 34 + } 35 + > 36 + <img 37 + src={props.src} 38 + alt={`@${props.handle}'s avatar`} 39 + class={`${sizeClasses[size()]} rounded-full object-cover ${props.class || ""}`} 40 + /> 41 + </Show> 42 + ); 43 + }
+8 -2
src/components/QuestionCard.tsx
··· 1 + import { Avatar } from "./Avatar"; 2 + 1 3 export type QuestionCardProps = { 2 4 questionContent: string; 3 5 askerHandle: string; 6 + askerAvatar?: string; 4 7 answerContent: string; 5 8 responderHandle: string; 9 + responderAvatar?: string; 6 10 answeredAt: number; 7 11 }; 8 12 ··· 19 23 <div class="border rounded-lg p-4 space-y-3 bg-white"> 20 24 <div class="space-y-2"> 21 25 <div class="flex items-center gap-2 text-sm text-gray-500"> 26 + <Avatar src={props.askerAvatar} handle={props.askerHandle} size="sm" /> 22 27 <span class="font-medium text-gray-700">@{props.askerHandle}</span> 23 28 <span>asked</span> 24 29 <a href={`/${props.responderHandle}/answers`} class="font-medium text-blue-600 hover:underline"> 25 30 @{props.responderHandle} 26 31 </a> 27 32 </div> 28 - <p class="text-gray-800">{props.questionContent}</p> 33 + <p class="text-gray-800 pl-8">{props.questionContent}</p> 29 34 </div> 30 35 31 36 <div class="border-t pt-3 space-y-2"> 32 37 <div class="flex items-center gap-2 text-sm text-gray-500"> 38 + <Avatar src={props.responderAvatar} handle={props.responderHandle} size="sm" /> 33 39 <a href={`/${props.responderHandle}/answers`} class="font-medium text-blue-600 hover:underline"> 34 40 @{props.responderHandle} 35 41 </a> 36 42 <span>answered</span> 37 43 <span class="text-gray-400">{formatDate(props.answeredAt)}</span> 38 44 </div> 39 - <p class="text-gray-800">{props.answerContent}</p> 45 + <p class="text-gray-800 pl-8">{props.answerContent}</p> 40 46 </div> 41 47 </div> 42 48 );
+14 -2
src/routes/[userhandle]/answers.tsx
··· 1 1 import { createAsync, type RouteDefinition, useParams } from "@solidjs/router"; 2 - import { For, Show } from "solid-js"; 2 + import { For, Show, createResource } from "solid-js"; 3 3 import { getAnsweredQuestionsForUser } from "~/db/utils"; 4 + import { getProfilesByHandles } from "~/api/profile"; 4 5 import { QuestionCard } from "~/components/QuestionCard"; 5 6 6 7 export const route = { ··· 14 15 const params = useParams(); 15 16 const answeredQuestions = createAsync(() => getAnsweredQuestionsForUser(params.userhandle)); 16 17 18 + // Fetch profiles for all unique handles in the questions 19 + const [profiles] = createResource( 20 + () => answeredQuestions(), 21 + async (questions) => { 22 + if (!questions || questions.length === 0) return new Map(); 23 + const handles = questions.flatMap((q) => [q.askerHandle, q.responderHandle]); 24 + return getProfilesByHandles(handles); 25 + } 26 + ); 27 + 17 28 return ( 18 29 <main class="w-full max-w-2xl mx-auto p-4 space-y-6"> 19 30 <div class="flex items-center justify-between"> 20 31 <div> 21 - <a href="/" class="text-blue-500 hover:underline text-sm">← Home</a> 22 32 <h1 class="text-2xl font-bold mt-1">@{params.userhandle}'s Answers</h1> 23 33 </div> 24 34 <a ··· 44 54 <QuestionCard 45 55 questionContent={q.questionContent} 46 56 askerHandle={q.askerHandle} 57 + askerAvatar={profiles()?.get(q.askerHandle)?.avatar} 47 58 answerContent={q.answerContent} 48 59 responderHandle={q.responderHandle} 60 + responderAvatar={profiles()?.get(q.responderHandle)?.avatar} 49 61 answeredAt={q.answeredAt} 50 62 /> 51 63 )}
+15 -24
src/routes/index.tsx
··· 1 1 import { query, createAsync, revalidate, type RouteDefinition } from "@solidjs/router"; 2 - import { For, Show, onMount, onCleanup } from "solid-js"; 3 - import { getUser, logout } from "~/api"; 2 + import { For, Show, onMount, onCleanup, createResource } from "solid-js"; 3 + import { getUser } from "~/api"; 4 4 import { getLatestAnsweredQuestions } from "~/db/utils"; 5 + import { getProfilesByHandles } from "~/api/profile"; 5 6 import { QuestionCard } from "~/components/QuestionCard"; 6 7 7 8 const getAnsweredQuestionsQuery = query( ··· 18 19 } satisfies RouteDefinition; 19 20 20 21 export default function Home() { 21 - const user = createAsync(() => getUser(), { deferStream: true }); 22 22 const answeredQuestions = createAsync(() => getAnsweredQuestionsQuery()); 23 23 24 + // Fetch profiles for all unique handles in the questions 25 + const [profiles] = createResource( 26 + () => answeredQuestions(), 27 + async (questions) => { 28 + if (!questions || questions.length === 0) return new Map(); 29 + const handles = questions.flatMap((q) => [q.askerHandle, q.responderHandle]); 30 + return getProfilesByHandles(handles); 31 + } 32 + ); 33 + 24 34 // Live updates: poll every 5 seconds 25 35 onMount(() => { 26 36 const interval = setInterval(() => { ··· 31 41 32 42 return ( 33 43 <main class="w-full max-w-2xl mx-auto p-4 space-y-6"> 34 - <div class="flex items-center justify-between"> 35 - <h1 class="text-2xl font-bold">Askimut</h1> 36 - <Show 37 - when={user()} 38 - fallback={ 39 - <a href="/login" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"> 40 - Login 41 - </a> 42 - } 43 - > 44 - <div class="flex items-center gap-4"> 45 - <span class="text-gray-600">@{user()?.handle}</span> 46 - <form action={logout} method="post"> 47 - <button type="submit" class="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"> 48 - Logout 49 - </button> 50 - </form> 51 - </div> 52 - </Show> 53 - </div> 54 - 55 44 <div class="space-y-4"> 56 45 <h2 class="text-lg font-semibold text-gray-700">Latest Answered Questions</h2> 57 46 ··· 67 56 <QuestionCard 68 57 questionContent={q.questionContent} 69 58 askerHandle={q.askerHandle} 59 + askerAvatar={profiles()?.get(q.askerHandle)?.avatar} 70 60 answerContent={q.answerContent} 71 61 responderHandle={q.responderHandle} 62 + responderAvatar={profiles()?.get(q.responderHandle)?.avatar} 72 63 answeredAt={q.answeredAt} 73 64 /> 74 65 )}