Add support for S3-backed image storage
This commit is contained in:
1 parent
7fcb2eb747
commit
1d87291890
51 files changed
+2394
-92
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,5 +1,8 @@
|
||||
import { Modal } from "../Modal";
|
||||
import { useState } from "preact/hooks";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { useQuery } from "react-query";
|
||||
import axios from "axios";
|
||||
import { fetchUserInfo, uploadImage } from "../../api";
|
||||
|
||||
export const ImagePicker = ({ alt, url, setImage, ...props }) => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -18,14 +21,61 @@ export const ImagePicker = ({ alt, url, setImage, ...props }) => {
|
||||
<ImagePickerModal
|
||||
setModalOpen={setModalOpen}
|
||||
setImage={setImage}
|
||||
url={url}
|
||||
></ImagePickerModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePickerModal = ({ setModalOpen, setImage }) => {
|
||||
const [value, setValue] = useState("");
|
||||
const ImagePickerModal = ({ setModalOpen, setImage, url }) => {
|
||||
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 { data: userInfo } = useQuery(fetchUserInfo());
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setError("");
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
setError("Images must be no larger than 8 MiB.");
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (blocked) {
|
||||
return (
|
||||
<Modal
|
||||
setModalOpen={() => setBlocked(false)}
|
||||
title="Image upload blocked"
|
||||
onSubmit={() => setBlocked(false)}
|
||||
onSubmitText="OK"
|
||||
>
|
||||
<p>
|
||||
This image was flagged as probably NSFW (not safe for work) and cannot be
|
||||
uploaded. Please choose a different image that does not contain sexual or
|
||||
explicit content.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -36,6 +86,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 +97,44 @@ const ImagePickerModal = ({ setModalOpen, setImage }) => {
|
||||
class="input"
|
||||
id="urlInput"
|
||||
placeholder="Image URL..."
|
||||
value={value}
|
||||
disabled={uploading}
|
||||
onInput={(ev) => {
|
||||
setValue(ev.currentTarget.value);
|
||||
}}
|
||||
></input>
|
||||
{userInfo?.image_uploads && (
|
||||
<div class="mt-4">
|
||||
<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">PNG, JPEG, GIF or WebP, up to 8 MiB. Saved as a static PNG.</p>
|
||||
{uploading && <p role="status">Checking and uploading image…</p>}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p class="help is-danger" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user