grain.social is a photo sharing platform built on atproto.

bring back avatar uploads, move photo manip functions to a separate file, show profile dialog errors in an alert if any

-41
src/components/AvatarForm.tsx
··· 1 - export function AvatarForm( 2 - { src, alt }: Readonly<{ src?: string; alt?: string }>, 3 - ) { 4 - return ( 5 - <form 6 - id="avatar-file-form" 7 - hx-post="/actions/avatar/upload" 8 - hx-target="#image-preview" 9 - hx-swap="innerHTML" 10 - hx-encoding="multipart/form-data" 11 - hx-trigger="change from:#file" 12 - > 13 - <label htmlFor="file"> 14 - <span class="sr-only">Upload avatar</span> 15 - <div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer"> 16 - <div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 17 - <i class="fa-solid fa-camera text-white text-xs"></i> 18 - </div> 19 - <div id="image-preview" class="w-full h-full"> 20 - {src 21 - ? ( 22 - <img 23 - src={src} 24 - alt={alt} 25 - className="rounded-full w-full h-full object-cover" 26 - /> 27 - ) 28 - : null} 29 - </div> 30 - </div> 31 - <input 32 - class="hidden" 33 - type="file" 34 - id="file" 35 - name="file" 36 - accept="image/*" 37 - /> 38 - </label> 39 - </form> 40 - ); 41 - }
+33
src/components/AvatarInput.tsx
··· 1 + export function AvatarInput( 2 + { src, alt }: Readonly<{ src?: string; alt?: string }>, 3 + ) { 4 + return ( 5 + <label htmlFor="file"> 6 + <span class="sr-only">Upload avatar</span> 7 + <div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer"> 8 + <div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 9 + <i class="fa-solid fa-camera text-white text-xs"></i> 10 + </div> 11 + <div id="image-preview" class="w-full h-full"> 12 + {src 13 + ? ( 14 + <img 15 + src={src} 16 + alt={alt} 17 + className="rounded-full w-full h-full object-cover" 18 + /> 19 + ) 20 + : null} 21 + </div> 22 + </div> 23 + <input 24 + class="hidden" 25 + type="file" 26 + id="file" 27 + name="file" 28 + accept="image/*" 29 + _="on change call Grain.handleAvatarImageSelect(me)" 30 + /> 31 + </label> 32 + ); 33 + }
+3 -3
src/components/GalleryPage.tsx
··· 90 90 title="Justified layout" 91 91 variant="primary" 92 92 class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 93 - _="on click call toggleLayout('justified') 93 + _="on click call Grain.toggleLayout('justified') 94 94 set @data-selected to 'true' 95 95 set #masonry-button's @data-selected to 'false'" 96 96 > ··· 141 141 variant="primary" 142 142 data-selected="false" 143 143 class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 144 - _="on click call toggleLayout('masonry') 144 + _="on click call Grain.toggleLayout('masonry') 145 145 set @data-selected to 'true' 146 146 set #justified-button's @data-selected to 'false'" 147 147 > ··· 190 190 <div 191 191 id="masonry-container" 192 192 class="h-0 overflow-hidden relative mx-auto w-full" 193 - _="on load or htmx:afterSettle call computeLayout()" 193 + _="on load or htmx:afterSettle call Grain.computeLayout()" 194 194 > 195 195 {gallery.items?.filter(isPhotoView)?.length 196 196 ? gallery?.items
+23 -19
src/components/ProfileDialog.tsx
··· 1 1 import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2 2 import { Button, Dialog, Input, Textarea } from "@bigmoves/bff/components"; 3 + import { AvatarInput } from "./AvatarInput.tsx"; 3 4 4 5 export function ProfileDialog({ 5 6 profile, ··· 11 12 <Dialog.Content class="dark:bg-zinc-950 relative"> 12 13 <Dialog.X class="fill-zinc-950 dark:fill-zinc-50" /> 13 14 <Dialog.Title>Edit my profile</Dialog.Title> 14 - <div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2"> 15 - { 16 - /* <div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 17 - <i class="fa-solid fa-camera text-white text-xs"></i> 18 - </div> */ 19 - } 20 - <div id="image-preview" class="w-full h-full"> 21 - <img 22 - src={profile.avatar} 23 - alt={profile.handle} 24 - className="rounded-full w-full h-full object-cover" 25 - /> 26 - </div> 27 - </div> 28 15 <form 29 - hx-post="/actions/profile/update" 30 - hx-swap="none" 31 - _="on htmx:afterOnLoad trigger closeModal" 16 + id="profile-form" 17 + hx-encoding="multipart/form-data" 18 + _="on submit 19 + halt the event 20 + put 'Updating...' into #submit-button.innerText 21 + add @disabled to #submit-button 22 + call Grain.updateProfile(me) 23 + on htmx:afterOnLoad 24 + put 'Update' into #submit-button.innerText 25 + remove @disabled from #submit-button 26 + if event.detail.xhr.status != 200 27 + alert('Error: ' + event.detail.xhr.responseText) 28 + else 29 + trigger closeDialog 30 + end" 32 31 > 33 - <div id="image-input" /> 32 + <AvatarInput src={profile.avatar} alt={profile.handle} /> 34 33 <div class="mb-4 relative"> 35 34 <label htmlFor="displayName">Display Name</label> 36 35 <Input ··· 54 53 {profile.description} 55 54 </Textarea> 56 55 </div> 57 - <Button type="submit" variant="primary" class="w-full"> 56 + <Button 57 + type="submit" 58 + id="submit-button" 59 + variant="primary" 60 + class="w-full" 61 + > 58 62 Update 59 63 </Button> 60 64 <Button
+1 -1
src/components/UploadPage.tsx
··· 35 35 type="file" 36 36 multiple 37 37 accept="image/*" 38 - _="on change call uploadPhotos(me)" 38 + _="on change call Grain.uploadPhotos(me)" 39 39 /> 40 40 </label> 41 41 </Button>
+14
src/routes/actions.tsx
··· 351 351 const formData = await req.formData(); 352 352 const displayName = formData.get("displayName") as string; 353 353 const description = formData.get("description") as string; 354 + const file = formData.get("file") as File | null; 354 355 355 356 const record = ctx.indexService.getRecord<Profile>( 356 357 `at://${did}/social.grain.actor.profile/self`, ··· 360 361 return new Response("Profile record not found", { status: 404 }); 361 362 } 362 363 364 + if (file) { 365 + try { 366 + const blobResponse = await ctx.agent?.uploadBlob(file); 367 + record.avatar = blobResponse?.data?.blob; 368 + } catch (e) { 369 + console.error("Failed to upload avatar:", e); 370 + } 371 + } 372 + 363 373 try { 364 374 await ctx.updateRecord<Profile>("social.grain.actor.profile", "self", { 365 375 displayName, ··· 368 378 }); 369 379 } catch (e) { 370 380 console.error("Error updating record:", e); 381 + const errorMessage = e instanceof Error 382 + ? e.message 383 + : "Unknown error occurred"; 384 + return new Response(errorMessage, { status: 400 }); 371 385 } 372 386 373 387 return ctx.redirect(`/profile/${handle}`);
+1
src/routes/onboard.tsx
··· 7 7 ctx: BffContext<State>, 8 8 ) => { 9 9 ctx.requireAuth(); 10 + ctx.state.scripts = ["photo_manip.js", "profile_dialog.js"]; 10 11 return ctx.render( 11 12 <div 12 13 hx-get="/dialogs/profile"
+1
src/routes/profile.tsx
··· 34 34 }, 35 35 ...getPageMeta(profileLink(handle)), 36 36 ]; 37 + ctx.state.scripts = ["photo_manip.js", "profile_dialog.js"]; 37 38 if (tab) { 38 39 return ctx.html( 39 40 <ProfilePage
+1 -1
src/routes/upload.tsx
··· 15 15 const galleryRkey = url.searchParams.get("returnTo"); 16 16 const photos = getActorPhotos(did, ctx); 17 17 ctx.state.meta = [{ title: "Upload — Grain" }, ...getPageMeta("/upload")]; 18 - ctx.state.scripts = ["upload_page.js"]; 18 + ctx.state.scripts = ["photo_manip.js", "upload_page.js"]; 19 19 return ctx.render( 20 20 <UploadPage 21 21 handle={handle}
+4
static/masonry.js
··· 164 164 computeMasonry(); 165 165 observeMasonry(); 166 166 }); 167 + 168 + window.Grain = window.Grain || {}; 169 + window.Grain.toggleLayout = toggleLayout; 170 + window.Grain.computeLayout = computeLayout;
+113
static/photo_manip.js
··· 1 + // deno-lint-ignore-file no-window 2 + function readFileAsDataURL(file) { 3 + return new Promise((resolve, reject) => { 4 + const reader = new FileReader(); 5 + reader.onload = () => resolve(reader.result); 6 + reader.onerror = reject; 7 + reader.readAsDataURL(file); 8 + }); 9 + } 10 + 11 + function dataURLToBlob(dataUrl) { 12 + const [meta, base64] = dataUrl.split(","); 13 + const mime = meta.match(/:(.*?);/)[1]; 14 + const binary = atob(base64); 15 + const array = new Uint8Array(binary.length); 16 + for (let i = 0; i < binary.length; i++) { 17 + array[i] = binary.charCodeAt(i); 18 + } 19 + return new Blob([array], { type: mime }); 20 + } 21 + 22 + function getDataUriSize(dataUri) { 23 + const base64 = dataUri.split(",")[1]; 24 + return Math.ceil((base64.length * 3) / 4); 25 + } 26 + 27 + function createResizedImage(dataUri, options) { 28 + return new Promise((resolve, reject) => { 29 + const img = new Image(); 30 + img.onload = () => { 31 + let scale = 1; 32 + if (options.mode === "cover") { 33 + scale = Math.max( 34 + options.width / img.width, 35 + options.height / img.height, 36 + ); 37 + } else if (options.mode === "contain") { 38 + scale = Math.min( 39 + options.width / img.width, 40 + options.height / img.height, 41 + ); 42 + } else { 43 + scale = 1; // stretch or fallback 44 + } 45 + 46 + const w = Math.round(img.width * scale); 47 + const h = Math.round(img.height * scale); 48 + 49 + const canvas = document.createElement("canvas"); 50 + canvas.width = w; 51 + canvas.height = h; 52 + 53 + const ctx = canvas.getContext("2d"); 54 + if (!ctx) return reject(new Error("Failed to get canvas context")); 55 + 56 + ctx.fillStyle = "#fff"; 57 + ctx.fillRect(0, 0, w, h); 58 + ctx.imageSmoothingEnabled = true; 59 + ctx.imageSmoothingQuality = "high"; 60 + ctx.drawImage(img, 0, 0, w, h); 61 + 62 + resolve({ 63 + dataUrl: canvas.toDataURL("image/jpeg", options.quality), 64 + width: w, 65 + height: h, 66 + }); 67 + }; 68 + img.onerror = (e) => reject(e); 69 + img.src = dataUri; 70 + }); 71 + } 72 + 73 + async function doResize(dataUri, opts) { 74 + let bestResult = null; 75 + let minQuality = 0; 76 + let maxQuality = 101; 77 + 78 + while (maxQuality - minQuality > 1) { 79 + const quality = Math.round((minQuality + maxQuality) / 2); 80 + const result = await createResizedImage(dataUri, { 81 + width: opts.width, 82 + height: opts.height, 83 + quality: quality / 100, 84 + mode: opts.mode, 85 + }); 86 + 87 + const size = getDataUriSize(result.dataUrl); 88 + 89 + if (size < opts.maxSize) { 90 + minQuality = quality; 91 + bestResult = result; 92 + } else { 93 + maxQuality = quality; 94 + } 95 + } 96 + 97 + if (!bestResult) { 98 + throw new Error("Failed to compress image"); 99 + } 100 + 101 + return { 102 + path: bestResult.dataUrl, 103 + mime: "image/jpeg", 104 + size: getDataUriSize(bestResult.dataUrl), 105 + width: bestResult.width, 106 + height: bestResult.height, 107 + }; 108 + } 109 + 110 + window.Grain = window.Grain || {}; 111 + window.Grain.readFileAsDataURL = readFileAsDataURL; 112 + window.Grain.dataURLToBlob = dataURLToBlob; 113 + window.Grain.doResize = doResize;
+55
static/profile_dialog.js
··· 1 + // deno-lint-ignore-file no-window 2 + 3 + function handleAvatarImageSelect(fileInput) { 4 + if (fileInput.files.length > 0) { 5 + const file = fileInput.files[0]; 6 + Grain.readFileAsDataURL(file).then((dataUrl) => { 7 + const previewImg = document.createElement("img"); 8 + previewImg.src = dataUrl; 9 + previewImg.className = "rounded-full w-full h-full object-cover"; 10 + previewImg.alt = "Avatar preview"; 11 + 12 + const imagePreview = fileInput.closest("form").querySelector( 13 + "#image-preview", 14 + ); 15 + if (imagePreview) { 16 + imagePreview.innerHTML = ""; 17 + imagePreview.appendChild(previewImg); 18 + } 19 + }); 20 + } 21 + } 22 + 23 + async function updateProfile(formElement) { 24 + const formData = new FormData(formElement); 25 + 26 + const avatarFile = formData.get("file"); 27 + if (avatarFile && avatarFile.type.startsWith("image/")) { 28 + try { 29 + const dataUrl = await Grain.readFileAsDataURL(file); 30 + 31 + const resized = await Grain.doResize(dataUrl, { 32 + width: 2000, 33 + height: 2000, 34 + maxSize: 1000 * 1000, // 1MB 35 + mode: "contain", 36 + }); 37 + 38 + const blob = Grain.dataURLToBlob(resized.path); 39 + formData.set("file", blob, avatarFile.name); 40 + } catch (err) { 41 + console.error("Error resizing image:", err); 42 + formData.delete("file"); 43 + } 44 + } 45 + 46 + htmx.ajax("POST", "/actions/profile/update", { 47 + "swap": "none", 48 + "values": Object.fromEntries(formData), 49 + "source": formElement, 50 + }); 51 + } 52 + 53 + window.Grain = window.Grain || {}; 54 + window.Grain.handleAvatarImageSelect = handleAvatarImageSelect; 55 + window.Grain.updateProfile = updateProfile;
+7 -110
static/upload_page.js
··· 1 + // deno-lint-ignore-file no-window 2 + 1 3 async function uploadPhotos(inputElement) { 2 4 const fileList = Array.from(inputElement.files); 3 5 ··· 10 12 11 13 const uploadPromises = fileList.map(async (file) => { 12 14 try { 13 - const dataUrl = await readFileAsDataURL(file); 15 + const dataUrl = await Grain.readFileAsDataURL(file); 14 16 15 - const resized = await doResize(dataUrl, { 17 + const resized = await Grain.doResize(dataUrl, { 16 18 width: 2000, 17 19 height: 2000, 18 20 maxSize: 1000 * 1000, // 1MB 19 21 mode: "contain", 20 22 }); 21 23 22 - const blob = dataURLToBlob(resized.path); 24 + const blob = Grain.dataURLToBlob(resized.path); 23 25 24 26 const fd = new FormData(); 25 27 fd.append("file", blob, file.name); ··· 51 53 inputElement.value = ""; 52 54 } 53 55 54 - function readFileAsDataURL(file) { 55 - return new Promise((resolve, reject) => { 56 - const reader = new FileReader(); 57 - reader.onload = () => resolve(reader.result); 58 - reader.onerror = reject; 59 - reader.readAsDataURL(file); 60 - }); 61 - } 62 - 63 - function dataURLToBlob(dataUrl) { 64 - const [meta, base64] = dataUrl.split(","); 65 - const mime = meta.match(/:(.*?);/)[1]; 66 - const binary = atob(base64); 67 - const array = new Uint8Array(binary.length); 68 - for (let i = 0; i < binary.length; i++) { 69 - array[i] = binary.charCodeAt(i); 70 - } 71 - return new Blob([array], { type: mime }); 72 - } 73 - 74 - function getDataUriSize(dataUri) { 75 - const base64 = dataUri.split(",")[1]; 76 - return Math.ceil((base64.length * 3) / 4); 77 - } 78 - 79 - function createResizedImage(dataUri, options) { 80 - return new Promise((resolve, reject) => { 81 - const img = new Image(); 82 - img.onload = () => { 83 - let scale = 1; 84 - if (options.mode === "cover") { 85 - scale = Math.max( 86 - options.width / img.width, 87 - options.height / img.height, 88 - ); 89 - } else if (options.mode === "contain") { 90 - scale = Math.min( 91 - options.width / img.width, 92 - options.height / img.height, 93 - ); 94 - } else { 95 - scale = 1; // stretch or fallback 96 - } 97 - 98 - const w = Math.round(img.width * scale); 99 - const h = Math.round(img.height * scale); 100 - 101 - const canvas = document.createElement("canvas"); 102 - canvas.width = w; 103 - canvas.height = h; 104 - 105 - const ctx = canvas.getContext("2d"); 106 - if (!ctx) return reject(new Error("Failed to get canvas context")); 107 - 108 - ctx.fillStyle = "#fff"; 109 - ctx.fillRect(0, 0, w, h); 110 - ctx.imageSmoothingEnabled = true; 111 - ctx.imageSmoothingQuality = "high"; 112 - ctx.drawImage(img, 0, 0, w, h); 113 - 114 - resolve({ 115 - dataUrl: canvas.toDataURL("image/jpeg", options.quality), 116 - width: w, 117 - height: h, 118 - }); 119 - }; 120 - img.onerror = (e) => reject(e); 121 - img.src = dataUri; 122 - }); 123 - } 124 - 125 - async function doResize(dataUri, opts) { 126 - let bestResult = null; 127 - let minQuality = 0; 128 - let maxQuality = 101; 129 - 130 - while (maxQuality - minQuality > 1) { 131 - const quality = Math.round((minQuality + maxQuality) / 2); 132 - const result = await createResizedImage(dataUri, { 133 - width: opts.width, 134 - height: opts.height, 135 - quality: quality / 100, 136 - mode: opts.mode, 137 - }); 138 - 139 - const size = getDataUriSize(result.dataUrl); 140 - 141 - if (size < opts.maxSize) { 142 - minQuality = quality; 143 - bestResult = result; 144 - } else { 145 - maxQuality = quality; 146 - } 147 - } 148 - 149 - if (!bestResult) { 150 - throw new Error("Failed to compress image"); 151 - } 152 - 153 - return { 154 - path: bestResult.dataUrl, 155 - mime: "image/jpeg", 156 - size: getDataUriSize(bestResult.dataUrl), 157 - width: bestResult.width, 158 - height: bestResult.height, 159 - }; 160 - } 56 + window.Grain = window.Grain || {}; 57 + window.Grain.uploadPhotos = uploadPhotos;