an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
93
fork

Configure Feed

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

basic notifs

rimar1337 de4321b1 b819cd67

+466 -154
+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 }