mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
User submitted tournament logos (#1742)
* Can upload * Remove avif tournament logos * Progress * TODOs * Update logic * Approval * Fix e2e test
This commit is contained in:
parent
619f437545
commit
a4a8af94dc
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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=""
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -483,6 +483,10 @@ export class Tournament {
|
|||
}
|
||||
|
||||
get logoSrc() {
|
||||
if (this.ctx.logoUrl) {
|
||||
return userSubmittedImage(this.ctx.logoUrl);
|
||||
}
|
||||
|
||||
return HACKY_resolvePicture(this.ctx);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export const testTournament = (
|
|||
id: 1,
|
||||
description: null,
|
||||
rules: null,
|
||||
logoUrl: null,
|
||||
avatarImgId: null,
|
||||
discordUrl: null,
|
||||
startTime: 1705858842,
|
||||
isFinalized: 0,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
migrations/057-calendar-event-avatar.js
Normal file
11
migrations/057-calendar-event-avatar.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user