User submitted tournament logos (#1742)

* Can upload

* Remove avif tournament logos

* Progress

* TODOs

* Update logic

* Approval

* Fix e2e test
This commit is contained in:
Kalle 2024-05-26 10:55:34 +03:00 committed by GitHub
parent 619f437545
commit a4a8af94dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 569 additions and 106 deletions

View File

@ -108,6 +108,8 @@ export const Layout = React.memo(function Layout({
function BreadcrumbLink({ data }: { data: Breadcrumb }) {
if (data.type === "IMAGE") {
const imageIsWithExtension = data.imgPath.includes(".");
return (
<Link
to={data.href}
@ -115,15 +117,27 @@ function BreadcrumbLink({ data }: { data: Breadcrumb }) {
"stack horizontal sm items-center": data.text,
})}
>
<Image
className={clsx("layout__breadcrumb__image", {
"rounded-full": data.rounded,
})}
alt=""
path={data.imgPath}
width={24}
height={24}
/>
{imageIsWithExtension ? (
<img
className={clsx("layout__breadcrumb__image", {
"rounded-full": data.rounded,
})}
alt=""
src={data.imgPath}
width={24}
height={24}
/>
) : (
<Image
className={clsx("layout__breadcrumb__image", {
"rounded-full": data.rounded,
})}
alt=""
path={data.imgPath}
width={24}
height={24}
/>
)}
{data.text}
</Link>
);

View File

@ -1,5 +1,3 @@
import type { CalendarEventTag } from "./db/types";
import { tags } from "./features/calendar/calendar-constants";
import type { BuildAbilitiesTupleWithUnknown } from "./modules/in-game-lists";
export const TWEET_LENGTH_MAX_LENGTH = 280;
@ -17,17 +15,6 @@ export const USER = {
export const PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH = 500;
export const PlUS_SUGGESTION_COMMENT_MAX_LENGTH = TWEET_LENGTH_MAX_LENGTH;
export const CALENDAR_EVENT = {
NAME_MIN_LENGTH: 2,
NAME_MAX_LENGTH: 100,
DESCRIPTION_MAX_LENGTH: 3000,
RULES_MAX_LENGTH: 10_000,
DISCORD_INVITE_CODE_MAX_LENGTH: 50,
BRACKET_URL_MAX_LENGTH: 200,
MAX_AMOUNT_OF_DATES: 5,
TAGS: Object.keys(tags) as Array<CalendarEventTag>,
};
export const CALENDAR_EVENT_RESULT = {
MAX_PARTICIPANTS_COUNT: 1000,
MAX_PLAYERS_LENGTH: 8,

View File

@ -129,6 +129,12 @@ export interface BuildWeapon {
weaponSplId: MainWeaponId;
}
/** Image associated with the avatar when the event is showcased on the front page */
export type CalendarEventAvatarMetadata = {
backgroundColor: string;
textColor: string;
};
export interface CalendarEvent {
authorId: number;
bracketUrl: string;
@ -140,6 +146,12 @@ export interface CalendarEvent {
participantCount: number | null;
tags: string | null;
tournamentId: number | null;
avatarImgId: number | null;
avatarMetadata: ColumnType<
CalendarEventAvatarMetadata | null,
string | null,
string | null
>;
}
export interface CalendarEventBadge {

View File

@ -96,7 +96,7 @@ export const action: ActionFunction = async ({ request }) => {
const fileName = urlParts[urlParts.length - 1];
invariant(fileName);
const data = parseFormData({
const data = await parseFormData({
formData,
schema: newArtSchema,
});

View File

@ -2,10 +2,15 @@ import type { ExpressionBuilder, Transaction } from "kysely";
import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB, Tables, TournamentSettings } from "~/db/tables";
import type {
CalendarEventAvatarMetadata,
DB,
Tables,
TournamentSettings,
} from "~/db/tables";
import type { CalendarEventTag } from "~/db/types";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { sumArray } from "~/utils/number";
import type { Unwrapped } from "~/utils/types";
import invariant from "tiny-invariant";
@ -84,6 +89,8 @@ export async function findById({
"CalendarEvent.tags",
"CalendarEvent.tournamentId",
"CalendarEvent.participantCount",
"CalendarEvent.avatarMetadata",
"CalendarEvent.avatarImgId",
"Tournament.mapPickingStyle",
"User.id as authorId",
"CalendarEventDate.startTime",
@ -153,6 +160,11 @@ export async function findAllBetweenTwoTimestamps({
"User.discordName",
"User.discordDiscriminator",
"CalendarEventRanks.nthAppearance",
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
eb
.selectFrom("Tournament")
.select("Tournament.settings")
@ -421,6 +433,10 @@ type CreateArgs = Pick<
tournamentToCopyId?: number | null;
swissGroupCount?: number;
swissRoundCount?: number;
avatarFileName?: string;
avatarMetadata?: CalendarEventAvatarMetadata;
avatarImgId?: number;
autoValidateAvatar?: boolean;
};
export async function create(args: CreateArgs) {
const copiedStaff = args.tournamentToCopyId
@ -483,6 +499,15 @@ export async function create(args: CreateArgs) {
}
}
const avatarImgId = args.avatarFileName
? await createSubmittedImageInTrx({
trx,
avatarFileName: args.avatarFileName,
autoValidateAvatar: args.autoValidateAvatar,
userId: args.authorId,
})
: null;
const { id: eventId } = await trx
.insertInto("CalendarEvent")
.values({
@ -492,6 +517,10 @@ export async function create(args: CreateArgs) {
description: args.description,
discordInviteCode: args.discordInviteCode,
bracketUrl: args.bracketUrl,
avatarImgId: args.avatarImgId ?? avatarImgId,
avatarMetadata: args.avatarMetadata
? JSON.stringify(args.avatarMetadata)
: null,
tournamentId,
})
.returning("id")
@ -514,14 +543,47 @@ export async function create(args: CreateArgs) {
});
}
async function createSubmittedImageInTrx({
trx,
autoValidateAvatar,
avatarFileName,
userId,
}: {
trx: Transaction<DB>;
avatarFileName: string;
autoValidateAvatar?: boolean;
userId: number;
}) {
const result = await trx
.insertInto("UnvalidatedUserSubmittedImage")
.values({
url: avatarFileName,
validatedAt: autoValidateAvatar ? databaseTimestampNow() : null,
submitterUserId: userId,
})
.returning("id")
.executeTakeFirstOrThrow();
return result.id;
}
type UpdateArgs = Omit<
CreateArgs,
"authorId" | "createTournament" | "mapPickingStyle" | "isFullTournament"
"createTournament" | "mapPickingStyle" | "isFullTournament"
> & {
eventId: number;
};
export async function update(args: UpdateArgs) {
return db.transaction().execute(async (trx) => {
const avatarImgId = args.avatarFileName
? await createSubmittedImageInTrx({
trx,
avatarFileName: args.avatarFileName,
autoValidateAvatar: args.autoValidateAvatar,
userId: args.authorId,
})
: null;
const { tournamentId } = await trx
.updateTable("CalendarEvent")
.set({
@ -530,6 +592,10 @@ export async function update(args: UpdateArgs) {
description: args.description,
discordInviteCode: args.discordInviteCode,
bracketUrl: args.bracketUrl,
avatarMetadata: args.avatarMetadata
? JSON.stringify(args.avatarMetadata)
: null,
avatarImgId: args.avatarImgId ?? avatarImgId,
})
.where("id", "=", args.eventId)
.returning("tournamentId")

View File

@ -1,7 +1,11 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import {
redirect,
unstable_composeUploadHandlers as composeUploadHandlers,
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
unstable_parseMultipartFormData as parseMultipartFormData,
} from "@remix-run/node";
import { z } from "zod";
import { CALENDAR_EVENT } from "~/constants";
import type { CalendarEventTag } from "~/db/types";
import { requireUser } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
@ -17,17 +21,14 @@ import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import {
badRequestIfFalsy,
parseRequestFormData,
validate,
} from "~/utils/remix";
import { badRequestIfFalsy, parseFormData, validate } from "~/utils/remix";
import { calendarEventPage } from "~/utils/urls";
import {
actualNumber,
checkboxValueToBoolean,
date,
falsyToNull,
hexCode,
id,
processMany,
removeDuplicates,
@ -39,13 +40,18 @@ import {
canCreateTournament,
formValuesToBracketProgression,
} from "../calendar-utils.server";
import { REG_CLOSES_AT_OPTIONS } from "../calendar-constants";
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "../calendar-constants";
import { calendarEventMaxDate, calendarEventMinDate } from "../calendar-utils";
import { nanoid } from "nanoid";
import { s3UploadHandler } from "~/features/img-upload";
import invariant from "tiny-invariant";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
const { avatarFileName, formData } = await uploadAvatarIfExists(request);
const data = await parseFormData({
formData,
schema: newCalendarEventActionSchema,
parseAsync: true,
});
@ -54,6 +60,7 @@ export const action: ActionFunction = async ({ request }) => {
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
const commonArgs = {
authorId: user.id,
name: data.name,
description: data.description,
rules: data.rules,
@ -70,6 +77,18 @@ export const action: ActionFunction = async ({ request }) => {
.join(",")
: data.tags,
badges: data.badges ?? [],
// newly uploaded avatar
avatarFileName,
// reused avatar either via edit or template
avatarImgId: data.avatarImgId ?? undefined,
avatarMetadata:
data.backgroundColor && data.textColor
? {
backgroundColor: data.backgroundColor,
textColor: data.textColor,
}
: undefined,
autoValidateAvatar: Boolean(user.patronTier),
toToolsEnabled: canCreateTournament(user) ? Number(data.toToolsEnabled) : 0,
toToolsMode:
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
@ -137,7 +156,6 @@ export const action: ActionFunction = async ({ request }) => {
return "AUTO_ALL" as const;
};
const createdEventId = await CalendarRepository.create({
authorId: user.id,
mapPoolMaps: deserializedMaps,
isFullTournament: data.toToolsEnabled,
mapPickingStyle: mapPickingStyle(),
@ -148,6 +166,38 @@ export const action: ActionFunction = async ({ request }) => {
}
};
async function uploadAvatarIfExists(request: Request) {
const uploadHandler = composeUploadHandlers(
s3UploadHandler(`tournament-logo-${nanoid()}-${Date.now()}`),
createMemoryUploadHandler(),
);
try {
const formData = await parseMultipartFormData(request, uploadHandler);
const imgSrc = formData.get("img") as string | null;
invariant(imgSrc);
const urlParts = imgSrc.split("/");
const fileName = urlParts[urlParts.length - 1];
invariant(fileName);
return {
avatarFileName: fileName,
formData,
};
} catch (err) {
// user did not submit image
if (err instanceof TypeError) {
return {
avatarFileName: undefined,
formData: await request.formData(),
};
}
throw err;
}
}
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
@ -201,6 +251,9 @@ export const newCalendarEventActionSchema = z
processMany(safeJSONParse, removeDuplicates),
z.array(id).nullable(),
),
backgroundColor: hexCode.nullish(),
textColor: hexCode.nullish(),
avatarImgId: id.nullish(),
pool: z.string().optional(),
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),

View File

@ -1,3 +1,5 @@
import type { CalendarEventTag } from "~/db/types";
export const tags = {
BADGE: {
color: "#000",
@ -49,6 +51,18 @@ export const tags = {
},
};
export const CALENDAR_EVENT = {
NAME_MIN_LENGTH: 2,
NAME_MAX_LENGTH: 100,
DESCRIPTION_MAX_LENGTH: 3000,
RULES_MAX_LENGTH: 10_000,
DISCORD_INVITE_CODE_MAX_LENGTH: 50,
BRACKET_URL_MAX_LENGTH: 200,
MAX_AMOUNT_OF_DATES: 5,
TAGS: Object.keys(tags) as Array<CalendarEventTag>,
AVATAR_SIZE: 512,
};
export const REG_CLOSES_AT_OPTIONS = [
"0",
"30min",

View File

@ -1,5 +1,5 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
@ -20,7 +20,6 @@ import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
import { CrossIcon } from "~/components/icons/Cross";
import { TrashIcon } from "~/components/icons/Trash";
import { CALENDAR_EVENT } from "~/constants";
import type { Tables } from "~/db/tables";
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
@ -39,6 +38,7 @@ import {
import { type SendouRouteHandle } from "~/utils/remix";
import { pathnameFromPotentialURL } from "~/utils/strings";
import {
CALENDAR_EVENT,
REG_CLOSES_AT_OPTIONS,
type RegClosesAtOption,
} from "../calendar-constants";
@ -52,6 +52,9 @@ import {
validateFollowUpBrackets,
} from "../calendar-utils";
import { Tags } from "../components/Tags";
import invariant from "tiny-invariant";
import Compressor from "compressorjs";
import { userSubmittedImage } from "~/utils/urls";
import "~/styles/calendar-new.css";
import "~/styles/maps.css";
@ -129,6 +132,7 @@ function TemplateTournamentForm() {
}
function EventForm() {
const fetcher = useFetcher();
const data = useLoaderData<typeof loader>();
const { t } = useTranslation();
const { eventToEdit, eventToCopy } = useLoaderData<typeof loader>();
@ -136,9 +140,35 @@ function EventForm() {
const [isTournament, setIsTournament] = React.useState(
Boolean(baseEvent?.tournamentId),
);
const ref = React.useRef<HTMLFormElement>(null);
const [avatarImg, setAvatarImg] = React.useState<File | null>(null);
const handleSubmit = () => {
const formData = new FormData(ref.current!);
// if "avatarImgId" it means they want to reuse an existing avatar
const includeImage = avatarImg && !formData.has("avatarImgId");
if (includeImage) {
// replace with the compressed version
formData.delete("img");
formData.append("img", avatarImg, avatarImg.name);
}
fetcher.submit(formData, {
encType: includeImage ? "multipart/form-data" : undefined,
method: "post",
});
};
const submitButtonDisabled = () => {
if (fetcher.state !== "idle") return true;
return false;
};
return (
<Form className="stack md items-start" method="post">
<Form className="stack md items-start" ref={ref}>
{eventToEdit && (
<input type="hidden" name="eventToEditId" value={eventToEdit.eventId} />
)}
@ -163,6 +193,9 @@ function EventForm() {
<DiscordLinkInput />
<TagsAdder />
<BadgesAdder />
{isTournament ? (
<AvatarImageInput avatarImg={avatarImg} setAvatarImg={setAvatarImg} />
) : null}
{isTournament ? (
<>
<Divider>Tournament settings</Divider>
@ -176,7 +209,14 @@ function EventForm() {
) : null}
{isTournament ? <TournamentMapPickingStyleSelect /> : <MapPoolSection />}
{isTournament ? <TournamentFormatSelector /> : null}
<SubmitButton className="mt-4">{t("actions.submit")}</SubmitButton>
<Button
className="mt-4"
onClick={handleSubmit}
disabled={submitButtonDisabled()}
testId="submit-button"
>
{t("actions.submit")}
</Button>
</Form>
);
}
@ -558,6 +598,188 @@ function BadgesAdder() {
);
}
function AvatarImageInput({
avatarImg,
setAvatarImg,
}: {
avatarImg: File | null;
setAvatarImg: (img: File | null) => void;
}) {
const baseEvent = useBaseEvent();
const [backgroundColor, setBackgroundColor] = React.useState(
baseEvent?.avatarMetadata?.backgroundColor ?? "#000000",
);
const [textColor, setTextColor] = React.useState(
baseEvent?.avatarMetadata?.textColor ?? "#FFFFFF",
);
const [showPrevious, setShowPrevious] = React.useState(true);
if (
baseEvent?.avatarImgId &&
baseEvent?.tournamentCtx?.logoUrl &&
showPrevious
) {
const logoImgUrl = userSubmittedImage(baseEvent.tournamentCtx.logoUrl);
return (
<div className="stack horizontal md flex-wrap">
<input type="hidden" name="avatarImgId" value={baseEvent.avatarImgId} />
<div className="stack md items-center">
<img
src={logoImgUrl}
alt=""
className="calendar-new__avatar-preview"
/>
<Button
variant="outlined"
size="tiny"
onClick={() => setShowPrevious(false)}
>
Edit logo
</Button>
</div>
<TournamentLogoColorInputsWithShowcase
backgroundColor={backgroundColor}
setBackgroundColor={setBackgroundColor}
textColor={textColor}
setTextColor={setTextColor}
avatarUrl={logoImgUrl}
/>
</div>
);
}
const hasPreviousAvatar = Boolean(baseEvent?.avatarImgId);
return (
<div>
<Label htmlFor="avatarImage">Logo</Label>
<input
id="avatarImage"
className="plain"
type="file"
name="img"
accept="image/png, image/jpeg, image/jpg, image/webp"
onChange={(e) => {
const uploadedFile = e.target.files?.[0];
if (!uploadedFile) {
setAvatarImg(null);
return;
}
new Compressor(uploadedFile, {
width: CALENDAR_EVENT.AVATAR_SIZE,
height: CALENDAR_EVENT.AVATAR_SIZE,
maxHeight: CALENDAR_EVENT.AVATAR_SIZE,
maxWidth: CALENDAR_EVENT.AVATAR_SIZE,
resize: "cover",
success(result) {
invariant(result instanceof Blob);
const file = new File([result], `img.webp`, {
type: "image/webp",
});
setAvatarImg(file);
},
error(err) {
console.error(err.message);
},
});
}}
/>
{avatarImg && (
<div className="mt-4 stack horizontal md flex-wrap">
<img
src={URL.createObjectURL(avatarImg)}
alt=""
className="calendar-new__avatar-preview"
/>
<TournamentLogoColorInputsWithShowcase
backgroundColor={backgroundColor}
setBackgroundColor={setBackgroundColor}
textColor={textColor}
setTextColor={setTextColor}
avatarUrl={URL.createObjectURL(avatarImg)}
/>
</div>
)}
<FormMessage type="info">
Note that for non-patrons there is a validation process before avatar is
shown.
</FormMessage>
{hasPreviousAvatar && (
<Button
variant="minimal-destructive"
size="tiny"
onClick={() => setShowPrevious(true)}
className="mt-2"
>
Cancel changing avatar image
</Button>
)}
</div>
);
}
function TournamentLogoColorInputsWithShowcase({
backgroundColor,
setBackgroundColor,
textColor,
setTextColor,
avatarUrl,
}: {
backgroundColor: string;
setBackgroundColor: (color: string) => void;
textColor: string;
setTextColor: (color: string) => void;
avatarUrl: string;
}) {
return (
<div>
<div
style={{ backgroundColor }}
className="calendar-new__showcase-preview"
>
<img
src={avatarUrl}
alt=""
className="calendar-new__avatar-preview__small"
/>
<div style={{ color: textColor }} className="mt-4">
Choose a combination that is easy to read
<div className="text-xs">
(otherwise will be excluded from front page promotion)
</div>
</div>
</div>
<div className="mt-2 stack horizontal items-center justify-center sm">
<Label htmlFor="backgroundColor" spaced={false}>
BG
</Label>
<input
type="color"
className="plain"
name="backgroundColor"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
/>
<Label htmlFor="textColor" spaced={false}>
Text
</Label>
<input
type="color"
className="plain"
name="textColor"
value={textColor}
onChange={(e) => setTextColor(e.target.value)}
/>
</div>
</div>
);
}
function RankedToggle() {
const baseEvent = useBaseEvent();
const [isRanked, setIsRanked] = React.useState(

View File

@ -39,12 +39,12 @@ import {
navIconUrl,
resolveBaseUrl,
tournamentPage,
userSubmittedImage,
} from "~/utils/urls";
import { actualNumber } from "~/utils/zod";
import * as CalendarRepository from "../CalendarRepository.server";
import { canAddNewEvent } from "../calendar-utils";
import { Tags } from "../components/Tags";
import { Image } from "~/components/Image";
import { Toggle } from "~/components/Toggle";
import { Label } from "~/components/Label";
import { currentSeason } from "~/features/mmr/season";
@ -463,12 +463,17 @@ function EventsList({
<div className="stack xs">
<div className="stack horizontal sm-plus items-center">
{calendarEvent.tournamentId ? (
<Image
path={HACKY_resolvePicture({
name: calendarEvent.name,
})}
<img
src={
calendarEvent.logoUrl
? userSubmittedImage(calendarEvent.logoUrl)
: HACKY_resolvePicture({
name: calendarEvent.name,
})
}
alt=""
size={40}
width={40}
height={40}
className="calendar__event-logo"
/>
) : null}

View File

@ -10,6 +10,7 @@ import {
navIconUrl,
tournamentPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { useTranslation } from "react-i18next";
import { Link } from "@remix-run/react";
@ -131,7 +132,8 @@ function TournamentCard({
const { t } = useTranslation(["common"]);
const isMounted = useIsMounted();
const { i18n } = useTranslation();
const theme = HACKY_resolveThemeColors(tournament);
const theme =
tournament.avatarMetadata ?? HACKY_resolveThemeColors(tournament);
const happeningNow =
tournament.firstPlacers.length === 0 &&
@ -166,14 +168,19 @@ function TournamentCard({
to={tournamentPage(tournament.id)}
className="front__tournament-card"
style={{
"--card-bg": theme.bg,
"--card-text": theme.text,
"--card-bg": theme.backgroundColor,
"--card-text": theme.textColor,
}}
>
<div className="stack horizontal justify-between items-center">
<Image
path={HACKY_resolvePicture(tournament)}
size={24}
<img
src={
tournament.logoUrl
? userSubmittedImage(tournament.logoUrl)
: HACKY_resolvePicture(tournament)
}
width={24}
height={24}
className="rounded-full"
alt=""
/>

View File

@ -7,8 +7,10 @@ const stm = sql.prepare(/*sql*/ `
"UnvalidatedUserSubmittedImage"."id" = "Team"."bannerImgId"
left join "Art" on
"UnvalidatedUserSubmittedImage"."id" = "Art"."imgId"
left join "CalendarEvent" on
"UnvalidatedUserSubmittedImage"."id" = "CalendarEvent"."avatarImgId"
where "UnvalidatedUserSubmittedImage"."validatedAt" is null
and ("Team"."id" is not null or "Art"."id" is not null)
and ("Team"."id" is not null or "Art"."id" is not null or "CalendarEvent"."id" is not null)
`);
export function countAllUnvalidatedImg() {

View File

@ -12,8 +12,10 @@ const stm = sql.prepare(/* sql */ `
"UnvalidatedUserSubmittedImage"."id" = "Team"."bannerImgId"
left join "Art" on
"UnvalidatedUserSubmittedImage"."id" = "Art"."imgId"
left join "CalendarEvent" on
"UnvalidatedUserSubmittedImage"."id" = "CalendarEvent"."avatarImgId"
where "UnvalidatedUserSubmittedImage"."validatedAt" is null
and ("Team"."id" is not null or "Art"."id" is not null)
and ("Team"."id" is not null or "Art"."id" is not null or "CalendarEvent"."id" is not null)
limit 1
`);

View File

@ -483,6 +483,10 @@ export class Tournament {
}
get logoSrc() {
if (this.ctx.logoUrl) {
return userSubmittedImage(this.ctx.logoUrl);
}
return HACKY_resolvePicture(this.ctx);
}

View File

@ -2285,6 +2285,8 @@ export const PADDLING_POOL_257 = () =>
description:
"Hosted by Dapple Productions.\n\nThe longest tournament series in Splatoon!\nEvery week a tournament!\n\n✓ DE or Groups into SE\n✓ All Modes (Picnic system)\n✓ Badge prize\n✓ A well-ran tournament experience\n\nCome join!",
rules: null,
logoUrl: null,
avatarImgId: null,
startTime: 1709748000,
author: {
id: 860,
@ -8129,6 +8131,8 @@ export const PADDLING_POOL_255 = () =>
name: "Paddling Pool 255",
description: null,
rules: null,
logoUrl: null,
avatarImgId: null,
startTime: 1708538400,
author: {
id: 860,
@ -14290,6 +14294,8 @@ export const IN_THE_ZONE_32 = () =>
name: "In The Zone 32",
description: "Part of sendou.ink ranked season 2",
rules: null,
logoUrl: null,
avatarImgId: null,
startTime: 1707588000,
author: {
id: 274,

View File

@ -43,6 +43,8 @@ export const testTournament = (
id: 1,
description: null,
rules: null,
logoUrl: null,
avatarImgId: null,
discordUrl: null,
startTime: 1705858842,
isFinalized: 0,

View File

@ -32,8 +32,14 @@ export async function findById(id: number) {
"Tournament.mapPickingStyle",
"Tournament.rules",
"CalendarEvent.name",
"CalendarEvent.avatarImgId",
"CalendarEvent.description",
"CalendarEventDate.startTime",
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
jsonObjectFrom(
eb
.selectFrom("User")
@ -224,6 +230,12 @@ export async function forShowcase() {
"Tournament.id",
"CalendarEvent.name",
"CalendarEventDate.startTime",
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
"CalendarEvent.avatarMetadata",
jsonArrayFrom(
eb
.selectFrom("TournamentResult")

View File

@ -43,7 +43,7 @@ export function TournamentStream({
</div>
) : (
<div className="tournament__stream__user-container">
<Avatar size="xxs" url={tournament.logoSrc + ".png"} />
<Avatar size="xxs" url={tournament.logoSrc} />
Cast <span className="text-lighter">{stream.twitchUserName}</span>
</div>
)}

View File

@ -281,20 +281,27 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function TournamentRegisterPage() {
const user = useUser();
const isMounted = useIsMounted();
const { i18n } = useTranslation();
const tournament = useTournament();
const startsAtEvenHour = tournament.ctx.startTime.getMinutes() === 0;
const showAvatarPendingApprovalText =
!tournament.ctx.logoUrl &&
tournament.ctx.avatarImgId &&
tournament.isOrganizer(user);
return (
<div className="stack lg">
<div className="tournament__logo-container">
<Image
path={tournament.logoSrc}
<img
src={tournament.logoSrc}
alt=""
className="tournament__logo"
size={124}
width={124}
height={124}
/>
<div>
<div className="tournament__title">{tournament.ctx.name}</div>
@ -334,6 +341,12 @@ export default function TournamentRegisterPage() {
</div>
</div>
</div>
{showAvatarPendingApprovalText ? (
<div className="text-warning text-sm font-semi-bold">
Tournament logo pending moderator review. Will be shown automatically
once approved.
</div>
) : null}
<TournamentRegisterInfoTabs />
</div>
);

View File

@ -30,7 +30,7 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { isAdmin } from "~/permissions";
import { tournamentPage } from "~/utils/urls";
import { tournamentPage, userSubmittedImage } from "~/utils/urls";
import "../tournament.css";
import "~/styles/maps.css";
@ -56,7 +56,7 @@ export const meta: MetaFunction = (args) => {
},
{
property: "og:image",
content: HACKY_resolvePicture(data.tournament.ctx) + ".png",
content: HACKY_resolvePicture(data.tournament.ctx),
},
];
};
@ -70,7 +70,9 @@ export const handle: SendouRouteHandle = {
return [
{
imgPath: HACKY_resolvePicture(data.tournament.ctx),
imgPath: data.tournament.ctx.logoUrl
? userSubmittedImage(data.tournament.ctx.logoUrl)
: HACKY_resolvePicture(data.tournament.ctx),
href: tournamentPage(data.tournament.ctx.id),
type: "IMAGE",
text: data.tournament.ctx.name,

View File

@ -50,6 +50,17 @@ export function isOneModeTournamentOf(
: null;
}
export function tournamentRoundI18nKey(round: PlayedSet["round"]) {
if (round.round === "grand_finals") return `bracket.grand_finals`;
if (round.round === "bracket_reset") {
return `bracket.grand_finals.bracket_reset`;
}
if (round.round === "finals") return `bracket.${round.type}.finals` as const;
return `bracket.${round.type}` as const;
}
// legacy approach, new tournament should use the avatarImgId column in CalendarEvent
export function HACKY_resolvePicture(event: { name: string }) {
const normalizedEventName = event.name.toLowerCase();
@ -172,136 +183,127 @@ export function HACKY_resolvePicture(event: { name: string }) {
return tournamentLogoUrl("default");
}
// legacy approach, new tournament should use the avatarMetadata column in CalendarEvent
const BLACK = "#1e1e1e";
const WHITE = "#fffcfc";
export function HACKY_resolveThemeColors(event: { name: string }) {
const normalizedEventName = event.name.toLowerCase();
if (normalizedEventName.includes("sendouq")) {
return { bg: "#1e1e1e", text: WHITE };
return { backgroundColor: "#1e1e1e", textColor: WHITE };
}
if (normalizedEventName.includes("paddling pool")) {
return { bg: "#fff", text: BLACK };
return { backgroundColor: "#fff", textColor: BLACK };
}
if (normalizedEventName.includes("in the zone")) {
return { bg: "#8b0000", text: WHITE };
return { backgroundColor: "#8b0000", textColor: WHITE };
}
if (normalizedEventName.includes("picnic")) {
return { bg: "#e3fefe", text: BLACK };
return { backgroundColor: "#e3fefe", textColor: BLACK };
}
if (normalizedEventName.includes("proving grounds")) {
return { bg: "#ffe809", text: BLACK };
return { backgroundColor: "#ffe809", textColor: BLACK };
}
if (normalizedEventName.includes("triton")) {
return { bg: "#aee8ff", text: BLACK };
return { backgroundColor: "#aee8ff", textColor: BLACK };
}
if (normalizedEventName.includes("swim or sink")) {
return { bg: "#d7f8ea", text: BLACK };
return { backgroundColor: "#d7f8ea", textColor: BLACK };
}
if (normalizedEventName.includes("from the ink up")) {
return { bg: "#ffdfc6", text: BLACK };
return { backgroundColor: "#ffdfc6", textColor: BLACK };
}
if (normalizedEventName.includes("coral clash")) {
return { bg: "#f0f4ff", text: BLACK };
return { backgroundColor: "#f0f4ff", textColor: BLACK };
}
if (normalizedEventName.includes("level up")) {
return { bg: "#383232", text: WHITE };
return { backgroundColor: "#383232", textColor: WHITE };
}
if (normalizedEventName.includes("all 4 one")) {
return { bg: "#2b262a", text: WHITE };
return { backgroundColor: "#2b262a", textColor: WHITE };
}
if (normalizedEventName.includes("fry basket")) {
return { bg: "#fff", text: BLACK };
return { backgroundColor: "#fff", textColor: BLACK };
}
if (normalizedEventName.includes("the depths")) {
return { bg: "#183e42", text: WHITE };
return { backgroundColor: "#183e42", textColor: WHITE };
}
if (normalizedEventName.includes("eclipse")) {
return { bg: "#191919", text: WHITE };
return { backgroundColor: "#191919", textColor: WHITE };
}
if (normalizedEventName.includes("homecoming")) {
return { bg: "#1c1c1c", text: WHITE };
return { backgroundColor: "#1c1c1c", textColor: WHITE };
}
if (normalizedEventName.includes("bad ideas")) {
return { bg: "#000000", text: WHITE };
return { backgroundColor: "#000000", textColor: WHITE };
}
if (normalizedEventName.includes("tenoch")) {
return { bg: "#425969", text: WHITE };
return { backgroundColor: "#425969", textColor: WHITE };
}
if (normalizedEventName.includes("megalodon monday")) {
return { bg: "#288eb5", text: WHITE };
return { backgroundColor: "#288eb5", textColor: WHITE };
}
if (normalizedEventName.includes("heaven 2 ocean")) {
return { bg: "#8cf1ff", text: BLACK };
return { backgroundColor: "#8cf1ff", textColor: BLACK };
}
if (normalizedEventName.includes("kraken royale")) {
return { bg: "#32333a", text: WHITE };
return { backgroundColor: "#32333a", textColor: WHITE };
}
if (normalizedEventName.includes("menu royale")) {
return { bg: "#000", text: WHITE };
return { backgroundColor: "#000", textColor: WHITE };
}
if (normalizedEventName.includes("barracuda co")) {
return { bg: "#47b6fe", text: BLACK };
return { backgroundColor: "#47b6fe", textColor: BLACK };
}
if (normalizedEventName.includes("crimson ink")) {
return { bg: "#000000", text: WHITE };
return { backgroundColor: "#000000", textColor: WHITE };
}
if (normalizedEventName.includes("mesozoic mayhem")) {
return { bg: "#ccd5da", text: BLACK };
return { backgroundColor: "#ccd5da", textColor: BLACK };
}
if (normalizedEventName.includes("rain or shine")) {
return { bg: "#201c3b", text: WHITE };
return { backgroundColor: "#201c3b", textColor: WHITE };
}
if (normalizedEventName.includes("squid junction")) {
return { bg: "#fed09f", text: BLACK };
return { backgroundColor: "#fed09f", textColor: BLACK };
}
if (normalizedEventName.includes("silly sausage")) {
return { bg: "#ffd76f", text: BLACK };
return { backgroundColor: "#ffd76f", textColor: BLACK };
}
if (normalizedEventName.includes("united-lan")) {
return { bg: "#fff", text: BLACK };
return { backgroundColor: "#fff", textColor: BLACK };
}
if (normalizedEventName.includes("soul cup")) {
return { bg: "#101011", text: WHITE };
return { backgroundColor: "#101011", textColor: WHITE };
}
return { bg: "#3430ad", text: WHITE };
}
export function tournamentRoundI18nKey(round: PlayedSet["round"]) {
if (round.round === "grand_finals") return `bracket.grand_finals`;
if (round.round === "bracket_reset") {
return `bracket.grand_finals.bracket_reset`;
}
if (round.round === "finals") return `bracket.${round.type}.finals` as const;
return `bracket.${round.type}` as const;
return { backgroundColor: "#3430ad", textColor: WHITE };
}

View File

@ -22,3 +22,22 @@
.calendar-new__range-input {
width: 4.25rem;
}
.calendar-new__avatar-preview {
width: 124px;
height: 124px;
border-radius: 100%;
}
.calendar-new__avatar-preview__small {
width: 32px;
height: 32px;
border-radius: 100%;
}
.calendar-new__showcase-preview {
border-radius: var(--rounded);
padding: var(--s-4);
font-size: var(--fonts-sm);
font-weight: var(--semi-bold);
}

View File

@ -90,16 +90,22 @@ export async function parseRequestFormData<T extends z.ZodTypeAny>({
}
/** Parse formData with the given schema. Throws HTTP 400 response if fails. */
export function parseFormData<T extends z.ZodTypeAny>({
export async function parseFormData<T extends z.ZodTypeAny>({
formData,
schema,
parseAsync,
}: {
formData: FormData;
schema: T;
}): z.infer<T> {
parseAsync?: boolean;
}): Promise<z.infer<T>> {
const formDataObj = formDataToObject(formData);
try {
return schema.parse(formDataObj);
const parsed = parseAsync
? await schema.parseAsync(formDataObj)
: schema.parse(formDataObj);
return parsed;
} catch (e) {
if (e instanceof z.ZodError) {
noticeError(e, { formData: JSON.stringify(formDataObj) });

View File

@ -391,7 +391,7 @@ export const preferenceEmojiUrl = (preference?: Preference) => {
return `/static-assets/img/emoji/${emoji}.svg`;
};
export const tournamentLogoUrl = (identifier: string) =>
`/static-assets/img/tournament-logos/${identifier}`;
`/static-assets/img/tournament-logos/${identifier}.png`;
export const TIER_PLUS_URL = `/static-assets/img/tiers/plus`;
export const winnersImageUrl = ({

View File

@ -10,6 +10,8 @@ export const optionalId = z.coerce.number().int().positive().optional();
export const dbBoolean = z.coerce.number().min(0).max(1).int();
export const hexCode = z.string().regex(/^#[0-9a-fA-F]{6}$/);
const abilityNameToType = (val: string) =>
abilities.find((ability) => ability.name === val)?.type;
export const headMainSlotAbility = z

View File

@ -0,0 +1,11 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "CalendarEvent" add "avatarImgId" integer`,
).run();
db.prepare(
/* sql */ `alter table "CalendarEvent" add "avatarMetadata" text`,
).run();
})();
}