-41
src/components/AvatarForm.tsx
-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
+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
+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
+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
+1
-1
src/components/UploadPage.tsx
+14
src/routes/actions.tsx
+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
+1
src/routes/onboard.tsx
+1
src/routes/profile.tsx
+1
src/routes/profile.tsx
+1
-1
src/routes/upload.tsx
+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
+4
static/masonry.js
+113
static/photo_manip.js
+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
+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
+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;