diff --git a/app/components/FriendCodeInput.tsx b/app/components/FriendCodeInput.tsx index 7b49ed8b5..5ec7aff81 100644 --- a/app/components/FriendCodeInput.tsx +++ b/app/components/FriendCodeInput.tsx @@ -1,13 +1,8 @@ -import clsx from "clsx"; -import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; import { Image } from "~/components/Image"; import { InfoPopover } from "~/components/InfoPopover"; -import { Input } from "~/components/Input"; -import { Label } from "~/components/Label"; -import { SubmitButton } from "~/components/SubmitButton"; -import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants"; +import { addFriendCodeSchema } from "~/features/sendouq/q-schemas"; +import { SendouForm } from "~/form/SendouForm"; import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls"; const FC_INFO_IMAGE_URL = navIconUrl("fc-info"); @@ -17,60 +12,39 @@ export function FriendCodeInput({ }: { friendCode?: string | null; }) { - const fetcher = useFetcher(); const { t } = useTranslation(["common"]); - const id = React.useId(); + + if (friendCode) { + return
SW-{friendCode}
; + } return ( - - -
-
- {!friendCode ? ( -
- - -
-
- {t("common:fc.whereToFind")} -
- {t("common:fc.whereToFind")} + + {({ FormField }) => ( +
+ +
+ {t("common:fc.onceSetStaffOnly")} + +
+
+ {t("common:fc.whereToFind")}
- -
- ) : null} - {friendCode ? ( -
SW-{friendCode}
- ) : ( - - )} + {t("common:fc.whereToFind")} +
+ +
- {!friendCode ? ( - - {t("common:actions.save")} - - ) : null} -
- {!friendCode ? ( -
- {t("common:fc.onceSetStaffOnly")} -
- ) : null} - + )} + ); } diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 207a0a1e5..715efcd1f 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -15,7 +15,7 @@ import { import * as React from "react"; import { Dialog, Modal, ModalOverlay } from "react-aria-components"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; import { useUser } from "~/features/auth/core/user"; import { useChatContext } from "~/features/chat/useChatContext"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; @@ -27,6 +27,7 @@ import { EVENTS_PAGE, FRIENDS_PAGE, navIconUrl, + SENDOU_INK_BASE_URL, SETTINGS_PAGE, SUPPORT_PAGE, userPage, @@ -44,6 +45,7 @@ import { import { navItems } from "./layout/nav-items"; import styles from "./MobileNav.module.css"; import { NotificationDot } from "./NotificationDot"; +import { ShareUrlButton } from "./ShareUrlButton"; import { StreamListItems } from "./StreamListItems"; type SidebarData = RootLoaderData["sidebar"] | undefined; @@ -346,6 +348,7 @@ function MenuOverlay({ }) { const { t } = useTranslation(["front", "common"]); const user = useUser(); + const location = useLocation(); return ( ) : null} +
diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index f3c50e7f5..45a8d8092 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -1,20 +1,20 @@ import type { ActionFunctionArgs } from "react-router"; -import { z } from "zod"; import * as AdminRepository from "~/features/admin/AdminRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { parseFormData } from "~/form/parse.server"; import { requireRole } from "~/modules/permissions/guards.server"; import { errorToast, notFoundIfNullish, - parseRequestPayload, successToast, } from "~/utils/remix.server"; import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; -import { _action, actualNumber, friendCode } from "~/utils/zod"; +import { normalizeFriendCode } from "~/utils/zod"; +import { adminActionSchema } from "../admin-schemas"; import { sendUserBannedWebhook, sendUserUnbannedWebhook, @@ -22,10 +22,16 @@ import { import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server"; export const action = async ({ request }: ActionFunctionArgs) => { - const data = await parseRequestPayload({ + const result = await parseFormData({ request, schema: adminActionSchema, }); + + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + const data = result.data; const user = requireUser(); let message: string; @@ -35,8 +41,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { try { const errorMessage = await AdminRepository.migrate({ - oldUserId: data["old-user"], - newUserId: data["new-user"], + oldUserId: data.oldUser, + newUserId: data.newUser, }); if (errorMessage) { @@ -75,8 +81,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { await AdminRepository.forcePatron({ id: data.user, patronStartedAt: new Date(), - patronTier: data.patronTier, - patronExpiresAt: new Date(data.patronExpiresAt), + patronTier: Number(data.patronTier), + patronExpiresAt: data.patronExpiresAt, }); message = "Patron status updated"; @@ -123,7 +129,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { const bannedUser = notFoundIfNullish( await UserRepository.findLeanById(data.user), ); - const banExpiresAt = data.duration ? new Date(data.duration) : null; + const banExpiresAt = data.expiresAt ?? null; await AdminRepository.banUser({ bannedReason: data.reason ?? null, @@ -170,7 +176,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { requireRole("STAFF"); await UserRepository.insertFriendCode({ - friendCode: data.friendCode, + friendCode: normalizeFriendCode(data.friendCode), submitterUserId: user.id, userId: data.user, }); @@ -193,56 +199,3 @@ export const action = async ({ request }: ActionFunctionArgs) => { return successToast(message); }; - -export const adminActionSchema = z.union([ - z.object({ - _action: _action("MIGRATE"), - "old-user": z.preprocess(actualNumber, z.number().positive()), - "new-user": z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("REFRESH"), - }), - z.object({ - _action: _action("FORCE_PATRON"), - user: z.preprocess(actualNumber, z.number().positive()), - patronTier: z.preprocess(actualNumber, z.number()), - patronExpiresAt: z.string(), - }), - z.object({ - _action: _action("VIDEO_ADDER"), - user: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("TOURNAMENT_ORGANIZER"), - user: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("ARTIST"), - user: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("LINK_PLAYER"), - user: z.preprocess(actualNumber, z.number().positive()), - playerId: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("BAN_USER"), - user: z.preprocess(actualNumber, z.number().positive()), - reason: z.string().nullish(), - duration: z.string().nullish(), - }), - z.object({ - _action: _action("UNBAN_USER"), - user: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("UPDATE_FRIEND_CODE"), - friendCode, - user: z.preprocess(actualNumber, z.number().positive()), - }), - z.object({ - _action: _action("API_ACCESS"), - user: z.preprocess(actualNumber, z.number().positive()), - }), -]); diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts index 0ddc89efe..3a72e5175 100644 --- a/app/features/admin/admin-constants.ts +++ b/app/features/admin/admin-constants.ts @@ -1,3 +1,5 @@ +export const BAN_REASON_MAX_LENGTH = 200; + export const ADMIN_DISCORD_ID = "79237403620945920"; export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274; diff --git a/app/features/admin/admin-schemas.ts b/app/features/admin/admin-schemas.ts index bc587733e..6a6a4b1a5 100644 --- a/app/features/admin/admin-schemas.ts +++ b/app/features/admin/admin-schemas.ts @@ -1,26 +1,132 @@ import { z } from "zod"; +import { friendCodeField } from "~/features/sendouq/q-schemas"; import { - datetimeRequired, + datetime, + datetimeOptional, image, + numberField, + select, stringConstant, - textFieldRequired, + textField, + textFieldOptional, + userSearch, } from "~/form/fields"; import { friendCode, id } from "~/utils/zod"; +import { BAN_REASON_MAX_LENGTH } from "./admin-constants"; export const adminActionSearchParamsSchema = z.object({ friendCode, }); +const userField = userSearch({ label: "labels.user" }); + +export const friendCodeSearchSchema = z.object({ + friendCode: friendCodeField, +}); + +export const migrateUserSchema = z.object({ + _action: stringConstant("MIGRATE"), + oldUser: userSearch({ label: "labels.adminOldUser" }), + newUser: userSearch({ + label: "labels.adminNewUser", + bottomText: "bottomTexts.adminMigrateNewUser", + }), +}); + +export const linkPlayerSchema = z.object({ + _action: stringConstant("LINK_PLAYER"), + user: userField, + playerId: numberField({ label: "labels.adminPlayerId", min: 1 }), +}); + +export const giveArtistSchema = z.object({ + _action: stringConstant("ARTIST"), + user: userField, +}); + +export const giveVideoAdderSchema = z.object({ + _action: stringConstant("VIDEO_ADDER"), + user: userField, +}); + +export const giveTournamentOrganizerSchema = z.object({ + _action: stringConstant("TOURNAMENT_ORGANIZER"), + user: userField, +}); + +export const giveApiAccessSchema = z.object({ + _action: stringConstant("API_ACCESS"), + user: userField, +}); + +export const updateFriendCodeSchema = z.object({ + _action: stringConstant("UPDATE_FRIEND_CODE"), + user: userField, + friendCode: friendCodeField, +}); + +export const forcePatronSchema = z.object({ + _action: stringConstant("FORCE_PATRON"), + user: userField, + patronTier: select({ + label: "labels.patronTier", + items: [ + { value: "1", label: "options.patronTier.1" }, + { value: "2", label: "options.patronTier.2" }, + { value: "3", label: "options.patronTier.3" }, + ], + }), + patronExpiresAt: datetime({ label: "labels.patronExpiresAt" }), +}); + +export const banUserSchema = z.object({ + _action: stringConstant("BAN_USER"), + user: userField, + expiresAt: datetimeOptional({ + label: "labels.banUserExpiresAt", + bottomText: "bottomTexts.banUserExpiresAtHelp", + min: () => new Date(), + minMessage: "errors.dateInPast", + }), + reason: textFieldOptional({ + label: "labels.reason", + maxLength: BAN_REASON_MAX_LENGTH, + }), +}); + +export const unbanUserSchema = z.object({ + _action: stringConstant("UNBAN_USER"), + user: userField, +}); + +export const refreshPlusTiersSchema = z.object({ + _action: stringConstant("REFRESH"), +}); + +export const adminActionSchema = z.union([ + migrateUserSchema, + linkPlayerSchema, + giveArtistSchema, + giveVideoAdderSchema, + giveTournamentOrganizerSchema, + giveApiAccessSchema, + updateFriendCodeSchema, + forcePatronSchema, + banUserSchema, + unbanUserSchema, + refreshPlusTiersSchema, +]); + export const createExternalStreamSchema = z.object({ _action: stringConstant("CREATE"), - name: textFieldRequired({ label: "labels.name", maxLength: 64 }), - url: textFieldRequired({ + name: textField({ label: "labels.name", maxLength: 64 }), + url: textField({ label: "labels.link", maxLength: 200, validate: "url", }), avatar: image({ label: "labels.logo", autoValidate: true }), - startTime: datetimeRequired({ label: "labels.startTime" }), + startTime: datetime({ label: "labels.startTime" }), }); const deleteExternalStreamSchema = z.object({ diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index dbbf0e16d..c5eba109b 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -10,10 +10,13 @@ import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/le import * as TeamRepository from "~/features/team/TeamRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { assertResponseErrored, wrappedAction } from "~/utils/Test"; -import type { adminActionSchema } from "../actions/admin.server"; +import type { adminActionSchema } from "../admin-schemas"; import { action } from "./admin"; -const adminAction = wrappedAction({ action }); +const adminAction = wrappedAction({ + action, + isJsonSubmission: true, +}); const users = UserFactory.pool(); @@ -260,8 +263,8 @@ const migrateUserAction = () => adminAction( { _action: "MIGRATE", - "old-user": users.id(1), - "new-user": users.id(2), + oldUser: users.id(1), + newUser: users.id(2), }, { user: "admin" }, ); diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 11d40c459..d915d9b97 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -1,4 +1,3 @@ -import { Search } from "lucide-react"; import * as React from "react"; import type { MetaFunction } from "react-router"; import { @@ -6,7 +5,6 @@ import { Link, useFetcher, useLoaderData, - useNavigation, useSearchParams, } from "react-router"; import { Avatar } from "~/components/Avatar"; @@ -19,11 +17,9 @@ import { SendouTabs, } from "~/components/elements/Tabs"; import { UserSearch } from "~/components/elements/UserSearch"; -import { FormMessage } from "~/components/FormMessage"; -import { Input } from "~/components/Input"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; -import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants"; +import { SendouForm } from "~/form/SendouForm"; import { useHasRole } from "~/modules/permissions/hooks"; import { metaTags } from "~/utils/remix"; import { @@ -33,6 +29,20 @@ import { userPage, } from "~/utils/urls"; import { action } from "../actions/admin.server"; +import { + banUserSchema, + forcePatronSchema, + friendCodeSearchSchema, + giveApiAccessSchema, + giveArtistSchema, + giveTournamentOrganizerSchema, + giveVideoAdderSchema, + linkPlayerSchema, + migrateUserSchema, + refreshPlusTiersSchema, + unbanUserSchema, + updateFriendCodeSchema, +} from "../admin-schemas"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls"; import { loader } from "../loaders/admin.server"; @@ -59,7 +69,7 @@ export default function AdminPage() { } return ( -
+
Actions @@ -79,28 +89,17 @@ export default function AdminPage() { function FriendCodeLookUp() { const data = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); - const [friendCode, setFriendCode] = React.useState( - searchParams.get("friendCode") ?? "", - ); - const fetcher = useFetcher(); return ( -
-
- setFriendCode(e.target.value)} - /> - } - onPress={() => setSearchParams({ friendCode })} - > - Search - -
+
+ setSearchParams({ friendCode })} + > + {({ FormField }) => } +
{data.friendCodeSearchUsers?.map((user) => ( (); - const [newUserId, setNewUserId] = React.useState(); - const navigation = useNavigation(); - const fetcher = useFetcher(); - return ( - -

Migrate user data

-
-
- setOldUserId(newUser?.id)} - /> -
-
- setNewUserId(newUser?.id)} - /> -
-
-
- - Migrate - -
- - Note: data on "New user" will be deleted (e.g. builds) - -
+ + {({ FormField }) => ( + <> + + + + )} + ); } function LinkPlayer() { - const fetcher = useFetcher(); - return ( - -

Link player

-
-
- -
-
- - -
-
-
- - Link player - -
-
+ + {({ FormField }) => ( + <> + + + + )} + ); } function GiveArtist() { - const fetcher = useFetcher(); - return ( - -

Add as artist

-
- -
-
- - Add as artist - -
-
+ + {({ FormField }) => } + ); } function GiveVideoAdder() { - const fetcher = useFetcher(); - return ( - -

Give video adder

-
- -
-
- - Add as video adder - -
-
+ + {({ FormField }) => } + ); } function GiveTournamentOrganizer() { - const fetcher = useFetcher(); - return ( - -

Give tournament organizer

- -
- - Add as tournament organizer - -
-
+ + {({ FormField }) => } + ); } function GiveApiAccess() { - const fetcher = useFetcher(); - return ( - -

Give API access

- -
- - Grant API access - -
-
+ + {({ FormField }) => } + ); } function UpdateFriendCode() { - const fetcher = useFetcher(); - const id = React.useId(); - return ( - -

Update friend code

-
-
- -
-
- - -
-
-
- - Submit - -
-
+ + {({ FormField }) => ( + <> + + + + )} + ); } function ForcePatron() { - const fetcher = useFetcher(); - return ( - -

Force patron

-
-
- -
- -
- - -
- -
- - -
-
-
- - Save - -
-
+ + {({ FormField }) => ( + <> + + + + + )} + ); } function BanUser() { - const fetcher = useFetcher(); - return ( - -

Ban user

-
-
- -
- -
- - -
- -
- - -
-
-
- - Save - -
-
+ Ban user} + submitButtonText="Save" + > + {({ FormField }) => ( + <> + + + + + )} + ); } function UnbanUser() { - const fetcher = useFetcher(); - return ( - -

Unban user

- -
- - Save - -
-
+ Unban user} + submitButtonText="Save" + > + {({ FormField }) => } + ); } function RefreshPlusTiers() { - const fetcher = useFetcher(); - return ( - -

Refresh Plus Tiers

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