+26
src/routeTree.gen.ts
+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
+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
+
}