diff options
Diffstat (limited to 'web/source/settings')
| -rw-r--r-- | web/source/settings/components/error.tsx | 91 | ||||
| -rw-r--r-- | web/source/settings/components/form/inputs.tsx | 26 | ||||
| -rw-r--r-- | web/source/settings/components/form/mutation-button.tsx | 4 | ||||
| -rw-r--r-- | web/source/settings/lib/form/file.tsx | 37 | ||||
| -rw-r--r-- | web/source/settings/style.css | 65 | ||||
| -rw-r--r-- | web/source/settings/views/admin/emoji/local/new-emoji.tsx | 105 | 
6 files changed, 206 insertions, 122 deletions
| diff --git a/web/source/settings/components/error.tsx b/web/source/settings/components/error.tsx index 15c3bccd4..a2b4772dc 100644 --- a/web/source/settings/components/error.tsx +++ b/web/source/settings/components/error.tsx @@ -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; + +	/** +	 * 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; +} -	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} -		</>); +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>  	);  } diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx index f82937fc1..c68095d95 100644 --- a/web/source/settings/components/form/inputs.tsx +++ b/web/source/settings/components/form/inputs.tsx @@ -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> +			<label className="label-label" htmlFor={id}> +				{label} +			</label> +			<label className="label-button" htmlFor={id}>  				<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> +			<input +				id={id} +				type="file" +				className="hidden" +				onChange={onChange} +				ref={ref ? ref as RefObject<HTMLInputElement> : undefined} +				{...props} +			/> +			{infoComponent}  		</div>  	);  } diff --git a/web/source/settings/components/form/mutation-button.tsx b/web/source/settings/components/form/mutation-button.tsx index 1e6d8c968..5d831cd24 100644 --- a/web/source/settings/components/form/mutation-button.tsx +++ b/web/source/settings/components/form/mutation-button.tsx @@ -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" diff --git a/web/source/settings/lib/form/file.tsx b/web/source/settings/lib/form/file.tsx index 944d77ae1..cf9407827 100644 --- a/web/source/settings/lib/form/file.tsx +++ b/web/source/settings/lib/form/file.tsx @@ -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 = ( diff --git a/web/source/settings/style.css b/web/source/settings/style.css index b73978e33..b63c47701 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -257,31 +257,35 @@ input, select, textarea {  		overflow: auto;  		margin: 0;  	} -} - -.hidden { -	display: none; -} -.messagebutton, .messagebutton > div { -	display: flex; -	align-items: center; -	flex-wrap: wrap; +	&.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; -	div.padded { -		margin-left: 1rem; +		.dismiss { +			display: flex; +			flex-shrink: 0; +			align-items: center; +			align-self: stretch; +			gap: 0.25rem; +		}  	} +} -	button, .button { -		white-space: nowrap; -		margin-right: 1rem; -	} +.mutation-button { +	display: flex; +	flex-direction: column; +	gap: 1rem;  } -.messagebutton > div { -	button, .button { -		margin-top: 1rem; -	} +.hidden { +	display: none;  }  .notImplemented { @@ -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-button { +		grid-area: label-button; +	} -	.label { -		grid-column: 1 / span 2; +	.form-info { +		grid-area: file-info; +		.error { +			padding: 0.1rem; +  			line-height: 1.4rem; +		}  	}  } diff --git a/web/source/settings/views/admin/emoji/local/new-emoji.tsx b/web/source/settings/views/admin/emoji/local/new-emoji.tsx index 8ff8236a7..20f45f372 100644 --- a/web/source/settings/views/admin/emoji/local/new-emoji.tsx +++ b/web/source/settings/views/admin/emoji/local/new-emoji.tsx @@ -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 category = useComboBoxInput("category"); - -	const [submitForm, result] = useFormSubmit({ -		shortcode, image, category -	}, useAddEmojiMutation()); +	const prettierMaxSize = useMemo(() => { +		return prettierBytes(emojiMaxSize); +	}, [emojiMaxSize]); + +	const form = { +		shortcode: useShortcode(), +		image: useFileInput("image", { +			withPreview: true, +			maxSize: emojiMaxSize +		}), +		category: useComboBoxInput("category"), +	}; + +	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]); - -	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}:`; +		// 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: 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}  				/> | 
