mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 15:53:59 -05:00
Migrate /art/new to SendouForm
This commit is contained in:
parent
77b7e05b2c
commit
02c2d74ddc
|
|
@ -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: {
|
||||
|
|
|
|||
43
app/features/art/art-image.server.ts
Normal file
43
app/features/art/art-image.server.ts
Normal 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);
|
||||
}
|
||||
81
app/features/art/art-image.ts
Normal file
81
app/features/art/art-image.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
52
app/features/art/art-schemas.ts
Normal file
52
app/features/art/art-schemas.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
3
app/features/art/components/ArtImageFormField.module.css
Normal file
3
app/features/art/components/ArtImageFormField.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
115
app/features/art/components/ArtImageFormField.tsx
Normal file
115
app/features/art/components/ArtImageFormField.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
11
app/features/art/components/ArtTagsFormField.module.css
Normal file
11
app/features/art/components/ArtTagsFormField.module.css
Normal 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;
|
||||
}
|
||||
152
app/features/art/components/ArtTagsFormField.tsx
Normal file
152
app/features/art/components/ArtTagsFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
53
app/features/img-upload/image-bytes.server.ts
Normal file
53
app/features/img-upload/image-bytes.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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 ?",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "לא מוצא תג קיים?",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "タグが見つからない場合:",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "Не можете найти существующий тег?",
|
||||
|
|
|
|||
|
|
@ -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": "Соц. ссылки",
|
||||
|
|
|
|||
|
|
@ -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": "找不到已有标签?",
|
||||
|
|
|
|||
|
|
@ -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": "社交媒体链接",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user