diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index d2bcf37eb..3216ffa30 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -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 ( - + {imageIsWithExtension ? ( + + ) : ( + + )} {data.text} ); diff --git a/app/constants.ts b/app/constants.ts index 30f7a076c..9b2b76309 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -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, -}; - export const CALENDAR_EVENT_RESULT = { MAX_PARTICIPANTS_COUNT: 1000, MAX_PLAYERS_LENGTH: 8, diff --git a/app/db/tables.ts b/app/db/tables.ts index 87b8bc2af..dc5f13bd2 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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 { diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index 03334d8ab..abed84d24 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -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, }); diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index b87bd3bd6..7d9ae9836 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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; + 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") diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index f604840ad..a91e59ef1 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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(), diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index ed69d8de3..7c375a945 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -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, + AVATAR_SIZE: 512, +}; + export const REG_CLOSES_AT_OPTIONS = [ "0", "30min", diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 727e0d069..b362f77a2 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -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(); const { t } = useTranslation(); const { eventToEdit, eventToCopy } = useLoaderData(); @@ -136,9 +140,35 @@ function EventForm() { const [isTournament, setIsTournament] = React.useState( Boolean(baseEvent?.tournamentId), ); + const ref = React.useRef(null); + const [avatarImg, setAvatarImg] = React.useState(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 ( -
+ {eventToEdit && ( )} @@ -163,6 +193,9 @@ function EventForm() { + {isTournament ? ( + + ) : null} {isTournament ? ( <> Tournament settings @@ -176,7 +209,14 @@ function EventForm() { ) : null} {isTournament ? : } {isTournament ? : null} - {t("actions.submit")} + ); } @@ -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 ( +
+ +
+ + +
+ +
+ ); + } + + const hasPreviousAvatar = Boolean(baseEvent?.avatarImgId); + + return ( +
+ + { + 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 && ( +
+ + + +
+ )} + + Note that for non-patrons there is a validation process before avatar is + shown. + + {hasPreviousAvatar && ( + + )} +
+ ); +} + +function TournamentLogoColorInputsWithShowcase({ + backgroundColor, + setBackgroundColor, + textColor, + setTextColor, + avatarUrl, +}: { + backgroundColor: string; + setBackgroundColor: (color: string) => void; + textColor: string; + setTextColor: (color: string) => void; + avatarUrl: string; +}) { + return ( +
+
+ +
+ Choose a combination that is easy to read +
+ (otherwise will be excluded from front page promotion) +
+
+
+ +
+ + setBackgroundColor(e.target.value)} + /> + + setTextColor(e.target.value)} + /> +
+
+ ); +} + function RankedToggle() { const baseEvent = useBaseEvent(); const [isRanked, setIsRanked] = React.useState( diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index b83dc4cd4..8eee6e6b0 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -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({
{calendarEvent.tournamentId ? ( - ) : null} diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index b5568ed2e..7c78677d9 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -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, }} >
- diff --git a/app/features/img-upload/queries/countAllUnvalidatedImg.server.ts b/app/features/img-upload/queries/countAllUnvalidatedImg.server.ts index f79ea6b09..b4af9a837 100644 --- a/app/features/img-upload/queries/countAllUnvalidatedImg.server.ts +++ b/app/features/img-upload/queries/countAllUnvalidatedImg.server.ts @@ -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() { diff --git a/app/features/img-upload/queries/oneUnvalidatedImage.ts b/app/features/img-upload/queries/oneUnvalidatedImage.ts index dc48bac62..05c2af308 100644 --- a/app/features/img-upload/queries/oneUnvalidatedImage.ts +++ b/app/features/img-upload/queries/oneUnvalidatedImage.ts @@ -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 `); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index efd12eee6..be453daba 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -483,6 +483,10 @@ export class Tournament { } get logoSrc() { + if (this.ctx.logoUrl) { + return userSubmittedImage(this.ctx.logoUrl); + } + return HACKY_resolvePicture(this.ctx); } diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index eacd6825f..d44472c01 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -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, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index c0f2f298f..980b7a56b 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -43,6 +43,8 @@ export const testTournament = ( id: 1, description: null, rules: null, + logoUrl: null, + avatarImgId: null, discordUrl: null, startTime: 1705858842, isFinalized: 0, diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 457448adf..995010bf6 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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") diff --git a/app/features/tournament/components/TournamentStream.tsx b/app/features/tournament/components/TournamentStream.tsx index 63f4a6b4f..2ebcbf618 100644 --- a/app/features/tournament/components/TournamentStream.tsx +++ b/app/features/tournament/components/TournamentStream.tsx @@ -43,7 +43,7 @@ export function TournamentStream({
) : (
- + Cast {stream.twitchUserName}
)} diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 13df6f069..5cf5f8e81 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -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 (
-
{tournament.ctx.name}
@@ -334,6 +341,12 @@ export default function TournamentRegisterPage() {
+ {showAvatarPendingApprovalText ? ( +
+ Tournament logo pending moderator review. Will be shown automatically + once approved. +
+ ) : null}
); diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index d12ca848a..f5b53c69e 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -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, diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 832a1caac..3bb70059e 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -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 }; } diff --git a/app/styles/calendar-new.css b/app/styles/calendar-new.css index 204788f87..7f87c2f4e 100644 --- a/app/styles/calendar-new.css +++ b/app/styles/calendar-new.css @@ -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); +} diff --git a/app/utils/remix.ts b/app/utils/remix.ts index 067be1d36..efb91ec7c 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -90,16 +90,22 @@ export async function parseRequestFormData({ } /** Parse formData with the given schema. Throws HTTP 400 response if fails. */ -export function parseFormData({ +export async function parseFormData({ formData, schema, + parseAsync, }: { formData: FormData; schema: T; -}): z.infer { + parseAsync?: boolean; +}): Promise> { 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) }); diff --git a/app/utils/urls.ts b/app/utils/urls.ts index aca5a506f..7a8799509 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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 = ({ diff --git a/app/utils/zod.ts b/app/utils/zod.ts index 0e290c047..0119101a5 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -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 diff --git a/migrations/057-calendar-event-avatar.js b/migrations/057-calendar-event-avatar.js new file mode 100644 index 000000000..0b71966ef --- /dev/null +++ b/migrations/057-calendar-event-avatar.js @@ -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(); + })(); +} diff --git a/public/static-assets/img/tournament-logos/a41.avif b/public/static-assets/img/tournament-logos/a41.avif deleted file mode 100644 index 9b3f29a60..000000000 Binary files a/public/static-assets/img/tournament-logos/a41.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/ai.avif b/public/static-assets/img/tournament-logos/ai.avif deleted file mode 100644 index bb9223a9b..000000000 Binary files a/public/static-assets/img/tournament-logos/ai.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/bc.avif b/public/static-assets/img/tournament-logos/bc.avif deleted file mode 100644 index 91f810f08..000000000 Binary files a/public/static-assets/img/tournament-logos/bc.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/bio.avif b/public/static-assets/img/tournament-logos/bio.avif deleted file mode 100644 index 2f7822854..000000000 Binary files a/public/static-assets/img/tournament-logos/bio.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/cc.avif b/public/static-assets/img/tournament-logos/cc.avif deleted file mode 100644 index cdbe26284..000000000 Binary files a/public/static-assets/img/tournament-logos/cc.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/ci.avif b/public/static-assets/img/tournament-logos/ci.avif deleted file mode 100644 index 64be281da..000000000 Binary files a/public/static-assets/img/tournament-logos/ci.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/d.avif b/public/static-assets/img/tournament-logos/d.avif deleted file mode 100644 index bb61d2d04..000000000 Binary files a/public/static-assets/img/tournament-logos/d.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/default.avif b/public/static-assets/img/tournament-logos/default.avif deleted file mode 100644 index 3364d2366..000000000 Binary files a/public/static-assets/img/tournament-logos/default.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/e.avif b/public/static-assets/img/tournament-logos/e.avif deleted file mode 100644 index 34c780027..000000000 Binary files a/public/static-assets/img/tournament-logos/e.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/fb.avif b/public/static-assets/img/tournament-logos/fb.avif deleted file mode 100644 index 8a504526c..000000000 Binary files a/public/static-assets/img/tournament-logos/fb.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/ftiu.avif b/public/static-assets/img/tournament-logos/ftiu.avif deleted file mode 100644 index c4177ebb7..000000000 Binary files a/public/static-assets/img/tournament-logos/ftiu.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/hc.avif b/public/static-assets/img/tournament-logos/hc.avif deleted file mode 100644 index b12f13a44..000000000 Binary files a/public/static-assets/img/tournament-logos/hc.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/ho.avif b/public/static-assets/img/tournament-logos/ho.avif deleted file mode 100644 index c0f9860ed..000000000 Binary files a/public/static-assets/img/tournament-logos/ho.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/itz.avif b/public/static-assets/img/tournament-logos/itz.avif deleted file mode 100644 index 77397dfe5..000000000 Binary files a/public/static-assets/img/tournament-logos/itz.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/kr.avif b/public/static-assets/img/tournament-logos/kr.avif deleted file mode 100644 index 77b374162..000000000 Binary files a/public/static-assets/img/tournament-logos/kr.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/lu.avif b/public/static-assets/img/tournament-logos/lu.avif deleted file mode 100644 index 16a595cff..000000000 Binary files a/public/static-assets/img/tournament-logos/lu.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/me.avif b/public/static-assets/img/tournament-logos/me.avif deleted file mode 100644 index df4f75be4..000000000 Binary files a/public/static-assets/img/tournament-logos/me.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/mm.avif b/public/static-assets/img/tournament-logos/mm.avif deleted file mode 100644 index 411ed9971..000000000 Binary files a/public/static-assets/img/tournament-logos/mm.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/mr.avif b/public/static-assets/img/tournament-logos/mr.avif deleted file mode 100644 index 01cbe1316..000000000 Binary files a/public/static-assets/img/tournament-logos/mr.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/pg.avif b/public/static-assets/img/tournament-logos/pg.avif deleted file mode 100644 index 32beaedb1..000000000 Binary files a/public/static-assets/img/tournament-logos/pg.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/pn.avif b/public/static-assets/img/tournament-logos/pn.avif deleted file mode 100644 index b26c80c68..000000000 Binary files a/public/static-assets/img/tournament-logos/pn.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/pp.avif b/public/static-assets/img/tournament-logos/pp.avif deleted file mode 100644 index dec998077..000000000 Binary files a/public/static-assets/img/tournament-logos/pp.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/ros.avif b/public/static-assets/img/tournament-logos/ros.avif deleted file mode 100644 index a46143a49..000000000 Binary files a/public/static-assets/img/tournament-logos/ros.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/sc.avif b/public/static-assets/img/tournament-logos/sc.avif deleted file mode 100644 index 890eaee8a..000000000 Binary files a/public/static-assets/img/tournament-logos/sc.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/sf.avif b/public/static-assets/img/tournament-logos/sf.avif deleted file mode 100644 index 653fbf2fa..000000000 Binary files a/public/static-assets/img/tournament-logos/sf.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/sj.avif b/public/static-assets/img/tournament-logos/sj.avif deleted file mode 100644 index 8a19390dd..000000000 Binary files a/public/static-assets/img/tournament-logos/sj.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/sos.avif b/public/static-assets/img/tournament-logos/sos.avif deleted file mode 100644 index 328c54d50..000000000 Binary files a/public/static-assets/img/tournament-logos/sos.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/ss.avif b/public/static-assets/img/tournament-logos/ss.avif deleted file mode 100644 index 2629e6f48..000000000 Binary files a/public/static-assets/img/tournament-logos/ss.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/tc.avif b/public/static-assets/img/tournament-logos/tc.avif deleted file mode 100644 index 5f0b73b4c..000000000 Binary files a/public/static-assets/img/tournament-logos/tc.avif and /dev/null differ diff --git a/public/static-assets/img/tournament-logos/ul.avif b/public/static-assets/img/tournament-logos/ul.avif deleted file mode 100644 index 41c9dc994..000000000 Binary files a/public/static-assets/img/tournament-logos/ul.avif and /dev/null differ