Migrate /art/new to SendouForm

This commit is contained in:
Kalle 2026-07-28 16:17:33 +03:00
parent 77b7e05b2c
commit 02c2d74ddc
53 changed files with 778 additions and 726 deletions

View File

@ -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: {

View File

@ -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 `<name>-small.<ext>` 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<string> {
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);
}

View File

@ -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<typeof artImageValue>;
/**
* 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;
}

View File

@ -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"),

View File

@ -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",
});
}
});

View File

@ -0,0 +1,3 @@
.preview {
max-width: 100%;
}

View File

@ -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<ArtImageValue>,
"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<string>();
const { t } = useTranslation(["common"]);
if (value?.type === "EXISTING") {
return <img src={previewUrl(value.url)} alt="" />;
}
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
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 (
<FormFieldWrapper
id={id}
name="img"
label={t("common:upload.imageToUpload")}
error={tooLargeError ?? error}
required
>
<div className="stack sm items-start">
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={handleFileChange}
/>
{value ? (
<img
src={value.dataUrl}
alt=""
className={clsx(styles.preview, "rounded")}
/>
) : null}
</div>
</FormFieldWrapper>
);
}
function compressToDataUrl(
file: File,
options: Compressor.Options,
): Promise<string> {
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,
});
});
}

View File

@ -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;
}

View File

@ -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<CustomFieldRenderProps<ArtTag[]>, "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 (
<FormFieldWrapper
id={id}
name="tags"
label={t("art:forms.tags.title")}
error={error}
>
<div className="stack xs">
{value.length >= ART.TAGS_MAX_LENGTH ? (
<div className="text-sm text-warning">
{t("art:forms.tags.maxReached")}
</div>
) : creationMode ? (
<>
<div className="stack horizontal sm items-center">
<input
id={id}
placeholder={t("art:forms.tags.addNew.placeholder")}
value={newTagValue}
onChange={(e) => setNewTagValue(e.target.value)}
onKeyDown={(event) => {
if (event.code === "Enter") {
handleAddNewTag();
}
}}
/>
<SendouButton
size="small"
variant="outlined"
className={styles.addButton}
onPress={handleAddNewTag}
>
{t("common:actions.add")}
</SendouButton>
</div>
<div className="text-xs text-lighter">
<SendouButton
variant="minimal"
className={styles.switcherButton}
onPress={() => setCreationMode(false)}
>
{t("art:forms.tags.selectFromExisting")}
</SendouButton>
</div>
</>
) : (
<>
<TagSelect
// empty combobox on select
key={value.length}
tags={existingTags}
disabledKeys={value
.map((tag) => tag.id)
.filter((id) => id !== undefined)}
onSelectionChange={(tagName) =>
onChange([
...value,
existingTags.find((tag) => tag.name === tagName)!,
])
}
/>
<div className="stack horizontal xs items-center text-xs text-lighter">
{t("art:forms.tags.cantFindExisting")}
<SendouButton
variant="minimal"
className={styles.switcherButton}
onPress={() => setCreationMode(true)}
>
{t("art:forms.tags.addNew")}
</SendouButton>
</div>
</>
)}
{value.length > 0 ? (
<div className="text-sm stack sm flex-wrap horizontal">
{value.map((tag) => (
<div key={tag.name} className="stack horizontal xs items-center">
{tag.name}
<SendouButton
icon={<X />}
size="miniscule"
variant="minimal-destructive"
onPress={() =>
onChange(value.filter((it) => it.name !== tag.name))
}
/>
</div>
))}
</div>
) : null}
</div>
</FormFieldWrapper>
);
}

View File

@ -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<typeof loader>();
const [img, setImg] = React.useState<File | null>(null);
const [smallImg, setSmallImg] = React.useState<File | null>(null);
const { t } = useTranslation(["common", "art"]);
const ref = React.useRef<HTMLFormElement>(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 (
<Main className="stack items-center">
@ -76,321 +47,51 @@ export default function NewArtPage() {
);
}
const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
return (
<Main halfWidth>
<Form ref={ref} className="stack md">
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
<ImageUpload img={img} setImg={setImg} setSmallImg={setSmallImg} />
<Description />
<Tags />
<LinkedUsers />
{data.art ? <ShowcaseToggle /> : null}
<div>
<SendouButton
onPress={handleSubmit}
isDisabled={submitButtonDisabled()}
>
{t("common:actions.save")}
</SendouButton>
</div>
</Form>
<SendouForm
schema={artFormSchema}
defaultValues={{
artId: data.art?.id,
img: data.art ? { type: "EXISTING", url: data.art.url } : null,
description: data.art?.description ?? "",
tags: data.art?.tags ?? [],
linkedUsers: data.art?.linkedUsers?.map((user) => user.id) ?? [],
isShowcase: isCurrentlyShowcase,
}}
>
{({ FormField }) => (
<>
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
<FormField name="img">
{({ value, onChange, error }: CustomFieldRenderProps) => (
<ArtImageFormField
value={value as ArtImageValue}
onChange={onChange as (value: ArtImageValue) => void}
error={error}
/>
)}
</FormField>
<FormField name="description" />
<FormField name="tags">
{({ value, onChange, error }: CustomFieldRenderProps) => (
<ArtTagsFormField
value={value as ArtTag[]}
onChange={onChange as (value: ArtTag[]) => void}
error={error}
existingTags={data.tags}
/>
)}
</FormField>
<FormField name="linkedUsers" />
{data.art ? (
<FormField name="isShowcase" disabled={isCurrentlyShowcase} />
) : null}
</>
)}
</SendouForm>
</Main>
);
}
function ImageUpload({
img,
setImg,
setSmallImg,
}: {
img: File | null;
setImg: (file: File | null) => void;
setSmallImg: (file: File | null) => void;
}) {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
const id = React.useId();
if (data.art) {
return <img src={previewUrl(data.art.url)} alt="" />;
}
return (
<div>
<label htmlFor={id}>{t("common:upload.imageToUpload")}</label>
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={(e) => {
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 && <img src={URL.createObjectURL(img)} alt="" className="mt-4" />}
</div>
);
}
function Description() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.art?.description ?? "");
const id = React.useId();
return (
<div>
<Label
htmlFor={id}
valueLimits={{ current: value.length, max: ART.DESCRIPTION_MAX_LENGTH }}
>
{t("art:forms.description.title")}
</Label>
<textarea
id={id}
name="description"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={ART.DESCRIPTION_MAX_LENGTH}
/>
</div>
);
}
// 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
function Tags() {
const { t } = useTranslation(["art", "common"]);
const data = useLoaderData<typeof loader>();
const [creationMode, setCreationMode] = React.useState(false);
const [tags, setTags] = React.useState<{ name?: string; id?: number }[]>(
data.art?.tags ?? [],
);
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 = data.tags.find(
(t) => t.name === normalizedNewTagValue,
);
if (alreadyCreatedTag) {
setTags((tags) => [...tags, alreadyCreatedTag]);
} else if (tags.every((tag) => tag.name !== normalizedNewTagValue)) {
setTags((tags) => [...tags, { name: normalizedNewTagValue }]);
}
setNewTagValue("");
setCreationMode(false);
};
return (
<div className="stack xs items-start">
<Label htmlFor="tags" className="mb-0">
{t("art:forms.tags.title")}
</Label>
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
{creationMode ? (
<div className="art__creation-mode-switcher-container">
<SendouButton
variant="minimal"
onPress={() => setCreationMode(false)}
>
{t("art:forms.tags.selectFromExisting")}
</SendouButton>
</div>
) : (
<div className="stack horizontal sm text-xs text-lighter art__creation-mode-switcher-container">
{t("art:forms.tags.cantFindExisting")}{" "}
<SendouButton variant="minimal" onPress={() => setCreationMode(true)}>
{t("art:forms.tags.addNew")}
</SendouButton>
</div>
)}
{tags.length >= ART.TAGS_MAX_LENGTH ? (
<div className="text-sm text-warning">
{t("art:forms.tags.maxReached")}
</div>
) : creationMode ? (
<div className="stack horizontal sm items-center">
<input
placeholder={t("art:forms.tags.addNew.placeholder")}
name="tag"
value={newTagValue}
onChange={(e) => setNewTagValue(e.target.value)}
onKeyDown={(event) => {
if (event.code === "Enter") {
handleAddNewTag();
}
}}
/>
<SendouButton
size="small"
variant="outlined"
onPress={handleAddNewTag}
>
{t("common:actions.add")}
</SendouButton>
</div>
) : (
<TagSelect
// empty combobox on select
key={tags.length}
tags={data.tags}
disabledKeys={tags.map((t) => t.id).filter((id) => id !== undefined)}
onSelectionChange={(tagName) =>
setTags([...tags, data.tags.find((t) => t.name === tagName)!])
}
/>
)}
<div className="text-sm stack sm flex-wrap horizontal">
{tags.map((t) => {
return (
<div key={t.name} className="stack horizontal">
{t.name}{" "}
<SendouButton
icon={<X />}
size="small"
variant="minimal-destructive"
className="art__delete-tag-button"
onPress={() => {
setTags(tags.filter((tag) => tag.name !== t.name));
}}
/>
</div>
);
})}
</div>
</div>
);
}
function LinkedUsers() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
const [users, setUsers] = React.useState<
{ inputId: string; userId?: number }[]
>(
data.art?.linkedUsers && data.art.linkedUsers.length > 0
? data.art.linkedUsers.map((user) => ({
userId: user.id,
inputId: nanoid(),
}))
: [{ inputId: nanoid() }],
);
return (
<div>
<label htmlFor="user">{t("art:forms.linkedUsers.title")}</label>
<input
type="hidden"
name="linkedUsers"
value={JSON.stringify(
users.filter((u) => u.userId).map((u) => u.userId),
)}
/>
{users.map(({ inputId, userId }, i) => {
return (
<div key={inputId} className="stack horizontal sm mb-2 items-center">
<UserSearch
name="user"
onChange={(newUser) => {
const newUsers = structuredClone(users);
newUsers[i] = { ...newUsers[i], userId: newUser?.id };
setUsers(newUsers);
}}
initialUserId={userId}
/>
{users.length > 1 || users[0].userId ? (
<SendouButton
size="small"
variant="minimal-destructive"
onPress={() => {
if (users.length === 1) {
setUsers([{ inputId: nanoid() }]);
} else {
setUsers(users.filter((u) => u.inputId !== inputId));
}
}}
icon={<X />}
/>
) : null}
</div>
);
})}
<SendouButton
size="small"
onPress={() => setUsers([...users, { inputId: nanoid() }])}
isDisabled={users.length >= ART.LINKED_USERS_MAX_LENGTH}
className="my-3"
variant="outlined"
>
{t("art:forms.linkedUsers.anotherOne")}
</SendouButton>
<FormMessage type="info">{t("art:forms.linkedUsers.info")}</FormMessage>
</div>
);
}
function ShowcaseToggle() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
const [checked, setChecked] = React.useState(isCurrentlyShowcase);
const id = React.useId();
return (
<div>
<label htmlFor={id}>{t("art:forms.showcase.title")}</label>
<SendouSwitch
isSelected={checked}
onChange={setChecked}
name="isShowcase"
id={id}
isDisabled={isCurrentlyShowcase}
/>
<FormMessage type="info">{t("art:forms.showcase.info")}</FormMessage>
</div>
);
}

View File

@ -0,0 +1,53 @@
import invariant from "~/utils/invariant";
export type ImageExtension = "webp" | "png" | "jpeg";
/**
* Decodes a base64 image data URL to a buffer, resolving the format from the buffer's own magic
* bytes rather than trusting the client-declared mime type, and asserting it is one of
* `allowedExtensions`.
*/
export function dataUrlToImageBuffer(
dataUrl: string,
allowedExtensions: ReadonlyArray<ImageExtension>,
) {
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
const buffer = Buffer.from(base64, "base64");
const extension = imageExtensionFromMagicBytes(buffer);
invariant(
extension && allowedExtensions.includes(extension),
`Submitted image is not a valid ${allowedExtensions.join(" or ")}`,
);
return { buffer, extension };
}
function imageExtensionFromMagicBytes(buffer: Buffer): ImageExtension | null {
if (
buffer.length > 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "webp";
}
if (
buffer.length > 8 &&
buffer[0] === 0x89 &&
buffer.toString("ascii", 1, 4) === "PNG"
) {
return "png";
}
if (
buffer.length > 3 &&
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff
) {
return "jpeg";
}
return null;
}

