diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts index bd90d2a9a..3a3794936 100644 --- a/app/features/art/actions/art.new.server.ts +++ b/app/features/art/actions/art.new.server.ts @@ -1,62 +1,57 @@ -import type { FileUpload } from "@remix-run/form-data-parser"; -import { nanoid } from "nanoid"; import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; +import * as R from "remeda"; import * as ArtRepository from "~/features/art/ArtRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; -import { uploadStreamToS3 } from "~/features/img-upload/s3.server"; -import { ALLOWED_IMAGE_EXTENSIONS } from "~/features/img-upload/upload-constants"; import { notify } from "~/features/notifications/core/notify.server"; +import { parseFormData } from "~/form/parse.server"; import { requireRole } from "~/modules/permissions/guards.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; -import invariant from "~/utils/invariant"; -import { - errorToastIfFalsy, - parseFormData, - parseRequestPayload, - safeParseMultipartFormData, -} from "~/utils/remix.server"; +import { errorToastIfFalsy } from "~/utils/remix.server"; +import { toDBBoolean } from "~/utils/sql"; import { userArtPage } from "~/utils/urls"; -import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; -import { editArtSchema, newArtSchema } from "../art-schemas.server"; +import { ART_FORM_MAX_BODY_BYTES } from "../art-image"; +import { uploadArtImage } from "../art-image.server"; +import { artFormSchema } from "../art-schemas"; -export const action: ActionFunction = async ({ request, url }) => { +export const action: ActionFunction = async ({ request }) => { const user = requireUser(); requireRole("ARTIST"); - const searchParams = url.searchParams; - const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY); + const result = await parseFormData({ + request, + schema: artFormSchema, + maxBodyBytes: ART_FORM_MAX_BODY_BYTES, + }); - // updating logic - if (artIdRaw) { - const artId = Number(artIdRaw); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + const data = result.data; + const linkedUsers = R.unique( + data.linkedUsers.filter((userId) => typeof userId === "number"), + ); + + if (data.artId) { const userArts = await ArtRepository.findArtsByUserId(user.id, { includeTagged: false, }); - const existingArt = userArts.find((art) => art.id === artId); + const existingArt = userArts.find((art) => art.id === data.artId); errorToastIfFalsy(existingArt, "Art author is someone else"); - const data = await parseRequestPayload({ - request, - schema: editArtSchema, - }); - - const editedArtId = await ArtRepository.update(artId, { + const editedArtId = await ArtRepository.update(data.artId, { description: data.description, - isShowcase: data.isShowcase, - linkedUsers: data.linkedUsers, + isShowcase: toDBBoolean(data.isShowcase), + linkedUsers, tags: data.tags, }); const existingLinkedUserIds = existingArt.linkedUsers?.map((u) => u.id) ?? []; - const newLinkedUsers = data.linkedUsers.filter( - (userId) => !existingLinkedUserIds.includes(userId), - ); notify({ - userIds: newLinkedUsers, + userIds: R.difference(linkedUsers, existingLinkedUserIds), notification: { type: "TAGGED_TO_ART", meta: { @@ -67,61 +62,18 @@ export const action: ActionFunction = async ({ request, url }) => { }, }); } else { - const preDecidedFilename = `art-${nanoid()}-${Date.now()}`; - - const uploadHandler = async (fileUpload: FileUpload) => { - if ( - fileUpload.fieldName === "img" || - fileUpload.fieldName === "smallImg" - ) { - const ending = fileUpload.name.split(".").pop()?.toLowerCase(); - invariant( - ending && ending !== fileUpload.name, - `File missing extension: "${fileUpload.name}"`, - ); - invariant( - ALLOWED_IMAGE_EXTENSIONS.includes(ending), - `Invalid file extension: "${ending}"`, - ); - const newFilename = `${preDecidedFilename}${fileUpload.fieldName === "smallImg" ? "-small" : ""}.${ending}`; - - const uploadedFileLocation = await uploadStreamToS3( - fileUpload.stream(), - newFilename, - ); - return uploadedFileLocation; - } - return null; - }; - - const formData = await safeParseMultipartFormData( - request, - // 5MB - { maxFileSize: 5 * 1024 * 1024 }, - uploadHandler, - ); - const imgSrc = formData.get("img") as string | null; - invariant(imgSrc); - - const urlParts = imgSrc.split("/"); - const fileName = urlParts[urlParts.length - 1]; - invariant(fileName); - - const data = await parseFormData({ - formData, - schema: newArtSchema, - }); + errorToastIfFalsy(data.img?.type === "NEW", "Art image is missing"); const addedArt = await ArtRepository.insert({ description: data.description, - url: fileName, + url: await uploadArtImage(data.img), validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null, - linkedUsers: data.linkedUsers, + linkedUsers, tags: data.tags, }); notify({ - userIds: data.linkedUsers, + userIds: linkedUsers, notification: { type: "TAGGED_TO_ART", meta: { diff --git a/app/features/art/art-image.server.ts b/app/features/art/art-image.server.ts new file mode 100644 index 000000000..d227b2405 --- /dev/null +++ b/app/features/art/art-image.server.ts @@ -0,0 +1,43 @@ +import { basename } from "node:path"; +import { Readable } from "node:stream"; +import { dataUrlToImageBuffer } from "~/features/img-upload/image-bytes.server"; +import { uploadStreamToS3 } from "~/features/img-upload/s3.server"; +import { shortNanoid } from "~/utils/id"; +import invariant from "~/utils/invariant"; +import { previewUrl } from "./art-utils"; + +const ALLOWED_ART_IMAGE_EXTENSIONS = ["png", "jpeg", "webp"] as const; + +/** + * Uploads both assets a newly submitted art needs — the full image and its thumbnail, following + * the `-small.` convention {@link previewUrl} resolves — and returns the full image's + * file name to store on the art's image row. + */ +export async function uploadArtImage({ + dataUrl, + thumbnailDataUrl, +}: { + dataUrl: string; + thumbnailDataUrl: string; +}): Promise { + const image = dataUrlToImageBuffer(dataUrl, ALLOWED_ART_IMAGE_EXTENSIONS); + const thumbnail = dataUrlToImageBuffer( + thumbnailDataUrl, + ALLOWED_ART_IMAGE_EXTENSIONS, + ); + + invariant( + image.extension === thumbnail.extension, + "Art image and its thumbnail are of a different format", + ); + + const fileName = `art-${Date.now()}-${shortNanoid()}.${image.extension}`; + + const [uploadedLocation] = await Promise.all([ + uploadStreamToS3(Readable.from(image.buffer), fileName), + uploadStreamToS3(Readable.from(thumbnail.buffer), previewUrl(fileName)), + ]); + invariant(uploadedLocation, "Art image upload failed"); + + return basename(uploadedLocation); +} diff --git a/app/features/art/art-image.ts b/app/features/art/art-image.ts new file mode 100644 index 000000000..eeab10b57 --- /dev/null +++ b/app/features/art/art-image.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +/** + * Allowed prefixes for an art data URL. Unlike the generic `image()` form field, art keeps the + * uploaded image's own format instead of normalizing everything to webp. + */ +const ART_IMAGE_DATA_URL_PREFIX_REGEX = /^data:image\/(png|jpeg|webp);base64,/; + +/** + * Largest full-size art image accepted, decoded. Art is submitted at its original resolution and + * png keeps its detail losslessly, so this needs the same headroom the multipart upload flow used + * to allow. + */ +const ART_IMAGE_MAX_BYTES = 5 * 1024 * 1024; + +/** + * Largest art thumbnail accepted, decoded. The client caps the thumbnail's width to + * `ART.THUMBNAIL_WIDTH`, which lands well below this. + */ +const ART_THUMBNAIL_MAX_BYTES = 2 * 1024 * 1024; + +/** + * Ceiling for the whole art submit body. Fits both data URLs at their maximum plus the rest of the + * form's fields. + */ +export const ART_FORM_MAX_BODY_BYTES = + maxDataUrlLength(ART_IMAGE_MAX_BYTES + ART_THUMBNAIL_MAX_BYTES) + 100_000; + +/** Error shown when a picked image doesn't fit within the limits above. */ +export const ART_IMAGE_TOO_LARGE_ERROR = "forms:errors.imageTooLarge"; + +const artImageDataUrl = (maxBytes: number) => + z + .string() + .max(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR) + .regex(ART_IMAGE_DATA_URL_PREFIX_REGEX); + +/** + * JSON-serializable value of the art image form field. Art can't use the generic `image()` field: + * it preserves aspect ratio, keeps the original format and derives a separate thumbnail, which is + * why a `NEW` value carries two data URLs. An `EXISTING` value marks art whose image was already + * uploaded (only the preview url rides along, never bytes) — art images can't be swapped after + * upload. + */ +export const artImageValue = z + .union([ + z.object({ + type: z.literal("EXISTING"), + url: z.string(), + }), + z.object({ + type: z.literal("NEW"), + dataUrl: artImageDataUrl(ART_IMAGE_MAX_BYTES), + thumbnailDataUrl: artImageDataUrl(ART_THUMBNAIL_MAX_BYTES), + }), + ]) + .nullable(); + +export type ArtImageValue = z.infer; + +/** + * Does a freshly compressed art image exceed what the schema accepts? Lets the form field reject + * an oversized pick right away instead of only when the filled-out form is submitted. + */ +export function isArtImageTooLarge({ + dataUrl, + thumbnailDataUrl, +}: { + dataUrl: string; + thumbnailDataUrl: string; +}) { + return ( + dataUrl.length > maxDataUrlLength(ART_IMAGE_MAX_BYTES) || + thumbnailDataUrl.length > maxDataUrlLength(ART_THUMBNAIL_MAX_BYTES) + ); +} + +/** Length a base64 data URL encoding `bytes` decoded bytes can reach, `data:` prefix included. */ +function maxDataUrlLength(bytes: number) { + return Math.ceil(bytes / 3) * 4 + 32; +} diff --git a/app/features/art/art-schemas.server.ts b/app/features/art/art-schemas.server.ts index e8c92401b..015ba59b3 100644 --- a/app/features/art/art-schemas.server.ts +++ b/app/features/art/art-schemas.server.ts @@ -1,47 +1,5 @@ import { z } from "zod"; -import { - _action, - checkboxValueToDbBoolean, - dbBoolean, - falsyToNull, - id, - processMany, - removeDuplicates, - safeJSONParse, -} from "~/utils/zod"; -import { ART } from "./art-constants"; - -const description = z.preprocess( - falsyToNull, - z.string().max(ART.DESCRIPTION_MAX_LENGTH).nullable(), -); -const linkedUsers = z.preprocess( - processMany(safeJSONParse, removeDuplicates), - z.array(id).max(ART.LINKED_USERS_MAX_LENGTH), -); -const tags = z.preprocess( - safeJSONParse, - z - .array( - z.object({ - name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(), - id: id.optional(), - }), - ) - .max(ART.TAG_MAX_LENGTH), -); -export const newArtSchema = z.object({ - description, - linkedUsers, - tags, -}); - -export const editArtSchema = z.object({ - description, - linkedUsers, - tags, - isShowcase: z.preprocess(checkboxValueToDbBoolean, dbBoolean), -}); +import { _action, id } from "~/utils/zod"; const deleteArtSchema = z.object({ _action: _action("DELETE_ART"), diff --git a/app/features/art/art-schemas.ts b/app/features/art/art-schemas.ts new file mode 100644 index 000000000..eb0ea3a0d --- /dev/null +++ b/app/features/art/art-schemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { + array, + customField, + idConstantOptional, + textAreaOptional, + toggle, + userSearchOptional, +} from "~/form/fields"; +import { id } from "~/utils/zod"; +import { ART } from "./art-constants"; +import { artImageValue } from "./art-image"; + +const artTags = z + .array( + z.object({ + name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(), + id: id.optional(), + }), + ) + .max(ART.TAGS_MAX_LENGTH); + +export const artFormSchema = z + .object({ + artId: idConstantOptional(), + img: customField({ initialValue: null }, artImageValue), + description: textAreaOptional({ + label: "labels.description", + maxLength: ART.DESCRIPTION_MAX_LENGTH, + }), + tags: customField({ initialValue: [] }, artTags), + linkedUsers: array({ + label: "labels.linkedUsers", + bottomText: "bottomTexts.linkedUsers", + max: ART.LINKED_USERS_MAX_LENGTH, + field: userSearchOptional({ label: "labels.user" }), + }), + isShowcase: toggle({ + label: "labels.showcase", + bottomText: "bottomTexts.showcase", + }), + }) + .superRefine((data, ctx) => { + // existing art keeps its image, new art must bring one + if (!data.artId && data.img?.type !== "NEW") { + ctx.addIssue({ + path: ["img"], + code: "custom", + message: "forms:errors.required", + }); + } + }); diff --git a/app/features/art/components/ArtImageFormField.module.css b/app/features/art/components/ArtImageFormField.module.css new file mode 100644 index 000000000..7922e577f --- /dev/null +++ b/app/features/art/components/ArtImageFormField.module.css @@ -0,0 +1,3 @@ +.preview { + max-width: 100%; +} diff --git a/app/features/art/components/ArtImageFormField.tsx b/app/features/art/components/ArtImageFormField.tsx new file mode 100644 index 000000000..4752113d3 --- /dev/null +++ b/app/features/art/components/ArtImageFormField.tsx @@ -0,0 +1,115 @@ +import clsx from "clsx"; +import Compressor from "compressorjs"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import type { CustomFieldRenderProps } from "~/form"; +import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; +import { logger } from "~/utils/logger"; +import { ART } from "../art-constants"; +import { + ART_IMAGE_TOO_LARGE_ERROR, + type ArtImageValue, + isArtImageTooLarge, +} from "../art-image"; +import { previewUrl } from "../art-utils"; +import styles from "./ArtImageFormField.module.css"; + +type ArtImageFormFieldProps = Omit< + CustomFieldRenderProps, + "name" +>; + +/** + * Image picker for art. Produces both derived assets the art pipeline needs — the full image with + * its aspect ratio and format preserved, and a thumbnail — as base64 data URLs so they can ride + * along in `SendouForm`'s single JSON submit. Art of already uploaded art can't be swapped, so an + * `EXISTING` value renders as a plain preview. + */ +export function ArtImageFormField({ + value, + onChange, + error, +}: ArtImageFormFieldProps) { + const id = React.useId(); + const [tooLargeError, setTooLargeError] = React.useState(); + const { t } = useTranslation(["common"]); + + if (value?.type === "EXISTING") { + return ; + } + + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + setTooLargeError(undefined); + + const uploadedFile = event.target.files?.[0]; + if (!uploadedFile) { + onChange(null); + return; + } + + try { + const [dataUrl, thumbnailDataUrl] = await Promise.all([ + compressToDataUrl(uploadedFile, {}), + compressToDataUrl(uploadedFile, { maxWidth: ART.THUMBNAIL_WIDTH }), + ]); + + if (isArtImageTooLarge({ dataUrl, thumbnailDataUrl })) { + setTooLargeError(ART_IMAGE_TOO_LARGE_ERROR); + onChange(null); + return; + } + + onChange({ type: "NEW", dataUrl, thumbnailDataUrl }); + } catch (err) { + logger.error(err); + onChange(null); + } + }; + + return ( + +
+ + {value ? ( + + ) : null} +
+
+ ); +} + +function compressToDataUrl( + file: File, + options: Compressor.Options, +): Promise { + return new Promise((resolve, reject) => { + new Compressor(file, { + ...options, + success(result) { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => + reject(new Error("Failed to read compressed image")); + reader.readAsDataURL(result); + }, + error: reject, + }); + }); +} diff --git a/app/features/art/components/ArtTagsFormField.module.css b/app/features/art/components/ArtTagsFormField.module.css new file mode 100644 index 000000000..36bd0a006 --- /dev/null +++ b/app/features/art/components/ArtTagsFormField.module.css @@ -0,0 +1,11 @@ +/* the minimal button variant keeps the full field height and its own font size, which breaks + the baseline when it sits inline in a line of helper text */ +.switcherButton { + height: auto; + font-size: inherit; +} + +/* the new tag input is stretched to the field width, so the button must keep its own */ +.addButton { + flex-shrink: 0; +} diff --git a/app/features/art/components/ArtTagsFormField.tsx b/app/features/art/components/ArtTagsFormField.tsx new file mode 100644 index 000000000..6e26f157e --- /dev/null +++ b/app/features/art/components/ArtTagsFormField.tsx @@ -0,0 +1,152 @@ +import { X } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import type { CustomFieldRenderProps } from "~/form"; +import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; +import { ART } from "../art-constants"; +import styles from "./ArtTagsFormField.module.css"; +import { TagSelect } from "./TagSelect"; + +export type ArtTag = { name?: string; id?: number }; + +type ArtTagsFormFieldProps = Omit, "name"> & { + /** All tags that exist in the database, selectable without creating a new one. */ + existingTags: Array<{ id: number; name: string }>; +}; + +// note: not handling edge case where a tag was added by another user while this +// user was adding a new art with the same tag -> will crash +export function ArtTagsFormField({ + value, + onChange, + error, + existingTags, +}: ArtTagsFormFieldProps) { + const id = React.useId(); + const { t } = useTranslation(["art", "common"]); + const [creationMode, setCreationMode] = React.useState(false); + const [newTagValue, setNewTagValue] = React.useState(""); + + const handleAddNewTag = () => { + const normalizedNewTagValue = newTagValue + .trim() + // replace many whitespaces with one + .replace(/\s\s+/g, " ") + .toLowerCase(); + + if ( + normalizedNewTagValue.length === 0 || + normalizedNewTagValue.length > ART.TAG_MAX_LENGTH + ) { + return; + } + + const alreadyCreatedTag = existingTags.find( + (tag) => tag.name === normalizedNewTagValue, + ); + + if (alreadyCreatedTag) { + onChange([...value, alreadyCreatedTag]); + } else if (value.every((tag) => tag.name !== normalizedNewTagValue)) { + onChange([...value, { name: normalizedNewTagValue }]); + } + + setNewTagValue(""); + setCreationMode(false); + }; + + return ( + +
+ {value.length >= ART.TAGS_MAX_LENGTH ? ( +
+ {t("art:forms.tags.maxReached")} +
+ ) : creationMode ? ( + <> +
+ setNewTagValue(e.target.value)} + onKeyDown={(event) => { + if (event.code === "Enter") { + handleAddNewTag(); + } + }} + /> + + {t("common:actions.add")} + +
+
+ setCreationMode(false)} + > + {t("art:forms.tags.selectFromExisting")} + +
+ + ) : ( + <> + tag.id) + .filter((id) => id !== undefined)} + onSelectionChange={(tagName) => + onChange([ + ...value, + existingTags.find((tag) => tag.name === tagName)!, + ]) + } + /> +
+ {t("art:forms.tags.cantFindExisting")} + setCreationMode(true)} + > + {t("art:forms.tags.addNew")} + +
+ + )} + {value.length > 0 ? ( +
+ {value.map((tag) => ( +
+ {tag.name} + } + size="miniscule" + variant="minimal-destructive" + onPress={() => + onChange(value.filter((it) => it.name !== tag.name)) + } + /> +
+ ))} +
+ ) : null} +
+
+ ); +} diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index 44f585e6c..79d935b8d 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -1,27 +1,20 @@ -import Compressor from "compressorjs"; -import { X } from "lucide-react"; -import { nanoid } from "nanoid"; -import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Form, useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; import { Alert } from "~/components/Alert"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouSwitch } from "~/components/elements/Switch"; -import { UserSearch } from "~/components/elements/UserSearch"; import { FormMessage } from "~/components/FormMessage"; -import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; +import type { CustomFieldRenderProps } from "~/form"; +import { SendouForm } from "~/form/SendouForm"; import { useHasRole } from "~/modules/permissions/hooks"; -import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { artPage, navIconUrl } from "~/utils/urls"; import { metaTitle } from "../../../utils/remix"; import { action } from "../actions/art.new.server"; -import { ART } from "../art-constants"; -import { previewUrl } from "../art-utils"; -import { TagSelect } from "../components/TagSelect"; +import type { ArtImageValue } from "../art-image"; +import { artFormSchema } from "../art-schemas"; +import { ArtImageFormField } from "../components/ArtImageFormField"; +import { type ArtTag, ArtTagsFormField } from "../components/ArtTagsFormField"; import { loader } from "../loaders/art.new.server"; export { action, loader }; @@ -43,31 +36,9 @@ export const meta: MetaFunction = () => { export default function NewArtPage() { const data = useLoaderData(); - const [img, setImg] = React.useState(null); - const [smallImg, setSmallImg] = React.useState(null); - const { t } = useTranslation(["common", "art"]); - const ref = React.useRef(null); - const fetcher = useFetcher(); + const { t } = useTranslation(["art"]); const isArtist = useHasRole("ARTIST"); - const handleSubmit = () => { - const formData = new FormData(ref.current!); - - if (img) formData.append("img", img, img.name); - if (smallImg) formData.append("smallImg", smallImg, smallImg.name); - - fetcher.submit(formData, { - encType: "multipart/form-data", - method: "post", - }); - }; - - const submitButtonDisabled = () => { - if (fetcher.state !== "idle") return true; - - return (!img || !smallImg) && !data.art; - }; - if (!isArtist) { return (
@@ -76,321 +47,51 @@ export default function NewArtPage() { ); } + const isCurrentlyShowcase = Boolean(data.art?.isShowcase); + return (
-
- {t("art:forms.caveats")} - - - - - {data.art ? : null} -
- - {t("common:actions.save")} - -
- + user.id) ?? [], + isShowcase: isCurrentlyShowcase, + }} + > + {({ FormField }) => ( + <> + {t("art:forms.caveats")} + + {({ value, onChange, error }: CustomFieldRenderProps) => ( + void} + error={error} + /> + )} + + + + {({ value, onChange, error }: CustomFieldRenderProps) => ( + void} + error={error} + existingTags={data.tags} + /> + )} + + + {data.art ? ( + + ) : null} + + )} +
); } - -function ImageUpload({ - img, - setImg, - setSmallImg, -}: { - img: File | null; - setImg: (file: File | null) => void; - setSmallImg: (file: File | null) => void; -}) { - const data = useLoaderData(); - const { t } = useTranslation(["common"]); - const id = React.useId(); - - if (data.art) { - return ; - } - - return ( -
- - { - const uploadedFile = e.target.files?.[0]; - if (!uploadedFile) { - setImg(null); - return; - } - - new Compressor(uploadedFile, { - success(result) { - invariant(result instanceof Blob); - const file = new File([result], uploadedFile.name); - - setImg(file); - }, - error(err) { - logger.error(err.message); - }, - }); - - new Compressor(uploadedFile, { - maxWidth: ART.THUMBNAIL_WIDTH, - success(result) { - invariant(result instanceof Blob); - const file = new File([result], uploadedFile.name); - - setSmallImg(file); - }, - error(err) { - logger.error(err.message); - }, - }); - }} - /> - {img && } -
- ); -} - -function Description() { - const { t } = useTranslation(["art"]); - const data = useLoaderData(); - const [value, setValue] = React.useState(data.art?.description ?? ""); - const id = React.useId(); - - return ( -
- -