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

add dummy-lexicon image uploader

Natalie B ffcb397f 37f5991e

+3
pnpm-workspace.yaml
··· 1 + onlyBuiltDependencies: 2 + - '@tsparticles/engine' 3 + - esbuild
+26
src/routeTree.gen.ts
··· 19 19 20 20 // Create Virtual Routes 21 21 22 + const PostLazyImport = createFileRoute('/post')() 22 23 const JetstreamLazyImport = createFileRoute('/jetstream')() 23 24 const CounterLazyImport = createFileRoute('/counter')() 24 25 const AboutLazyImport = createFileRoute('/about')() ··· 36 37 )() 37 38 38 39 // Create/Update Routes 40 + 41 + const PostLazyRoute = PostLazyImport.update({ 42 + id: '/post', 43 + path: '/post', 44 + getParentRoute: () => rootRoute, 45 + } as any).lazy(() => import('./routes/post.lazy').then((d) => d.Route)) 39 46 40 47 const JetstreamLazyRoute = JetstreamLazyImport.update({ 41 48 id: '/jetstream', ··· 168 175 preLoaderRoute: typeof JetstreamLazyImport 169 176 parentRoute: typeof rootRoute 170 177 } 178 + '/post': { 179 + id: '/post' 180 + path: '/post' 181 + fullPath: '/post' 182 + preLoaderRoute: typeof PostLazyImport 183 + parentRoute: typeof rootRoute 184 + } 171 185 '/auth/callback': { 172 186 id: '/auth/callback' 173 187 path: '/auth/callback' ··· 248 262 '/about': typeof AboutLazyRoute 249 263 '/counter': typeof CounterLazyRoute 250 264 '/jetstream': typeof JetstreamLazyRoute 265 + '/post': typeof PostLazyRoute 251 266 '/auth/callback': typeof AuthCallbackLazyRoute 252 267 '/auth/login': typeof AuthLoginLazyRoute 253 268 '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute ··· 265 280 '/about': typeof AboutLazyRoute 266 281 '/counter': typeof CounterLazyRoute 267 282 '/jetstream': typeof JetstreamLazyRoute 283 + '/post': typeof PostLazyRoute 268 284 '/auth/callback': typeof AuthCallbackLazyRoute 269 285 '/auth/login': typeof AuthLoginLazyRoute 270 286 '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute ··· 283 299 '/about': typeof AboutLazyRoute 284 300 '/counter': typeof CounterLazyRoute 285 301 '/jetstream': typeof JetstreamLazyRoute 302 + '/post': typeof PostLazyRoute 286 303 '/auth/callback': typeof AuthCallbackLazyRoute 287 304 '/auth/login': typeof AuthLoginLazyRoute 288 305 '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute ··· 302 319 | '/about' 303 320 | '/counter' 304 321 | '/jetstream' 322 + | '/post' 305 323 | '/auth/callback' 306 324 | '/auth/login' 307 325 | '/rnfgrertt/typing' ··· 318 336 | '/about' 319 337 | '/counter' 320 338 | '/jetstream' 339 + | '/post' 321 340 | '/auth/callback' 322 341 | '/auth/login' 323 342 | '/rnfgrertt/typing' ··· 334 353 | '/about' 335 354 | '/counter' 336 355 | '/jetstream' 356 + | '/post' 337 357 | '/auth/callback' 338 358 | '/auth/login' 339 359 | '/rnfgrertt/typing' ··· 352 372 AboutLazyRoute: typeof AboutLazyRoute 353 373 CounterLazyRoute: typeof CounterLazyRoute 354 374 JetstreamLazyRoute: typeof JetstreamLazyRoute 375 + PostLazyRoute: typeof PostLazyRoute 355 376 AuthCallbackLazyRoute: typeof AuthCallbackLazyRoute 356 377 AuthLoginLazyRoute: typeof AuthLoginLazyRoute 357 378 RnfgrerttTypingLazyRoute: typeof RnfgrerttTypingLazyRoute ··· 369 390 AboutLazyRoute: AboutLazyRoute, 370 391 CounterLazyRoute: CounterLazyRoute, 371 392 JetstreamLazyRoute: JetstreamLazyRoute, 393 + PostLazyRoute: PostLazyRoute, 372 394 AuthCallbackLazyRoute: AuthCallbackLazyRoute, 373 395 AuthLoginLazyRoute: AuthLoginLazyRoute, 374 396 RnfgrerttTypingLazyRoute: RnfgrerttTypingLazyRoute, ··· 395 417 "/about", 396 418 "/counter", 397 419 "/jetstream", 420 + "/post", 398 421 "/auth/callback", 399 422 "/auth/login", 400 423 "/rnfgrertt/typing", ··· 418 441 }, 419 442 "/jetstream": { 420 443 "filePath": "jetstream.lazy.tsx" 444 + }, 445 + "/post": { 446 + "filePath": "post.lazy.tsx" 421 447 }, 422 448 "/auth/callback": { 423 449 "filePath": "auth/callback.lazy.tsx"
+246
src/routes/post.lazy.tsx
··· 1 + import ShowError from "@/components/error"; 2 + import { Button } from "@/components/ui/button"; 3 + import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 4 + import { Input } from "@/components/ui/input"; 5 + import { QtContext } from "@/providers/qtprovider"; 6 + import { AppBskyEmbedImages, AppBskyFeedPost } from "@atcute/client/lexicons"; 7 + import { 8 + createLazyFileRoute, 9 + useNavigate, 10 + useRouter, 11 + } from "@tanstack/react-router"; 12 + import { ImagePlus, Loader2, X } from "lucide-react"; 13 + import { useContext, useRef, useState } from "preact/hooks"; 14 + 15 + interface ImageMetadata { 16 + file: File; 17 + altText: string; 18 + aspectRatio?: { width: number; height: number }; 19 + } 20 + 21 + export const Route = createLazyFileRoute("/post")({ 22 + component: RouteComponent, 23 + }); 24 + 25 + function RouteComponent() { 26 + const qt = useContext(QtContext); 27 + const [images, setImages] = useState<ImageMetadata[]>([]); 28 + const [isUploading, setIsUploading] = useState(false); 29 + const [uploadError, setUploadError] = useState<Error | null>(null); 30 + const fileInputRef = useRef<HTMLInputElement | null>(null); 31 + 32 + const navigate = useNavigate(); 33 + 34 + if (!qt?.currentAgent) { 35 + return ( 36 + <ShowError 37 + error={new Error("You need to be logged in to use this tool")} 38 + /> 39 + ); 40 + } 41 + 42 + const getImageAspectRatio = ( 43 + file: File, 44 + ): Promise<{ width: number; height: number }> => { 45 + return new Promise((resolve) => { 46 + const img = new Image(); 47 + img.onload = () => { 48 + resolve({ width: img.width, height: img.height }); 49 + }; 50 + img.src = URL.createObjectURL(file); 51 + }); 52 + }; 53 + 54 + const postImages = async () => { 55 + try { 56 + setIsUploading(true); 57 + setUploadError(null); 58 + 59 + let blobs = []; 60 + 61 + for (const imageData of images) { 62 + const blob = new Blob([imageData.file], { type: imageData.file.type }); 63 + const res = await qt.client.rpc.call("com.atproto.repo.uploadBlob", { 64 + data: blob, 65 + }); 66 + if (!res.data) { 67 + throw new Error(`Failed to post image!`); 68 + } 69 + blobs.push({ blob: res.data.blob, metadata: imageData }); 70 + } 71 + 72 + let processedImages = blobs.map((b) => ({ 73 + image: b.blob, 74 + alt: b.metadata.altText, 75 + aspectRatio: b.metadata.aspectRatio, 76 + })); 77 + 78 + let postRecord: AppBskyFeedPost.Record = { 79 + text: "", 80 + createdAt: new Date().toISOString(), 81 + embed: { 82 + $type: "app.bsky.embed.images", 83 + images: processedImages, 84 + }, 85 + // i know 86 + $type: "com.example.feed.post", 87 + } as any as AppBskyFeedPost.Record; 88 + 89 + let did = qt.currentAgent?.sub; 90 + if (!did) { 91 + alert("COULD NOT GET DID????"); 92 + } 93 + 94 + let res = await qt.client.rpc.call("com.atproto.repo.createRecord", { 95 + data: { 96 + collection: "com.example.feed.post", 97 + record: postRecord, 98 + repo: did!, 99 + }, 100 + }); 101 + 102 + // for example: at://did:web:nat.vg/com.example.feed.post/rkey 103 + const segs = res.data.uri.replace("at://", "").split("/"); 104 + navigate({ 105 + to: "/at:/$handle/$collection/$rkey", 106 + params: { 107 + handle: segs[0], 108 + collection: segs[1], 109 + rkey: segs[2], 110 + }, 111 + }); 112 + 113 + setImages([]); 114 + } catch (error) { 115 + console.error(error); 116 + setUploadError( 117 + error instanceof Error ? error : new Error("Upload failed"), 118 + ); 119 + } finally { 120 + setIsUploading(false); 121 + } 122 + }; 123 + const handleFileChange = async (e: Event) => { 124 + const input = e.target as HTMLInputElement; 125 + const newFiles = Array.from(input.files || []); 126 + 127 + const processedImages = await Promise.all( 128 + newFiles.map(async (file) => ({ 129 + file, 130 + altText: "", 131 + aspectRatio: await getImageAspectRatio(file), 132 + })), 133 + ); 134 + 135 + setImages((currentImages) => { 136 + const updatedImages = [...currentImages, ...processedImages]; 137 + return updatedImages.slice(0, 4); 138 + }); 139 + 140 + // Reset input value to allow selecting the same file again 141 + input.value = ""; 142 + }; 143 + 144 + const removeImage = (index: number) => { 145 + setImages((currentImages) => currentImages.filter((_, i) => i !== index)); 146 + }; 147 + 148 + const updateAltText = (index: number, altText: string) => { 149 + setImages((currentImages) => 150 + currentImages.map((img, i) => (i === index ? { ...img, altText } : img)), 151 + ); 152 + }; 153 + 154 + return ( 155 + <div className="container max-w-2xl mx-auto py-8 h-full w-full flex justify-center items-center"> 156 + <Card> 157 + <CardHeader> 158 + <CardTitle className="text-center">Post Images</CardTitle> 159 + <p className="max-w-lg self-center text-center text-pretty"> 160 + This tool posts images to a dummy lexicon in case one needs to link 161 + a high res image on a third-party ATProto CDN. 162 + </p> 163 + </CardHeader> 164 + <CardContent className="space-y-6"> 165 + <div className="flex flex-col items-center gap-4"> 166 + <Input 167 + type="file" 168 + accept="image/*" 169 + multiple 170 + disabled={isUploading || images.length >= 4} 171 + onChange={handleFileChange} 172 + className="hidden" 173 + ref={fileInputRef} 174 + /> 175 + <Button 176 + variant="outline" 177 + className="w-full max-w-sm" 178 + onClick={() => fileInputRef.current?.click()} 179 + disabled={isUploading || images.length >= 4} 180 + > 181 + <ImagePlus className="mr-2 h-4 w-4" /> 182 + Select Images (up to 4) 183 + </Button> 184 + </div> 185 + 186 + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> 187 + {images.map((imageData, index) => ( 188 + <Card key={index} className="relative overflow-hidden"> 189 + <CardContent className="p-3"> 190 + <div className="relative aspect-video mb-3"> 191 + <img 192 + src={URL.createObjectURL(imageData.file)} 193 + alt={imageData.altText || imageData.file.name} 194 + className="absolute inset-0 w-full h-full object-cover rounded-md" 195 + /> 196 + <Button 197 + variant="destructive" 198 + size="icon" 199 + className="absolute top-2 right-2 h-8 w-8" 200 + onClick={() => removeImage(index)} 201 + disabled={isUploading} 202 + > 203 + <X className="h-4 w-4" /> 204 + </Button> 205 + </div> 206 + <Input 207 + type="text" 208 + value={imageData.altText} 209 + onChange={(e) => 210 + updateAltText(index, (e.target as HTMLInputElement).value) 211 + } 212 + placeholder="Add alt text for accessibility" 213 + className="w-full" 214 + disabled={isUploading} 215 + /> 216 + </CardContent> 217 + </Card> 218 + ))} 219 + {images.length < 1 && ( 220 + <div className="h-full w-full min-w-64 aspect-video bg-muted rounded-xl"></div> 221 + )} 222 + </div> 223 + 224 + {images.length > 0 && ( 225 + <Button 226 + className="w-full" 227 + onClick={postImages} 228 + disabled={isUploading} 229 + > 230 + {isUploading ? ( 231 + <> 232 + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 233 + Uploading... 234 + </> 235 + ) : ( 236 + "Post Images" 237 + )} 238 + </Button> 239 + )} 240 + 241 + {uploadError && <ShowError error={uploadError} />} 242 + </CardContent> 243 + </Card> 244 + </div> 245 + ); 246 + }