grain.social is a photo sharing platform built on atproto.
at main 130 lines 3.7 kB view raw
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}