mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2024-11-10 06:54:16 +00:00
[bugfix] Reset emoji fields on upload error (#2905)
This commit is contained in:
parent
f24ce34c3a
commit
578a4e0cf5
6 changed files with 206 additions and 122 deletions
|
@ -17,7 +17,9 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { SerializedError } from "@reduxjs/toolkit";
|
||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
|
@ -44,39 +46,70 @@ function ErrorFallback({ error, resetErrorBoundary }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Error({ error }) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error("Rendering error:", error);
|
||||
let message;
|
||||
interface GtsError {
|
||||
/**
|
||||
* Error message returned from the API.
|
||||
*/
|
||||
error: string;
|
||||
|
||||
if (error.data != undefined) { // RTK Query error with data
|
||||
if (error.status) {
|
||||
message = (<>
|
||||
<b>{error.status}:</b> {error.data.error}
|
||||
{error.data.error_description &&
|
||||
<p>
|
||||
{error.data.error_description}
|
||||
</p>
|
||||
}
|
||||
</>);
|
||||
} else {
|
||||
message = error.data.error;
|
||||
}
|
||||
} else if (error.name != undefined || error.type != undefined) { // JS error
|
||||
message = (<>
|
||||
<b>{error.type && error.name}:</b> {error.message}
|
||||
</>);
|
||||
} else if (error.status && typeof error.error == "string") {
|
||||
message = (<>
|
||||
<b>{error.status}:</b> {error.error}
|
||||
</>);
|
||||
/**
|
||||
* For OAuth errors: description of the error.
|
||||
*/
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface ErrorProps {
|
||||
error: FetchBaseQueryError | SerializedError | Error | undefined;
|
||||
|
||||
/**
|
||||
* Optional function to clear the error.
|
||||
* If provided, rendered error will have
|
||||
* a "dismiss" button.
|
||||
*/
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
function Error({ error, reset }: ErrorProps) {
|
||||
if (error === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error("caught error: ", error);
|
||||
|
||||
let message: ReactNode;
|
||||
if ("status" in error) {
|
||||
// RTK Query error with data.
|
||||
const gtsError = error.data as GtsError;
|
||||
const errMsg = gtsError.error_description ?? gtsError.error;
|
||||
message = <>Code {error.status} {errMsg}</>;
|
||||
} else {
|
||||
message = error.message ?? error;
|
||||
// SerializedError or Error.
|
||||
const errMsg = error.message ?? JSON.stringify(error);
|
||||
message = (
|
||||
<>{error.name && `${error.name}: `}{errMsg}</>
|
||||
);
|
||||
}
|
||||
|
||||
let className = "error";
|
||||
if (reset) {
|
||||
className += " with-dismiss";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="error">
|
||||
{message}
|
||||
<div className={className}>
|
||||
<span>{message}</span>
|
||||
{ reset &&
|
||||
<span
|
||||
className="dismiss"
|
||||
onClick={reset}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>Dismiss</span>
|
||||
<i className="fa fa-fw fa-close" aria-hidden="true" />
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import type {
|
|||
RadioFormInputHook,
|
||||
TextFormInputHook,
|
||||
} from "../../lib/form/types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export interface TextInputProps extends React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
|
@ -92,22 +93,25 @@ export interface FileInputProps extends React.DetailedHTMLProps<
|
|||
|
||||
export function FileInput({ label, field, ...props }: FileInputProps) {
|
||||
const { onChange, ref, infoComponent } = field;
|
||||
const id = nanoid();
|
||||
|
||||
return (
|
||||
<div className="form-field file">
|
||||
<label>
|
||||
<div className="label">{label}</div>
|
||||
<div className="file-input button">Browse</div>
|
||||
{infoComponent}
|
||||
{/* <a onClick={removeFile("header")}>remove</a> */}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
|
||||
{...props}
|
||||
/>
|
||||
<label className="label-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<label className="label-button" htmlFor={id}>
|
||||
<div className="file-input button">Browse</div>
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{infoComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -51,9 +51,9 @@ export default function MutationButton({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div className={wrapperClassName ? wrapperClassName : "mutation-button"}>
|
||||
{(showError && targetsThisButton && result.error) &&
|
||||
<Error error={result.error} />
|
||||
<Error error={result.error} reset={result.reset} />
|
||||
}
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
@ -27,6 +27,7 @@ import type {
|
|||
HookOpts,
|
||||
FileFormInputHook,
|
||||
} from "./types";
|
||||
import { Error as ErrorC } from "../../components/error";
|
||||
|
||||
const _default = undefined;
|
||||
export default function useFileInput(
|
||||
|
@ -41,6 +42,15 @@ export default function useFileInput(
|
|||
const [imageURL, setImageURL] = useState<string>();
|
||||
const [info, setInfo] = useState<React.JSX.Element>();
|
||||
|
||||
function reset() {
|
||||
if (imageURL) {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
}
|
||||
setImageURL(undefined);
|
||||
setFile(undefined);
|
||||
setInfo(undefined);
|
||||
}
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = e.target.files;
|
||||
if (!files) {
|
||||
|
@ -59,25 +69,18 @@ export default function useFileInput(
|
|||
setImageURL(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
let size = prettierBytes(file.size);
|
||||
const sizePrettier = prettierBytes(file.size);
|
||||
if (maxSize && file.size > maxSize) {
|
||||
size = <span className="error-text">{size}</span>;
|
||||
const maxSizePrettier = prettierBytes(maxSize);
|
||||
setInfo(
|
||||
<ErrorC
|
||||
error={new Error(`file size ${sizePrettier} is larger than max size ${maxSizePrettier}`)}
|
||||
reset={(reset)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
setInfo(<>{file.name} ({sizePrettier})</>);
|
||||
}
|
||||
|
||||
setInfo(
|
||||
<>
|
||||
{file.name} ({size})
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (imageURL) {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
}
|
||||
setImageURL(undefined);
|
||||
setFile(undefined);
|
||||
setInfo(undefined);
|
||||
}
|
||||
|
||||
const infoComponent = (
|
||||
|
|
|
@ -257,33 +257,37 @@ input, select, textarea {
|
|||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.with-dismiss {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.dismiss {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mutation-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messagebutton, .messagebutton > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
div.padded {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
white-space: nowrap;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.messagebutton > div {
|
||||
button, .button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notImplemented {
|
||||
border: 2px solid rgb(70, 79, 88);
|
||||
background: repeating-linear-gradient(
|
||||
|
@ -500,12 +504,29 @@ form {
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-field.file label {
|
||||
.form-field.file {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
"label-label label-label"
|
||||
"label-button file-info"
|
||||
;
|
||||
|
||||
.label-label {
|
||||
grid-area: label-label;
|
||||
}
|
||||
|
||||
.label {
|
||||
grid-column: 1 / span 2;
|
||||
.label-button {
|
||||
grid-area: label-button;
|
||||
}
|
||||
|
||||
.form-info {
|
||||
grid-area: file-info;
|
||||
.error {
|
||||
padding: 0.1rem;
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
import React, { useMemo, useEffect, ReactNode } from "react";
|
||||
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
|
||||
import useShortcode from "./use-shortcode";
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
|
@ -27,52 +27,74 @@ import FakeToot from "../../../../components/fake-toot";
|
|||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
|
||||
import prettierBytes from "prettier-bytes";
|
||||
|
||||
export default function NewEmojiForm() {
|
||||
const shortcode = useShortcode();
|
||||
|
||||
const { data: instance } = useInstanceV1Query();
|
||||
const emojiMaxSize = useMemo(() => {
|
||||
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
|
||||
}, [instance]);
|
||||
|
||||
const image = useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: emojiMaxSize
|
||||
});
|
||||
const prettierMaxSize = useMemo(() => {
|
||||
return prettierBytes(emojiMaxSize);
|
||||
}, [emojiMaxSize]);
|
||||
|
||||
const category = useComboBoxInput("category");
|
||||
const form = {
|
||||
shortcode: useShortcode(),
|
||||
image: useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: emojiMaxSize
|
||||
}),
|
||||
category: useComboBoxInput("category"),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit({
|
||||
shortcode, image, category
|
||||
}, useAddEmojiMutation());
|
||||
const [submitForm, result] = useFormSubmit(
|
||||
form,
|
||||
useAddEmojiMutation(),
|
||||
{
|
||||
changedOnly: false,
|
||||
// On submission, reset form values
|
||||
// no matter what the result was.
|
||||
onFinish: (_res) => {
|
||||
form.shortcode.reset();
|
||||
form.image.reset();
|
||||
form.category.reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcode.value === undefined || shortcode.value.length == 0) {
|
||||
if (image.value != undefined) {
|
||||
let [name, _ext] = image.value.name.split(".");
|
||||
shortcode.setter(name);
|
||||
}
|
||||
// If shortcode has not been entered yet, but an image file
|
||||
// has been submitted, suggest a shortcode based on filename.
|
||||
if (
|
||||
(form.shortcode.value === undefined || form.shortcode.value.length === 0) &&
|
||||
form.image.value !== undefined
|
||||
) {
|
||||
let [name, _ext] = form.image.value.name.split(".");
|
||||
form.shortcode.setter(name);
|
||||
}
|
||||
|
||||
/* We explicitly don't want to have 'shortcode' as a dependency here
|
||||
because we only want to change the shortcode to the filename if the field is empty
|
||||
at the moment the file is selected, not some time after when the field is emptied
|
||||
*/
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [image.value]);
|
||||
// We explicitly don't want to have 'shortcode' as a
|
||||
// dependency here because we only want to change the
|
||||
// shortcode to the filename if the field is empty at
|
||||
// the moment the file is selected, not some time after
|
||||
// when the field is emptied.
|
||||
//
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form.image.value]);
|
||||
|
||||
let emojiOrShortcode;
|
||||
|
||||
if (image.previewValue != undefined) {
|
||||
emojiOrShortcode = <img
|
||||
className="emoji"
|
||||
src={image.previewValue}
|
||||
title={`:${shortcode.value}:`}
|
||||
alt={shortcode.value}
|
||||
/>;
|
||||
} else if (shortcode.value !== undefined && shortcode.value.length > 0) {
|
||||
emojiOrShortcode = `:${shortcode.value}:`;
|
||||
let emojiOrShortcode: ReactNode;
|
||||
if (form.image.previewValue !== undefined) {
|
||||
emojiOrShortcode = (
|
||||
<img
|
||||
className="emoji"
|
||||
src={form.image.previewValue}
|
||||
title={`:${form.shortcode.value}:`}
|
||||
alt={form.shortcode.value}
|
||||
/>
|
||||
);
|
||||
} else if (form.shortcode.value !== undefined && form.shortcode.value.length > 0) {
|
||||
emojiOrShortcode = `:${form.shortcode.value}:`;
|
||||
} else {
|
||||
emojiOrShortcode = `:your_emoji_here:`;
|
||||
}
|
||||
|
@ -87,22 +109,23 @@ export default function NewEmojiForm() {
|
|||
|
||||
<form onSubmit={submitForm} className="form-flex">
|
||||
<FileInput
|
||||
field={image}
|
||||
field={form.image}
|
||||
label={`Image file: png, gif, or static webp; max size ${prettierMaxSize}`}
|
||||
accept="image/png,image/gif,image/webp"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={shortcode}
|
||||
field={form.shortcode}
|
||||
label="Shortcode, must be unique among the instance's local emoji"
|
||||
{...{pattern: "^\\w{2,30}$"}}
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
field={category}
|
||||
children={[]}
|
||||
field={form.category}
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={image.previewValue === undefined}
|
||||
disabled={form.image.previewValue === undefined || form.shortcode.value?.length === 0}
|
||||
label="Upload emoji"
|
||||
result={result}
|
||||
/>
|
||||
|
|
Loading…
Reference in a new issue