Add support for S3-backed image storage
This commit is contained in:
1 parent
7fcb2eb747
commit
8763a8e74a
55 files changed
+2611
-111
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user