View File

@ -7,6 +7,7 @@ import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { errorToastIfFalsy } from "~/utils/remix.server";
import * as ImageRepository from "./ImageRepository.server";
import { dataUrlToImageBuffer } from "./image-bytes.server";
import { uploadStreamToS3 } from "./s3.server";
import { MAX_UNVALIDATED_IMG_COUNT } from "./upload-constants";
@ -44,7 +45,11 @@ export async function imageFieldValueToImgId({
);
}
const { buffer, extension } = dataUrlToImageBuffer(value.dataUrl);
// the client compresses to webp, but browsers without canvas webp encoding fall back to png
const { buffer, extension } = dataUrlToImageBuffer(value.dataUrl, [
"webp",
"png",
]);
const uploadedFileLocation = await uploadStreamToS3(
Readable.from(buffer),
@ -63,37 +68,3 @@ export async function imageFieldValueToImgId({
return img.id;
}
function dataUrlToImageBuffer(dataUrl: string) {
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
const buffer = Buffer.from(base64, "base64");
const extension = imageExtensionFromMagicBytes(buffer);
invariant(extension, "Submitted image is not a valid webp or png");
return { buffer, extension };
}
/**
* Resolves the image format from the buffer's magic bytes. The client compresses to webp,
* but browsers without canvas webp encoding silently fall back to png.
*/
function imageExtensionFromMagicBytes(buffer: Buffer): "webp" | "png" | null {
if (
buffer.length > 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "webp";
}
if (
buffer.length > 8 &&
buffer[0] === 0x89 &&
buffer.toString("ascii", 1, 4) === "PNG"
) {
return "png";
}
return null;
}

View File

@ -1,5 +1,3 @@
export const ALLOWED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
export const MAX_UNVALIDATED_IMG_COUNT = 5;
export const IMAGES_TO_VALIDATE_AT_ONCE = 5;

View File

@ -10,6 +10,13 @@ export type ParseResult<T> =
| { success: true; data: T }
| { success: false; fieldErrors: Record<string, string> };
/**
* Ceiling for a request body, in bytes. Sized to fit a form with a couple of `image()` fields (each
* capped at ~3M base64 characters) plus its other fields. Forms that legitimately submit more (e.g.
* art) pass their own `maxBodyBytes`.
*/
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
/**
* Maps a {@link z.ZodError} to field-level errors keyed by form field name
* (e.g. `members[0].userId`), keeping the first error per field.
@ -34,14 +41,14 @@ function fieldErrorsFromZodError(error: z.ZodError): Record<string, string> {
export async function parseFormData<T extends z.ZodTypeAny>({
request,
schema,
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
}: {
request: Request;
schema: T;
/** Overrides {@link DEFAULT_MAX_BODY_BYTES} for forms that legitimately submit a bigger body. */
maxBodyBytes?: number;
}): Promise<ParseResult<z.infer<T>>> {
const data =
request.headers.get("Content-Type") === "application/json"
? await request.json()
: formDataToObject(await request.formData());
const data = await requestBodyToObject(request, maxBodyBytes);
const result = await schema.safeParseAsync(data);
@ -116,3 +123,52 @@ function imageFields(
return [...fields].map(([key, autoValidate]) => ({ key, autoValidate }));
}
/**
* Reads the request body into the plain object the schema parses, refusing anything over
* `maxBytes`. `Content-Length` is checked up front so an oversized body is rejected before a byte
* of it is read.
*/
async function requestBodyToObject(request: Request, maxBytes: number) {
if (Number(request.headers.get("Content-Length")) > maxBytes) {
throw payloadTooLarge();
}
if (request.headers.get("Content-Type") === "application/json") {
return JSON.parse(await readBodyText(request, maxBytes));
}
return formDataToObject(await request.formData());
}
/**
* Reads the body as text, aborting the stream as soon as `maxBytes` is exceeded. The running total
* is enforced (rather than trusting `Content-Length`) so a chunked body that omits or understates
* the header can't be buffered in full either.
*/
async function readBodyText(request: Request, maxBytes: number) {
const reader = request.body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder();
let bytesRead = 0;
let text = "";
let chunk = await reader.read();
while (!chunk.done) {
bytesRead += chunk.value.byteLength;
if (bytesRead > maxBytes) {
await reader.cancel();
throw payloadTooLarge();
}
text += decoder.decode(chunk.value, { stream: true });
chunk = await reader.read();
}
return text + decoder.decode();
}
function payloadTooLarge() {
return new Response(null, { status: 413 });
}

View File

@ -1,5 +1,3 @@
import type { FileUpload } from "@remix-run/form-data-parser";
import { parseFormData as parseMultipartFormData } from "@remix-run/form-data-parser";
import type { Namespace, TFunction } from "i18next";
import type { Ok, Result } from "neverthrow";
import type { Params, UIMatch } from "react-router";
@ -122,28 +120,6 @@ export async function parseRequestPayload<T extends z.ZodTypeAny>({
}
}
/**
* @deprecated - use parseFormData from /app/form/parse.server.ts (with SendouForm) or parseRequestPayload (without SendouForm)
*
* Parse formData with the given schema. Throws a request to show an error toast if it fails.
*/
export async function parseFormData<T extends z.ZodTypeAny>({
formData,
schema,
}: {
formData: FormData;
schema: T;
}): Promise<z.infer<T>> {
const formDataObj = formDataToObject(formData);
try {
return await schema.parseAsync(formDataObj);
} catch (e) {
logger.error("Error parsing form data", e);
throw errorToastRedirect("Validation failed");
}
}
/** Parse params with the given schema. Throws HTTP 404 response if fails. */
export function parseParams<T extends z.ZodTypeAny>({
params,
@ -340,53 +316,3 @@ export function privatelyCachedJson<T>(dataValue: T) {
headers: { "Cache-Control": "private, max-age=5" },
});
}
const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
type FileUploadHandler = (
fileUpload: FileUpload,
) => Promise<string | null | undefined>;
type ParseFormDataOptions = { maxFileSize?: number };
export function safeParseMultipartFormData(
request: Request,
uploadHandler?: FileUploadHandler,
): Promise<FormData>;
export function safeParseMultipartFormData(
request: Request,
options?: ParseFormDataOptions,
uploadHandler?: FileUploadHandler,
): Promise<FormData>;
export async function safeParseMultipartFormData(
request: Request,
optionsOrHandler?: ParseFormDataOptions | FileUploadHandler,
uploadHandler?: FileUploadHandler,
): Promise<FormData> {
const maxFileSize =
typeof optionsOrHandler === "object" && optionsOrHandler?.maxFileSize
? optionsOrHandler.maxFileSize
: DEFAULT_MAX_FILE_SIZE_BYTES;
try {
if (typeof optionsOrHandler === "function") {
return await parseMultipartFormData(request, optionsOrHandler);
}
return await parseMultipartFormData(
request,
optionsOrHandler,
uploadHandler,
);
} catch (err) {
if (
err instanceof Error &&
(err.name === "MaxFileSizeExceededError" ||
(err.cause instanceof Error &&
err.cause.name === "MaxFileSizeExceededError"))
) {
throw errorToastRedirect(
`File size exceeds maximum allowed size of ${maxFileSize / 1024 / 1024}MB`,
);
}
throw err;
}
}

View File

@ -1,6 +1,5 @@
import type { ZodType } from "zod";
import { z } from "zod";
import type { DBBoolean } from "~/db/tables";
import {
abilities,
type abilitiesShort,
@ -30,13 +29,6 @@ export const nonEmptyString = z.string().trim().min(1, {
message: "Required",
});
export const dbBoolean = z.coerce
.number()
.int()
.min(0)
.max(1)
.transform((value): DBBoolean => (value === 0 ? 0 : 1));
// matches #RGB and #RRGGBB only (no alpha) https://stackoverflow.com/a/1636354
const hexCodeWithoutAlphaRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const hexCodeWithoutAlpha = z.string().regex(hexCodeWithoutAlphaRegex);
@ -432,12 +424,6 @@ export function checkboxValueToBoolean(value: unknown) {
return value === "on";
}
export function checkboxValueToDbBoolean(value: unknown): DBBoolean {
if (checkboxValueToBoolean(value)) return 1;
return 0;
}
export const _action = <T extends string>(value: T) =>
z.preprocess(deduplicate, z.literal(value));

View File

@ -316,10 +316,12 @@ const badgeOptions = badges.map((b) => ({
pipeline and the `UnvalidatedUserSubmittedImage` admin-validation / supporter auto-validation
flow, while keeping `SendouForm`'s single-submit `application/json` model unchanged.
> **Art upload is out of scope.** Art stays on its dedicated multipart route (`/art/new`): it
> produces two derived assets (full + thumbnail), preserves aspect ratio, keeps the original
> format, allows up to 5MB, and has its own `Art` table. Any future "large / aspect-preserving
> / multi-derivative" upload should likewise stay off this field.
> **Art upload is out of scope.** Art (`/art/new`) preserves aspect ratio, keeps the original
> format, produces two derived assets (full + thumbnail) and stores them on its own `Art` table
> rather than as a `UserSubmittedImage` id. It therefore uses a `customField` with its own
> renderer (`ArtImageFormField`) and its own server resolver (`uploadArtImage`), while still
> submitting as base64 within `SendouForm`'s single JSON submit. Any future "aspect-preserving /
> multi-derivative" upload should likewise stay off this field and follow that pattern.
### Schema

View File

@ -1,6 +1,7 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { artFormSchema } from "~/features/art/art-schemas";
import {
expect,
impersonate,
@ -8,6 +9,7 @@ import {
seed,
test,
} from "./helpers/playwright";
import { createFormHelpers } from "./helpers/playwright-form";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -20,12 +22,14 @@ test.describe("Art", () => {
await navigate({ page, url: "/art/new" });
const form = createFormHelpers(page, artFormSchema);
const testImagePath = path.join(__dirname, "fixtures/test-image.png");
await page.locator('input[type="file"]').setInputFiles(testImagePath);
await expect(page.locator("form img")).toBeVisible();
await page.getByRole("button", { name: "Save" }).click();
await form.submit();
await expect(page).toHaveURL(/\/u\/.*\/art/);
await expect(page.getByText(/pending moderator approval/i)).toBeVisible();
@ -49,4 +53,32 @@ test.describe("Art", () => {
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
});
test("edits already uploaded art keeping its image", async ({ page }) => {
await seed(page);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/u/nzap/art" });
const form = createFormHelpers(page, artFormSchema);
const editNewestArt = () =>
page.locator('a[href^="/art/new?art="]').first().click();
await editNewestArt();
// the already uploaded image is shown but can't be swapped
await expect(page.locator('form img[src*="-small."]')).toBeVisible();
await expect(page.locator('input[type="file"]')).toHaveCount(0);
await form.fill("description", "Squid drawing");
await form.submit();
await expect(page).toHaveURL(/\/u\/.*\/art/);
await editNewestArt();
await expect(page.getByLabel(form.getLabel("description"))).toHaveValue(
"Squid drawing",
);
});
});

View File

@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Vær opmærksom på følgende: 1) Upload kun Splatoon-kunst 2) upload kun kunst, som du selv har lavet 3) Ingen NSFW-kunst. 4) Kunst skal igennem en valideringsproces før det vises til andre brugere.",
"forms.description.title": "Beskrivelse",
"forms.linkedUsers.title": "tilknyttede brugere",
"forms.linkedUsers.anotherOne": "Endnu en",
"forms.linkedUsers.info": "Hvem er i kunstværket? Hvis du tilknytter en bruger dit kunstværk giver du tilladelse til at det bliver hvis på hans/huns profil.",
"forms.showcase.title": "Fremvist kunstværk",
"forms.showcase.info": "Dit fremviste værk bliver vist på den fælles kunst-side. Du kan kun fremvise et værk af gangen.",
"forms.tags.title": "Etiketter",
"forms.tags.selectFromExisting": "Vælg ud fra eksisterende etiketter",
"forms.tags.cantFindExisting": "Kan du ikke finde en eksisterende etiket?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Holdnavnet er taget af et andet hold",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Beskrivelse",
"labels.user": "",
"labels.linkedUsers": "tilknyttede brugere",
"bottomTexts.linkedUsers": "Hvem er i kunstværket? Hvis du tilknytter en bruger dit kunstværk giver du tilladelse til at det bliver hvis på hans/huns profil.",
"labels.showcase": "Fremvist kunstværk",
"bottomTexts.showcase": "Dit fremviste værk bliver vist på den fælles kunst-side. Du kan kun fremvise et værk af gangen.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",
"forms.linkedUsers.anotherOne": "",
"forms.linkedUsers.info": "",
"forms.showcase.title": "",
"forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Es gibt bereits ein Team mit diesem Namen",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Beschreibung",
"labels.user": "",
"labels.linkedUsers": "",
"bottomTexts.linkedUsers": "",
"labels.showcase": "",
"bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "Recently Uploaded",
"tabs.showcase": "Showcase",
"forms.caveats": "A few things to note: 1) Only upload Splatoon art 2) Only upload art you made yourself 3) No NSFW art. There is a validation process before art is shown to other users.",
"forms.description.title": "Description",
"forms.linkedUsers.title": "Linked users",
"forms.linkedUsers.anotherOne": "Another one",
"forms.linkedUsers.info": "Who is in the art? Linking users allows your art to show up on their profile.",
"forms.showcase.title": "Showcase",
"forms.showcase.info": "Your showcase piece is shown on the common /art page. Only one piece can be your showcase at a time.",
"forms.tags.title": "Tags",
"forms.tags.selectFromExisting": "Select from existing tags",
"forms.tags.cantFindExisting": "Can't find an existing tag?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "No SendouQ match found with this ID",
"errors.invalidUrl": "Must be a valid URL",
"errors.notAllowedCharacters": "Contains not allowed characters",
"errors.imageTooLarge": "Image is too large. Try one with a smaller file size.",
"errors.atLeastOneOption": "At least one option must be selected",
"errors.duplicateName": "There is already a team with this name",
"errors.duplicateOrgName": "There is already an organization with this name",
@ -147,6 +148,10 @@
"labels.urls": "URLs",
"labels.description": "Description",
"labels.user": "User",
"labels.linkedUsers": "Linked users",
"bottomTexts.linkedUsers": "Who is in the art? Linking users allows your art to show up on their profile.",
"labels.showcase": "Showcase",
"bottomTexts.showcase": "Your showcase piece is shown on the common /art page. Only one piece can be your showcase at a time.",
"labels.orgMemberRole": "Role",
"labels.orgMemberRoleDisplayName": "Role display name",
"labels.orgSocialLinks": "Social links",

View File

@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "Subidas recientemente",
"tabs.showcase": "Destacadas",
"forms.caveats": "NOTAS: 1) Solo sube arte de Splatoon; 2) Solo sube arte que tú creaste; 3) No se permite arte inapropiado (NSFW). Hay un proceso de evaluación antes de que se muestre tu arte públicamente.",
"forms.description.title": "Descripción",
"forms.linkedUsers.title": "Enlaces de usuarios",
"forms.linkedUsers.anotherOne": "Uno más",
"forms.linkedUsers.info": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
"forms.showcase.title": "Exhibición",
"forms.showcase.info": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a la vez.",
"forms.tags.title": "Etiquetas",
"forms.tags.selectFromExisting": "Seleccionar etiquetas existentes",
"forms.tags.cantFindExisting": "¿No encuentras una etiqueta existente?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "Debe ser una URL válida",
"errors.notAllowedCharacters": "Contiene caracteres no permitidos",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "Debe seleccionarse al menos una opción",
"errors.duplicateName": "Ya existe un equipo con ese nombre",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "URLs",
"labels.description": "Descripción",
"labels.user": "Usuario",
"labels.linkedUsers": "Enlaces de usuarios",
"bottomTexts.linkedUsers": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
"labels.showcase": "Exhibición",
"bottomTexts.showcase": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a la vez.",
"labels.orgMemberRole": "Rol",
"labels.orgMemberRoleDisplayName": "Nombre del rol",
"labels.orgSocialLinks": "Enlaces sociales",

View File

@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "NOTAS: 1) Solo sube arte de Splatoon; 2) Solo sube arte que tu creaste; 3) No se permite arte inapropiada (NSFW). Hay un proceso de evaluación antes de que se muestre tu arte públicamente.",
"forms.description.title": "Descripción",
"forms.linkedUsers.title": "Enlaces de usuarios",
"forms.linkedUsers.anotherOne": "Uno más",
"forms.linkedUsers.info": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
"forms.showcase.title": "Exhibición",
"forms.showcase.info": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a a la vez.",
"forms.tags.title": "Etiquetas",
"forms.tags.selectFromExisting": "Seleccionar etiquetas existentes",
"forms.tags.cantFindExisting": "¿No eucuentras una etiqueta existente?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Ya existe un equipo con ese nombre",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Descripción",
"labels.user": "Usuario",
"labels.linkedUsers": "Enlaces de usuarios",
"bottomTexts.linkedUsers": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
"labels.showcase": "Exhibición",
"bottomTexts.showcase": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a a la vez.",
"labels.orgMemberRole": "Rol",
"labels.orgMemberRoleDisplayName": "Nombre del rol",
"labels.orgSocialLinks": "Enlaces sociales",

View File

@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Quelques notes: 1) Doit être en rapport avec Splatoon 2) Doit avoir été créé par vous 3) Pas de contenu NSFW/explicite. Il y a une procédure de validation avant que votre poste puisse être vu par les autres.",
"forms.description.title": "Description",
"forms.linkedUsers.title": "Utilisateurs liés",
"forms.linkedUsers.anotherOne": "Un autre",
"forms.linkedUsers.info": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
"forms.showcase.title": "Épinglée",
"forms.showcase.info": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"forms.tags.title": "Tags",
"forms.tags.selectFromExisting": "Selectionner depuis des tags existants",
"forms.tags.cantFindExisting": "Impossible de trouver un tag existant ?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Il y a déjà une équipe avec ce nom",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Description",
"labels.user": "",
"labels.linkedUsers": "Utilisateurs liés",
"bottomTexts.linkedUsers": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
"labels.showcase": "Épinglée",
"bottomTexts.showcase": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Quelques notes: 1) Doit être en rapport avec Splatoon 2) Doit avoir été créé par vous 3) Pas de contenu NSFW/explicite. Il y a une procédure de validation avant que votre poste puisse être vu par les autres.",
"forms.description.title": "Description",
"forms.linkedUsers.title": "Utilisateurs liés",
"forms.linkedUsers.anotherOne": "Un autre",
"forms.linkedUsers.info": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
"forms.showcase.title": "Épinglée",
"forms.showcase.info": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"forms.tags.title": "Tags",
"forms.tags.selectFromExisting": "Selectionner depuis des tags existants",
"forms.tags.cantFindExisting": "Impossible de trouver un tag existant ?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Il y a déjà une équipe avec ce nom",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Description",
"labels.user": "utilisateur",
"labels.linkedUsers": "Utilisateurs liés",
"bottomTexts.linkedUsers": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
"labels.showcase": "Épinglée",
"bottomTexts.showcase": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"labels.orgMemberRole": "Role",
"labels.orgMemberRoleDisplayName": "Nom d'affichage du rôle",
"labels.orgSocialLinks": "Liens de réseau saciaux",

