diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 8a1991494..bd59784bd 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -1,4 +1,4 @@ -import type { Insertable, Transaction } from "kysely"; +import { type Insertable, sql, type Transaction } from "kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { CustomTheme, DB, Tables } from "~/db/tables"; @@ -90,17 +90,20 @@ export type findByCustomUrl = NonNullable< export function findByCustomUrl( customUrl: string, - { includeInviteCode = false } = {}, + { includeInviteCode = false, includeUnvalidatedImages = false } = {}, ) { + // join the unvalidated table (instead of the validated-only `UserSubmittedImage` view) so the + // edit page can preview images still pending moderation; for everyone else the url is gated on + // `validatedAt` so pending images stay hidden return db .selectFrom("Team") .leftJoin( - "UserSubmittedImage as AvatarImage", + "UnvalidatedUserSubmittedImage as AvatarImage", "AvatarImage.id", "Team.avatarImgId", ) .leftJoin( - "UserSubmittedImage as BannerImage", + "UnvalidatedUserSubmittedImage as BannerImage", "BannerImage.id", "Team.bannerImgId", ) @@ -114,8 +117,24 @@ export function findByCustomUrl( "Team.customTheme", "Team.avatarImgId", "Team.bannerImgId", - concatUserSubmittedImagePrefix(eb.ref("AvatarImage.url")).as("avatarUrl"), - concatUserSubmittedImagePrefix(eb.ref("BannerImage.url")).as("bannerUrl"), + concatUserSubmittedImagePrefix( + includeUnvalidatedImages + ? eb.ref("AvatarImage.url") + : eb.fn("iif", [ + eb("AvatarImage.validatedAt", "is not", null), + eb.ref("AvatarImage.url"), + sql`null`, + ]), + ).as("avatarUrl"), + concatUserSubmittedImagePrefix( + includeUnvalidatedImages + ? eb.ref("BannerImage.url") + : eb.fn("iif", [ + eb("BannerImage.validatedAt", "is not", null), + eb.ref("BannerImage.url"), + sql`null`, + ]), + ).as("bannerUrl"), jsonArrayFrom( eb .selectFrom("TeamMemberWithSecondary") diff --git a/app/features/team/loaders/t.$customUrl.edit.server.ts b/app/features/team/loaders/t.$customUrl.edit.server.ts index 829e9a6b3..da88821af 100644 --- a/app/features/team/loaders/t.$customUrl.edit.server.ts +++ b/app/features/team/loaders/t.$customUrl.edit.server.ts @@ -11,7 +11,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const user = requireUser(); const { customUrl } = teamParamsSchema.parse(params); - const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl)); + const team = notFoundIfFalsy( + await TeamRepository.findByCustomUrl(customUrl, { + includeUnvalidatedImages: true, + }), + ); if (!isTeamManager({ team, user }) && !user.roles.includes("ADMIN")) { throw redirect(teamPage(customUrl)); diff --git a/app/features/tournament-organization/actions/org.$slug.edit.server.ts b/app/features/tournament-organization/actions/org.$slug.edit.server.ts index af5a2e14d..630751b30 100644 --- a/app/features/tournament-organization/actions/org.$slug.edit.server.ts +++ b/app/features/tournament-organization/actions/org.$slug.edit.server.ts @@ -16,7 +16,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const result = await parseFormDataWithImages({ request, schema: organizationEditFormSchema, - autoValidate: true, }); if (!result.success) { diff --git a/app/features/tournament-organization/tournament-organization-schemas.ts b/app/features/tournament-organization/tournament-organization-schemas.ts index 078f01de5..7d8af7780 100644 --- a/app/features/tournament-organization/tournament-organization-schemas.ts +++ b/app/features/tournament-organization/tournament-organization-schemas.ts @@ -34,7 +34,7 @@ export const newOrganizationSchema = z.object({ export const organizationEditFormSchema = z.object({ name: orgNameField, - logo: image({ label: "labels.logo" }), + logo: image({ label: "labels.logo", autoValidate: true }), description: textAreaOptional({ label: "labels.description", maxLength: TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH, diff --git a/app/form/fields.ts b/app/form/fields.ts index 0be92a4a0..6096d5e9c 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -83,12 +83,14 @@ function prefixItems( export function image(args: { label: FormsTranslationKey; dimensions?: "logo" | "thick-banner" | { width: number; height: number }; + autoValidate?: boolean; }) { // clone so each field gets its own registry entry (the shared `imageValue` // instance would otherwise have its metadata overwritten by later fields) return imageValue.clone().register(formRegistry, { label: prefixKey(args.label), dimensions: args.dimensions ?? "logo", + autoValidate: args.autoValidate ?? false, type: "image", initialValue: null, }); diff --git a/app/form/fields/ImageFormField.tsx b/app/form/fields/ImageFormField.tsx index 526c2dca8..820e27496 100644 --- a/app/form/fields/ImageFormField.tsx +++ b/app/form/fields/ImageFormField.tsx @@ -21,6 +21,7 @@ export function ImageFormField({ name, label, dimensions, + autoValidate, error, value, onChange, @@ -65,7 +66,15 @@ export function ImageFormField({ (typeof dimensions === "object" && dimensions.width > dimensions.height); return ( - +
{previewUrl ? ( = T extends unknown export async function parseFormDataWithImages({ request, schema, - autoValidate = false, }: { request: Request; schema: T; - /** Validate uploaded images immediately, bypassing the moderator queue (e.g. trusted org logos). */ - autoValidate?: boolean; }): Promise>>> { const result = await parseFormData({ request, schema }); if (!result.success) return result; @@ -71,7 +68,7 @@ export async function parseFormDataWithImages({ const user = requireUser(); const data = { ...(result.data as Record) }; - for (const key of imageFieldKeys(schema)) { + for (const { key, autoValidate } of imageFields(schema)) { if (key in data) { data[key] = await imageFieldValueToImgId({ value: data[key] as ImageFieldValue, @@ -84,8 +81,13 @@ export async function parseFormDataWithImages({ return { success: true, data: data as ResolvedImages> }; } -/** Collects the keys of every `image()` field across a schema object or union of objects. */ -function imageFieldKeys(schema: z.ZodTypeAny): string[] { +/** + * Collects every `image()` field across a schema object or union of objects, along with each + * field's `autoValidate` flag (whether its uploads bypass the moderator queue). + */ +function imageFields( + schema: z.ZodTypeAny, +): Array<{ key: string; autoValidate: boolean }> { const objects = schema instanceof z.ZodUnion ? (schema.options as z.ZodObject[]) @@ -93,13 +95,15 @@ function imageFieldKeys(schema: z.ZodTypeAny): string[] { ? [schema] : []; - const keys = new Set(); + const fields = new Map(); for (const object of objects) { for (const [key, fieldSchema] of Object.entries(object.shape)) { const meta = formRegistry.get(fieldSchema); - if (meta?.type === "image") keys.add(key); + if (meta?.type === "image") { + fields.set(key, meta.autoValidate ?? false); + } } } - return [...keys]; + return [...fields].map(([key, autoValidate]) => ({ key, autoValidate })); } diff --git a/app/form/types.ts b/app/form/types.ts index 74ba66474..347899d3a 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -118,6 +118,8 @@ interface FormFieldMapPool extends FormFieldBase { interface FormFieldImage extends Omit, "bottomText"> { dimensions?: ImageFieldDimensions; + /** Validate uploaded images immediately, bypassing the moderator queue (e.g. trusted org logos). */ + autoValidate?: boolean; } export interface FormFieldArray diff --git a/locales/da/forms.json b/locales/da/forms.json index 4eb2da061..68efe3b41 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Note: Hvis du ændrer dit holds navn, så kan andre hold overtage det tidligere holdnavn og URL-adresse.", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 0d47da6c8..c91c1553a 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Hinweis: Wenn du den Namen deines Teams änderst, können andere Teams den Namen und und die URL für sich beanspruchen.", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index d67fe9740..48e0b589c 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "[Accessibility] Avoid Splattercolor Screen", "labels.noSplatnet": "No SplatNet access", "labels.spoilerFreeMode": "Spoiler-free mode", + "bottomTexts.imageModeration": "Unless you're a Supporter, newly uploaded images are shown publicly only after a moderator has checked them.", "bottomTexts.name": "Note that if you change your team's name then someone else can claim the name and URL for their team", "bottomTexts.tag": "Typically used before in-game name to indicate membership of a team (e.g. [TAG] PlayerName)", "bottomTexts.disableBuildAbilitySorting": "Outside of your profile page, build abilities are sorted so that same abilities are next to each other. This setting allows you to see the abilities in the order they were authored everywhere.", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index c768b31ec..13f973d18 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "[Accesibilidad] Evitar Pantintalla", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome", "bottomTexts.tag": "Normalmente se usa antes del nombre en el juego para indicar pertenencia a un equipo (ej. [TAG] NombreJugador)", "bottomTexts.disableBuildAbilitySorting": "Fuera de tu perfil, los potenciadores se agrupan. Activa esta opción para verlos siempre en el orden en que los creaste.", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index cd34c16e6..5028af659 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index fdd4b58fc..c99df498f 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 76a7175df..379c6154c 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index dc7945cb1..f41b7e064 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "שימו לב שאם תשנו את שם הצוות שלכם, מישהו אחר יוכל לקחת בעלות על השם ועל כתובת האתר עבור הצוות שלו", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 7dd663e86..393e7a21b 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Nota che se cambi il nome del team, qualcun altro può assumere nome e URL per il proprio team", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 03b9c79c0..2007914bb 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "注意: チーム名を変更した場合、他のプレイヤーが変更前の名前と URL を別のチームのために使用することができるようになります。", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 950d572f9..217850464 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index a58f81d78..b33cb9ba6 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index b216003bc..17d436091 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Uwaga: Jeśli zmienisz imię drużyny, ktoś inny może użyć twoje stare imię i URL", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index f9ac75a6f..c43581758 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Lembre-se que se você mudar o nome do seu time, alguém pode resgatar o nome e o URL para o time dele(a)", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index c7ec33e76..82084923b 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "Обратите внимание, что если вы измените название команды, то кто-то другой может забрать себе URL и название для своей команды", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 23ccd6e41..95c057dac 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -12,6 +12,7 @@ "labels.noScreen": "", "labels.noSplatnet": "", "labels.spoilerFreeMode": "", + "bottomTexts.imageModeration": "", "bottomTexts.name": "请注意,如果您更改了队名,那么其他人便可以使用之前的队名和URL了。", "bottomTexts.tag": "", "bottomTexts.disableBuildAbilitySorting": "",