diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d1cc873 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - '@tsparticles/engine' + - esbuild diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b5e9758..a285240 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ConstellationDidsCollectionImport } from './routes/constellati // Create Virtual Routes +const PostLazyImport = createFileRoute('/post')() const JetstreamLazyImport = createFileRoute('/jetstream')() const CounterLazyImport = createFileRoute('/counter')() const AboutLazyImport = createFileRoute('/about')() @@ -37,6 +38,12 @@ const AtHandleCollectionRkeyLazyImport = createFileRoute( // Create/Update Routes +const PostLazyRoute = PostLazyImport.update({ + id: '/post', + path: '/post', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/post.lazy').then((d) => d.Route)) + const JetstreamLazyRoute = JetstreamLazyImport.update({ id: '/jetstream', path: '/jetstream', @@ -168,6 +175,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof JetstreamLazyImport parentRoute: typeof rootRoute } + '/post': { + id: '/post' + path: '/post' + fullPath: '/post' + preLoaderRoute: typeof PostLazyImport + parentRoute: typeof rootRoute + } '/auth/callback': { id: '/auth/callback' path: '/auth/callback' @@ -248,6 +262,7 @@ export interface FileRoutesByFullPath { '/about': typeof AboutLazyRoute '/counter': typeof CounterLazyRoute '/jetstream': typeof JetstreamLazyRoute + '/post': typeof PostLazyRoute '/auth/callback': typeof AuthCallbackLazyRoute '/auth/login': typeof AuthLoginLazyRoute '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute @@ -265,6 +280,7 @@ export interface FileRoutesByTo { '/about': typeof AboutLazyRoute '/counter': typeof CounterLazyRoute '/jetstream': typeof JetstreamLazyRoute + '/post': typeof PostLazyRoute '/auth/callback': typeof AuthCallbackLazyRoute '/auth/login': typeof AuthLoginLazyRoute '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute @@ -283,6 +299,7 @@ export interface FileRoutesById { '/about': typeof AboutLazyRoute '/counter': typeof CounterLazyRoute '/jetstream': typeof JetstreamLazyRoute + '/post': typeof PostLazyRoute '/auth/callback': typeof AuthCallbackLazyRoute '/auth/login': typeof AuthLoginLazyRoute '/rnfgrertt/typing': typeof RnfgrerttTypingLazyRoute @@ -302,6 +319,7 @@ export interface FileRouteTypes { | '/about' | '/counter' | '/jetstream' + | '/post' | '/auth/callback' | '/auth/login' | '/rnfgrertt/typing' @@ -318,6 +336,7 @@ export interface FileRouteTypes { | '/about' | '/counter' | '/jetstream' + | '/post' | '/auth/callback' | '/auth/login' | '/rnfgrertt/typing' @@ -334,6 +353,7 @@ export interface FileRouteTypes { | '/about' | '/counter' | '/jetstream' + | '/post' | '/auth/callback' | '/auth/login' | '/rnfgrertt/typing' @@ -352,6 +372,7 @@ export interface RootRouteChildren { AboutLazyRoute: typeof AboutLazyRoute CounterLazyRoute: typeof CounterLazyRoute JetstreamLazyRoute: typeof JetstreamLazyRoute + PostLazyRoute: typeof PostLazyRoute AuthCallbackLazyRoute: typeof AuthCallbackLazyRoute AuthLoginLazyRoute: typeof AuthLoginLazyRoute RnfgrerttTypingLazyRoute: typeof RnfgrerttTypingLazyRoute @@ -369,6 +390,7 @@ const rootRouteChildren: RootRouteChildren = { AboutLazyRoute: AboutLazyRoute, CounterLazyRoute: CounterLazyRoute, JetstreamLazyRoute: JetstreamLazyRoute, + PostLazyRoute: PostLazyRoute, AuthCallbackLazyRoute: AuthCallbackLazyRoute, AuthLoginLazyRoute: AuthLoginLazyRoute, RnfgrerttTypingLazyRoute: RnfgrerttTypingLazyRoute, @@ -395,6 +417,7 @@ export const routeTree = rootRoute "/about", "/counter", "/jetstream", + "/post", "/auth/callback", "/auth/login", "/rnfgrertt/typing", @@ -419,6 +442,9 @@ export const routeTree = rootRoute "/jetstream": { "filePath": "jetstream.lazy.tsx" }, + "/post": { + "filePath": "post.lazy.tsx" + }, "/auth/callback": { "filePath": "auth/callback.lazy.tsx" }, diff --git a/src/routes/post.lazy.tsx b/src/routes/post.lazy.tsx new file mode 100644 index 0000000..87c3485 --- /dev/null +++ b/src/routes/post.lazy.tsx @@ -0,0 +1,246 @@ +import ShowError from "@/components/error"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { QtContext } from "@/providers/qtprovider"; +import { AppBskyEmbedImages, AppBskyFeedPost } from "@atcute/client/lexicons"; +import { + createLazyFileRoute, + useNavigate, + useRouter, +} from "@tanstack/react-router"; +import { ImagePlus, Loader2, X } from "lucide-react"; +import { useContext, useRef, useState } from "preact/hooks"; + +interface ImageMetadata { + file: File; + altText: string; + aspectRatio?: { width: number; height: number }; +} + +export const Route = createLazyFileRoute("/post")({ + component: RouteComponent, +}); + +function RouteComponent() { + const qt = useContext(QtContext); + const [images, setImages] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const fileInputRef = useRef(null); + + const navigate = useNavigate(); + + if (!qt?.currentAgent) { + return ( + + ); + } + + const getImageAspectRatio = ( + file: File, + ): Promise<{ width: number; height: number }> => { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + resolve({ width: img.width, height: img.height }); + }; + img.src = URL.createObjectURL(file); + }); + }; + + const postImages = async () => { + try { + setIsUploading(true); + setUploadError(null); + + let blobs = []; + + for (const imageData of images) { + const blob = new Blob([imageData.file], { type: imageData.file.type }); + const res = await qt.client.rpc.call("com.atproto.repo.uploadBlob", { + data: blob, + }); + if (!res.data) { + throw new Error(`Failed to post image!`); + } + blobs.push({ blob: res.data.blob, metadata: imageData }); + } + + let processedImages = blobs.map((b) => ({ + image: b.blob, + alt: b.metadata.altText, + aspectRatio: b.metadata.aspectRatio, + })); + + let postRecord: AppBskyFeedPost.Record = { + text: "", + createdAt: new Date().toISOString(), + embed: { + $type: "app.bsky.embed.images", + images: processedImages, + }, + // i know + $type: "com.example.feed.post", + } as any as AppBskyFeedPost.Record; + + let did = qt.currentAgent?.sub; + if (!did) { + alert("COULD NOT GET DID????"); + } + + let res = await qt.client.rpc.call("com.atproto.repo.createRecord", { + data: { + collection: "com.example.feed.post", + record: postRecord, + repo: did!, + }, + }); + + // for example: at://did:web:nat.vg/com.example.feed.post/rkey + const segs = res.data.uri.replace("at://", "").split("/"); + navigate({ + to: "/at:/$handle/$collection/$rkey", + params: { + handle: segs[0], + collection: segs[1], + rkey: segs[2], + }, + }); + + setImages([]); + } catch (error) { + console.error(error); + setUploadError( + error instanceof Error ? error : new Error("Upload failed"), + ); + } finally { + setIsUploading(false); + } + }; + const handleFileChange = async (e: Event) => { + const input = e.target as HTMLInputElement; + const newFiles = Array.from(input.files || []); + + const processedImages = await Promise.all( + newFiles.map(async (file) => ({ + file, + altText: "", + aspectRatio: await getImageAspectRatio(file), + })), + ); + + setImages((currentImages) => { + const updatedImages = [...currentImages, ...processedImages]; + return updatedImages.slice(0, 4); + }); + + // Reset input value to allow selecting the same file again + input.value = ""; + }; + + const removeImage = (index: number) => { + setImages((currentImages) => currentImages.filter((_, i) => i !== index)); + }; + + const updateAltText = (index: number, altText: string) => { + setImages((currentImages) => + currentImages.map((img, i) => (i === index ? { ...img, altText } : img)), + ); + }; + + return ( +
+ + + Post Images +

+ This tool posts images to a dummy lexicon in case one needs to link + a high res image on a third-party ATProto CDN. +

+
+ +
+ = 4} + onChange={handleFileChange} + className="hidden" + ref={fileInputRef} + /> + +
+ +
+ {images.map((imageData, index) => ( + + +
+ {imageData.altText + +
+ + updateAltText(index, (e.target as HTMLInputElement).value) + } + placeholder="Add alt text for accessibility" + className="w-full" + disabled={isUploading} + /> +
+
+ ))} + {images.length < 1 && ( +
+ )} +
+ + {images.length > 0 && ( + + )} + + {uploadError && } +
+
+
+ ); +}