View File

@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "הועלה לאחרונה",
"tabs.showcase": "תצוגה",
"forms.caveats": "כמה הבהרות: 1) רק להעלות ציור של Splatoon 2) רק להעלות ציור שנעשתה על ידכם 3) בלי NSFW. יש תהליך בדיקה לפני שציור מופיעה למשתמשים אחרים.",
"forms.description.title": "תיאור",
"forms.linkedUsers.title": "תיוג משתמשים",
"forms.linkedUsers.anotherOne": "עוד אחד",
"forms.linkedUsers.info": "מי בציור? תיוג משתמשים מאפשר לציור שלכם להופיע בפרופיל שלהם.",
"forms.showcase.title": "מוצג",
"forms.showcase.info": "המוצג שלכם יופיע בעמוד /art. רק מוצג אחד מותר בכל פעם.",
"forms.tags.title": "תגים",
"forms.tags.selectFromExisting": "בחר מתגים קיימים",
"forms.tags.cantFindExisting": "לא מוצא תג קיים?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "יש כבר צוות בשם הזה",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "תיאור",
"labels.user": "",
"labels.linkedUsers": "תיוג משתמשים",
"bottomTexts.linkedUsers": "מי בציור? תיוג משתמשים מאפשר לציור שלכם להופיע בפרופיל שלהם.",
"labels.showcase": "מוצג",
"bottomTexts.showcase": "המוצג שלכם יופיע בעמוד /art. רק מוצג אחד מותר בכל פעם.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Un paio di cose di cui tener conto: 1) Puoi caricare soltanto art relative a Splatoon 2) Puoi caricare soltanto art create da te 3) Niente art NSFW. Vi è un processo di convalida svolto prima che la propria art sia visibile agli altri utenti.",
"forms.description.title": "Descrizione",
"forms.linkedUsers.title": "Utenti collegati",
"forms.linkedUsers.anotherOne": "Un'altra",
"forms.linkedUsers.info": "Chi è presente nell'art? Collegare utenti permette alla tua art di comparire sui loro profili.",
"forms.showcase.title": "Presentazione",
"forms.showcase.info": "La tua opera per la presentazione è mostrata nella pagina comune /art. Può essere usata come presentazione una sola tua opera per volta",
"forms.tags.title": "Tag",
"forms.tags.selectFromExisting": "Seleziona da tag esistenti",
"forms.tags.cantFindExisting": "Non riesci a trovare un tag esistente?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Esiste già un team con questo nome",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Descrizione",
"labels.user": "Utente",
"labels.linkedUsers": "Utenti collegati",
"bottomTexts.linkedUsers": "Chi è presente nell'art? Collegare utenti permette alla tua art di comparire sui loro profili.",
"labels.showcase": "Presentazione",
"bottomTexts.showcase": "La tua opera per la presentazione è mostrata nella pagina comune /art. Può essere usata come presentazione una sola tua opera per volta",
"labels.orgMemberRole": "Ruolo",
"labels.orgMemberRoleDisplayName": "Nome visualizzato ruolo",
"labels.orgSocialLinks": "Link per social",

