Fork of atp.tools as a universal profile for people on the ATmosphere

fixed vpositioning on a bunch of screens

+445 -50
+4 -2
src/components/error.tsx
··· 6 6 export default function ShowError({ error }: { error: Error }) { 7 7 const router = useRouter(); 8 8 return ( 9 - <div className="flex flex-col min-h-screen justify-center items-center gap-4"> 9 + <div className="flex flex-col max-h-[calc(100vh-5rem)] h-screen justify-center items-center gap-4"> 10 10 <div className="flex flex-col gap-2 items-center"> 11 11 <CircleAlert className="text-red-500" width={48} height={48} /> 12 - <div className="h-min text-wrap break-words max-w-md w-full text-center">Error: {error.message}</div> 12 + <div className="h-min text-wrap break-words max-w-md w-full text-center"> 13 + Error: {error.message} 14 + </div> 13 15 </div> 14 16 <div className="flex flex-row gap-2 items-center"> 15 17 <Button variant="secondary" onClick={() => router.history.back()}>
+115
src/components/views/app-bsky/actorProfile.tsx
··· 1 + import { AppBskyActorProfile } from "@atcute/client/lexicons"; 2 + import { CollectionViewComponent, CollectionViewProps } from "../getView"; 3 + import { getBlueskyCdnLink } from "@/components/json/appBskyEmbedImages"; 4 + import { AtSign, Pin, Tag, WalletCards } from "lucide-react"; 5 + import { preprocessText } from "@/lib/preprocess"; 6 + import { BlueskyPostWithoutEmbed } from "./embed"; 7 + import { Link } from "@tanstack/react-router"; 8 + 9 + const StarterPackInfo = ({ 10 + profile, 11 + }: { 12 + profile: AppBskyActorProfile.Record; 13 + }) => { 14 + if (!profile.joinedViaStarterPack) return null; 15 + 16 + const [, , handle, collection, rkey] = 17 + profile.joinedViaStarterPack.uri.split("/"); 18 + 19 + return ( 20 + <> 21 + <WalletCards height="1rem" className="inline" /> 22 + <Link 23 + to="/at:/$handle/$collection/$rkey" 24 + params={{ handle, collection, rkey }} 25 + className="text-muted-foreground text-sm mt-4 mb-1" 26 + > 27 + Joined via starter pack: {profile.joinedViaStarterPack.uri} 28 + </Link> 29 + </> 30 + ); 31 + }; 32 + 33 + export const AppBskyActorProfileView: CollectionViewComponent< 34 + CollectionViewProps 35 + > = ({ data, repoData }: CollectionViewProps) => { 36 + const profile = data.value as AppBskyActorProfile.Record; 37 + return ( 38 + <> 39 + {profile ? ( 40 + profile?.banner ? ( 41 + <div className="relative"> 42 + <img 43 + src={getBlueskyCdnLink( 44 + repoData?.did!, 45 + profile?.banner?.ref.$link, 46 + "jpeg", 47 + )} 48 + className="w-full rounded-lg -z-10 border object-cover -mb-16" 49 + /> 50 + {profile.avatar ? ( 51 + <img 52 + src={getBlueskyCdnLink( 53 + repoData?.did!, 54 + profile?.avatar?.ref.$link, 55 + "jpeg", 56 + )} 57 + className="w-32 h-32 rounded-full object-cover ml-12" 58 + /> 59 + ) : ( 60 + <div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center ml-12"> 61 + <AtSign className="w-16 h-16" /> 62 + </div> 63 + )} 64 + </div> 65 + ) : profile.avatar ? ( 66 + <img 67 + src={getBlueskyCdnLink( 68 + repoData?.did!, 69 + profile?.avatar?.ref.$link, 70 + "jpeg", 71 + )} 72 + className="w-32 h-32 rounded-full object-cover ml-12" 73 + /> 74 + ) : ( 75 + <div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center ml-12"> 76 + <AtSign className="w-16 h-16" /> 77 + </div> 78 + ) 79 + ) : ( 80 + <div> Nothing doing!</div> 81 + )} 82 + <div className="ml-12 mt-2"> 83 + <h1 className="text-2xl"> 84 + {profile.displayName}{" "} 85 + <span className="text-muted-foreground"> 86 + {repoData?.handle && "@" + repoData.handle} 87 + {repoData?.handleIsCorrect ? "" : " (invalid)"} 88 + </span> 89 + </h1> 90 + <p>{profile.description && preprocessText(profile.description)}</p> 91 + {profile.labels && ( 92 + <div className="text-muted-foreground text-sm mt-4 mb-1"> 93 + <Tag height="1rem" className="inline" /> Labels 94 + </div> 95 + )} 96 + <StarterPackInfo profile={profile} /> 97 + {profile.labels?.values.map((l) => ( 98 + <div className="bg-blue-400 dark:bg-blue-700">{l.val}</div> 99 + ))} 100 + {profile.pinnedPost && ( 101 + <> 102 + <div className="text-muted-foreground text-sm mt-4 mb-1"> 103 + <Pin height="1rem" className="inline" /> 104 + Pinned Post{" "} 105 + </div> 106 + <BlueskyPostWithoutEmbed 107 + uri={profile.pinnedPost.uri} 108 + showEmbeddedPost={true} 109 + /> 110 + </> 111 + )} 112 + </div> 113 + </> 114 + ); 115 + };
+20
src/lib/getDidDoc.ts
··· 1 + import { DidDocument } from "@atcute/client/utils/did"; 2 + 3 + export default async function getDidDoc(did: string): Promise<DidDocument> { 4 + try { 5 + if (did.startsWith("did:web:")) { 6 + const response = await fetch( 7 + `https://${did.replace("did:web:", "")}/.well-known/did.json`, 8 + ); 9 + return await response.json(); 10 + } else if (did.startsWith("did:plc")) { 11 + const response = await fetch(`https://plc.directory/${did}`); 12 + return await response.json(); 13 + } 14 + throw new Error(`Unsupported DID format: ${did}`); 15 + } catch (error) { 16 + throw new Error( 17 + `Failed to fetch DID document: ${error instanceof Error ? error.message : String(error)}`, 18 + ); 19 + } 20 + }
+39
src/lib/preprocess.tsx
··· 1 + import React from "preact/compat"; 2 + 3 + export function preprocessText(text: string): React.ReactNode[] { 4 + // URL regex pattern 5 + const urlPattern = /(https?:\/\/[^\s]+)/g; 6 + 7 + // Split the text by URLs 8 + const parts = text.split(urlPattern); 9 + 10 + // Process each part and create React elements 11 + return parts.map((part, index) => { 12 + // Check if this part is a URL 13 + if (urlPattern.test(part)) { 14 + return ( 15 + <a 16 + className="text-blue-700 dark:text-blue-400" 17 + key={index} 18 + href={part} 19 + target="_blank" 20 + rel="noopener noreferrer" 21 + > 22 + {part} 23 + </a> 24 + ); 25 + } 26 + 27 + // Handle newlines in text parts 28 + if (part) { 29 + return part.split("\n").map((line, lineIndex, array) => ( 30 + <React.Fragment key={`${index}-${lineIndex}`}> 31 + {line} 32 + {lineIndex < array.length - 1 && <br />} 33 + </React.Fragment> 34 + )); 35 + } 36 + 37 + return null; 38 + }); 39 + }
+97 -3
src/lib/utils.ts
··· 1 - import { clsx, type ClassValue } from "clsx" 2 - import { twMerge } from "tailwind-merge" 1 + import { clsx, type ClassValue } from "clsx"; 2 + import { twMerge } from "tailwind-merge"; 3 3 4 4 export function cn(...inputs: ClassValue[]) { 5 - return twMerge(clsx(inputs)) 5 + return twMerge(clsx(inputs)); 6 + } 7 + type TimeUnit = 8 + | "year" 9 + | "month" 10 + | "week" 11 + | "day" 12 + | "hour" 13 + | "minute" 14 + | "second"; 15 + 16 + interface TimeInterval { 17 + seconds: number; 18 + label: TimeUnit; 19 + } 20 + 21 + interface TimeagoOptions { 22 + maxUnit?: TimeUnit; 23 + minUnit?: TimeUnit; 24 + future?: boolean; 25 + useShortLabels?: boolean; 26 + } 27 + 28 + export function timeAgo( 29 + date: Date | string | number, 30 + options: TimeagoOptions = {}, 31 + ): string { 32 + const { 33 + maxUnit = "year", 34 + minUnit = "second", 35 + future = true, 36 + useShortLabels = false, 37 + } = options; 38 + 39 + const currentDate = new Date(); 40 + const targetDate = new Date(date); 41 + 42 + const seconds = Math.floor( 43 + (currentDate.getTime() - targetDate.getTime()) / 1000, 44 + ); 45 + const isFuture = seconds < 0; 46 + const absoluteSeconds = Math.abs(seconds); 47 + 48 + const intervals: TimeInterval[] = [ 49 + { seconds: 31536000, label: "year" }, 50 + { seconds: 2592000, label: "month" }, 51 + { seconds: 604800, label: "week" }, 52 + { seconds: 86400, label: "day" }, 53 + { seconds: 3600, label: "hour" }, 54 + { seconds: 60, label: "minute" }, 55 + { seconds: 1, label: "second" }, 56 + ]; 57 + 58 + // Short labels mapping 59 + const shortLabels: Record<TimeUnit, string> = { 60 + year: "y", 61 + month: "mo", 62 + week: "w", 63 + day: "d", 64 + hour: "h", 65 + minute: "m", 66 + second: "s", 67 + }; 68 + 69 + // Handle future dates if not allowed 70 + if (isFuture && !future) { 71 + return "in the future"; 72 + } 73 + 74 + // Handle just now 75 + if (absoluteSeconds < 30 && minUnit === "second") { 76 + return "just now"; 77 + } 78 + 79 + // Filter intervals based on max and min units 80 + const filteredIntervals = intervals.filter((interval) => { 81 + const unitIndex = intervals.findIndex((i) => i.label === interval.label); 82 + const maxUnitIndex = intervals.findIndex((i) => i.label === maxUnit); 83 + const minUnitIndex = intervals.findIndex((i) => i.label === minUnit); 84 + return unitIndex >= maxUnitIndex && unitIndex <= minUnitIndex; 85 + }); 86 + 87 + for (const { seconds: secondsInUnit, label } of filteredIntervals) { 88 + const interval = Math.floor(absoluteSeconds / secondsInUnit); 89 + 90 + if (interval >= 1) { 91 + const unitLabel = useShortLabels ? shortLabels[label] : label; 92 + const plural = interval === 1 ? "" : "s"; 93 + const timeLabel = `${interval}${useShortLabels ? "" : " "}${unitLabel}${useShortLabels ? "" : plural}`; 94 + 95 + return isFuture ? `in ${timeLabel}` : `${timeLabel} ago`; 96 + } 97 + } 98 + 99 + return "just now"; 6 100 }
+31 -10
src/routes/at:/$handle.index.tsx
··· 1 1 import ShowError from "@/components/error"; 2 + import { RenderJson } from "@/components/renderJson"; 2 3 import RepoIcons from "@/components/repoIcons"; 3 4 import { Loader } from "@/components/ui/loader"; 4 5 import { useDocumentTitle } from "@/hooks/useDocumentTitle"; 6 + import getDidDoc from "@/lib/getDidDoc"; 5 7 import { QtClient, useXrpc } from "@/providers/qtprovider"; 6 8 import "@atcute/bluesky/lexicons"; 7 9 import { 8 10 AppBskyActorGetProfile, 9 11 ComAtprotoRepoDescribeRepo, 10 12 } from "@atcute/client/lexicons"; 13 + import { DidDocument } from "@atcute/client/utils/did"; 11 14 import { 15 + AuthorizationServerMetadata, 12 16 IdentityMetadata, 13 17 resolveFromIdentity, 14 18 } from "@atcute/oauth-browser-client"; ··· 19 23 interface RepoData { 20 24 data?: ComAtprotoRepoDescribeRepo.Output; 21 25 blueSkyData?: AppBskyActorGetProfile.Output | null; 22 - identity?: IdentityMetadata; 26 + identity?: { 27 + identity: IdentityMetadata; 28 + metadata: AuthorizationServerMetadata; 29 + }; 23 30 isLoading: boolean; 31 + didDoc?: DidDocument; 24 32 error: Error | null; 25 33 } 26 34 ··· 53 61 params: { repo: id.identity.id }, 54 62 signal: abortController.signal, 55 63 }); 64 + let doc = await getDidDoc(id.identity.id); 56 65 // can we get bsky data? 57 66 if (response.data.collections.includes("app.bsky.actor.profile")) { 58 67 // reuse client dumbass ··· 67 76 setState({ 68 77 blueSkyData: bskyData.data, 69 78 data: response.data, 70 - identity: id.identity, 79 + identity: id, 71 80 isLoading: false, 81 + didDoc: doc, 72 82 error: null, 73 83 }); 74 84 } else { 75 85 setState({ 76 86 blueSkyData: null, 77 87 data: response.data, 78 - identity: id.identity, 88 + identity: id, 79 89 isLoading: false, 90 + didDoc: doc, 80 91 error: null, 81 92 }); 82 93 } ··· 108 119 109 120 function RouteComponent() { 110 121 const { handle } = Route.useParams(); 111 - const { blueSkyData, data, identity, isLoading, error } = useRepoData(handle); 122 + const { blueSkyData, data, identity, isLoading, error, didDoc } = 123 + useRepoData(handle); 112 124 if (error) { 113 125 return <ShowError error={error} />; 114 126 } 115 127 116 128 if (isLoading && !blueSkyData) { 117 - return <Loader className="min-h-screen" />; 129 + return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />; 118 130 } 119 131 120 132 return ( 121 - <div className="flex flex-row justify-center w-full min-h-screen"> 133 + <div className="flex flex-row justify-center w-full"> 122 134 <div className="max-w-2xl w-screen p-4 md:mt-16 space-y-2"> 123 135 {blueSkyData ? ( 124 136 blueSkyData?.banner ? ( ··· 132 144 className="absolute -bottom-12 md:-bottom-16 w-24 lg:w-32 aspect-square rounded-full border" 133 145 /> 134 146 </div> 135 - ) : ( 147 + ) : blueSkyData.avatar ? ( 136 148 <img src={blueSkyData?.avatar} className="w-32 h-32 rounded-full" /> 149 + ) : ( 150 + <div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center"> 151 + <AtSign className="w-16 h-16" /> 152 + </div> 137 153 ) 138 154 ) : ( 139 155 <div className="w-32 h-32 bg-neutral-500 rounded-full grid place-items-center"> ··· 153 169 <RepoIcons 154 170 collections={data?.collections} 155 171 handle={data?.handle} 156 - did={identity?.id} 172 + did={identity?.identity.id} 157 173 /> 158 174 </div> 159 175 )} ··· 161 177 <br /> 162 178 163 179 <div> 164 - PDS: {identity?.pds.hostname.includes("bsky.network") && "🍄"}{" "} 165 - {identity?.pds.hostname} 180 + PDS:{" "} 181 + {identity?.identity.pds.hostname.includes("bsky.network") && "🍄"}{" "} 182 + {identity?.identity.pds.hostname} 166 183 </div> 167 184 168 185 <div> ··· 182 199 </li> 183 200 ))} 184 201 </ul> 202 + </div> 203 + <div className="pt-2"> 204 + <h2 className="text-xl font-bold">DID Document</h2> 205 + <RenderJson data={didDoc} did={identity?.identity.id!} /> 185 206 </div> 186 207 </div> 187 208 </div>
+52 -6
src/routes/at:/$handle/$collection.$rkey.lazy.tsx
··· 114 114 } 115 115 116 116 if (isLoading && !data) { 117 - return <Loader className="min-h-screen" />; 117 + return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />; 118 118 } 119 119 120 120 if (data === undefined) return <div>No data</div>; ··· 146 146 </div> 147 147 {!View && ( 148 148 <div className="text-muted-foreground text-xs"> 149 - if you see this message please bug me to add a custom view for this 150 - repo type 149 + This View is not yet implemented. If you have a need, state your 150 + case{" "} 151 + <Link 152 + to="/at:/$handle" 153 + params={{ handle: "natalie.sh" }} 154 + className="text-blue-700 dark:text-blue-400" 155 + > 156 + @natalie.sh 157 + </Link> 151 158 </div> 152 159 )} 160 + <div className="text-muted-foreground group"> 161 + <Link 162 + to={`/at:/$handle`} 163 + params={{ 164 + handle: repoInfo?.did || "", 165 + }} 166 + className="dark:hover:text-blue-400 group-hover:text-blue-500 transition-colors duration-300" 167 + > 168 + at://{handle} 169 + </Link> 170 + <Link 171 + to={`/at:/$handle/$collection`} 172 + params={{ 173 + handle: repoInfo?.did || "", 174 + collection, 175 + }} 176 + className="dark:hover:text-blue-400 group-hover:text-blue-500 transition-colors duration-300" 177 + > 178 + /{collection} 179 + </Link> 180 + /{rkey} 181 + </div> 153 182 <div className="border-b" /> 154 183 <Tabs defaultValue={View ? "view" : "json"} className="w-full"> 155 184 <TabsList> 156 - {View && <TabsTrigger value="view">View</TabsTrigger>} 157 - <TabsTrigger value="json">JSON</TabsTrigger> 158 - <TabsTrigger value="text">JSON (Text)</TabsTrigger> 185 + {View && ( 186 + <TabsTrigger 187 + value="view" 188 + className="dark:hover:text-gray-300 hover:text-gray-700 transition-colors duration-300" 189 + > 190 + View 191 + </TabsTrigger> 192 + )} 193 + <TabsTrigger 194 + value="json" 195 + className="dark:hover:text-gray-300 hover:text-gray-700 transition-colors duration-300" 196 + > 197 + JSON 198 + </TabsTrigger> 199 + <TabsTrigger 200 + value="text" 201 + className="dark:hover:text-gray-300 hover:text-gray-700 transition-colors duration-300" 202 + > 203 + JSON (Text) 204 + </TabsTrigger> 159 205 </TabsList> 160 206 {View && ( 161 207 <TabsContent value="view" className="w-full overflow-x-auto">
+1 -1
src/routes/at:/$handle/$collection.index.lazy.tsx
··· 116 116 } 117 117 118 118 if ((isLoading && !cursor) || !records) { 119 - return <Loader className="min-h-screen" />; 119 + return <Loader className="max-h-[calc(100vh-5rem)] h-screen" />; 120 120 } 121 121 122 122 return (
+1 -1
src/routes/counter.lazy.tsx
··· 207 207 } = stats; 208 208 209 209 return ( 210 - <div className="container mx-auto max-w-screen h-screen"> 210 + <div className="container mx-auto max-w-screen max-h-[calc(100vh-5rem)] h-screen"> 211 211 <ParticlesComponent 212 212 isAnimating={isConfettiActive} 213 213 setIsAnimating={setIsConfettiActive}
+85 -27
src/routes/index.lazy.tsx
··· 2 2 import { useDocumentTitle } from "@/hooks/useDocumentTitle"; 3 3 import { createLazyFileRoute, Link } from "@tanstack/react-router"; 4 4 import { AtSign, Star } from "lucide-react"; 5 + import { useMemo } from "react"; 6 + 7 + const examples = [ 8 + <Link 9 + key="danabra" 10 + to="/at:/$handle" 11 + params={{ handle: "danabra.mov" }} 12 + className="text-blue-500" 13 + > 14 + <div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors"> 15 + at://danabra.mov 16 + </div> 17 + </Link>, 18 + <Link 19 + key="kot-posts" 20 + to="/at:/$handle/$collection" 21 + params={{ handle: "kot.pink", collection: "app.bsky.feed.post" }} 22 + className="text-blue-500" 23 + > 24 + <div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors"> 25 + at://kot.pink/app.bsky.feed.post 26 + </div> 27 + </Link>, 28 + <Link 29 + key="robk" 30 + to="/at:/$handle" 31 + params={{ handle: "komaniecki.bsky.social" }} 32 + className="text-blue-500" 33 + > 34 + <div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors"> 35 + at://komaniecki.bsky.social 36 + </div> 37 + </Link>, 38 + <Link 39 + key="why-generator" 40 + to="/at:/$handle/$collection" 41 + params={{ handle: "why.bsky.team", collection: "app.bsky.feed.generator" }} 42 + className="text-blue-500" 43 + > 44 + <div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors"> 45 + at://why.bsky.team/app.bsky.feed.generator 46 + </div> 47 + </Link>, 48 + <Link 49 + key="jay" 50 + to="/at:/$handle" 51 + params={{ handle: "jay.bsky.social" }} 52 + className="text-blue-500" 53 + > 54 + <div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors"> 55 + at://jay.bsky.social 56 + </div> 57 + </Link>, 58 + <Link 59 + key="nobody-knows" 60 + to="/at:/$handle" 61 + params={{ handle: "pippy.bsky.social" }} 62 + className="text-blue-500" 63 + > 64 + <div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors"> 65 + at://pippy.bsky.social 66 + </div> 67 + </Link>, 68 + <Link 69 + key="jay" 70 + to="/at:/$handle/$collection" 71 + params={{ handle: "ngerakines.me", collection: "blue.badge.collection" }} 72 + className="text-blue-500" 73 + > 74 + <div className="bg-muted text-muted-foreground rounded-full px-3 py-1 hover:bg-muted/80 transition-colors"> 75 + at://ngerakines.me/blue.badge.collection 76 + </div> 77 + </Link>, 78 + ]; 5 79 6 80 export const Route = createLazyFileRoute("/")({ 7 81 component: Index, ··· 9 83 10 84 export default function Index() { 11 85 useDocumentTitle("atp.tools"); 86 + 87 + const randomExamples = useMemo(() => { 88 + return [...examples].sort(() => Math.random() - 0.5).slice(0, 2); 89 + }, []); 90 + 12 91 return ( 13 - <main className="min-h-screen relative max-w-[100vw]"> 92 + <main className="h-screen relative max-h-[calc(100vh-5rem)]"> 14 93 <div className="container mx-auto px-4 py-16"> 15 94 <div className="flex flex-col items-center justify-center md:min-h-[80vh]"> 16 95 {/* Header section */} ··· 24 103 <div className="w-full max-w-xl mx-auto"> 25 104 <SmartSearchBar /> 26 105 </div> 27 - <div className="flex justify-center items-center gap-x-2 mt-4"> 28 - <div className="flex flex-row gap-x-1 text-muted-foreground"> 29 - <Star /> Try: 106 + <div className="flex flex-row items-center mt-6 gap-2 justify-center"> 107 + <div className="flex items-center gap-x-2 text-muted-foreground"> 108 + <Star className="h-4 w-4" /> Try:{" "} 30 109 </div> 31 - <div className="flex flex-col gap-1 md:flex-row"> 32 - <Link 33 - to="/at:/$handle" 34 - params={{ handle: "danabra.mov" }} 35 - className="text-blue-500" 36 - > 37 - <div className="bg-muted text-muted-foreground rounded-full px-2"> 38 - at://danabra.mov 39 - </div> 40 - </Link> 41 - 42 - <Link 43 - to="/at:/$handle/$collection" 44 - params={{ 45 - handle: "kot.pink", 46 - collection: "app.bsky.feed.post", 47 - }} 48 - className="text-blue-500" 49 - > 50 - <div className="bg-muted text-muted-foreground rounded-full px-2"> 51 - at://kot.pink/app.bsky.feed.post 52 - </div> 53 - </Link> 110 + <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm"> 111 + {randomExamples} 54 112 </div> 55 113 </div> 56 114 </div>