Add moderation note and autoValidate to image form field

Show a note under non-autoValidate image fields that uploads are
moderated before going public. Move autoValidate to a per-field schema
property (org logo only) driving both the action logic and the note.
Preview pending images on the team edit page while keeping them hidden
on the public page.
This commit is contained in:
Kalle 2026-06-06 10:55:53 +03:00
parent 9c5f61c0f2
commit 739e6f440b
24 changed files with 74 additions and 19 deletions

View File

@ -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<string | null>("iif", [
eb("AvatarImage.validatedAt", "is not", null),
eb.ref("AvatarImage.url"),
sql`null`,
]),
).as("avatarUrl"),
concatUserSubmittedImagePrefix(
includeUnvalidatedImages
? eb.ref("BannerImage.url")
: eb.fn<string | null>("iif", [
eb("BannerImage.validatedAt", "is not", null),
eb.ref("BannerImage.url"),
sql`null`,
]),
).as("bannerUrl"),
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary")

View File

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

View File

@ -16,7 +16,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const result = await parseFormDataWithImages({
request,
schema: organizationEditFormSchema,
autoValidate: true,
});
if (!result.success) {

View File

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

View File

@ -83,12 +83,14 @@ function prefixItems<V extends string>(
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,
});

View File

@ -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 (
<FormFieldWrapper id={id} name={name} label={label} error={error}>
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={
autoValidate ? undefined : "forms:bottomTexts.imageModeration"
}
>
<div className="stack sm items-start">
{previewUrl ? (
<img

View File

@ -58,12 +58,9 @@ type ResolvedImages<T> = T extends unknown
export async function parseFormDataWithImages<T extends z.ZodTypeAny>({
request,
schema,
autoValidate = false,
}: {
request: Request;
schema: T;
/** Validate uploaded images immediately, bypassing the moderator queue (e.g. trusted org logos). */
autoValidate?: boolean;
}): Promise<ParseResult<ResolvedImages<z.infer<T>>>> {
const result = await parseFormData({ request, schema });
if (!result.success) return result;
@ -71,7 +68,7 @@ export async function parseFormDataWithImages<T extends z.ZodTypeAny>({
const user = requireUser();
const data = { ...(result.data as Record<string, unknown>) };
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<T extends z.ZodTypeAny>({
return { success: true, data: data as ResolvedImages<z.infer<T>> };
}
/** 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<z.ZodRawShape>[])
@ -93,13 +95,15 @@ function imageFieldKeys(schema: z.ZodTypeAny): string[] {
? [schema]
: [];
const keys = new Set<string>();
const fields = new Map<string, boolean>();
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 }));
}

View File

@ -118,6 +118,8 @@ interface FormFieldMapPool<T extends string> extends FormFieldBase<T> {
interface FormFieldImage<T extends string>
extends Omit<FormFieldBase<T>, "bottomText"> {
dimensions?: ImageFieldDimensions;
/** Validate uploaded images immediately, bypassing the moderator queue (e.g. trusted org logos). */
autoValidate?: boolean;
}
export interface FormFieldArray<T extends string, S extends z.ZodType>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "שימו לב שאם תשנו את שם הצוות שלכם, מישהו אחר יוכל לקחת בעלות על השם ועל כתובת האתר עבור הצוות שלו",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",

View File

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

View File

@ -12,6 +12,7 @@
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "注意: チーム名を変更した場合、他のプレイヤーが変更前の名前と URL を別のチームのために使用することができるようになります。",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",

View File

@ -12,6 +12,7 @@
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",

View File

@ -12,6 +12,7 @@
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",

View File

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

View File

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

View File

@ -12,6 +12,7 @@
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Обратите внимание, что если вы измените название команды, то кто-то другой может забрать себе URL и название для своей команды",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",

View File

@ -12,6 +12,7 @@
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "请注意如果您更改了队名那么其他人便可以使用之前的队名和URL了。",
"bottomTexts.tag": "",
"bottomTexts.disableBuildAbilitySorting": "",