View File

@ -11,12 +11,6 @@
"tabs.recentlyUploaded": "最近のアップロード",
"tabs.showcase": "作品紹介",
"forms.caveats": "注意点: 1) スプラトゥーンの作品のみ追加してください 2) 自分で作成した作品のみ追加してください 3) NSFW(R18系)は NG. 他のユーザーに公表される前に確認プロセスが入ります。",
"forms.description.title": "説明",
"forms.linkedUsers.title": "リンクされたユーザー",
"forms.linkedUsers.anotherOne": "別のユーザーを追加",
"forms.linkedUsers.info": "作中の人は誰? リンクすることで、リンクされた人のプロフィールに作品が表示されます。",
"forms.showcase.title": "作品紹介",
"forms.showcase.info": "作品紹介の作品はイラストページに表示されます。ショーケースには1つの作品しか設定できません。",
"forms.tags.title": "タグ",
"forms.tags.selectFromExisting": "既存のタグから選択する",
"forms.tags.cantFindExisting": "タグが見つからない場合:",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "そのチーム名はすでに使用されています",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "説明",
"labels.user": "",
"labels.linkedUsers": "リンクされたユーザー",
"bottomTexts.linkedUsers": "作中の人は誰? リンクすることで、リンクされた人のプロフィールに作品が表示されます。",
"labels.showcase": "作品紹介",
"bottomTexts.showcase": "作品紹介の作品はイラストページに表示されます。ショーケースには1つの作品しか設定できません。",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -11,12 +11,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",
"forms.linkedUsers.anotherOne": "",
"forms.linkedUsers.info": "",
"forms.showcase.title": "",
"forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "설명",
"labels.user": "",
"labels.linkedUsers": "",
"bottomTexts.linkedUsers": "",
"labels.showcase": "",
"bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",
"forms.linkedUsers.anotherOne": "",
"forms.linkedUsers.info": "",
"forms.showcase.title": "",
"forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Beschrijving",
"labels.user": "",
"labels.linkedUsers": "",
"bottomTexts.linkedUsers": "",
"labels.showcase": "",
"bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -15,12 +15,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
"forms.description.title": "",
"forms.linkedUsers.title": "",
"forms.linkedUsers.anotherOne": "",
"forms.linkedUsers.info": "",
"forms.showcase.title": "",
"forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Istnieje już drużyna o tym imieniu",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Opis",
"labels.user": "",
"labels.linkedUsers": "",
"bottomTexts.linkedUsers": "",
"labels.showcase": "",
"bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Algumas coisas para lembrar: 1) Só faça upload de arte que envolva Splatoon 2) Só faça o upload de arte que você mesmo(a) fez 3) Sem arte +18. Há um processo de validação antes da arte ser mostrada para outros usuários.",
"forms.description.title": "Descrição",
"forms.linkedUsers.title": "Usuários conectados",
"forms.linkedUsers.anotherOne": "Outro",
"forms.linkedUsers.info": "Quem está na arte? Conectar usuários permite que sua arte apareça no perfil deles(as).",
"forms.showcase.title": "Destaque",
"forms.showcase.info": "Seu destaque é mostrado na página comum /art. Apenas um exemplar pode ser seu destaque de cada vez.",
"forms.tags.title": "Marcações",
"forms.tags.selectFromExisting": "Selecionar de marcações existentes",
"forms.tags.cantFindExisting": "Não consegue encontrar uma marcação existente?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Já existe um time com esse nome",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Descrição",
"labels.user": "",
"labels.linkedUsers": "Usuários conectados",
"bottomTexts.linkedUsers": "Quem está na arte? Conectar usuários permite que sua arte apareça no perfil deles(as).",
"labels.showcase": "Destaque",
"bottomTexts.showcase": "Seu destaque é mostrado na página comum /art. Apenas um exemplar pode ser seu destaque de cada vez.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",

