Atproto AMA app
0
fork

Configure Feed

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

factor some stuff 🧹

+992 -1029
+10 -2
src/app.tsx
··· 1 1 import { MetaProvider, Title } from "@solidjs/meta"; 2 2 import { Router } from "@solidjs/router"; 3 3 import { FileRoutes } from "@solidjs/start/router"; 4 - import { Suspense } from "solid-js"; 4 + import { ErrorBoundary, Suspense } from "solid-js"; 5 5 6 + import AppErrorBoundary from "~/components/ErrorBoundary"; 7 + import PageSkeleton from "~/components/PageSkeleton"; 6 8 import { Header } from "~/components/Header"; 7 9 8 10 import "./app.css"; ··· 14 16 <MetaProvider> 15 17 <Title>Askimut</Title> 16 18 <Header /> 17 - <Suspense>{props.children}</Suspense> 19 + <ErrorBoundary 20 + fallback={(error, reset) => ( 21 + <AppErrorBoundary error={error} reset={reset} /> 22 + )} 23 + > 24 + <Suspense fallback={<PageSkeleton />}>{props.children}</Suspense> 25 + </ErrorBoundary> 18 26 </MetaProvider> 19 27 )} 20 28 >
+31
src/components/AnswerBlock.tsx
··· 1 + import { SourceAttribution } from "~/components/SourceAttribution"; 2 + import { formatWhen } from "~/lib/format"; 3 + 4 + import styles from "~/routes/[handle].module.css"; 5 + 6 + interface AnswerBlockProps { 7 + content: string; 8 + createdAt: Date | string; 9 + sourceType?: string | null; 10 + sourceUri?: string | null; 11 + sourceData?: string | null; 12 + } 13 + 14 + export function AnswerBlock(props: AnswerBlockProps) { 15 + return ( 16 + <div class={styles.answerBlock}> 17 + <div class={styles.answerLabel}>Answer</div> 18 + <div class={styles.answerText}>{props.content}</div> 19 + <div class={styles.questionMeta}> 20 + {formatWhen(props.createdAt)} 21 + <SourceAttribution 22 + sourceType={props.sourceType || "askimut"} 23 + sourceUri={props.sourceUri} 24 + sourceData={props.sourceData} 25 + /> 26 + </div> 27 + </div> 28 + ); 29 + } 30 + 31 + export default AnswerBlock;
+24 -44
src/components/ErrorBoundary.tsx
··· 1 - import { JSX } from "solid-js"; 2 - import { RouteError, NotFoundError, ForbiddenError, UnauthorizedError } from "~/lib/route-guards"; 1 + import { Show, type JSX } from "solid-js"; 2 + 3 + import { RouteError } from "~/lib/errors"; 3 4 4 - import styles from "../routes/[handle].module.css"; 5 + import styles from "~/routes/[handle].module.css"; 5 6 6 7 interface ErrorBoundaryProps { 7 - error: Error; 8 + error: unknown; 8 9 reset: () => void; 9 10 } 10 11 11 12 export default function ErrorBoundary(props: ErrorBoundaryProps): JSX.Element { 12 - const error = props.error; 13 + const err = (): Error => 14 + props.error instanceof Error ? props.error : new Error(String(props.error)); 13 15 14 - // Handle specific route errors 15 - if (error instanceof RouteError) { 16 - return ( 17 - <div class={styles.page}> 18 - <div class={styles.errorContainer}> 19 - <h1 class={styles.errorTitle}>{error.title}</h1> 20 - <p class={styles.errorMessage}>{error.message}</p> 21 - <div class={styles.errorActions}> 22 - <button 23 - class={`${styles.button} ${styles.buttonSecondary}`} 24 - onClick={() => window.history.back()} 25 - > 26 - Go Back 27 - </button> 28 - <button 29 - class={styles.button} 30 - onClick={props.reset} 31 - > 32 - Try Again 33 - </button> 34 - </div> 35 - </div> 36 - </div> 37 - ); 38 - } 16 + const routeError = (): RouteError | null => 17 + props.error instanceof RouteError ? props.error : null; 39 18 40 - // Handle generic errors 41 19 return ( 42 20 <div class={styles.page}> 43 21 <div class={styles.errorContainer}> 44 - <h1 class={styles.errorTitle}>Something went wrong</h1> 22 + <h1 class={styles.errorTitle}> 23 + {routeError()?.title ?? "Something went wrong"} 24 + </h1> 45 25 <p class={styles.errorMessage}> 46 - An unexpected error occurred. Please try again. 26 + {routeError()?.message ?? 27 + "An unexpected error occurred. Please try again."} 47 28 </p> 48 - <details class={styles.errorDetails}> 49 - <summary>Error details</summary> 50 - <pre class={styles.errorStack}>{error.stack}</pre> 51 - </details> 29 + <Show when={!routeError()}> 30 + <details class={styles.errorDetails}> 31 + <summary>Error details</summary> 32 + <pre class={styles.errorStack}>{err().stack}</pre> 33 + </details> 34 + </Show> 52 35 <div class={styles.errorActions}> 53 - <button 54 - class={`${styles.button} ${styles.buttonSecondary}`} 36 + <button 37 + classList={{ [styles.button]: true, [styles.buttonSecondary]: true }} 55 38 onClick={() => window.history.back()} 56 39 > 57 40 Go Back 58 41 </button> 59 - <button 60 - class={styles.button} 61 - onClick={props.reset} 62 - > 42 + <button class={styles.button} onClick={props.reset}> 63 43 Try Again 64 44 </button> 65 45 </div> 66 46 </div> 67 47 </div> 68 48 ); 69 - } 49 + }
+20 -21
src/components/Header.tsx
··· 1 1 import { A, createAsync } from "@solidjs/router"; 2 - import { Show, createSignal, onMount } from "solid-js"; 2 + import { Show, Suspense } from "solid-js"; 3 3 4 4 import { getCurrentUser } from "~/lib/queries"; 5 5 ··· 8 8 export function Header() { 9 9 const user = createAsync(() => getCurrentUser()); 10 10 11 - const [isHydrated, setIsHydrated] = createSignal(false); 12 - onMount(() => setIsHydrated(true)); 13 - 14 11 return ( 15 12 <header class={styles.bar}> 16 13 <A class={styles.brand} href="/"> 17 14 Askimut 18 15 </A> 19 - <Show when={(isHydrated() || typeof window === "undefined") && user()} keyed> 20 - {(u) => ( 21 - <div class={styles.right}> 22 - <span class={styles.handle}>@{u.handle}</span> 23 - <a 24 - class={styles.signOut} 25 - href="/oauth/logout" 26 - onClick={(e) => { 27 - e.preventDefault(); 28 - window.location.href = "/oauth/logout"; 29 - }} 30 - > 31 - Sign out 32 - </a> 33 - </div> 34 - )} 35 - </Show> 16 + <Suspense fallback={null}> 17 + <Show when={user()} keyed> 18 + {(u) => ( 19 + <div class={styles.right}> 20 + <span class={styles.handle}>@{u.handle}</span> 21 + <a 22 + class={styles.signOut} 23 + href="/oauth/logout" 24 + onClick={(e) => { 25 + e.preventDefault(); 26 + window.location.href = "/oauth/logout"; 27 + }} 28 + > 29 + Sign out 30 + </a> 31 + </div> 32 + )} 33 + </Show> 34 + </Suspense> 36 35 </header> 37 36 ); 38 37 }
+11
src/components/PageSkeleton.tsx
··· 1 + import styles from "~/routes/[handle].module.css"; 2 + 3 + export function PageSkeleton() { 4 + return ( 5 + <div class={styles.page} aria-busy="true" aria-live="polite"> 6 + <div class={styles.status}>Loading…</div> 7 + </div> 8 + ); 9 + } 10 + 11 + export default PageSkeleton;
+55
src/components/ProfileHeader.tsx
··· 1 + import { Show } from "solid-js"; 2 + 3 + import styles from "~/routes/[handle].module.css"; 4 + 5 + export interface ProfileHeaderProfile { 6 + handle: string; 7 + displayName?: string | null; 8 + avatarUrl?: string | null; 9 + } 10 + 11 + interface ProfileHeaderProps { 12 + profile: ProfileHeaderProfile; 13 + /** Replaces the displayed display name (e.g. "Ask @alice", "Pending Questions"). */ 14 + title?: string; 15 + } 16 + 17 + export function ProfileHeader(props: ProfileHeaderProps) { 18 + const initial = () => 19 + (props.profile.displayName || props.profile.handle).slice(0, 1).toUpperCase(); 20 + 21 + return ( 22 + <header class={styles.header}> 23 + <Show 24 + when={props.profile.avatarUrl} 25 + keyed 26 + fallback={ 27 + <div 28 + class={`${styles.avatar} ${styles.avatarPlaceholder}`} 29 + aria-hidden 30 + > 31 + {initial()} 32 + </div> 33 + } 34 + > 35 + {(src) => ( 36 + <img 37 + class={styles.avatar} 38 + src={src} 39 + alt="" 40 + width={64} 41 + height={64} 42 + /> 43 + )} 44 + </Show> 45 + <div class={styles.profileText}> 46 + <div class={styles.displayName}> 47 + {props.title ?? (props.profile.displayName || props.profile.handle)} 48 + </div> 49 + <div class={styles.handle}>@{props.profile.handle}</div> 50 + </div> 51 + </header> 52 + ); 53 + } 54 + 55 + export default ProfileHeader;
+83
src/components/QuestionCard.tsx
··· 1 + import { For, Show, type JSX } from "solid-js"; 2 + 3 + import { SourceAttribution } from "~/components/SourceAttribution"; 4 + import { AnswerBlock } from "~/components/AnswerBlock"; 5 + import { formatWhen } from "~/lib/format"; 6 + 7 + import styles from "~/routes/[handle].module.css"; 8 + 9 + interface AnswerLike { 10 + id: string; 11 + content: string; 12 + createdAt: Date | string; 13 + sourceType?: string | null; 14 + sourceUri?: string | null; 15 + sourceData?: string | null; 16 + } 17 + 18 + interface AuthorLike { 19 + handle: string; 20 + displayName?: string | null; 21 + } 22 + 23 + interface QuestionCardProps { 24 + id?: string; 25 + content: string; 26 + createdAt: Date | string; 27 + anonymous: boolean; 28 + author?: AuthorLike | null; 29 + sourceType?: string | null; 30 + sourceUri?: string | null; 31 + sourceData?: string | null; 32 + answers?: AnswerLike[]; 33 + /** When true, show a "Pending answer" hint if no answers are present. */ 34 + showPendingHint?: boolean; 35 + /** Optional slot for trailing content (e.g. "Click to answer →"). */ 36 + trailing?: JSX.Element; 37 + } 38 + 39 + export function QuestionCard(props: QuestionCardProps) { 40 + const authorLabel = () => { 41 + if (props.anonymous) return "Anonymous"; 42 + return props.author?.displayName || props.author?.handle || "Unknown"; 43 + }; 44 + 45 + return ( 46 + <article id={props.id} class={styles.questionCard}> 47 + <div class={styles.questionContent}>{props.content}</div> 48 + <div class={styles.questionMeta}> 49 + {authorLabel()} · {formatWhen(props.createdAt)} 50 + <SourceAttribution 51 + sourceType={props.sourceType || "askimut"} 52 + sourceUri={props.sourceUri} 53 + sourceData={props.sourceData} 54 + /> 55 + </div> 56 + <Show when={props.answers}> 57 + <For each={props.answers}> 58 + {(a) => ( 59 + <AnswerBlock 60 + content={a.content} 61 + createdAt={a.createdAt} 62 + sourceType={a.sourceType} 63 + sourceUri={a.sourceUri} 64 + sourceData={a.sourceData} 65 + /> 66 + )} 67 + </For> 68 + </Show> 69 + <Show 70 + when={ 71 + props.showPendingHint && (props.answers?.length ?? 0) === 0 72 + } 73 + > 74 + <div class={styles.pendingIndicator}> 75 + <span class={styles.pendingText}>Pending answer</span> 76 + </div> 77 + </Show> 78 + {props.trailing} 79 + </article> 80 + ); 81 + } 82 + 83 + export default QuestionCard;
-2
src/components/SourceAttribution.tsx
··· 41 41 </Show> 42 42 ) 43 43 } 44 - 45 - export default SourceAttribution
+28
src/lib/errors.ts
··· 1 + export class RouteError extends Error { 2 + constructor( 3 + message: string, 4 + public statusCode: number = 500, 5 + public title: string = "Error", 6 + ) { 7 + super(message); 8 + this.name = "RouteError"; 9 + } 10 + } 11 + 12 + export class NotFoundError extends RouteError { 13 + constructor(message: string = "Page not found") { 14 + super(message, 404, "Not Found"); 15 + } 16 + } 17 + 18 + export class ForbiddenError extends RouteError { 19 + constructor(message: string = "Access denied") { 20 + super(message, 403, "Forbidden"); 21 + } 22 + } 23 + 24 + export class UnauthorizedError extends RouteError { 25 + constructor(message: string = "Please sign in to continue") { 26 + super(message, 401, "Unauthorized"); 27 + } 28 + }
+7
src/lib/format.ts
··· 1 + export function formatWhen(d: Date | string) { 2 + const date = typeof d === "string" ? new Date(d) : d; 3 + return date.toLocaleString(undefined, { 4 + dateStyle: "medium", 5 + timeStyle: "short", 6 + }); 7 + }
+325 -331
src/lib/queries.ts
··· 1 1 import { action, query, redirect, revalidate } from "@solidjs/router"; 2 - import { eq } from "drizzle-orm"; 3 - import { getCookie } from "vinxi/http"; 2 + import { and, desc, eq, asc, notExists } from "drizzle-orm"; 4 3 5 4 import { db } from "~/lib/db"; 6 5 import { answers, questions, users } from "~/lib/schema"; 7 - import { getSession, SESSION_COOKIE } from "~/lib/session"; 6 + import { requireSession } from "~/lib/route-guards"; 7 + import { NotFoundError, ForbiddenError } from "~/lib/errors"; 8 8 import { initiateLogin as iL } from "~/routes/oauth/login"; 9 - import { 10 - createValidatedQuestion, 11 - createValidatedAnswer, 9 + import { 10 + createValidatedQuestion, 11 + createValidatedAnswer, 12 12 createValidatedProfile, 13 - safeValidateAndConvert 14 13 } from "~/lib/schema-bridge"; 15 14 import { SOURCE_TYPES } from "~/lib/shared-schemas"; 16 - import { createAtClientForDid, constructAtUri, extractRkeyFromUri } from "~/lib/at-protocol"; 17 - import { isValidationEnabled, shouldFailOnValidationError, isPublishingEnabled } from "~/lib/config"; 18 - import { VALIDATION_ERRORS } from "~/lib/shared-schemas"; 15 + import { createAtClientForDid, constructAtUri } from "~/lib/at-protocol"; 16 + import { 17 + isValidationEnabled, 18 + shouldFailOnValidationError, 19 + isPublishingEnabled, 20 + } from "~/lib/config"; 19 21 20 22 export const initiateLogin = action(iL, "initiateLogin"); 23 + 24 + // ---------------------------------------------------------------------------- 25 + // Queries 26 + // ---------------------------------------------------------------------------- 21 27 22 28 export const getCurrentUser = query(async () => { 23 29 "use server"; 24 - const sessionId = getCookie(SESSION_COOKIE); 25 - if (!sessionId) return null; 26 - const session = await getSession(sessionId); 27 - return session?.user ?? null; 30 + try { 31 + const session = await requireSession(); 32 + return session.user; 33 + } catch { 34 + return null; 35 + } 28 36 }, "currentUser"); 29 37 30 38 export const getUserByHandle = query(async (handle: string) => { ··· 35 43 return row ?? null; 36 44 }, "userByHandle"); 37 45 46 + /** 47 + * Resolves everything a route keyed by `:handle` needs to know about the 48 + * current viewer: the target profile (or throws `NotFoundError`), the current 49 + * session user (or `null`), and whether the viewer owns the profile. 50 + * 51 + * Centralising this avoids chaining two `createAsync` calls in every route and 52 + * keeps ownership derivation server-side. 53 + */ 54 + export const getViewerContext = query(async (handle: string) => { 55 + "use server"; 56 + const profile = await db.query.users.findFirst({ 57 + where: eq(users.handle, handle), 58 + }); 59 + if (!profile) throw new NotFoundError("User not found"); 60 + 61 + let viewer: typeof profile | null = null; 62 + try { 63 + const session = await requireSession(); 64 + viewer = session.user; 65 + } catch { 66 + viewer = null; 67 + } 68 + 69 + return { 70 + profile, 71 + viewer, 72 + isOwner: Boolean(viewer && viewer.did === profile.did), 73 + }; 74 + }, "viewerContext"); 75 + 38 76 export const getQuestions = query(async (targetDid: string) => { 39 77 "use server"; 40 - const rows = await db.query.questions.findMany({ 78 + return db.query.questions.findMany({ 41 79 where: eq(questions.targetDid, targetDid), 42 - orderBy: (q, { desc }) => [desc(q.createdAt)], 80 + orderBy: [desc(questions.createdAt)], 43 81 with: { 44 82 author: true, 45 83 answers: { 46 - orderBy: (a, { asc }) => [asc(a.createdAt)], 47 - with: { author: true }, 84 + orderBy: [asc(answers.createdAt)], 48 85 }, 49 86 }, 50 87 }); 51 - return rows; 52 88 }, "questions"); 53 89 90 + /** 91 + * Unanswered questions targeted at `targetDid`. Access is restricted to the 92 + * owner. The "no answers" filter is evaluated in SQL via `notExists` so we 93 + * don't ship every answer row just to drop them client-side. 94 + */ 54 95 export const getPendingQuestions = query(async (targetDid: string) => { 55 96 "use server"; 56 - const sessionId = getCookie(SESSION_COOKIE); 57 - if (!sessionId) throw new Error("Unauthorized"); 58 - const session = await getSession(sessionId); 59 - if (!session) throw new Error("Unauthorized"); 60 - 61 - // Verify user owns the target profile 97 + const session = await requireSession(); 62 98 if (session.user.did !== targetDid) { 63 - throw new Error("Forbidden: Can only view own pending questions"); 99 + throw new ForbiddenError("Can only view own pending questions"); 64 100 } 65 - 66 - const rows = await db.query.questions.findMany({ 67 - where: eq(questions.targetDid, targetDid), 68 - orderBy: (q, { desc }) => [desc(q.createdAt)], 69 - with: { 70 - author: true, 71 - answers: { 72 - orderBy: (a, { asc }) => [asc(a.createdAt)], 73 - with: { author: true }, 74 - }, 75 - }, 101 + 102 + return db.query.questions.findMany({ 103 + where: and( 104 + eq(questions.targetDid, targetDid), 105 + notExists( 106 + db 107 + .select({ one: answers.id }) 108 + .from(answers) 109 + .where(eq(answers.questionId, questions.id)), 110 + ), 111 + ), 112 + orderBy: [desc(questions.createdAt)], 113 + with: { author: true }, 76 114 }); 77 - 78 - // Filter to only questions without answers 79 - return rows.filter(q => q.answers.length === 0); 80 115 }, "pendingQuestions"); 81 116 82 117 export const getQuestionById = query(async (questionId: string) => { 83 118 "use server"; 84 - const sessionId = getCookie(SESSION_COOKIE); 85 - if (!sessionId) throw new Error("Unauthorized"); 86 - const session = await getSession(sessionId); 87 - if (!session) throw new Error("Unauthorized"); 88 - 119 + const session = await requireSession(); 120 + 89 121 const question = await db.query.questions.findFirst({ 90 122 where: eq(questions.id, questionId), 91 123 with: { 92 124 author: true, 93 125 answers: { 94 - orderBy: (a, { asc }) => [asc(a.createdAt)], 95 - with: { author: true }, 126 + orderBy: [asc(answers.createdAt)], 96 127 }, 97 128 }, 98 129 }); 99 - 100 - if (!question) { 101 - throw new Error("Question not found"); 102 - } 103 - 104 - // Verify user owns the target profile (can only answer questions directed to them) 130 + 131 + if (!question) throw new NotFoundError("Question not found"); 105 132 if (session.user.did !== question.targetDid) { 106 - throw new Error("Forbidden: Can only answer questions directed to you"); 133 + throw new ForbiddenError("Can only answer questions directed to you"); 107 134 } 108 - 135 + 109 136 return question; 110 137 }, "questionById"); 111 138 139 + // ---------------------------------------------------------------------------- 140 + // Actions 141 + // ---------------------------------------------------------------------------- 142 + 112 143 export const toggleQuestionsOpen = action(async () => { 113 144 "use server"; 114 - const sessionId = getCookie(SESSION_COOKIE); 115 - if (!sessionId) throw new Error("Unauthorized"); 116 - const session = await getSession(sessionId); 117 - if (!session) throw new Error("Unauthorized"); 145 + const session = await requireSession(); 118 146 const user = session.user; 119 147 const next = !user.questionsOpen; 120 148 121 - // Validate profile update if validation is enabled 122 149 if (isValidationEnabled()) { 123 150 try { 124 151 const { lexRecord, dbData } = createValidatedProfile({ 125 152 displayName: user.displayName || undefined, 126 - questionsOpen: next 153 + questionsOpen: next, 127 154 }); 128 155 129 - // Publish to AT Protocol if enabled 130 - if (isPublishingEnabled('profile')) { 156 + if (isPublishingEnabled("profile")) { 131 157 try { 132 158 const atClient = await createAtClientForDid(user.did); 133 159 if (atClient.isAuthenticated()) { 134 160 await atClient.publishProfile(lexRecord); 135 161 } 136 162 } catch (error) { 137 - console.error('Failed to publish profile update to AT Protocol:', error); 138 - // Don't fail the operation if AT Protocol publishing fails 163 + console.error( 164 + "Failed to publish profile update to AT Protocol:", 165 + error, 166 + ); 139 167 } 140 168 } 141 169 142 - // Update database with validated data 143 - await db 144 - .update(users) 145 - .set(dbData) 146 - .where(eq(users.did, user.did)); 170 + await db.update(users).set(dbData).where(eq(users.did, user.did)); 147 171 } catch (error) { 148 - console.error('Profile validation failed:', error); 172 + console.error("Profile validation failed:", error); 149 173 if (shouldFailOnValidationError()) { 150 - throw new Error('Profile validation failed'); 174 + throw new Error("Profile validation failed"); 151 175 } 152 - // Fallback to original behavior 153 176 await db 154 177 .update(users) 155 178 .set({ questionsOpen: next, updatedAt: new Date() }) 156 179 .where(eq(users.did, user.did)); 157 180 } 158 181 } else { 159 - // Original behavior when validation is disabled 160 182 await db 161 183 .update(users) 162 184 .set({ questionsOpen: next, updatedAt: new Date() }) 163 185 .where(eq(users.did, user.did)); 164 186 } 165 187 166 - throw redirect(`/${user.handle}`, { 167 - revalidate: [ 168 - getUserByHandle.keyFor(user.handle), 169 - getQuestions.keyFor(user.did), 170 - getCurrentUser.keyFor(), 171 - ], 172 - }); 188 + // Auto-revalidation covers queries on the destination page. Keeping explicit 189 + // keys here for the viewer-context cache which isn't always co-located. 190 + await revalidate([ 191 + getViewerContext.keyFor(user.handle), 192 + getUserByHandle.keyFor(user.handle), 193 + getCurrentUser.keyFor(), 194 + ]); 173 195 }, "toggleQuestionsOpen"); 174 196 175 197 export const submitQuestion = action( 176 198 async (targetDid: string, formData: FormData) => { 177 199 "use server"; 178 - const sessionId = getCookie(SESSION_COOKIE); 179 - if (!sessionId) throw new Error("Unauthorized"); 180 - const session = await getSession(sessionId); 181 - if (!session) throw new Error("Unauthorized"); 200 + const session = await requireSession(); 201 + 182 202 const content = formData.get("content")?.toString()?.trim(); 183 203 if (!content) return; 184 204 const anonymous = formData.has("anonymous"); 185 205 186 - let insertedQuestion; 187 206 let atUri: string | null = null; 207 + let questionId: string; 188 208 189 - // Validate question if validation is enabled 190 209 if (isValidationEnabled()) { 191 210 try { 192 211 const { lexRecord, dbData } = createValidatedQuestion({ 193 212 content, 194 213 targetDid, 195 214 authorDid: session.user.did, 196 - sourceType: SOURCE_TYPES.ASKIMUT, // Native Askimut questions 197 - anonymous 215 + sourceType: SOURCE_TYPES.ASKIMUT, 216 + anonymous, 198 217 }); 199 218 200 - // Publish to AT Protocol if enabled 201 - if (isPublishingEnabled('question')) { 219 + if (isPublishingEnabled("question")) { 202 220 try { 203 221 const atClient = await createAtClientForDid(session.user.did); 204 222 if (atClient.isAuthenticated()) { 205 223 const result = await atClient.publishQuestion(lexRecord); 206 - if (result) { 207 - atUri = result.uri; 208 - } 224 + if (result) atUri = result.uri; 209 225 } 210 226 } catch (error) { 211 - console.error('Failed to publish question to AT Protocol:', error); 212 - // Don't fail the operation if AT Protocol publishing fails 227 + console.error( 228 + "Failed to publish question to AT Protocol:", 229 + error, 230 + ); 213 231 } 214 232 } 215 233 216 - // Insert into database with validated data 217 - const [inserted] = await db.insert(questions).values({ 218 - authorDid: dbData.authorDid!, 219 - targetDid: dbData.targetDid!, 220 - content: dbData.content!, 221 - sourceType: dbData.sourceType!, 222 - anonymous: dbData.anonymous!, 223 - createdAt: dbData.createdAt!, 224 - sourceUri: dbData.sourceUri, 225 - sourceData: dbData.sourceData, 226 - atUri 227 - }).returning(); 228 - insertedQuestion = inserted; 234 + const [inserted] = await db 235 + .insert(questions) 236 + .values({ 237 + authorDid: dbData.authorDid!, 238 + targetDid: dbData.targetDid!, 239 + content: dbData.content!, 240 + sourceType: dbData.sourceType!, 241 + anonymous: dbData.anonymous!, 242 + createdAt: dbData.createdAt!, 243 + sourceUri: dbData.sourceUri, 244 + sourceData: dbData.sourceData, 245 + atUri, 246 + }) 247 + .returning({ id: questions.id }); 248 + questionId = inserted.id; 229 249 } catch (error) { 230 - console.error('Question validation failed:', error); 250 + console.error("Question validation failed:", error); 231 251 if (shouldFailOnValidationError()) { 232 - throw new Error('Question validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 252 + throw new Error( 253 + "Question validation failed: " + 254 + (error instanceof Error ? error.message : "Unknown error"), 255 + ); 233 256 } 234 - // Fallback to original behavior 235 - const [inserted] = await db.insert(questions).values({ 236 - authorDid: session.user.did, 237 - targetDid, 238 - content, 239 - anonymous, 240 - }).returning(); 241 - insertedQuestion = inserted; 257 + const [inserted] = await db 258 + .insert(questions) 259 + .values({ authorDid: session.user.did, targetDid, content, anonymous }) 260 + .returning({ id: questions.id }); 261 + questionId = inserted.id; 242 262 } 243 263 } else { 244 - // Original behavior when validation is disabled 245 - const [inserted] = await db.insert(questions).values({ 246 - authorDid: session.user.did, 247 - targetDid, 248 - content, 249 - anonymous, 250 - }).returning(); 251 - insertedQuestion = inserted; 264 + const [inserted] = await db 265 + .insert(questions) 266 + .values({ authorDid: session.user.did, targetDid, content, anonymous }) 267 + .returning({ id: questions.id }); 268 + questionId = inserted.id; 252 269 } 253 270 254 271 const target = await db.query.users.findFirst({ 255 272 where: eq(users.did, targetDid), 256 273 }); 257 - throw redirect(`/${target!.handle}`, { 274 + throw redirect(`/${target!.handle}#question-${questionId}`, { 258 275 revalidate: getQuestions.keyFor(targetDid), 259 276 }); 260 277 }, ··· 264 281 export const submitAnswer = action( 265 282 async (questionId: string, formData: FormData) => { 266 283 "use server"; 267 - const sessionId = getCookie(SESSION_COOKIE); 268 - if (!sessionId) throw new Error("Unauthorized"); 269 - const session = await getSession(sessionId); 270 - if (!session) throw new Error("Unauthorized"); 284 + const session = await requireSession(); 285 + 271 286 const content = formData.get("content")?.toString()?.trim(); 272 287 if (!content) return; 273 - 288 + 274 289 const q = await db.query.questions.findFirst({ 275 290 where: eq(questions.id, questionId), 276 291 }); 277 - if (!q || q.targetDid !== session.user.did) throw new Error("Forbidden"); 292 + if (!q || q.targetDid !== session.user.did) { 293 + throw new ForbiddenError(); 294 + } 278 295 279 - let insertedAnswer; 280 296 let atUri: string | null = null; 281 297 282 - // Validate answer if validation is enabled 283 298 if (isValidationEnabled()) { 284 299 try { 285 - // Use question's atUri if available, otherwise construct a placeholder 286 - const questionAtUri = q.atUri || constructAtUri(q.authorDid, 'com.askimut.question', 'placeholder'); 287 - 300 + const questionAtUri = 301 + q.atUri || 302 + constructAtUri(q.authorDid, "com.askimut.question", "placeholder"); 303 + 288 304 const { lexRecord, dbData } = createValidatedAnswer({ 289 305 content, 290 306 questionId, 291 307 questionAtUri, 292 308 authorDid: session.user.did, 293 - sourceType: SOURCE_TYPES.ASKIMUT // Native Askimut answers 309 + sourceType: SOURCE_TYPES.ASKIMUT, 294 310 }); 295 311 296 - // Publish to AT Protocol if enabled 297 - if (isPublishingEnabled('answer')) { 312 + if (isPublishingEnabled("answer")) { 298 313 try { 299 314 const atClient = await createAtClientForDid(session.user.did); 300 315 if (atClient.isAuthenticated()) { 301 316 const result = await atClient.publishAnswer(lexRecord); 302 - if (result) { 303 - atUri = result.uri; 304 - } 317 + if (result) atUri = result.uri; 305 318 } 306 319 } catch (error) { 307 - console.error('Failed to publish answer to AT Protocol:', error); 308 - // Don't fail the operation if AT Protocol publishing fails 320 + console.error( 321 + "Failed to publish answer to AT Protocol:", 322 + error, 323 + ); 309 324 } 310 325 } 311 326 312 - // Insert into database with validated data 313 - const [inserted] = await db.insert(answers).values({ 327 + await db.insert(answers).values({ 314 328 authorDid: dbData.authorDid!, 315 329 questionId: dbData.questionId!, 316 330 content: dbData.content!, ··· 318 332 createdAt: dbData.createdAt!, 319 333 sourceUri: dbData.sourceUri, 320 334 sourceData: dbData.sourceData, 321 - atUri 322 - }).returning(); 323 - insertedAnswer = inserted; 335 + atUri, 336 + }); 324 337 } catch (error) { 325 - console.error('Answer validation failed:', error); 338 + console.error("Answer validation failed:", error); 326 339 if (shouldFailOnValidationError()) { 327 - throw new Error('Answer validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 340 + throw new Error( 341 + "Answer validation failed: " + 342 + (error instanceof Error ? error.message : "Unknown error"), 343 + ); 328 344 } 329 - // Fallback to original behavior 330 - const [inserted] = await db.insert(answers).values({ 345 + await db.insert(answers).values({ 331 346 questionId, 332 347 authorDid: session.user.did, 333 348 content, 334 - }).returning(); 335 - insertedAnswer = inserted; 349 + }); 336 350 } 337 351 } else { 338 - // Original behavior when validation is disabled 339 - const [inserted] = await db.insert(answers).values({ 352 + await db.insert(answers).values({ 340 353 questionId, 341 354 authorDid: session.user.did, 342 355 content, 343 - }).returning(); 344 - insertedAnswer = inserted; 356 + }); 345 357 } 346 358 347 359 const target = await db.query.users.findFirst({ 348 360 where: eq(users.did, q.targetDid), 349 361 }); 350 - throw redirect(`/${target!.handle}/pending`, { 351 - revalidate: getPendingQuestions.keyFor(q.targetDid), 352 - }); 362 + throw redirect(`/${target!.handle}/pending`); 353 363 }, 354 364 "submitAnswer", 355 365 ); 356 366 357 - export const updateProfile = action( 358 - async (formData: FormData) => { 359 - "use server"; 360 - const sessionId = getCookie(SESSION_COOKIE); 361 - if (!sessionId) throw new Error("Unauthorized"); 362 - const session = await getSession(sessionId); 363 - if (!session) throw new Error("Unauthorized"); 364 - 365 - const displayName = formData.get("displayName")?.toString()?.trim(); 366 - const description = formData.get("description")?.toString()?.trim(); 367 - 368 - // Validate profile if validation is enabled 369 - if (isValidationEnabled()) { 370 - try { 371 - const { lexRecord, dbData } = createValidatedProfile({ 372 - displayName: displayName || undefined, 373 - description: description || undefined, 374 - questionsOpen: session.user.questionsOpen 375 - }); 367 + export const updateProfile = action(async (formData: FormData) => { 368 + "use server"; 369 + const session = await requireSession(); 376 370 377 - // Publish to AT Protocol if enabled 378 - if (isPublishingEnabled('profile')) { 379 - try { 380 - const atClient = await createAtClientForDid(session.user.did); 381 - if (atClient.isAuthenticated()) { 382 - await atClient.publishProfile(lexRecord); 383 - } 384 - } catch (error) { 385 - console.error('Failed to publish profile to AT Protocol:', error); 386 - // Don't fail the operation if AT Protocol publishing fails 371 + const displayName = formData.get("displayName")?.toString()?.trim(); 372 + const description = formData.get("description")?.toString()?.trim(); 373 + 374 + if (isValidationEnabled()) { 375 + try { 376 + const { lexRecord, dbData } = createValidatedProfile({ 377 + displayName: displayName || undefined, 378 + description: description || undefined, 379 + questionsOpen: session.user.questionsOpen, 380 + }); 381 + 382 + if (isPublishingEnabled("profile")) { 383 + try { 384 + const atClient = await createAtClientForDid(session.user.did); 385 + if (atClient.isAuthenticated()) { 386 + await atClient.publishProfile(lexRecord); 387 387 } 388 + } catch (error) { 389 + console.error( 390 + "Failed to publish profile to AT Protocol:", 391 + error, 392 + ); 388 393 } 394 + } 389 395 390 - // Update database with validated data 391 - await db 392 - .update(users) 393 - .set({ 394 - ...dbData, 395 - displayName: displayName || null 396 - }) 397 - .where(eq(users.did, session.user.did)); 398 - } catch (error) { 399 - console.error('Profile validation failed:', error); 400 - if (shouldFailOnValidationError()) { 401 - throw new Error('Profile validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 402 - } 403 - // Fallback to original behavior 404 - await db 405 - .update(users) 406 - .set({ 407 - displayName: displayName || null, 408 - updatedAt: new Date() 409 - }) 410 - .where(eq(users.did, session.user.did)); 396 + await db 397 + .update(users) 398 + .set({ ...dbData, displayName: displayName || null }) 399 + .where(eq(users.did, session.user.did)); 400 + } catch (error) { 401 + console.error("Profile validation failed:", error); 402 + if (shouldFailOnValidationError()) { 403 + throw new Error( 404 + "Profile validation failed: " + 405 + (error instanceof Error ? error.message : "Unknown error"), 406 + ); 411 407 } 412 - } else { 413 - // Original behavior when validation is disabled 414 408 await db 415 409 .update(users) 416 - .set({ 417 - displayName: displayName || null, 418 - updatedAt: new Date() 419 - }) 410 + .set({ displayName: displayName || null, updatedAt: new Date() }) 420 411 .where(eq(users.did, session.user.did)); 421 412 } 413 + } else { 414 + await db 415 + .update(users) 416 + .set({ displayName: displayName || null, updatedAt: new Date() }) 417 + .where(eq(users.did, session.user.did)); 418 + } 422 419 423 - await revalidate([ 424 - getUserByHandle.keyFor(session.user.handle), 425 - getCurrentUser.keyFor(), 426 - ]); 427 - }, 428 - "updateProfile", 429 - ); 420 + await revalidate([ 421 + getViewerContext.keyFor(session.user.handle), 422 + getUserByHandle.keyFor(session.user.handle), 423 + getCurrentUser.keyFor(), 424 + ]); 425 + }, "updateProfile"); 430 426 431 - export const importQuestionFromSource = action( 432 - async (formData: FormData) => { 433 - "use server"; 434 - const sessionId = getCookie(SESSION_COOKIE); 435 - if (!sessionId) throw new Error("Unauthorized"); 436 - const session = await getSession(sessionId); 437 - if (!session) throw new Error("Unauthorized"); 438 - 439 - const content = formData.get("content")?.toString()?.trim(); 440 - const targetDid = formData.get("targetDid")?.toString()?.trim(); 441 - const sourceType = formData.get("sourceType")?.toString()?.trim(); 442 - const sourceUri = formData.get("sourceUri")?.toString()?.trim(); 443 - const sourceDataStr = formData.get("sourceData")?.toString()?.trim(); 444 - const anonymous = formData.has("anonymous"); 445 - 446 - if (!content || !targetDid || !sourceType) { 447 - throw new Error("Missing required fields"); 448 - } 427 + export const importQuestionFromSource = action(async (formData: FormData) => { 428 + "use server"; 429 + const session = await requireSession(); 449 430 450 - let sourceData: Record<string, unknown> | undefined; 451 - if (sourceDataStr) { 452 - try { 453 - sourceData = JSON.parse(sourceDataStr); 454 - } catch { 455 - throw new Error("Invalid source data JSON"); 456 - } 457 - } 431 + const content = formData.get("content")?.toString()?.trim(); 432 + const targetDid = formData.get("targetDid")?.toString()?.trim(); 433 + const sourceType = formData.get("sourceType")?.toString()?.trim(); 434 + const sourceUri = formData.get("sourceUri")?.toString()?.trim(); 435 + const sourceDataStr = formData.get("sourceData")?.toString()?.trim(); 436 + const anonymous = formData.has("anonymous"); 458 437 459 - // Validate that target user exists 460 - const target = await db.query.users.findFirst({ 461 - where: eq(users.did, targetDid), 462 - }); 463 - if (!target) { 464 - throw new Error("Target user not found"); 438 + if (!content || !targetDid || !sourceType) { 439 + throw new Error("Missing required fields"); 440 + } 441 + 442 + let sourceData: Record<string, unknown> | undefined; 443 + if (sourceDataStr) { 444 + try { 445 + sourceData = JSON.parse(sourceDataStr); 446 + } catch { 447 + throw new Error("Invalid source data JSON"); 465 448 } 449 + } 466 450 467 - let insertedQuestion; 468 - let atUri: string | null = null; 451 + const target = await db.query.users.findFirst({ 452 + where: eq(users.did, targetDid), 453 + }); 454 + if (!target) throw new NotFoundError("Target user not found"); 469 455 470 - if (isValidationEnabled()) { 471 - try { 472 - const { lexRecord, dbData } = createValidatedQuestion({ 473 - content, 474 - targetDid, 475 - authorDid: session.user.did, 476 - sourceType: sourceType as any, 477 - anonymous, 478 - sourceUri, 479 - sourceData 480 - }); 456 + let insertedId: string | undefined; 457 + let atUri: string | null = null; 481 458 482 - // Publish to AT Protocol if enabled 483 - if (isPublishingEnabled('question')) { 484 - try { 485 - const atClient = await createAtClientForDid(session.user.did); 486 - if (atClient.isAuthenticated()) { 487 - const result = await atClient.publishQuestion(lexRecord); 488 - if (result) { 489 - atUri = result.uri; 490 - } 491 - } 492 - } catch (error) { 493 - console.error('Failed to publish imported question to AT Protocol:', error); 459 + if (isValidationEnabled()) { 460 + try { 461 + const { lexRecord, dbData } = createValidatedQuestion({ 462 + content, 463 + targetDid, 464 + authorDid: session.user.did, 465 + sourceType: sourceType as any, 466 + anonymous, 467 + sourceUri, 468 + sourceData, 469 + }); 470 + 471 + if (isPublishingEnabled("question")) { 472 + try { 473 + const atClient = await createAtClientForDid(session.user.did); 474 + if (atClient.isAuthenticated()) { 475 + const result = await atClient.publishQuestion(lexRecord); 476 + if (result) atUri = result.uri; 494 477 } 478 + } catch (error) { 479 + console.error( 480 + "Failed to publish imported question to AT Protocol:", 481 + error, 482 + ); 495 483 } 484 + } 496 485 497 - // Insert into database with validated data 498 - const [inserted] = await db.insert(questions).values({ 486 + const [inserted] = await db 487 + .insert(questions) 488 + .values({ 499 489 authorDid: dbData.authorDid!, 500 490 targetDid: dbData.targetDid!, 501 491 content: dbData.content!, ··· 504 494 createdAt: dbData.createdAt!, 505 495 sourceUri: dbData.sourceUri, 506 496 sourceData: dbData.sourceData, 507 - atUri 508 - }).returning(); 509 - insertedQuestion = inserted; 510 - } catch (error) { 511 - console.error('Question import validation failed:', error); 512 - if (shouldFailOnValidationError()) { 513 - throw new Error('Question import validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 514 - } 515 - // Fallback to basic insertion 516 - const [inserted] = await db.insert(questions).values({ 497 + atUri, 498 + }) 499 + .returning({ id: questions.id }); 500 + insertedId = inserted.id; 501 + } catch (error) { 502 + console.error("Question import validation failed:", error); 503 + if (shouldFailOnValidationError()) { 504 + throw new Error( 505 + "Question import validation failed: " + 506 + (error instanceof Error ? error.message : "Unknown error"), 507 + ); 508 + } 509 + const [inserted] = await db 510 + .insert(questions) 511 + .values({ 517 512 authorDid: session.user.did, 518 513 targetDid, 519 514 content, ··· 521 516 anonymous, 522 517 sourceUri, 523 518 sourceData: sourceData ? JSON.stringify(sourceData) : null, 524 - }).returning(); 525 - insertedQuestion = inserted; 526 - } 527 - } else { 528 - // Direct insertion when validation is disabled 529 - const [inserted] = await db.insert(questions).values({ 519 + }) 520 + .returning({ id: questions.id }); 521 + insertedId = inserted.id; 522 + } 523 + } else { 524 + const [inserted] = await db 525 + .insert(questions) 526 + .values({ 530 527 authorDid: session.user.did, 531 528 targetDid, 532 529 content, ··· 534 531 anonymous, 535 532 sourceUri, 536 533 sourceData: sourceData ? JSON.stringify(sourceData) : null, 537 - }).returning(); 538 - insertedQuestion = inserted; 539 - } 534 + }) 535 + .returning({ id: questions.id }); 536 + insertedId = inserted.id; 537 + } 540 538 541 - await revalidate([ 542 - getQuestions.keyFor(targetDid), 543 - getPendingQuestions.keyFor(targetDid), 544 - getUserByHandle.keyFor(target.handle), 545 - ]); 539 + await revalidate([ 540 + getQuestions.keyFor(targetDid), 541 + getPendingQuestions.keyFor(targetDid), 542 + getViewerContext.keyFor(target.handle), 543 + getUserByHandle.keyFor(target.handle), 544 + ]); 546 545 547 - return { success: true, questionId: insertedQuestion.id }; 548 - }, 549 - "importQuestionFromSource", 550 - ); 546 + return { success: true, questionId: insertedId }; 547 + }, "importQuestionFromSource"); 551 548 552 - // Comprehensive cache clearing for AT Protocol reindexing scenarios 549 + /** Comprehensive cache clearing for AT Protocol reindexing scenarios. */ 553 550 export const clearAllQuestionCaches = action(async (targetDid: string) => { 554 551 "use server"; 555 - const sessionId = getCookie(SESSION_COOKIE); 556 - if (!sessionId) throw new Error("Unauthorized"); 557 - const session = await getSession(sessionId); 558 - if (!session) throw new Error("Unauthorized"); 559 - 560 - // Clear all question-related caches for the target user 552 + await requireSession(); 553 + 554 + const target = await db.query.users.findFirst({ 555 + where: eq(users.did, targetDid), 556 + }); 557 + 561 558 await revalidate([ 562 559 getQuestions.keyFor(targetDid), 563 560 getPendingQuestions.keyFor(targetDid), 564 561 getCurrentUser.keyFor(), 562 + ...(target 563 + ? [ 564 + getUserByHandle.keyFor(target.handle), 565 + getViewerContext.keyFor(target.handle), 566 + ] 567 + : []), 565 568 ]); 566 - 567 - // Also clear user profile cache if we have the user 568 - const target = await db.query.users.findFirst({ 569 - where: eq(users.did, targetDid), 570 - }); 571 - 572 - if (target) { 573 - await revalidate([getUserByHandle.keyFor(target.handle)]); 574 - } 575 569 }, "clearAllQuestionCaches");
+20 -41
src/lib/route-guards.ts
··· 1 1 import { redirect } from "@solidjs/router"; 2 2 import { getCookie } from "vinxi/http"; 3 3 import { getSession, SESSION_COOKIE } from "~/lib/session"; 4 + import { UnauthorizedError } from "~/lib/errors"; 4 5 5 - export async function requireAuth() { 6 + export { RouteError, NotFoundError, ForbiddenError, UnauthorizedError } from "~/lib/errors"; 7 + 8 + /** 9 + * Reads + validates the current session. Throws `UnauthorizedError` so callers 10 + * can either let the root `ErrorBoundary` handle it or catch and rethrow a 11 + * `redirect(...)` for UI flows. 12 + */ 13 + export async function requireSession() { 6 14 "use server"; 7 15 const sessionId = getCookie(SESSION_COOKIE); 8 - if (!sessionId) { 9 - throw redirect("/"); 10 - } 11 - 16 + if (!sessionId) throw new UnauthorizedError(); 12 17 const session = await getSession(sessionId); 13 - if (!session) { 18 + if (!session) throw new UnauthorizedError(); 19 + return session; 20 + } 21 + 22 + export async function requireAuth() { 23 + "use server"; 24 + try { 25 + const session = await requireSession(); 26 + return session.user; 27 + } catch { 14 28 throw redirect("/"); 15 29 } 16 - 17 - return session.user; 18 30 } 19 31 20 32 export async function requireNotOwner(targetHandle: string) { 21 33 "use server"; 22 34 const user = await requireAuth(); 23 - 24 35 if (user.handle === targetHandle) { 25 36 throw redirect(`/${targetHandle}`); 26 37 } 27 - 28 38 return user; 29 39 } 30 40 31 41 export async function requireOwner(targetHandle: string) { 32 42 "use server"; 33 43 const user = await requireAuth(); 34 - 35 44 if (user.handle !== targetHandle) { 36 45 throw redirect(`/${targetHandle}`); 37 46 } 38 - 39 47 return user; 40 48 } 41 - 42 - export class RouteError extends Error { 43 - constructor( 44 - message: string, 45 - public statusCode: number = 500, 46 - public title: string = "Error" 47 - ) { 48 - super(message); 49 - this.name = "RouteError"; 50 - } 51 - } 52 - 53 - export class NotFoundError extends RouteError { 54 - constructor(message: string = "Page not found") { 55 - super(message, 404, "Not Found"); 56 - } 57 - } 58 - 59 - export class ForbiddenError extends RouteError { 60 - constructor(message: string = "Access denied") { 61 - super(message, 403, "Forbidden"); 62 - } 63 - } 64 - 65 - export class UnauthorizedError extends RouteError { 66 - constructor(message: string = "Please sign in to continue") { 67 - super(message, 401, "Unauthorized"); 68 - } 69 - }
+154 -217
src/routes/[handle]/index.tsx
··· 3 3 createAsync, 4 4 useParams, 5 5 useSubmission, 6 + type RouteDefinition, 6 7 } from "@solidjs/router"; 7 8 import { For, Show } from "solid-js"; 8 9 9 10 import { 10 - getCurrentUser, 11 + getPendingQuestions, 11 12 getQuestions, 12 - getPendingQuestions, 13 - getUserByHandle, 13 + getViewerContext, 14 14 toggleQuestionsOpen, 15 15 } from "~/lib/queries"; 16 - import SourceAttribution from "~/components/SourceAttribution"; 16 + import ProfileHeader from "~/components/ProfileHeader"; 17 + import QuestionCard from "~/components/QuestionCard"; 17 18 18 19 import styles from "../[handle].module.css"; 19 20 21 + type Viewer = NonNullable<Awaited<ReturnType<typeof getViewerContext>>>; 22 + type Profile = Viewer["profile"]; 23 + type Question = Awaited<ReturnType<typeof getQuestions>>[number]; 24 + 20 25 export const route = { 21 - preload({ params }: { params: { handle: string } }) { 22 - getUserByHandle(params.handle); 23 - getCurrentUser(); 26 + preload: ({ params }) => { 27 + void getViewerContext(params.handle!); 24 28 }, 25 - }; 26 - 27 - function formatWhen(d: Date | string) { 28 - const date = typeof d === "string" ? new Date(d) : d; 29 - return date.toLocaleString(undefined, { 30 - dateStyle: "medium", 31 - timeStyle: "short", 32 - }); 33 - } 29 + } satisfies RouteDefinition; 34 30 35 31 export default function UserProfile() { 36 - const params = useParams(); 32 + const params = useParams<{ handle: string }>(); 33 + const viewer = createAsync(() => getViewerContext(params.handle)); 37 34 const toggling = useSubmission(toggleQuestionsOpen); 38 - const currentUser = createAsync(() => getCurrentUser()); 39 - const profileUser = createAsync(() => getUserByHandle(params.handle)); 40 - const questions = createAsync(async () => { 41 - const p = await getUserByHandle(params.handle); 42 - if (!p) return []; 43 - return getQuestions(p.did); 44 - }); 45 35 46 - const pendingQuestions = createAsync(async () => { 47 - const p = await getUserByHandle(params.handle); 48 - const c = await getCurrentUser(); 49 - if (!p || !c || c.did !== p.did) return []; 50 - try { 51 - return await getPendingQuestions(p.did); 52 - } catch { 53 - return []; 54 - } 55 - }); 56 - 57 - const isOwn = () => { 58 - const c = currentUser(); 59 - const p = profileUser(); 60 - return Boolean(c && p && c.did === p.did); 36 + // Optimistic: flip questionsOpen immediately while the toggle is in-flight. 37 + const questionsOpen = () => { 38 + const ctx = viewer(); 39 + if (!ctx) return false; 40 + return toggling.pending ? !ctx.profile.questionsOpen : ctx.profile.questionsOpen; 61 41 }; 62 42 63 43 return ( 64 44 <div class={styles.page}> 65 - <Show 66 - when={profileUser()} 67 - keyed 68 - fallback={<p class={styles.notFound}>User not found.</p>} 69 - > 70 - {(profile) => ( 45 + <Show when={viewer()}> 46 + {(ctx) => ( 71 47 <> 72 - <header class={styles.header}> 73 - <Show 74 - when={profile.avatarUrl} 75 - keyed 76 - fallback={ 77 - <div 78 - class={`${styles.avatar} ${styles.avatarPlaceholder}`} 79 - aria-hidden 80 - > 81 - {(profile.displayName || profile.handle) 82 - .slice(0, 1) 83 - .toUpperCase()} 84 - </div> 85 - } 86 - > 87 - {(src) => ( 88 - <img 89 - class={styles.avatar} 90 - src={src} 91 - alt="" 92 - width={64} 93 - height={64} 94 - /> 95 - )} 96 - </Show> 97 - <div class={styles.profileText}> 98 - <div class={styles.displayName}> 99 - {profile.displayName || profile.handle} 100 - </div> 101 - <div class={styles.handle}>@{profile.handle}</div> 102 - </div> 103 - </header> 48 + <ProfileHeader profile={ctx().profile} /> 49 + <Show when={ctx().isOwner} fallback={ 50 + <VisitorView 51 + profile={ctx().profile} 52 + viewerDid={ctx().viewer?.did ?? null} 53 + questionsOpen={questionsOpen()} 54 + /> 55 + }> 56 + <OwnerView 57 + profile={ctx().profile} 58 + questionsOpen={questionsOpen()} 59 + togglePending={Boolean(toggling.pending)} 60 + /> 61 + </Show> 62 + </> 63 + )} 64 + </Show> 65 + </div> 66 + ); 67 + } 68 + 69 + // ---------------------------------------------------------------------------- 70 + // Owner view 71 + // ---------------------------------------------------------------------------- 72 + 73 + function OwnerView(props: { 74 + profile: Profile; 75 + questionsOpen: boolean; 76 + togglePending: boolean; 77 + }) { 78 + const params = useParams(); 79 + const questions = createAsync(() => getQuestions(props.profile.did)); 80 + const pendingQuestions = createAsync(async () => 81 + props.questionsOpen ? getPendingQuestions(props.profile.did) : [], 82 + ); 83 + 84 + return ( 85 + <> 86 + <Show when={props.questionsOpen}> 87 + <div class={styles.pageActions}> 88 + <Show when={(pendingQuestions()?.length ?? 0) > 0}> 89 + <A href={`/${params.handle}/pending`} class={styles.button}> 90 + Answer Questions ({pendingQuestions()?.length}) 91 + </A> 92 + </Show> 93 + </div> 94 + </Show> 104 95 105 - <Show when={currentUser()}> 106 - <div class={styles.pageActions}> 107 - {/* Owner: answer pending — shown first, most prominent */} 108 - <Show when={isOwn() && profile.questionsOpen && pendingQuestions() && pendingQuestions()!.length > 0}> 109 - <A 110 - href={`/${params.handle}/pending`} 111 - class={styles.button} 112 - > 113 - Answer Questions ({pendingQuestions()?.length}) 114 - </A> 115 - </Show> 116 - {/* Visitor: ask a question */} 117 - <Show when={!isOwn() && profile.questionsOpen}> 118 - <A 119 - href={`/${params.handle}/new`} 120 - class={styles.button} 121 - > 122 - Ask a Question 123 - </A> 124 - </Show> 125 - </div> 126 - </Show> 96 + <Show when={!props.questionsOpen}> 97 + <p class={styles.status}> 98 + Your questions are closed. Open them to receive questions from others. 99 + </p> 100 + <form class={styles.form} action={toggleQuestionsOpen} method="post"> 101 + <button 102 + class={styles.button} 103 + type="submit" 104 + disabled={props.togglePending} 105 + > 106 + {props.togglePending ? "Updating…" : "Open your questions"} 107 + </button> 108 + </form> 109 + </Show> 110 + 111 + <Show when={props.questionsOpen}> 112 + <p class={styles.sectionTitle}>Your questions</p> 113 + <QuestionList questions={questions() ?? []} showPendingHint /> 114 + </Show> 115 + </> 116 + ); 117 + } 118 + 119 + // ---------------------------------------------------------------------------- 120 + // Visitor view 121 + // ---------------------------------------------------------------------------- 122 + 123 + function VisitorView(props: { 124 + profile: Profile; 125 + viewerDid: string | null; 126 + questionsOpen: boolean; 127 + }) { 128 + const params = useParams(); 129 + const questions = createAsync(async () => 130 + props.questionsOpen ? getQuestions(props.profile.did) : [], 131 + ); 127 132 128 - <Show when={isOwn()}> 129 - <Show when={!profile.questionsOpen}> 130 - <p class={styles.status}> 131 - Your questions are closed. Open them to receive questions from 132 - others. 133 - </p> 134 - <form 135 - class={styles.form} 136 - action={toggleQuestionsOpen} 137 - method="post" 138 - > 139 - <button class={styles.button} type="submit" disabled={toggling.pending}> 140 - {toggling.pending ? "Updating…" : "Open your questions"} 141 - </button> 142 - </form> 143 - </Show> 133 + return ( 134 + <> 135 + <Show when={props.questionsOpen && props.viewerDid}> 136 + <div class={styles.pageActions}> 137 + <A href={`/${params.handle}/new`} class={styles.button}> 138 + Ask a Question 139 + </A> 140 + </div> 141 + </Show> 144 142 145 - <Show when={profile.questionsOpen}> 146 - <p class={styles.sectionTitle}>Your questions</p> 147 - <div class={styles.questionList}> 148 - <For each={questions()}> 149 - {(q) => ( 150 - <article class={styles.questionCard}> 151 - <div class={styles.questionContent}>{q.content}</div> 152 - <div class={styles.questionMeta}> 153 - {q.anonymous 154 - ? "Anonymous" 155 - : q.author?.displayName || 156 - q.author?.handle || 157 - "Unknown"}{" "} 158 - · {formatWhen(q.createdAt)} 159 - <SourceAttribution 160 - sourceType={q.sourceType || 'askimut'} 161 - sourceUri={q.sourceUri} 162 - sourceData={q.sourceData} 163 - /> 164 - </div> 165 - <For each={q.answers}> 166 - {(a) => ( 167 - <div class={styles.answerBlock}> 168 - <div class={styles.answerLabel}>Answer</div> 169 - <div class={styles.answerText}>{a.content}</div> 170 - <div class={styles.questionMeta}> 171 - {formatWhen(a.createdAt)} 172 - <SourceAttribution 173 - sourceType={a.sourceType || 'askimut'} 174 - sourceUri={a.sourceUri} 175 - sourceData={a.sourceData} 176 - /> 177 - </div> 178 - </div> 179 - )} 180 - </For> 181 - <Show when={q.answers.length === 0}> 182 - <div class={styles.pendingIndicator}> 183 - <span class={styles.pendingText}>Pending answer</span> 184 - </div> 185 - </Show> 186 - </article> 187 - )} 188 - </For> 189 - </div> 190 - </Show> 191 - </Show> 143 + <Show when={!props.questionsOpen}> 144 + <p class={styles.status}> 145 + This user hasn&apos;t opened their questions yet. 146 + </p> 147 + </Show> 192 148 193 - <Show when={!isOwn()}> 194 - <Show when={!profile.questionsOpen}> 195 - <p class={styles.status}> 196 - This user hasn&apos;t opened their questions yet. 197 - </p> 198 - </Show> 149 + <Show when={props.questionsOpen}> 150 + <p class={styles.sectionTitle}>Questions</p> 151 + <QuestionList questions={questions() ?? []} /> 152 + <Show when={!props.viewerDid}> 153 + <p class={styles.status}> 154 + <A href="/">Sign in</A> to ask a question. 155 + </p> 156 + </Show> 157 + </Show> 158 + </> 159 + ); 160 + } 199 161 200 - <Show when={profile.questionsOpen}> 201 - <p class={styles.sectionTitle}>Questions</p> 202 - <div class={styles.questionList}> 203 - <For each={questions()}> 204 - {(q) => ( 205 - <article class={styles.questionCard}> 206 - <div class={styles.questionContent}>{q.content}</div> 207 - <div class={styles.questionMeta}> 208 - {q.anonymous 209 - ? "Anonymous" 210 - : q.author?.displayName || 211 - q.author?.handle || 212 - "Unknown"}{" "} 213 - · {formatWhen(q.createdAt)} 214 - <SourceAttribution 215 - sourceType={q.sourceType || 'askimut'} 216 - sourceUri={q.sourceUri} 217 - sourceData={q.sourceData} 218 - /> 219 - </div> 220 - <For each={q.answers}> 221 - {(a) => ( 222 - <div class={styles.answerBlock}> 223 - <div class={styles.answerLabel}>Answer</div> 224 - <div class={styles.answerText}>{a.content}</div> 225 - <div class={styles.questionMeta}> 226 - {formatWhen(a.createdAt)} 227 - <SourceAttribution 228 - sourceType={a.sourceType || 'askimut'} 229 - sourceUri={a.sourceUri} 230 - sourceData={a.sourceData} 231 - /> 232 - </div> 233 - </div> 234 - )} 235 - </For> 236 - </article> 237 - )} 238 - </For> 239 - </div> 162 + // ---------------------------------------------------------------------------- 163 + // Shared list 164 + // ---------------------------------------------------------------------------- 240 165 241 - <Show when={!currentUser()}> 242 - <p class={styles.status}> 243 - <A href="/">Sign in</A>{" "}to ask a question. 244 - </p> 245 - </Show> 246 - </Show> 247 - </Show> 248 - </> 166 + function QuestionList(props: { 167 + questions: Question[]; 168 + showPendingHint?: boolean; 169 + }) { 170 + return ( 171 + <div class={styles.questionList}> 172 + <For each={props.questions}> 173 + {(q) => ( 174 + <QuestionCard 175 + id={`question-${q.id}`} 176 + content={q.content} 177 + createdAt={q.createdAt} 178 + anonymous={q.anonymous} 179 + author={q.author} 180 + sourceType={q.sourceType} 181 + sourceUri={q.sourceUri} 182 + sourceData={q.sourceData} 183 + answers={q.answers} 184 + showPendingHint={props.showPendingHint} 185 + /> 249 186 )} 250 - </Show> 187 + </For> 251 188 </div> 252 189 ); 253 190 }
+39 -81
src/routes/[handle]/new.tsx
··· 1 1 import { 2 2 A, 3 3 createAsync, 4 - useAction, 5 4 useParams, 6 5 useSubmission, 6 + type RouteDefinition, 7 7 } from "@solidjs/router"; 8 - import { Show, ErrorBoundary } from "solid-js"; 8 + import { Show } from "solid-js"; 9 9 10 - import { 11 - getCurrentUser, 12 - getUserByHandle, 13 - submitQuestion, 14 - } from "~/lib/queries"; 15 - import { requireNotOwner, NotFoundError, ForbiddenError } from "~/lib/route-guards"; 16 - import ErrorBoundaryComponent from "~/components/ErrorBoundary"; 10 + import { getViewerContext, submitQuestion } from "~/lib/queries"; 11 + import { ForbiddenError } from "~/lib/errors"; 12 + import ProfileHeader from "~/components/ProfileHeader"; 17 13 18 14 import styles from "../[handle].module.css"; 19 15 20 16 export const route = { 21 - preload({ params }: { params: { handle: string } }) { 22 - getUserByHandle(params.handle); 23 - getCurrentUser(); 17 + preload: ({ params }) => { 18 + void getViewerContext(params.handle!); 24 19 }, 25 - }; 20 + } satisfies RouteDefinition; 26 21 27 22 export default function AskQuestion() { 28 - const params = useParams(); 29 - const doSubmit = useAction(submitQuestion); 23 + const params = useParams<{ handle: string }>(); 30 24 const submitting = useSubmission(submitQuestion); 31 25 32 - const currentUser = createAsync(async () => { 33 - try { 34 - return await requireNotOwner(params.handle); 35 - } catch { 36 - return null; 37 - } 38 - }); 39 - 40 - const profileUser = createAsync(async () => { 41 - const user = await getUserByHandle(params.handle); 42 - if (!user) { 43 - throw new NotFoundError("User not found"); 44 - } 45 - if (!user.questionsOpen) { 26 + const viewer = createAsync(async () => { 27 + const ctx = await getViewerContext(params.handle); 28 + if (!ctx.profile.questionsOpen) { 46 29 throw new ForbiddenError("This user hasn't opened their questions yet"); 47 30 } 48 - return user; 31 + return ctx; 49 32 }); 50 33 51 34 return ( 52 - <ErrorBoundary fallback={ErrorBoundaryComponent}> 53 - <div class={styles.page}> 54 - <Show when={profileUser() && currentUser()} keyed> 55 - {([profile]) => ( 35 + <div class={styles.page}> 36 + <Show when={viewer()}> 37 + {(ctx) => ( 56 38 <> 57 - <header class={styles.header}> 58 - <Show 59 - when={profile.avatarUrl} 60 - keyed 61 - fallback={ 62 - <div 63 - class={`${styles.avatar} ${styles.avatarPlaceholder}`} 64 - aria-hidden 65 - > 66 - {(profile.displayName || profile.handle) 67 - .slice(0, 1) 68 - .toUpperCase()} 69 - </div> 70 - } 71 - > 72 - {(src) => ( 73 - <img 74 - class={styles.avatar} 75 - src={src} 76 - alt="" 77 - width={64} 78 - height={64} 79 - /> 80 - )} 81 - </Show> 82 - <div class={styles.profileText}> 83 - <div class={styles.displayName}> 84 - Ask {profile.displayName || profile.handle} 85 - </div> 86 - <div class={styles.handle}>@{profile.handle}</div> 87 - </div> 88 - </header> 39 + <ProfileHeader 40 + profile={ctx().profile} 41 + title={`Ask ${ctx().profile.displayName || ctx().profile.handle}`} 42 + /> 89 43 90 44 <div class={styles.questionForm}> 91 45 <form 92 46 class={styles.form} 93 - onSubmit={async (e) => { 94 - e.preventDefault(); 95 - await doSubmit(profile.did, new FormData(e.currentTarget)); 96 - }} 47 + action={submitQuestion.with(ctx().profile.did)} 48 + method="post" 97 49 > 98 50 <textarea 99 51 class={styles.textarea} ··· 104 56 maxLength={500} 105 57 minLength={10} 106 58 onInput={(e) => { 107 - const target = e.target as HTMLTextAreaElement; 59 + const target = e.currentTarget; 108 60 if (target.value.length < 10) { 109 - target.setCustomValidity("Question must be at least 10 characters long"); 61 + target.setCustomValidity( 62 + "Question must be at least 10 characters long", 63 + ); 110 64 } else if (target.value.length > 500) { 111 - target.setCustomValidity("Question must be less than 500 characters"); 65 + target.setCustomValidity( 66 + "Question must be less than 500 characters", 67 + ); 112 68 } else { 113 69 target.setCustomValidity(""); 114 70 } 115 71 }} 116 72 /> 117 73 <label class={styles.toggleRow}> 118 - <input type="checkbox" name="anonymous" /> 119 - {" "}Ask anonymously 74 + <input type="checkbox" name="anonymous" /> Ask anonymously 120 75 </label> 121 76 <div class={styles.formActions}> 122 77 <A ··· 125 80 > 126 81 Cancel 127 82 </A> 128 - <button class={styles.button} type="submit" disabled={submitting.pending}> 83 + <button 84 + class={styles.button} 85 + type="submit" 86 + disabled={submitting.pending} 87 + > 129 88 {submitting.pending ? "Sending…" : "Send question"} 130 89 </button> 131 90 </div> 132 91 </form> 133 92 </div> 134 - </> 135 - )} 136 - </Show> 137 - </div> 138 - </ErrorBoundary> 93 + </> 94 + )} 95 + </Show> 96 + </div> 139 97 ); 140 98 }
+54 -114
src/routes/[handle]/pending/[id].tsx
··· 1 1 import { 2 2 A, 3 3 createAsync, 4 - useAction, 5 4 useParams, 6 5 useSubmission, 6 + type RouteDefinition, 7 7 } from "@solidjs/router"; 8 - import { Show, ErrorBoundary } from "solid-js"; 8 + import { Show } from "solid-js"; 9 9 10 10 import { 11 - getCurrentUser, 12 - getUserByHandle, 13 11 getQuestionById, 12 + getViewerContext, 14 13 submitAnswer, 15 14 } from "~/lib/queries"; 16 - import { NotFoundError } from "~/lib/route-guards"; 17 - import SourceAttribution from "~/components/SourceAttribution"; 18 - import ErrorBoundaryComponent from "~/components/ErrorBoundary"; 15 + import { ForbiddenError } from "~/lib/errors"; 16 + import ProfileHeader from "~/components/ProfileHeader"; 17 + import QuestionCard from "~/components/QuestionCard"; 19 18 20 19 import styles from "../../[handle].module.css"; 21 20 22 21 export const route = { 23 - preload({ params }: { params: { handle: string; id: string } }) { 24 - getUserByHandle(params.handle); 25 - getCurrentUser(); 26 - getQuestionById(params.id); 22 + preload: ({ params }) => { 23 + void getViewerContext(params.handle!); 24 + void getQuestionById(params.id!); 27 25 }, 28 - }; 29 - 30 - function formatWhen(d: Date | string) { 31 - const date = typeof d === "string" ? new Date(d) : d; 32 - return date.toLocaleString(undefined, { 33 - dateStyle: "medium", 34 - timeStyle: "short", 35 - }); 36 - } 26 + } satisfies RouteDefinition; 37 27 38 28 export default function AnswerQuestion() { 39 - const params = useParams(); 40 - const doSubmit = useAction(submitAnswer); 29 + const params = useParams<{ handle: string; id: string }>(); 41 30 const submitting = useSubmission(submitAnswer); 42 31 43 - const profileUser = createAsync(async () => { 44 - const user = await getUserByHandle(params.handle); 45 - if (!user) { 46 - throw new NotFoundError("User not found"); 47 - } 48 - return user; 49 - }); 50 - 51 - const currentUser = createAsync(() => getCurrentUser()); 52 - 53 - const question = createAsync(async () => { 54 - const user = await getCurrentUser(); 55 - if (!user) return null; 56 - try { 57 - return await getQuestionById(params.id); 58 - } catch (error) { 59 - if (error instanceof Error && error.message === "Question not found") { 60 - throw new NotFoundError("Question not found"); 61 - } 62 - throw error; 32 + const viewer = createAsync(async () => { 33 + const ctx = await getViewerContext(params.handle); 34 + if (!ctx.isOwner) { 35 + throw new ForbiddenError("Only the profile owner can answer questions"); 63 36 } 37 + return ctx; 64 38 }); 65 39 66 - const ownerContext = () => { 67 - const profile = profileUser(); 68 - const user = currentUser(); 69 - const q = question(); 70 - if (!profile || !user || !q) return undefined; 71 - return [profile, user, q] as const; 72 - }; 40 + const question = createAsync(() => getQuestionById(params.id)); 73 41 74 42 return ( 75 - <ErrorBoundary fallback={ErrorBoundaryComponent}> 76 - <div class={styles.page}> 77 - <Show when={ownerContext()} keyed> 78 - {([profile, , q]) => ( 43 + <div class={styles.page}> 44 + <Show when={viewer() && question()}> 45 + {(_ready) => { 46 + const ctx = viewer()!; 47 + const q = question()!; 48 + return ( 79 49 <> 80 - <header class={styles.header}> 81 - <Show 82 - when={profile.avatarUrl} 83 - keyed 84 - fallback={ 85 - <div 86 - class={`${styles.avatar} ${styles.avatarPlaceholder}`} 87 - aria-hidden 88 - > 89 - {(profile.displayName || profile.handle) 90 - .slice(0, 1) 91 - .toUpperCase()} 92 - </div> 93 - } 94 - > 95 - {(src) => ( 96 - <img 97 - class={styles.avatar} 98 - src={src} 99 - alt="" 100 - width={64} 101 - height={64} 102 - /> 103 - )} 104 - </Show> 105 - <div class={styles.profileText}> 106 - <div class={styles.displayName}> 107 - Answer Question 108 - </div> 109 - <div class={styles.handle}>@{profile.handle}</div> 110 - </div> 111 - </header> 50 + <ProfileHeader 51 + profile={ctx.profile} 52 + title="Answer Question" 53 + /> 112 54 113 55 <div class={styles.pageActions}> 114 56 <A ··· 120 62 </div> 121 63 122 64 <div class={styles.questionDetail}> 123 - <article class={styles.questionCard}> 124 - <div class={styles.questionLabel}>Question</div> 125 - <div class={styles.questionContent}>{q.content}</div> 126 - <div class={styles.questionMeta}> 127 - {q.anonymous 128 - ? "Anonymous" 129 - : q.author?.displayName || 130 - q.author?.handle || 131 - "Unknown"}{" "} 132 - · {formatWhen(q.createdAt)} 133 - <SourceAttribution 134 - sourceType={q.sourceType || 'askimut'} 135 - sourceUri={q.sourceUri} 136 - sourceData={q.sourceData} 137 - /> 138 - </div> 139 - </article> 65 + <QuestionCard 66 + content={q.content} 67 + createdAt={q.createdAt} 68 + anonymous={q.anonymous} 69 + author={q.author} 70 + sourceType={q.sourceType} 71 + sourceUri={q.sourceUri} 72 + sourceData={q.sourceData} 73 + /> 140 74 141 75 <div class={styles.answerForm}> 142 76 <form 143 77 class={styles.form} 144 - onSubmit={async (e) => { 145 - e.preventDefault(); 146 - await doSubmit(q.id, new FormData(e.currentTarget)); 147 - }} 78 + action={submitAnswer.with(q.id)} 79 + method="post" 148 80 > 149 81 <label class={styles.formLabel} for="answer-content"> 150 82 Your Answer ··· 159 91 maxLength={1000} 160 92 minLength={5} 161 93 onInput={(e) => { 162 - const target = e.target as HTMLTextAreaElement; 94 + const target = e.currentTarget; 163 95 if (target.value.length < 5) { 164 - target.setCustomValidity("Answer must be at least 5 characters long"); 96 + target.setCustomValidity( 97 + "Answer must be at least 5 characters long", 98 + ); 165 99 } else if (target.value.length > 1000) { 166 - target.setCustomValidity("Answer must be less than 1000 characters"); 100 + target.setCustomValidity( 101 + "Answer must be less than 1000 characters", 102 + ); 167 103 } else { 168 104 target.setCustomValidity(""); 169 105 } ··· 176 112 > 177 113 Cancel 178 114 </A> 179 - <button class={styles.button} type="submit" disabled={submitting.pending}> 115 + <button 116 + class={styles.button} 117 + type="submit" 118 + disabled={submitting.pending} 119 + > 180 120 {submitting.pending ? "Posting…" : "Post answer"} 181 121 </button> 182 122 </div> ··· 184 124 </div> 185 125 </div> 186 126 </> 187 - )} 188 - </Show> 189 - </div> 190 - </ErrorBoundary> 127 + ); 128 + }} 129 + </Show> 130 + </div> 191 131 ); 192 132 }
+79 -131
src/routes/[handle]/pending/index.tsx
··· 2 2 A, 3 3 createAsync, 4 4 useParams, 5 + type RouteDefinition, 5 6 } from "@solidjs/router"; 6 - import { For, Show, ErrorBoundary } from "solid-js"; 7 + import { For, Show } from "solid-js"; 7 8 8 - import { 9 - getCurrentUser, 10 - getUserByHandle, 11 - getPendingQuestions, 12 - } from "~/lib/queries"; 13 - import { NotFoundError } from "~/lib/route-guards"; 14 - import SourceAttribution from "~/components/SourceAttribution"; 15 - import ErrorBoundaryComponent from "~/components/ErrorBoundary"; 9 + import { getPendingQuestions, getViewerContext } from "~/lib/queries"; 10 + import { ForbiddenError } from "~/lib/errors"; 11 + import ProfileHeader from "~/components/ProfileHeader"; 12 + import QuestionCard from "~/components/QuestionCard"; 16 13 17 14 import styles from "../../[handle].module.css"; 18 15 19 16 export const route = { 20 - preload({ params }: { params: { handle: string } }) { 21 - getUserByHandle(params.handle); 22 - getCurrentUser(); 17 + preload: ({ params }) => { 18 + void getViewerContext(params.handle!); 23 19 }, 24 - }; 25 - 26 - function formatWhen(d: Date | string) { 27 - const date = typeof d === "string" ? new Date(d) : d; 28 - return date.toLocaleString(undefined, { 29 - dateStyle: "medium", 30 - timeStyle: "short", 31 - }); 32 - } 20 + } satisfies RouteDefinition; 33 21 34 22 export default function PendingQuestions() { 35 - const params = useParams(); 23 + const params = useParams<{ handle: string }>(); 36 24 37 - const profileUser = createAsync(async () => { 38 - const user = await getUserByHandle(params.handle); 39 - if (!user) { 40 - throw new NotFoundError("User not found"); 25 + const viewer = createAsync(async () => { 26 + const ctx = await getViewerContext(params.handle); 27 + if (!ctx.isOwner) { 28 + throw new ForbiddenError("Only the profile owner can view pending questions"); 41 29 } 42 - return user; 30 + return ctx; 43 31 }); 44 - 45 - const currentUser = createAsync(() => getCurrentUser()); 46 32 47 33 const pendingQuestions = createAsync(async () => { 48 - const profile = await getUserByHandle(params.handle); 49 - const user = await getCurrentUser(); 50 - if (!profile || !user || user.did !== profile.did) return []; 51 - return getPendingQuestions(profile.did); 34 + const ctx = await viewer(); 35 + if (!ctx) return []; 36 + return getPendingQuestions(ctx.profile.did); 52 37 }); 53 38 54 - const ownerContext = () => { 55 - const profile = profileUser(); 56 - const user = currentUser(); 57 - if (!profile || !user || user.did !== profile.did) return undefined; 58 - return [profile, user] as const; 59 - }; 60 - 61 39 return ( 62 - <ErrorBoundary fallback={ErrorBoundaryComponent}> 63 - <div class={styles.page}> 64 - <Show when={ownerContext()} keyed> 65 - {([profile]) => ( 66 - <> 67 - <header class={styles.header}> 68 - <Show 69 - when={profile.avatarUrl} 70 - keyed 71 - fallback={ 72 - <div 73 - class={`${styles.avatar} ${styles.avatarPlaceholder}`} 74 - aria-hidden 75 - > 76 - {(profile.displayName || profile.handle) 77 - .slice(0, 1) 78 - .toUpperCase()} 79 - </div> 80 - } 81 - > 82 - {(src) => ( 83 - <img 84 - class={styles.avatar} 85 - src={src} 86 - alt="" 87 - width={64} 88 - height={64} 89 - /> 90 - )} 91 - </Show> 92 - <div class={styles.profileText}> 93 - <div class={styles.displayName}> 94 - Pending Questions 95 - </div> 96 - <div class={styles.handle}>@{profile.handle}</div> 97 - </div> 98 - </header> 99 - 100 - <div class={styles.pageActions}> 101 - <A 102 - href={`/${params.handle}`} 103 - class={`${styles.button} ${styles.buttonSecondary}`} 104 - > 105 - ← Back to Profile 106 - </A> 107 - </div> 40 + <div class={styles.page}> 41 + <Show when={viewer()}> 42 + {(ctx) => ( 43 + <> 44 + <ProfileHeader 45 + profile={ctx().profile} 46 + title="Pending Questions" 47 + /> 108 48 109 - <Show 110 - when={pendingQuestions() && pendingQuestions()!.length > 0} 111 - fallback={ 112 - <div class={styles.emptyState}> 113 - <p class={styles.status}> 114 - No pending questions. All caught up! 115 - </p> 116 - </div> 117 - } 49 + <div class={styles.pageActions}> 50 + <A 51 + href={`/${params.handle}`} 52 + class={`${styles.button} ${styles.buttonSecondary}`} 118 53 > 119 - <p class={styles.sectionTitle}> 120 - Questions waiting for your answer ({pendingQuestions()?.length ?? 0}) 121 - </p> 122 - <div class={styles.questionList}> 123 - <For each={pendingQuestions()}> 124 - {(q) => ( 125 - <A 126 - href={`/${params.handle}/pending/${q.id}`} 127 - class={`${styles.questionCard} ${styles.clickable}`} 128 - > 129 - <div class={styles.questionContent}>{q.content}</div> 130 - <div class={styles.questionMeta}> 131 - {q.anonymous 132 - ? "Anonymous" 133 - : q.author?.displayName || 134 - q.author?.handle || 135 - "Unknown"}{" "} 136 - · {formatWhen(q.createdAt)} 137 - <SourceAttribution 138 - sourceType={q.sourceType || 'askimut'} 139 - sourceUri={q.sourceUri} 140 - sourceData={q.sourceData} 141 - /> 142 - </div> 143 - <div class={styles.questionActions}> 144 - <span class={styles.actionHint}>Click to answer →</span> 145 - </div> 146 - </A> 147 - )} 148 - </For> 54 + ← Back to Profile 55 + </A> 56 + </div> 57 + 58 + <Show 59 + when={(pendingQuestions()?.length ?? 0) > 0} 60 + fallback={ 61 + <div class={styles.emptyState}> 62 + <p class={styles.status}> 63 + No pending questions. All caught up! 64 + </p> 149 65 </div> 150 - </Show> 151 - </> 152 - )} 153 - </Show> 154 - </div> 155 - </ErrorBoundary> 66 + } 67 + > 68 + <p class={styles.sectionTitle}> 69 + Questions waiting for your answer ({pendingQuestions()?.length ?? 0}) 70 + </p> 71 + <div class={styles.questionList}> 72 + <For each={pendingQuestions()}> 73 + {(q) => ( 74 + <A 75 + href={`/${params.handle}/pending/${q.id}`} 76 + class={styles.clickable} 77 + style={{ "text-decoration": "none", color: "inherit" }} 78 + > 79 + <QuestionCard 80 + content={q.content} 81 + createdAt={q.createdAt} 82 + anonymous={q.anonymous} 83 + author={q.author} 84 + sourceType={q.sourceType} 85 + sourceUri={q.sourceUri} 86 + sourceData={q.sourceData} 87 + trailing={ 88 + <div class={styles.questionActions}> 89 + <span class={styles.actionHint}> 90 + Click to answer → 91 + </span> 92 + </div> 93 + } 94 + /> 95 + </A> 96 + )} 97 + </For> 98 + </div> 99 + </Show> 100 + </> 101 + )} 102 + </Show> 103 + </div> 156 104 ); 157 105 }
+44 -43
src/routes/index.tsx
··· 1 - import { Navigate, createAsync, useSubmission } from "@solidjs/router"; 2 - import { Show, createEffect } from "solid-js"; 1 + import { 2 + createAsync, 3 + redirect, 4 + useSubmission, 5 + type RouteDefinition, 6 + } from "@solidjs/router"; 7 + import { Show } from "solid-js"; 3 8 4 9 import { getCurrentUser, initiateLogin } from "~/lib/queries"; 5 10 6 11 import styles from "./index.module.css"; 7 12 13 + export const route = { 14 + async preload() { 15 + const user = await getCurrentUser(); 16 + if (user) throw redirect(`/${user.handle}`); 17 + }, 18 + } satisfies RouteDefinition; 19 + 8 20 export default function Home() { 9 21 const user = createAsync(() => getCurrentUser()); 10 22 const loggingIn = useSubmission(initiateLogin); 11 23 12 - // The app must be served from http://[::1]:3000 (IPv6 loopback) so that the 13 - // browser treats it as cross-site versus the PDS on http://localhost:2584. 14 - // The atproto oauth-provider rejects same-site authorize requests with a 15 - // 400 "Invalid request" page. 16 - createEffect(() => { 17 - const url = loggingIn.result?.redirectUrl; 18 - if (!url) return; 19 - window.location.assign(url); 20 - }); 21 - 22 24 return ( 23 - <> 24 - <Show when={user()} keyed> 25 - {(u) => <Navigate href={`/${u.handle}`} />} 26 - </Show> 27 - <Show when={!user()}> 28 - <div class={styles.page}> 29 - <div class={styles.card}> 30 - <h1 class={styles.title}>Askimut</h1> 31 - <p class={styles.subtitle}> 32 - Ask me anything, powered by AT Protocol 25 + <Show when={!user()}> 26 + <div class={styles.page}> 27 + <div class={styles.card}> 28 + <h1 class={styles.title}>Askimut</h1> 29 + <p class={styles.subtitle}> 30 + Ask me anything, powered by AT Protocol 31 + </p> 32 + <form class={styles.form} action={initiateLogin} method="post"> 33 + <input 34 + class={styles.input} 35 + name="handle" 36 + type="text" 37 + autocomplete="username" 38 + placeholder="your.handle.bsky.social" 39 + required 40 + /> 41 + <button 42 + class={styles.button} 43 + type="submit" 44 + disabled={loggingIn.pending} 45 + > 46 + {loggingIn.pending ? "Redirecting…" : "Sign in with Bluesky"} 47 + </button> 48 + </form> 49 + <Show when={loggingIn.result?.error}> 50 + <p style={{ color: "red", "margin-top": "0.5rem" }}> 51 + {loggingIn.result!.error} 33 52 </p> 34 - <form class={styles.form} action={initiateLogin} method="post"> 35 - <input 36 - class={styles.input} 37 - name="handle" 38 - type="text" 39 - autocomplete="username" 40 - placeholder="your.handle.bsky.social" 41 - required 42 - /> 43 - <button class={styles.button} type="submit"> 44 - Sign in with Bluesky 45 - </button> 46 - </form> 47 - <Show when={loggingIn.result?.error}> 48 - <p style={{ color: "red", "margin-top": "0.5rem" }}> 49 - {loggingIn.result!.error} 50 - </p> 51 - </Show> 52 - </div> 53 + </Show> 53 54 </div> 54 - </Show> 55 - </> 55 + </div> 56 + </Show> 56 57 ); 57 58 }
+8 -2
src/routes/oauth/login.ts
··· 1 1 "use server"; 2 2 3 + import { redirect } from "@solidjs/router"; 4 + 3 5 import { getOAuthClient } from "~/lib/oauth"; 4 6 5 7 export async function initiateLogin(formData: FormData) { ··· 9 11 return { error: "Handle is required" }; 10 12 } 11 13 14 + let url: URL; 12 15 try { 13 16 const oauth = await getOAuthClient(); 14 - const url = await oauth.authorize(handle, { 17 + url = await oauth.authorize(handle, { 15 18 scope: "atproto transition:generic", 16 19 }); 17 - return { redirectUrl: url.toString() }; 18 20 } catch (err) { 19 21 return { 20 22 error: `Could not initiate login: ${err instanceof Error ? err.message : String(err)}`, 21 23 }; 22 24 } 25 + 26 + // Cross-site redirect to the PDS authorize endpoint. Solid Router follows 27 + // throw redirect() from server actions and issues a real navigation. 28 + throw redirect(url.toString()); 23 29 }