mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-06 04:55:15 -05:00
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:
parent
9c5f61c0f2
commit
739e6f440b
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
const result = await parseFormDataWithImages({
|
||||
request,
|
||||
schema: organizationEditFormSchema,
|
||||
autoValidate: true,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"labels.noScreen": "",
|
||||
"labels.noSplatnet": "",
|
||||
"labels.spoilerFreeMode": "",
|
||||
"bottomTexts.imageModeration": "",
|
||||
"bottomTexts.name": "שימו לב שאם תשנו את שם הצוות שלכם, מישהו אחר יוכל לקחת בעלות על השם ועל כתובת האתר עבור הצוות שלו",
|
||||
"bottomTexts.tag": "",
|
||||
"bottomTexts.disableBuildAbilitySorting": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"labels.noScreen": "",
|
||||
"labels.noSplatnet": "",
|
||||
"labels.spoilerFreeMode": "",
|
||||
"bottomTexts.imageModeration": "",
|
||||
"bottomTexts.name": "注意: チーム名を変更した場合、他のプレイヤーが変更前の名前と URL を別のチームのために使用することができるようになります。",
|
||||
"bottomTexts.tag": "",
|
||||
"bottomTexts.disableBuildAbilitySorting": "",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"labels.noScreen": "",
|
||||
"labels.noSplatnet": "",
|
||||
"labels.spoilerFreeMode": "",
|
||||
"bottomTexts.imageModeration": "",
|
||||
"bottomTexts.name": "",
|
||||
"bottomTexts.tag": "",
|
||||
"bottomTexts.disableBuildAbilitySorting": "",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"labels.noScreen": "",
|
||||
"labels.noSplatnet": "",
|
||||
"labels.spoilerFreeMode": "",
|
||||
"bottomTexts.imageModeration": "",
|
||||
"bottomTexts.name": "",
|
||||
"bottomTexts.tag": "",
|
||||
"bottomTexts.disableBuildAbilitySorting": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"labels.noScreen": "",
|
||||
"labels.noSplatnet": "",
|
||||
"labels.spoilerFreeMode": "",
|
||||
"bottomTexts.imageModeration": "",
|
||||
"bottomTexts.name": "Обратите внимание, что если вы измените название команды, то кто-то другой может забрать себе URL и название для своей команды",
|
||||
"bottomTexts.tag": "",
|
||||
"bottomTexts.disableBuildAbilitySorting": "",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"labels.noScreen": "",
|
||||
"labels.noSplatnet": "",
|
||||
"labels.spoilerFreeMode": "",
|
||||
"bottomTexts.imageModeration": "",
|
||||
"bottomTexts.name": "请注意,如果您更改了队名,那么其他人便可以使用之前的队名和URL了。",
|
||||
"bottomTexts.tag": "",
|
||||
"bottomTexts.disableBuildAbilitySorting": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user