View File

@ -15,12 +15,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Примите к сведению: 1) Загружайте только арты по Splatoon 2) Загружайте только арты, созданные вами 3) NSFW запрещено. Перед тем как арт будет показан другим пользователям, он пройдёт процесс подтверждения модераторами.",
"forms.description.title": "Описание",
"forms.linkedUsers.title": "Отмеченный пользователь",
"forms.linkedUsers.anotherOne": "Добавить",
"forms.linkedUsers.info": "Кто изображен на арте? В профиле отмеченного пользователя будет отображен ваш арт.",
"forms.showcase.title": "Закрепить",
"forms.showcase.info": "Закреплённый арт будет показан на общей /art странице. Одновременно может быть закреплён только один арт.",
"forms.tags.title": "Теги",
"forms.tags.selectFromExisting": "Выбрать существующий тег",
"forms.tags.cantFindExisting": "Не можете найти существующий тег?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Уже существует команда с таким названием",
"errors.duplicateOrgName": "",
@ -147,6 +148,10 @@
"labels.urls": "",
"labels.description": "Описание",
"labels.user": "Пользователь",
"labels.linkedUsers": "Отмеченный пользователь",
"bottomTexts.linkedUsers": "Кто изображен на арте? В профиле отмеченного пользователя будет отображен ваш арт.",
"labels.showcase": "Закрепить",
"bottomTexts.showcase": "Закреплённый арт будет показан на общей /art странице. Одновременно может быть закреплён только один арт.",
"labels.orgMemberRole": "Роль",
"labels.orgMemberRoleDisplayName": "Отображаемое название роли",
"labels.orgSocialLinks": "Соц. ссылки",

