grain.social is a photo sharing platform built on atproto.
1import {
2 dataURLToBlob,
3 doResize,
4 readFileAsDataURL,
5} from "@bigmoves/bff/browser";
6import exifr from "exifr";
7import htmx from "htmx.org";
8import hyperscript from "hyperscript.org";
9import { Exif, normalizeExif, tags as supportedTags } from "./exif.ts";
10
11export class UploadPage {
12 public async uploadPhotos(formElement: HTMLFormElement): Promise<void> {
13 const formData = new FormData(formElement);
14 const fileList = formData.getAll("files") as File[] ?? [];
15 const parseExif = formData.get("parseExif") === "on";
16 const galleryUri = formData.get("galleryUri") as string;
17
18 if (fileList.length > 10) {
19 alert("You can only upload 10 photos at a time");
20 return;
21 }
22
23 const uploadPromises = fileList.map(async (file) => {
24 let fileDataUri: string | ArrayBuffer | null;
25 let tags: Exif | undefined = undefined;
26 let resized;
27
28 try {
29 fileDataUri = await readFileAsDataURL(file);
30 if (fileDataUri === null || typeof fileDataUri !== "string") {
31 console.error("File data URL is not a string:", fileDataUri);
32 alert("Error reading file.");
33 return;
34 }
35 } catch (err) {
36 console.error("Error reading file as Data URL:", err);
37 alert("Error reading file.");
38 return;
39 }
40
41 if (parseExif) {
42 try {
43 const rawTags = await exifr.parse(file, { pick: supportedTags });
44 console.log("EXIF tags:", await exifr.parse(file));
45 tags = normalizeExif(rawTags);
46 } catch (err) {
47 console.error("Error reading EXIF data:", err);
48 }
49 }
50
51 try {
52 resized = await doResize(fileDataUri, {
53 width: 2000,
54 height: 2000,
55 maxSize: 1000 * 1000, // 1MB
56 mode: "contain",
57 });
58 } catch (err) {
59 console.error("Error resizing image:", err);
60 alert("Error resizing image.");
61 return;
62 }
63
64 const blob = dataURLToBlob(resized.path);
65
66 const fd = new FormData();
67 fd.append("file", blob, file.name);
68 fd.append("width", String(resized.width));
69 fd.append("height", String(resized.height));
70
71 if (tags) {
72 fd.append("exif", JSON.stringify(tags));
73 }
74
75 if (galleryUri) {
76 fd.append("galleryUri", galleryUri);
77 }
78
79 const response = await fetch("/actions/photo", {
80 method: "POST",
81 body: fd,
82 });
83
84 if (!response.ok) {
85 alert(await response.text());
86 return;
87 }
88
89 const html = await response.text();
90 const temp = document.createElement("div");
91 temp.innerHTML = html;
92 const photoId = temp?.firstElementChild?.id;
93
94 const preview = document.querySelector("#image-preview");
95 if (preview) {
96 const firstChild = temp.firstElementChild;
97
98 if (firstChild) {
99 preview.insertBefore(firstChild, preview.firstChild);
100 }
101
102 htmx.process(preview);
103
104 const deleteButton = preview.querySelector(
105 `#delete-photo-${photoId}`,
106 );
107 if (deleteButton) {
108 htmx.process(deleteButton);
109 hyperscript.processNode(deleteButton);
110 }
111 }
112
113 const photosCount = document.querySelector("#photos-count");
114 if (photosCount) {
115 const firstChild = temp.firstElementChild;
116 if (firstChild) {
117 photosCount.replaceWith(firstChild.innerHTML);
118 }
119 }
120 });
121
122 await Promise.all(uploadPromises);
123
124 // Clear the file input after upload
125 const fileInput = formElement.querySelector("input[type='file']");
126 if (fileInput instanceof HTMLInputElement) {
127 fileInput.value = "";
128 }
129 }
130}