Add support for S3-backed image storage

This commit is contained in:
jude committed 2026-10-11 21:41:55 +01:00
1 parent 7fcb2eb747
commit 8763a8e74a
55 files changed
+2611 -111

No files matched your search

+9 -1
View File
@@ -9,6 +9,7 @@ enum ColorScheme {
type UserInfo = {
name: string;
patreon: boolean;
image_uploads: boolean;
preferences: {
timezone: string | null;
reset_inputs_on_create: boolean;
@@ -126,6 +127,13 @@ export const fetchUserInfo = () => ({
staleTime: USER_INFO_STALE_TIME,
});
export const uploadImage = (file: File) =>
axios
.post<{ url: string }>("/dashboard/api/images", file, {
headers: { "Content-Type": "application/octet-stream" },
})
.then((response) => response.data.url);
export const patchUserInfo = () => ({
mutationFn: (userInfo: UpdateUserInfo) => axios.patch(`/dashboard/api/user`, userInfo),
});
@@ -236,7 +244,7 @@ export const fetchGuildTodos = (guild: string) => ({
});
export const patchGuildTodo = (guild: string) => ({
mutationFn: ({id, todo}) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
mutationFn: ({ id, todo }) => axios.patch(`/dashboard/api/guild/${guild}/todos/${id}`, todo),
});
export const postGuildTodo = (guild: string) => ({
@@ -2,14 +2,22 @@ import { JSX } from "preact";
import { createPortal } from "preact/compat";
type Props = {
setModalOpen: (open: boolean) => never;
setModalOpen: (open: boolean) => void;
title: string | JSX.Element;
onSubmitText?: string;
onSubmit?: () => void;
onSubmitDisabled?: boolean;
children: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
};
export const Modal = ({ setModalOpen, title, onSubmit, onSubmitText, children }: Props) => {
export const Modal = ({
setModalOpen,
title,
onSubmit,
onSubmitText,
onSubmitDisabled,
children,
}: Props) => {
const body = document.querySelector("body");
return createPortal(
@@ -34,7 +42,11 @@ export const Modal = ({ setModalOpen, title, onSubmit, onSubmitText, children }:
<section class="modal-card-body">{children}</section>
{onSubmit && (
<footer class="modal-card-foot">
<button class="button is-success" onClick={onSubmit}>
<button
class="button is-success"
onClick={onSubmit}
disabled={onSubmitDisabled}
>
{onSubmitText || "Save"}
</button>
<button
@@ -1,13 +1,65 @@
import { Modal } from "../Modal";
import { useState } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import { useQuery } from "react-query";
import axios from "axios";
import { fetchUserInfo, uploadImage } from "../../api";
const useImageDrop = (enabled: boolean, onFiles: (files: File[]) => void, busy = false) => {
const [dragging, setDragging] = useState(false);
const dragDepth = useRef(0);
const acceptsDrag = (event: DragEvent) =>
enabled && event.dataTransfer?.types.includes("Files");
return {
dragging,
handlers: {
onDragEnter: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth.current += 1;
if (!busy) setDragging(true);
},
onDragOver: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = busy ? "none" : "copy";
},
onDragLeave: () => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
},
onDrop: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth.current = 0;
setDragging(false);
if (!busy) onFiles(Array.from(event.dataTransfer.files));
},
},
};
};
export const ImagePicker = ({ alt, url, setImage, ...props }) => {
const [modalOpen, setModalOpen] = useState(false);
const [droppedFiles, setDroppedFiles] = useState<File[] | null>(null);
const { data: userInfo } = useQuery(fetchUserInfo());
const uploadsEnabled = userInfo?.image_uploads === true;
const { dragging, handlers } = useImageDrop(uploadsEnabled, (files) => {
setDroppedFiles(files);
setModalOpen(true);
});
return (
<>
<a
{...handlers}
class={dragging ? "image-picker-dragging" : ""}
title={uploadsEnabled ? "Click to edit or drop an image to upload" : undefined}
onClick={() => {
setDroppedFiles(null);
setModalOpen(true);
}}
role={"button"}
@@ -18,14 +70,84 @@ export const ImagePicker = ({ alt, url, setImage, ...props }) => {
<ImagePickerModal
setModalOpen={setModalOpen}
setImage={setImage}
url={url}
uploadsEnabled={uploadsEnabled}
droppedFiles={droppedFiles}
></ImagePickerModal>
)}
</>
);
};
const ImagePickerModal = ({ setModalOpen, setImage }) => {
const [value, setValue] = useState("");
const ImagePickerModal = ({ setModalOpen, setImage, url, uploadsEnabled, droppedFiles }) => {
const [value, setValue] = useState(url || "");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const [blocked, setBlocked] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
const uploadInProgress = useRef(false);
const upload = async (files: File[]) => {
if (!uploadsEnabled || uploadInProgress.current) return;
setError("");
if (files.length !== 1) {
setError("Please upload one image at a time.");
return;
}
const file = files[0];
if (file.size > 8 * 1024 * 1024) {
setError("Images must be no larger than 8 MiB.");
return;
}
uploadInProgress.current = true;
setUploading(true);
try {
setValue(await uploadImage(file));
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 451) {
setBlocked(true);
} else {
setError(
axios.isAxiosError(error) && typeof error.response?.data?.error === "string"
? error.response.data.error
: "Could not upload image. Please try again.",
);
}
} finally {
uploadInProgress.current = false;
setUploading(false);
}
};
const { dragging, handlers } = useImageDrop(
uploadsEnabled,
(files) => void upload(files),
uploading,
);
useEffect(() => {
if (droppedFiles) void upload(droppedFiles);
}, [droppedFiles]);
if (blocked) {
return (
<Modal
setModalOpen={() => setBlocked(false)}
title="Image upload blocked"
onSubmit={() => setBlocked(false)}
onSubmitText="OK"
>
<p>
This image was flagged as potentially NSFW. Unfortunately, due to the UK's
Online Safety Act, we cannot handle explicit content. Please defer to using
Imgur or another image storage platform who have the resources to deal with
idiotic laws.
</p>
<p>
The image you attempted to upload <strong>has not been stored.</strong>
</p>
</Modal>
);
}
return (
<Modal
@@ -36,6 +158,7 @@ const ImagePickerModal = ({ setModalOpen, setImage }) => {
setModalOpen(false);
}}
onSubmitText={"Save"}
onSubmitDisabled={uploading}
>
<p>
Please note: if you attach an image directly from Discord, it will not be visible in
@@ -46,10 +169,50 @@ const ImagePickerModal = ({ setModalOpen, setImage }) => {
class="input"
id="urlInput"
placeholder="Image URL..."
value={value}
disabled={uploading}
onInput={(ev) => {
setValue(ev.currentTarget.value);
}}
></input>
{uploadsEnabled && (
<div
{...handlers}
class={`mt-4 image-upload-dropzone ${dragging ? "image-picker-dragging" : ""}`}
>
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
disabled={uploading}
aria-label="Choose an image to upload"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void upload([file]);
}}
/>
<button
type="button"
class={`button is-info ${uploading ? "is-loading" : ""}`}
disabled={uploading}
onClick={() => fileInput.current?.click()}
>
Upload image
</button>
<p class="help">Or drag and drop an image here.</p>
<p class="help">
PNG, JPEG, GIF or WebP, up to 8 MiB. All uploads are converted to PNGs.
</p>
{uploading && <p role="status">Uploading image…</p>}
</div>
)}
{error && (
<p class="help is-danger" role="alert">
{error}
</p>
)}
</Modal>
);
};
@@ -34,3 +34,24 @@ div.reminderContent {
background-color: #f5f5f5;
border-radius: 8px;
}
.image-upload-dropzone {
border: 2px dashed #b5b5b5;
border-radius: 4px;
padding: 1rem;
text-align: center;
}
.image-picker-dragging {
outline: 3px solid #3e8ed0;
outline-offset: 2px;
img {
opacity: 0.6;
}
&.image-upload-dropzone {
background-color: #eff5fb;
border-color: #3e8ed0;
}
}