View File

@ -12,12 +12,6 @@
"tabs.recentlyUploaded": "最近上传",
"tabs.showcase": "展示区",
"forms.caveats": "请注意1) 仅上传与《斯普拉遁》相关的作品 2) 仅上传由您亲自创作的作品 3) 禁止 NSFW 作品。作品经过审核后才对其他用户可见。",
"forms.description.title": "描述",
"forms.linkedUsers.title": "关联用户",
"forms.linkedUsers.anotherOne": "另一个",
"forms.linkedUsers.info": "作品与谁有关?关联用户可以让您的作品显示在对方的个人资料中。",
"forms.showcase.title": "展示区",
"forms.showcase.info": "您的展示作品将会显示在插画的主页面。只能将一个作品设置为展示作品。",
"forms.tags.title": "标签",
"forms.tags.selectFromExisting": "从已有标签中选择",
"forms.tags.cantFindExisting": "找不到已有标签?",

View File

@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "请输入有效的 URL 地址",
"errors.notAllowedCharacters": "包含非法字符",
"errors.imageTooLarge": "",
"errors.atLeastOneOption": "请至少选择一个选项",
"errors.duplicateName": "该队伍名称已被占用",
"errors.duplicateOrgName": "该组织名称已被占用",
@ -147,6 +148,10 @@
"labels.urls": "URL",
"labels.description": "简介",
"labels.user": "用户",
"labels.linkedUsers": "关联用户",
"bottomTexts.linkedUsers": "作品与谁有关?关联用户可以让您的作品显示在对方的个人资料中。",
"labels.showcase": "展示区",
"bottomTexts.showcase": "您的展示作品将会显示在插画的主页面。只能将一个作品设置为展示作品。",
"labels.orgMemberRole": "职位",
"labels.orgMemberRoleDisplayName": "职位显示名称",
"labels.orgSocialLinks": "社交媒体链接",

