an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

basic notifs

rimar1337 de4321b1 b819cd67

Changed files
+466 -154
src
+1
src/auto-imports.d.ts
··· 18 18 const IconMaterialSymbolsSettingsOutline: typeof import('~icons/material-symbols/settings-outline.jsx').default 19 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 + const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 21 22 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 22 23 }
+4 -2
src/components/Header.tsx
··· 5 5 6 6 export function Header({ 7 7 backButtonCallback, 8 - title 8 + title, 9 + bottomBorderDisabled, 9 10 }: { 10 11 backButtonCallback?: () => void; 11 12 title?: string; 13 + bottomBorderDisabled?: boolean; 12 14 }) { 13 15 const router = useRouter(); 14 16 const [isAtTop] = useAtom(isAtTopAtom); 15 17 //const what = router.history. 16 18 return ( 17 - <div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}> 19 + <div className={`flex items-center gap-3 px-3 py-3 h-[52px] sticky top-0 bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-10 border-0 ${!bottomBorderDisabled && "sm:border-b"} ${!isAtTop && !bottomBorderDisabled && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}> 18 20 {backButtonCallback ? (<Link 19 21 to=".." 20 22 //className="px-3 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-900 font-bold text-lg"
+1
src/components/UniversalPostRenderer.tsx
··· 2564 2564 // = 2565 2565 if (AppBskyEmbedVideo.isView(embed)) { 2566 2566 // hls playlist 2567 + if (nopics) return; 2567 2568 const playlist = embed.playlist; 2568 2569 return ( 2569 2570 <SmartHLSPlayer
+424 -152
src/routes/notifications.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { AtUri } from "@atproto/api"; 2 + import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 + import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 4 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 5 import { useAtom } from "jotai"; 3 - import React, { useEffect, useRef,useState } from "react"; 6 + import * as React from "react"; 4 7 8 + import defaultpfp from "~/../public/favicon.png"; 9 + import { Header } from "~/components/Header"; 10 + import { 11 + MdiCardsHeartOutline, 12 + MdiCommentOutline, 13 + MdiRepeat, 14 + UniversalPostRendererATURILoader, 15 + } from "~/components/UniversalPostRenderer"; 5 16 import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 - import { constellationURLAtom } from "~/utils/atoms"; 17 + import { constellationURLAtom, imgCDNAtom, isAtTopAtom } from "~/utils/atoms"; 18 + import { 19 + useInfiniteQueryAuthorFeed, 20 + useQueryConstellation, 21 + useQueryIdentity, 22 + useQueryProfile, 23 + yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 24 + } from "~/utils/useQuery"; 7 25 8 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 26 + import { FollowButton, Mutual } from "./profile.$did"; 27 + 28 + export function NotificationsComponent() { 29 + return ( 30 + <div className=""> 31 + <Header 32 + title={`Notifications`} 33 + backButtonCallback={() => { 34 + if (window.history.length > 1) { 35 + window.history.back(); 36 + } else { 37 + window.location.assign("/"); 38 + } 39 + }} 40 + bottomBorderDisabled={true} 41 + /> 42 + <NotificationsTabs /> 43 + </div> 44 + ); 45 + } 9 46 10 47 export const Route = createFileRoute("/notifications")({ 11 48 component: NotificationsComponent, 12 49 }); 13 50 14 - function NotificationsComponent() { 15 - // /*mass comment*/ console.log("NotificationsComponent render"); 16 - const { agent, status } = useAuth(); 17 - const authed = !!agent?.did; 18 - const authLoading = status === "loading"; 19 - const [did, setDid] = useState<string | null>(null); 20 - const [resolving, setResolving] = useState(false); 21 - const [error, setError] = useState<string | null>(null); 22 - const [responses, setResponses] = useState<any[]>([null, null, null]); 23 - const [loading, setLoading] = useState(false); 24 - const inputRef = useRef<HTMLInputElement>(null); 25 51 26 - useEffect(() => { 27 - if (authLoading) return; 28 - if (authed && agent && agent.assertDid) { 29 - setDid(agent.assertDid); 30 - } 31 - }, [authed, agent, authLoading]); 52 + export default function NotificationsTabs() { 53 + const [activeTab, setActiveTab] = React.useState("mentions"); 54 + const [isAtTop] = useAtom(isAtTopAtom); 32 55 33 - async function handleSubmit() { 34 - // /*mass comment*/ console.log("handleSubmit called"); 35 - setError(null); 36 - setResponses([null, null, null]); 37 - const value = inputRef.current?.value?.trim() || ""; 38 - if (!value) return; 39 - if (value.startsWith("did:")) { 40 - setDid(value); 41 - setError(null); 42 - return; 43 - } 44 - setResolving(true); 45 - const cacheKey = `handleDid:${value}`; 46 - const now = Date.now(); 47 - const cached = undefined // await get(cacheKey); 48 - // if ( 49 - // cached && 50 - // cached.value && 51 - // cached.time && 52 - // now - cached.time < HANDLE_DID_CACHE_TIMEOUT 53 - // ) { 54 - // try { 55 - // const data = JSON.parse(cached.value); 56 - // setDid(data.did); 57 - // setResolving(false); 58 - // return; 59 - // } catch {} 60 - // } 61 - try { 62 - const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`; 63 - const res = await fetch(url); 64 - if (!res.ok) throw new Error("Failed to resolve handle"); 65 - const data = await res.json(); 66 - //set(cacheKey, JSON.stringify(data)); 67 - setDid(data.did); 68 - } catch (e: any) { 69 - setError("Failed to resolve handle: " + (e?.message || e)); 70 - } finally { 71 - setResolving(false); 72 - } 73 - } 56 + const scrollPositions = React.useRef<Record<string, number>>({}); 74 57 75 - const [constellationURL] = useAtom(constellationURLAtom) 58 + const handleValueChange = (newTab: string) => { 59 + scrollPositions.current[activeTab] = window.scrollY; 60 + setActiveTab(newTab); 61 + }; 76 62 77 - useEffect(() => { 78 - if (!did) return; 79 - setLoading(true); 80 - setError(null); 81 - const urls = [ 82 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`, 83 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`, 84 - `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`, 85 - ]; 86 - let ignore = false; 87 - Promise.all( 88 - urls.map(async (url) => { 89 - try { 90 - const r = await fetch(url); 91 - if (!r.ok) throw new Error("Failed to fetch"); 92 - const text = await r.text(); 93 - if (!text) return null; 94 - try { 95 - return JSON.parse(text); 96 - } catch { 97 - return null; 63 + React.useEffect(() => { 64 + const savedY = scrollPositions.current[activeTab] ?? 0; 65 + window.scrollTo(0, savedY); 66 + }, [activeTab]); 67 + 68 + return ( 69 + <TabsPrimitive.Root 70 + value={activeTab} 71 + onValueChange={handleValueChange} 72 + className={`w-full`} 73 + > 74 + <TabsPrimitive.List 75 + className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`} 76 + > 77 + <TabsPrimitive.Trigger 78 + value="mentions" 79 + // styling is in app.css 80 + > 81 + Mentions 82 + </TabsPrimitive.Trigger> 83 + <TabsPrimitive.Trigger value="follows">Follows</TabsPrimitive.Trigger> 84 + <TabsPrimitive.Trigger value="postInteractions"> 85 + Post Interactions 86 + </TabsPrimitive.Trigger> 87 + </TabsPrimitive.List> 88 + 89 + <TabsPrimitive.Content value="mentions" className="flex-1"> 90 + {activeTab === "mentions" && <MentionsTab />} 91 + </TabsPrimitive.Content> 92 + 93 + <TabsPrimitive.Content value="follows" className="flex-1"> 94 + {activeTab === "follows" && <FollowsTab />} 95 + </TabsPrimitive.Content> 96 + 97 + <TabsPrimitive.Content value="postInteractions" className="flex-1"> 98 + {activeTab === "postInteractions" && <PostInteractionsTab />} 99 + </TabsPrimitive.Content> 100 + </TabsPrimitive.Root> 101 + ); 102 + } 103 + 104 + function MentionsTab() { 105 + const { agent } = useAuth(); 106 + const [constellationurl] = useAtom(constellationURLAtom); 107 + const infinitequeryresults = useInfiniteQuery({ 108 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 109 + { 110 + constellation: constellationurl, 111 + method: "/links", 112 + target: agent?.did, 113 + collection: "app.bsky.feed.post", 114 + path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did", 115 + } 116 + ), 117 + enabled: !!agent?.did, 118 + }); 119 + 120 + const { 121 + data: infiniteMentionsData, 122 + fetchNextPage, 123 + hasNextPage, 124 + isFetchingNextPage, 125 + isLoading, 126 + isError, 127 + error, 128 + } = infinitequeryresults; 129 + 130 + const mentionsAturis = React.useMemo(() => { 131 + // Get all replies from the standard infinite query 132 + return ( 133 + infiniteMentionsData?.pages.flatMap( 134 + (page) => 135 + page?.linking_records.map( 136 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 137 + ) ?? [] 138 + ) ?? [] 139 + ); 140 + }, [infiniteMentionsData]); 141 + 142 + if (isLoading) return <LoadingState text="Loading mentions..." />; 143 + if (isError) return <ErrorState error={error} />; 144 + 145 + if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />; 146 + 147 + return ( 148 + <> 149 + {mentionsAturis.map((m) => ( 150 + <UniversalPostRendererATURILoader key={m} atUri={m} /> 151 + ))} 152 + 153 + {hasNextPage && ( 154 + <button 155 + onClick={() => fetchNextPage()} 156 + disabled={isFetchingNextPage} 157 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 158 + > 159 + {isFetchingNextPage ? "Loading..." : "Load More"} 160 + </button> 161 + )} 162 + </> 163 + ); 164 + } 165 + 166 + function FollowsTab() { 167 + const { agent } = useAuth(); 168 + const [constellationurl] = useAtom(constellationURLAtom); 169 + const infinitequeryresults = useInfiniteQuery({ 170 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 171 + { 172 + constellation: constellationurl, 173 + method: "/links", 174 + target: agent?.did, 175 + collection: "app.bsky.graph.follow", 176 + path: ".subject", 177 + } 178 + ), 179 + enabled: !!agent?.did, 180 + }); 181 + 182 + const { 183 + data: infiniteFollowsData, 184 + fetchNextPage, 185 + hasNextPage, 186 + isFetchingNextPage, 187 + isLoading, 188 + isError, 189 + error, 190 + } = infinitequeryresults; 191 + 192 + const followsAturis = React.useMemo(() => { 193 + // Get all replies from the standard infinite query 194 + return ( 195 + infiniteFollowsData?.pages.flatMap( 196 + (page) => 197 + page?.linking_records.map( 198 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 199 + ) ?? [] 200 + ) ?? [] 201 + ); 202 + }, [infiniteFollowsData]); 203 + 204 + if (isLoading) return <LoadingState text="Loading mentions..." />; 205 + if (isError) return <ErrorState error={error} />; 206 + 207 + if (!followsAturis?.length) return <EmptyState text="No mentions yet." />; 208 + 209 + return ( 210 + <> 211 + {followsAturis.map((m) => ( 212 + <NotificationItem key={m} notification={m} /> 213 + ))} 214 + 215 + {hasNextPage && ( 216 + <button 217 + onClick={() => fetchNextPage()} 218 + disabled={isFetchingNextPage} 219 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 220 + > 221 + {isFetchingNextPage ? "Loading..." : "Load More"} 222 + </button> 223 + )} 224 + </> 225 + ); 226 + } 227 + 228 + 229 + function PostInteractionsTab() { 230 + const { agent } = useAuth(); 231 + const { data: identity } = useQueryIdentity(agent?.did); 232 + const queryClient = useQueryClient(); 233 + const { 234 + data: postsData, 235 + fetchNextPage, 236 + hasNextPage, 237 + isFetchingNextPage, 238 + isLoading: arePostsLoading, 239 + } = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds); 240 + 241 + React.useEffect(() => { 242 + if (postsData) { 243 + postsData.pages.forEach((page) => { 244 + page.records.forEach((record) => { 245 + if (!queryClient.getQueryData(["post", record.uri])) { 246 + queryClient.setQueryData(["post", record.uri], record); 98 247 } 99 - } catch (e: any) { 100 - return { error: e?.message || String(e) }; 101 - } 102 - }) 103 - ) 104 - .then((results) => { 105 - if (!ignore) setResponses(results); 106 - }) 107 - .catch((e) => { 108 - if (!ignore) 109 - setError("Failed to fetch notifications: " + (e?.message || e)); 110 - }) 111 - .finally(() => { 112 - if (!ignore) setLoading(false); 248 + }); 113 249 }); 114 - return () => { 115 - ignore = true; 250 + } 251 + }, [postsData, queryClient]); 252 + 253 + const posts = React.useMemo( 254 + () => postsData?.pages.flatMap((page) => page.records) ?? [], 255 + [postsData] 256 + ); 257 + 258 + return ( 259 + <> 260 + {posts.map((m) => ( 261 + <PostInteractionsItem key={m.uri} uri={m.uri} /> 262 + ))} 263 + 264 + {hasNextPage && ( 265 + <button 266 + onClick={() => fetchNextPage()} 267 + disabled={isFetchingNextPage} 268 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 269 + > 270 + {isFetchingNextPage ? "Loading..." : "Load More"} 271 + </button> 272 + )} 273 + </> 274 + ); 275 + } 276 + 277 + function PostInteractionsItem({ uri }: { uri: string }) { 278 + const { data: links } = useQueryConstellation({ 279 + method: "/links/all", 280 + target: uri, 281 + }); 282 + 283 + const interactions = React.useMemo(() => { 284 + const likes = 285 + links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 286 + const replies = 287 + links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 288 + const reposts = 289 + links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 290 + const quotes1 = 291 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 292 + const quotes2 = 293 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 294 + ?.records || 0; 295 + 296 + const totals = { 297 + likes, 298 + replies, 299 + reposts, 300 + quotes: quotes1 + quotes2, 116 301 }; 117 - }, [did]); 302 + 303 + const list = ( 304 + [ 305 + ["reply", totals.replies], 306 + ["repost", totals.reposts], 307 + ["like", totals.likes], 308 + ["quote", totals.quotes], 309 + ] as const 310 + ).filter(([, count]) => count > 0); 311 + 312 + return { totals, list }; 313 + }, [links]); 118 314 119 315 return ( 120 - <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800"> 121 - <div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-800"> 122 - <span className="text-xl font-bold ml-2">Notifications</span> 123 - {!authed && ( 124 - <div className="flex items-center gap-2"> 125 - <input 126 - type="text" 127 - placeholder="Enter handle or DID" 128 - ref={inputRef} 129 - className="ml-4 px-2 py-1 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100" 130 - style={{ minWidth: 220 }} 131 - disabled={resolving} 132 - /> 133 - <button 134 - type="button" 135 - className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50" 136 - disabled={resolving} 137 - onClick={handleSubmit} 138 - > 139 - {resolving ? "Resolving..." : "Submit"} 140 - </button> 141 - </div> 316 + <div className="flex flex-col border-b pb-8"> 317 + <div className="border rounded-xl mx-4 mt-4 "> 318 + <UniversalPostRendererATURILoader 319 + isQuote 320 + key={uri} 321 + atUri={uri} 322 + nopics 323 + /> 324 + </div> 325 + <div className="flex flex-col"> 326 + {interactions.list.map(([type, count]) => ( 327 + <InteractionsButton key={type} type={type} uri={uri} count={count} /> 328 + ))} 329 + </div> 330 + </div> 331 + ); 332 + } 333 + 334 + function InteractionsButton({ 335 + type, 336 + uri, 337 + count, 338 + }: { 339 + type: "reply" | "repost" | "like" | "quote"; 340 + uri: string; 341 + count: number; 342 + }) { 343 + return ( 344 + <div className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2"> 345 + {type === "like" ? ( 346 + <MdiCardsHeartOutline height={22} width={22} /> 347 + ) : type === "repost" ? ( 348 + <MdiRepeat height={22} width={22} /> 349 + ) : type === "reply" ? ( 350 + <MdiCommentOutline height={22} width={22} /> 351 + ) : ( 352 + <></> 353 + )} 354 + {type} 355 + {/* bad grammar replys */} 356 + {count > 1 ? "s" : ""} <div className="flex-1" /> {count} 357 + </div> 358 + ); 359 + } 360 + 361 + function NotificationItem({ notification }: { notification: string }) { 362 + const aturi = new AtUri(notification); 363 + const navigate = useNavigate(); 364 + const { data: identity } = useQueryIdentity(aturi.host); 365 + const resolvedDid = identity?.did; 366 + const profileUri = resolvedDid 367 + ? `at://${resolvedDid}/app.bsky.actor.profile/self` 368 + : undefined; 369 + const { data: profileRecord } = useQueryProfile(profileUri); 370 + const profile = profileRecord?.value; 371 + 372 + const [imgcdn] = useAtom(imgCDNAtom); 373 + 374 + function getAvatarUrl(p: typeof profile) { 375 + const link = p?.avatar?.ref?.["$link"]; 376 + if (!link || !resolvedDid) return null; 377 + return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 378 + } 379 + 380 + const avatar = getAvatarUrl(profile); 381 + 382 + return ( 383 + <div 384 + className="flex items-center gap-3 p-4 cursor-pointer border-b flex-row" 385 + onClick={() => 386 + aturi && 387 + navigate({ 388 + to: "/profile/$did", 389 + params: { did: aturi.host }, 390 + }) 391 + } 392 + > 393 + <div> 394 + {aturi.collection === "app.bsky.graph.follow" ? ( 395 + <IconMdiAccountPlus /> 396 + ) : ( 397 + <></> 142 398 )} 143 399 </div> 144 - {error && <div className="p-4 text-red-500">{error}</div>} 145 - {loading && ( 146 - <div className="p-4 text-gray-500">Loading notifications...</div> 400 + {profile ? ( 401 + <img 402 + src={avatar || defaultpfp} 403 + alt={identity?.handle} 404 + className="w-10 h-10 rounded-full" 405 + /> 406 + ) : ( 407 + <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 147 408 )} 148 - {!loading && 149 - !error && 150 - responses.map((resp, i) => ( 151 - <div key={i} className="p-4"> 152 - <div className="font-bold mb-2">Query {i + 1}</div> 153 - {!resp || 154 - (typeof resp === "object" && Object.keys(resp).length === 0) || 155 - (Array.isArray(resp) && resp.length === 0) ? ( 156 - <div className="text-gray-500">No notifications found.</div> 157 - ) : ( 158 - <pre 159 - style={{ 160 - background: "#222", 161 - color: "#eee", 162 - borderRadius: 8, 163 - padding: 12, 164 - fontSize: 13, 165 - overflowX: "auto", 166 - }} 167 - > 168 - {JSON.stringify(resp, null, 2)} 169 - </pre> 170 - )} 171 - </div> 172 - ))} 173 - {/* <div className="p-4"> yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you</div> */} 409 + <div className="flex flex-col"> 410 + <div className="flex flex-row gap-2"> 411 + <span className="font-medium text-gray-900 dark:text-gray-100"> 412 + {profile?.displayName || identity?.handle || "Someone"} 413 + </span> 414 + <span className="text-gray-700 dark:text-gray-400"> 415 + @{identity?.handle} 416 + </span> 417 + </div> 418 + <div className="flex flex-row gap-2"> 419 + {identity?.did && <Mutual targetdidorhandle={identity?.did} />} 420 + {/* <span className="text-sm text-gray-600 dark:text-gray-400"> 421 + followed you 422 + </span> */} 423 + </div> 424 + </div> 425 + <div className="flex-1" /> 426 + {identity?.did && <FollowButton targetdidorhandle={identity?.did} />} 174 427 </div> 175 428 ); 176 429 } 430 + 431 + 432 + const EmptyState = ({ text }: { text: string }) => ( 433 + <div className="py-10 text-center text-gray-500 dark:text-gray-400"> 434 + {text} 435 + </div> 436 + ); 437 + 438 + const LoadingState = ({ text }: { text: string }) => ( 439 + <div className="py-10 text-center text-gray-500 dark:text-gray-400 italic"> 440 + {text} 441 + </div> 442 + ); 443 + 444 + const ErrorState = ({ error }: { error: unknown }) => ( 445 + <div className="py-10 text-center text-red-600 dark:text-red-400"> 446 + Error: {(error as Error)?.message || "Something went wrong."} 447 + </div> 448 + );
+36
src/styles/app.css
··· 233 233 /* radix i love you but like cmon man */ 234 234 body[data-scroll-locked]{ 235 235 margin-left: var(--removed-body-scroll-bar-size) !important; 236 + } 237 + 238 + /* radix tabs */ 239 + 240 + [data-radix-collection-item] { 241 + flex: 1; 242 + display: flex; 243 + padding: 12px 8px; 244 + align-items: center; 245 + justify-content: center; 246 + color: var(--color-gray-500); 247 + font-weight: 500; 248 + &[aria-selected="true"] { 249 + color: var(--color-gray-950); 250 + &::before{ 251 + content: ""; 252 + position: absolute; 253 + width: min(80px, 80%); 254 + border-radius: 99px 99px 0px 0px ; 255 + height: 3px; 256 + bottom: 0; 257 + background-color: var(--color-gray-400); 258 + } 259 + } 260 + } 261 + 262 + @media (prefers-color-scheme: dark) { 263 + [data-radix-collection-item] { 264 + color: var(--color-gray-400); 265 + &[aria-selected="true"] { 266 + color: var(--color-gray-50); 267 + &::before{ 268 + background-color: var(--color-gray-500); 269 + } 270 + } 271 + } 236 272 }