View File

@ -52,7 +52,6 @@
"@internationalized/date": "3.12.2",
"@react-router/node": "8.2.0",
"@react-router/serve": "8.1.0",
"@remix-run/form-data-parser": "0.17.4",
"@sentry/react-router": "10.65.0",
"@tldraw/tldraw": "3.12.1",
"@zumer/snapdom": "2.16.0",

View File

@ -48,9 +48,6 @@ importers:
'@react-router/serve':
specifier: 8.1.0
version: 8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)
'@remix-run/form-data-parser':
specifier: 0.17.4
version: 0.17.4
'@sentry/react-router':
specifier: 10.65.0
version: 10.65.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@react-router/node@8.2.0(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
@ -1418,15 +1415,6 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@remix-run/form-data-parser@0.17.4':
resolution: {integrity: sha512-mqWVpD2OaJTFEW2i+M49/iWoUp2Cai6WD606/iynZ+NIlzVdkty3bbGb8g7vTjXeV/sQRmPbg3SJypgvQitvJg==}
'@remix-run/headers@0.21.1':
resolution: {integrity: sha512-DRhRveigepAQDUIi0MrOFhpcl3/KTv/fkpIhulv7Asv1pUwM8RpY2ENjdI8lfii6Z3MEsWtYhGt6If8bi6bYpQ==}
'@remix-run/multipart-parser@0.16.3':
resolution: {integrity: sha512-XvMYPIyDTuquR7s1YF4wo4CqMPDrBkpWY0zHSa7SpX0F+t9awg7FWd5mJywc6vTn4oWeS7nQYQQarZ9CuoC/RQ==}
'@remix-run/node-fetch-server@0.13.3':
resolution: {integrity: sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==}
@ -5984,16 +5972,6 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
'@remix-run/form-data-parser@0.17.4':
dependencies:
'@remix-run/multipart-parser': 0.16.3
'@remix-run/headers@0.21.1': {}
'@remix-run/multipart-parser@0.16.3':
dependencies:
'@remix-run/headers': 0.21.1
'@remix-run/node-fetch-server@0.13.3': {}
'@rolldown/binding-android-arm64@1.1.5':

View File

@ -104,7 +104,6 @@ export default defineConfig((config) => {
"@dnd-kit/utilities",
"@epic-web/cachified",
"@internationalized/date",
"@remix-run/form-data-parser",
"@tldraw/tldraw",
"@zumer/snapdom",
"better-sqlite3",