mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-09 20:56:02 -05:00
New calendar UI, more filters & persisted filters (#2318)
* Add types * Delete stuff * wip * findAllBetweenTwoTimestamps refactor * wip * wip * wip * wip * wip * wip * wip * Fixes * wip * wip * Fix InfoPopover button styling * wip * wip * wip * Merge branch 'rewrite' into new-calendar * wip * wip * wip * wip * Rename myform -> sendouform * wip * wip * wip * wip * wip * wip * wip * wip * wip * rename * fix test
This commit is contained in:
@@ -1,17 +1,14 @@
|
||||
import { badgeUrl } from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
|
||||
export function Badge({
|
||||
badge,
|
||||
onClick,
|
||||
isAnimated,
|
||||
size,
|
||||
}: {
|
||||
export interface BadgeProps {
|
||||
badge: { displayName: string; hue?: number | null; code: string };
|
||||
onClick?: () => void;
|
||||
isAnimated: boolean;
|
||||
size: number;
|
||||
}) {
|
||||
}
|
||||
|
||||
export function Badge({ badge, onClick, isAnimated, size }: BadgeProps) {
|
||||
const commonProps = {
|
||||
title: badge.displayName,
|
||||
onClick,
|
||||
|
||||
46
app/components/CopyToClipboardPopover.tsx
Normal file
46
app/components/CopyToClipboardPopover.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { ClipboardIcon } from "~/components/icons/Clipboard";
|
||||
|
||||
interface CopyToClipboardPopoverProps {
|
||||
url: string;
|
||||
trigger: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CopyToClipboardPopover({
|
||||
trigger,
|
||||
url,
|
||||
}: CopyToClipboardPopoverProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [state, copyToClipboard] = useCopyToClipboard();
|
||||
const [copySuccess, setCopySuccess] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!state.value) return;
|
||||
|
||||
setCopySuccess(true);
|
||||
const timeout = setTimeout(() => setCopySuccess(false), 2000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<SendouPopover trigger={trigger}>
|
||||
<div className="stack sm">
|
||||
<input defaultValue={url} readOnly />
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="minimal"
|
||||
onPress={() => copyToClipboard(url)}
|
||||
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
|
||||
>
|
||||
{t("common:actions.copyToClipboard")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists";
|
||||
import type { ModeShortWithSpecial } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
TIER_PLUS_URL,
|
||||
mainWeaponImageUrl,
|
||||
@@ -104,7 +105,7 @@ export function WeaponImage({
|
||||
}
|
||||
|
||||
type ModeImageProps = {
|
||||
mode: ModeShort;
|
||||
mode: ModeShortWithSpecial;
|
||||
} & Omit<ImageProps, "path" | "alt">;
|
||||
|
||||
export function ModeImage({ mode, testId, title, ...rest }: ModeImageProps) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type StageId,
|
||||
modesShort,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { modes, stageIds } from "~/modules/in-game-lists";
|
||||
import { stageIds } from "~/modules/in-game-lists";
|
||||
import { split, startsWith } from "~/utils/strings";
|
||||
import { assertType } from "~/utils/types";
|
||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||
@@ -263,25 +263,24 @@ export function MapPoolStages({
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</div>
|
||||
<div className={styles.modeButtonsContainer}>
|
||||
{modes
|
||||
{modesShort
|
||||
.filter(
|
||||
(mode) =>
|
||||
!modesToInclude || modesToInclude.includes(mode.short),
|
||||
(mode) => !modesToInclude || modesToInclude.includes(mode),
|
||||
)
|
||||
.map((mode) => {
|
||||
const selected = mapPool.has({ stageId, mode: mode.short });
|
||||
const selected = mapPool.has({ stageId, mode });
|
||||
|
||||
if (isPresentational && !selected) return null;
|
||||
if (isPresentational && selected) {
|
||||
return (
|
||||
<Image
|
||||
key={mode.short}
|
||||
key={mode}
|
||||
className={clsx(styles.mode, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
title={t(`game-misc:MODE_LONG_${mode}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode}`)}
|
||||
path={modeImageUrl(mode)}
|
||||
width={33}
|
||||
height={33}
|
||||
/>
|
||||
@@ -290,24 +289,21 @@ export function MapPoolStages({
|
||||
|
||||
const preselected = preselectedMapPool?.has({
|
||||
stageId,
|
||||
mode: mode.short,
|
||||
mode,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.short}
|
||||
key={mode}
|
||||
className={clsx(styles.modeButton, "outline-theme", {
|
||||
[styles.selected]: selected,
|
||||
[styles.preselected]: preselected,
|
||||
invisible:
|
||||
hideBanned &&
|
||||
BANNED_MAPS[mode.short].includes(stageId),
|
||||
hideBanned && BANNED_MAPS[mode].includes(stageId),
|
||||
})}
|
||||
onClick={() =>
|
||||
handleModeChange?.({ mode: mode.short, stageId })
|
||||
}
|
||||
onClick={() => handleModeChange?.({ mode, stageId })}
|
||||
type="button"
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
title={t(`game-misc:MODE_LONG_${mode}`)}
|
||||
aria-describedby={`${id}-stage-name-${stageId}`}
|
||||
aria-pressed={selected}
|
||||
disabled={preselected}
|
||||
@@ -317,8 +313,8 @@ export function MapPoolStages({
|
||||
[styles.selected]: selected,
|
||||
[styles.preselected]: preselected,
|
||||
})}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode}`)}
|
||||
path={modeImageUrl(mode)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
@@ -405,10 +401,10 @@ function MapPoolTemplateSelect({
|
||||
{t(`common:maps.template.preset.${presetId}`)}
|
||||
</option>
|
||||
))}
|
||||
{modes.map((mode) => (
|
||||
<option key={mode.short} value={`preset:${mode.short}`}>
|
||||
{modesShort.map((mode) => (
|
||||
<option key={mode} value={`preset:${mode}`}>
|
||||
{t("common:maps.template.preset.onlyMode", {
|
||||
modeName: t(`game-misc:MODE_LONG_${mode.short}`),
|
||||
modeName: t(`game-misc:MODE_LONG_${mode}`),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -17,7 +17,7 @@ type ButtonVariant =
|
||||
| "minimal-success"
|
||||
| "minimal-destructive";
|
||||
|
||||
interface SendouButtonProps extends ReactAriaButtonProps {
|
||||
export interface SendouButtonProps extends ReactAriaButtonProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: "miniscule" | "small" | "medium" | "big";
|
||||
icon?: JSX.Element;
|
||||
|
||||
41
app/components/elements/Calendar.tsx
Normal file
41
app/components/elements/Calendar.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Button,
|
||||
Calendar,
|
||||
CalendarCell,
|
||||
CalendarGrid,
|
||||
type CalendarProps,
|
||||
type DateValue,
|
||||
Heading,
|
||||
} from "react-aria-components";
|
||||
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
|
||||
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
||||
|
||||
export interface SendouCalendarProps<T extends DateValue>
|
||||
extends CalendarProps<T> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SendouCalendar<T extends DateValue>({
|
||||
className,
|
||||
...rest
|
||||
}: SendouCalendarProps<T>) {
|
||||
return (
|
||||
<Calendar className={clsx(className, "react-aria-Calendar")} {...rest}>
|
||||
<header>
|
||||
<Button slot="previous">
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
<Heading />
|
||||
<Button slot="next">
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<CalendarGrid>
|
||||
{(date) => {
|
||||
return <CalendarCell date={date} data-testid="choose-date-button" />;
|
||||
}}
|
||||
</CalendarGrid>
|
||||
</Calendar>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Button,
|
||||
Calendar,
|
||||
CalendarCell,
|
||||
CalendarGrid,
|
||||
DateInput,
|
||||
type DatePickerProps,
|
||||
DateSegment,
|
||||
type DateValue,
|
||||
Dialog,
|
||||
Group,
|
||||
Heading,
|
||||
Popover,
|
||||
DatePicker as ReactAriaDatePicker,
|
||||
} from "react-aria-components";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouCalendar } from "~/components/elements/Calendar";
|
||||
import {
|
||||
type FormFieldSize,
|
||||
formFieldSizeToClassName,
|
||||
} from "../form/form-utils";
|
||||
import { ArrowLeftIcon } from "../icons/ArrowLeft";
|
||||
import { ArrowRightIcon } from "../icons/ArrowRight";
|
||||
import { CalendarIcon } from "../icons/Calendar";
|
||||
import { SendouLabel } from "./Label";
|
||||
|
||||
@@ -54,24 +49,7 @@ export function SendouDatePicker<T extends DateValue>({
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Calendar>
|
||||
<header>
|
||||
<Button slot="previous">
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
<Heading />
|
||||
<Button slot="next">
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<CalendarGrid>
|
||||
{(date) => {
|
||||
return (
|
||||
<CalendarCell date={date} data-testid="choose-date-button" />
|
||||
);
|
||||
}}
|
||||
</CalendarGrid>
|
||||
</Calendar>
|
||||
<SendouCalendar />
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</ReactAriaDatePicker>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
@@ -43,6 +43,7 @@
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px
|
||||
rgba(0, 0, 0, 0.05);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.fullScreenModal {
|
||||
|
||||
@@ -6,6 +6,19 @@ import {
|
||||
type PopoverProps,
|
||||
} from "react-aria-components";
|
||||
|
||||
/**
|
||||
* A reusable popover component that wraps around a trigger element (SendouButton or Button from React Aria Components library).
|
||||
* Supports controlled and uncontrolled open states.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SendouPopover
|
||||
* trigger={<SendouButton>Click me</SendouButton>}
|
||||
* >
|
||||
* Popover content goes here!
|
||||
* </SendouPopover>
|
||||
* ```
|
||||
*/
|
||||
export function SendouPopover({
|
||||
children,
|
||||
trigger,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { dateToYYYYMMDD } from "../../utils/dates";
|
||||
import { dayMonthYearToDateValue } from "../../utils/dates";
|
||||
import type { DayMonthYear } from "../../utils/zod";
|
||||
import { SendouDatePicker } from "../elements/DatePicker";
|
||||
import type { FormFieldSize } from "./form-utils";
|
||||
@@ -38,18 +37,7 @@ export function DateFormField<T extends FieldValues>({
|
||||
|
||||
if (!originalValue) return null;
|
||||
|
||||
const isoString = dateToYYYYMMDD(
|
||||
new Date(
|
||||
Date.UTC(
|
||||
originalValue.year,
|
||||
originalValue.month,
|
||||
originalValue.day,
|
||||
12,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return parseDate(isoString);
|
||||
return dayMonthYearToDateValue(originalValue);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,13 +9,14 @@ import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { type FormFieldSize, formFieldSizeToClassName } from "./form-utils";
|
||||
|
||||
export function TextFormField<T extends FieldValues>({
|
||||
export function InputFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
placeholder,
|
||||
required,
|
||||
size = "small",
|
||||
type,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
@@ -23,6 +24,7 @@ export function TextFormField<T extends FieldValues>({
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
size?: FormFieldSize;
|
||||
type?: React.HTMLInputTypeAttribute;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
@@ -37,6 +39,7 @@ export function TextFormField<T extends FieldValues>({
|
||||
<input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
{...methods.register(name)}
|
||||
className={formFieldSizeToClassName(size)}
|
||||
/>
|
||||
118
app/components/form/InputGroupFormField.tsx
Normal file
118
app/components/form/InputGroupFormField.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
|
||||
interface InputGroupFormFieldProps<T extends FieldValues> {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
type: "checkbox" | "radio";
|
||||
values: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function InputGroupFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
values,
|
||||
type,
|
||||
}: InputGroupFormFieldProps<T>) {
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={methods.control}
|
||||
render={({
|
||||
field: { name, value, onChange, ref },
|
||||
fieldState: { error },
|
||||
}) => {
|
||||
const handleCheckboxChange =
|
||||
(name: string) => (newChecked: boolean) => {
|
||||
const newValue = newChecked
|
||||
? [...(value || []), name]
|
||||
: value?.filter((v: string) => v !== name);
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleRadioChange = (name: string) => () => {
|
||||
onChange(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<fieldset className="stack sm" ref={ref}>
|
||||
<legend>{label}</legend>
|
||||
|
||||
{values.map((checkbox) => {
|
||||
const isChecked = value?.includes(checkbox.value);
|
||||
|
||||
return (
|
||||
<GroupInput
|
||||
key={checkbox.value}
|
||||
type={type}
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
onChange={
|
||||
type === "checkbox"
|
||||
? handleCheckboxChange(checkbox.value)
|
||||
: handleRadioChange(checkbox.value)
|
||||
}
|
||||
>
|
||||
{checkbox.label}
|
||||
</GroupInput>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupInput({
|
||||
children,
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
type,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
checked: boolean;
|
||||
onChange: (newChecked: boolean) => void;
|
||||
type: "checkbox" | "radio";
|
||||
}) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div className="stack horizontal sm items-center">
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
name={name}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={id} className="mb-0">
|
||||
{children}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type { ActionError } from "~/utils/remix.server";
|
||||
import { LinkButton } from "../Button";
|
||||
import { SubmitButton } from "../SubmitButton";
|
||||
|
||||
export function MyForm<T extends z.ZodTypeAny>({
|
||||
export function SendouForm<T extends z.ZodTypeAny>({
|
||||
schema,
|
||||
defaultValues,
|
||||
heading,
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFieldArray,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { AddFieldButton } from "./AddFieldButton";
|
||||
import { RemoveFieldButton } from "./RemoveFieldButton";
|
||||
|
||||
export function TextArrayFormField<T extends z.ZodTypeAny>({
|
||||
export function TextArrayFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
defaultFieldValue,
|
||||
bottomText,
|
||||
/** If "plain", value in the text array is a plain string. If "object" then an object containing the text under "value" key */
|
||||
format = "plain",
|
||||
}: {
|
||||
label: string;
|
||||
name: keyof z.infer<T> & string;
|
||||
defaultFieldValue: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
format?: "plain" | "object";
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
@@ -38,15 +43,19 @@ export function TextArrayFormField<T extends z.ZodTypeAny>({
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<div className="stack horizontal md">
|
||||
<input {...register(`${name}.${index}.value`)} />
|
||||
{fields.length > 1 ? (
|
||||
<RemoveFieldButton
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
clearErrors(`${name}.root`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
{...register(
|
||||
format === "plain"
|
||||
? `${name}.${index}`
|
||||
: `${name}.${index}.value`,
|
||||
)}
|
||||
/>
|
||||
<RemoveFieldButton
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
clearErrors(`${name}.root`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<FormMessage type="error">
|
||||
@@ -58,7 +67,7 @@ export function TextArrayFormField<T extends z.ZodTypeAny>({
|
||||
})}
|
||||
<AddFieldButton
|
||||
// @ts-expect-error
|
||||
onClick={() => append({ value: defaultFieldValue })}
|
||||
onClick={() => append(format === "plain" ? "" : { value: "" })}
|
||||
/>
|
||||
{rootError && (
|
||||
<FormMessage type="error">{rootError.message as string}</FormMessage>
|
||||
|
||||
@@ -27,7 +27,11 @@ export function ToggleFormField<T extends FieldValues>({
|
||||
control={methods.control}
|
||||
name={name}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SendouSwitch id={id} isSelected={value} onChange={onChange} />
|
||||
<SendouSwitch
|
||||
id={id}
|
||||
isSelected={value ?? false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
|
||||
17
app/components/icons/FilterFilled.tsx
Normal file
17
app/components/icons/FilterFilled.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function FilterFilledIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<title>Filter Icon</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.792 2.938A49.069 49.069 0 0 1 12 2.25c2.797 0 5.54.236 8.209.688a1.857 1.857 0 0 1 1.541 1.836v1.044a3 3 0 0 1-.879 2.121l-6.182 6.182a1.5 1.5 0 0 0-.439 1.061v2.927a3 3 0 0 1-1.658 2.684l-1.757.878A.75.75 0 0 1 9.75 21v-5.818a1.5 1.5 0 0 0-.44-1.06L3.13 7.938a3 3 0 0 1-.879-2.121V4.774c0-.897.64-1.683 1.542-1.836Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
app/components/icons/Trophy.tsx
Normal file
20
app/components/icons/Trophy.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export function TrophyIcon({
|
||||
className,
|
||||
title,
|
||||
}: { className?: string; title?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<title>{title ?? "Trophy Icon"}</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 0 0-.584.859 6.753 6.753 0 0 0 6.138 5.6 6.73 6.73 0 0 0 2.743 1.346A6.707 6.707 0 0 1 9.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 0 0-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 0 1-1.112-3.173 6.73 6.73 0 0 0 2.743-1.347 6.753 6.753 0 0 0 6.139-5.6.75.75 0 0 0-.585-.858 47.077 47.077 0 0 0-3.07-.543V2.62a.75.75 0 0 0-.658-.744 49.22 49.22 0 0 0-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 0 0-.657.744Zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 0 1 3.16 5.337a45.6 45.6 0 0 1 2.006-.343v.256Zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 0 1-2.863 3.207 6.72 6.72 0 0 0 .857-3.294Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ function NotificationContent({
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { revalidate, state } = useRevalidator();
|
||||
|
||||
// TODO: for some reason this makes "adds a badge owner sending a notification" E2E test flaky, figure out why and fix
|
||||
useMarkNotificationsAsSeen(unseenIds);
|
||||
|
||||
return (
|
||||
@@ -131,6 +132,7 @@ function NotificationsFooter() {
|
||||
size="tiny"
|
||||
to={NOTIFICATIONS_URL}
|
||||
className="mt-1-5"
|
||||
testId="notifications-see-all-button"
|
||||
>
|
||||
{t("common:notifications.seeAll")}
|
||||
</LinkButton>
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SeedVariation } from "~/features/api-private/routes/seed";
|
||||
import * as AssociationRepository from "~/features/associations/AssociationRepository.server";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { persistedTags } from "~/features/calendar/calendar-constants";
|
||||
import { tags } from "~/features/calendar/calendar-constants";
|
||||
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
|
||||
import { TIMEZONES } from "~/features/lfg/lfg-constants";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
@@ -718,7 +718,7 @@ function calendarEvents() {
|
||||
const userIds = userIdsInRandomOrder();
|
||||
|
||||
for (let id = 1; id <= AMOUNT_OF_CALENDAR_EVENTS; id++) {
|
||||
const shuffledTags = faker.helpers.shuffle(Object.keys(persistedTags));
|
||||
const shuffledTags = faker.helpers.shuffle(Object.keys(tags));
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
Updateable,
|
||||
} from "kysely";
|
||||
import type { AssociationVisibility } from "~/features/associations/associations-types";
|
||||
import type {
|
||||
persistedTags,
|
||||
tags,
|
||||
} from "~/features/calendar/calendar-constants";
|
||||
import type { tags } from "~/features/calendar/calendar-constants";
|
||||
import type { CalendarFilters } from "~/features/calendar/calendar-types";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
|
||||
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
|
||||
@@ -130,7 +128,6 @@ export type CalendarEventAvatarMetadata = {
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
export type PersistedCalendarEventTag = keyof typeof persistedTags;
|
||||
export type CalendarEventTag = keyof typeof tags;
|
||||
|
||||
export interface CalendarEvent {
|
||||
@@ -802,6 +799,7 @@ export type BuildSort = (typeof BUILD_SORT_IDENTIFIERS)[number];
|
||||
export interface UserPreferences {
|
||||
disableBuildAbilitySorting?: boolean;
|
||||
disallowScrimPickupsFromUntrusted?: boolean;
|
||||
defaultCalendarFilters?: CalendarFilters;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { type LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { cors } from "remix-utils/cors";
|
||||
import { z } from "zod";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { databaseTimestampToDate, weekNumberToDate } from "~/utils/dates";
|
||||
import { db } from "~/db/sql";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
weekNumberToDate,
|
||||
} from "~/utils/dates";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import {
|
||||
handleOptionsRequest,
|
||||
@@ -44,10 +48,30 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
|
||||
const endTime = new Date(startTime);
|
||||
endTime.setDate(endTime.getDate() + 7);
|
||||
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy: [],
|
||||
onlyTournaments: false,
|
||||
});
|
||||
return db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.select([
|
||||
"Tournament.id as tournamentId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEventDate.startTime",
|
||||
])
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
">=",
|
||||
dateToDatabaseTimestamp(startTime),
|
||||
)
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
"<=",
|
||||
dateToDatabaseTimestamp(endTime),
|
||||
)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy("CalendarEventDate.startTime", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { z } from "zod";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { MyForm } from "~/components/form/MyForm";
|
||||
import { TextFormField } from "~/components/form/TextFormField";
|
||||
import { InputFormField } from "~/components/form/InputFormField";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { associationsPage } from "~/utils/urls";
|
||||
@@ -24,17 +24,17 @@ export default function AssociationsNewPage() {
|
||||
heading={t("scrims:associations.forms.title")}
|
||||
onCloseTo={associationsPage()}
|
||||
>
|
||||
<MyForm
|
||||
<SendouForm
|
||||
schema={createNewAssociationSchema}
|
||||
defaultValues={{
|
||||
name: "",
|
||||
}}
|
||||
>
|
||||
<TextFormField<FormFields>
|
||||
<InputFormField<FormFields>
|
||||
label={t("scrims:associations.forms.name.title")}
|
||||
name="name"
|
||||
/>
|
||||
</MyForm>
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,16 @@ export interface BadgeDisplayProps {
|
||||
badges: Array<Omit<Tables["Badge"], "authorId"> & { count?: number }>;
|
||||
onChange?: (badgeIds: number[]) => void;
|
||||
children?: React.ReactNode;
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BadgeDisplay({
|
||||
badges: _badges,
|
||||
onChange,
|
||||
children,
|
||||
showText = true,
|
||||
className,
|
||||
}: BadgeDisplayProps) {
|
||||
const { t } = useTranslation("badges");
|
||||
const [badges, setBadges] = React.useState(_badges);
|
||||
@@ -58,13 +62,13 @@ export function BadgeDisplay({
|
||||
|
||||
return (
|
||||
<div data-testid="badge-display">
|
||||
{isPaginated ? (
|
||||
{isPaginated && showText ? (
|
||||
<div className={styles.badgeExplanation}>
|
||||
{badgeExplanationText(t, bigBadge)}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(styles.badges, {
|
||||
className={clsx(className, styles.badges, {
|
||||
"justify-center": smallBadges.length === 0,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Expression, ExpressionBuilder, Transaction } from "kysely";
|
||||
import type {
|
||||
Expression,
|
||||
ExpressionBuilder,
|
||||
NotNull,
|
||||
Transaction,
|
||||
} from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
@@ -6,15 +11,29 @@ import { db } from "~/db/sql";
|
||||
import type {
|
||||
CalendarEventTag,
|
||||
DB,
|
||||
PersistedCalendarEventTag,
|
||||
Tables,
|
||||
TournamentSettings,
|
||||
} from "~/db/tables";
|
||||
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
databaseTimestampNow,
|
||||
databaseTimestampToDate,
|
||||
databaseTimestampToJavascriptTimestamp,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
import { calendarEventPage, tournamentPage } from "~/utils/urls";
|
||||
import {
|
||||
modesIncluded,
|
||||
normalizedTeamCount,
|
||||
tournamentIsRanked,
|
||||
} from "../tournament/tournament-utils";
|
||||
import type { CalendarEvent } from "./calendar-types";
|
||||
import { calendarEventSorter } from "./calendar-utils";
|
||||
|
||||
// TODO: convert from raw to using the "exists" function
|
||||
const hasBadge = sql<number> /* sql */`exists (
|
||||
@@ -77,6 +96,248 @@ function tournamentOrganization(organizationId: Expression<number | null>) {
|
||||
);
|
||||
}
|
||||
|
||||
interface FindAllBetweenTwoTimestampsArgs {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}
|
||||
|
||||
export async function findAllBetweenTwoTimestamps(
|
||||
args: FindAllBetweenTwoTimestampsArgs,
|
||||
) {
|
||||
const rows = await findAllBetweenTwoTimestampsQuery(args);
|
||||
return findAllBetweenTwoTimestampsMapped(rows);
|
||||
}
|
||||
|
||||
const withOrganization = (eb: ExpressionBuilder<DB, "CalendarEvent">) =>
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("TournamentOrganization")
|
||||
.select(["TournamentOrganization.name", "TournamentOrganization.slug"])
|
||||
.whereRef(
|
||||
"TournamentOrganization.id",
|
||||
"=",
|
||||
"CalendarEvent.organizationId",
|
||||
),
|
||||
);
|
||||
|
||||
const withTeamsCount = (
|
||||
eb: ExpressionBuilder<DB, "CalendarEventDate" | "Tournament">,
|
||||
) =>
|
||||
eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.leftJoin("TournamentTeamCheckIn", (join) =>
|
||||
join
|
||||
.on("TournamentTeamCheckIn.bracketIdx", "is", null)
|
||||
.onRef(
|
||||
"TournamentTeamCheckIn.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
),
|
||||
)
|
||||
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("TournamentTeamCheckIn.checkedInAt", "is not", null),
|
||||
eb("CalendarEventDate.startTime", ">", databaseTimestampNow()),
|
||||
]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll<number>().as("teamsCount")]);
|
||||
|
||||
const withLogoUrl = (eb: ExpressionBuilder<DB, "CalendarEvent">) =>
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select(["UserSubmittedImage.url"])
|
||||
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id");
|
||||
|
||||
function findAllBetweenTwoTimestampsQuery({
|
||||
startTime,
|
||||
endTime,
|
||||
}: FindAllBetweenTwoTimestampsArgs) {
|
||||
return db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.select((eb) => [
|
||||
"CalendarEvent.id as eventId",
|
||||
"CalendarEvent.authorId",
|
||||
"Tournament.id as tournamentId",
|
||||
"Tournament.settings as tournamentSettings",
|
||||
"Tournament.mapPickingStyle",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.tags",
|
||||
"CalendarEventDate.startTime",
|
||||
// events get grouped to their closest :00 or :30 so for example users can't make their event start at :59 to make it show at the top
|
||||
sql<number>`(("CalendarEventDate"."startTime" + 900) / 1800) * 1800`.as(
|
||||
"normalizedStartTime",
|
||||
),
|
||||
withOrganization(eb).as("organization"),
|
||||
withTeamsCount(eb).as("teamsCount"),
|
||||
withLogoUrl(eb).as("logoUrl"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("MapPoolMap")
|
||||
.select(["MapPoolMap.mode"])
|
||||
.whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"),
|
||||
).as("toSetMapPool"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("CalendarEventBadge")
|
||||
.innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id")
|
||||
.select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"])
|
||||
.whereRef(
|
||||
"CalendarEventBadge.eventId",
|
||||
"=",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.orderBy("Badge.id", "asc"),
|
||||
).as("badges"),
|
||||
])
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
">=",
|
||||
dateToDatabaseTimestamp(startTime),
|
||||
)
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
"<=",
|
||||
dateToDatabaseTimestamp(endTime),
|
||||
)
|
||||
.$narrowType<{ teamsCount: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
function findAllBetweenTwoTimestampsMapped(
|
||||
rows: Awaited<ReturnType<typeof findAllBetweenTwoTimestampsQuery>>,
|
||||
): Array<{
|
||||
at: number;
|
||||
events: Array<CalendarEvent>;
|
||||
}> {
|
||||
const mapped: Array<CalendarEvent & { startTime: number }> = rows.map(
|
||||
(row) => {
|
||||
const tags = row.tags
|
||||
? (row.tags.split(",") as CalendarEvent["tags"])
|
||||
: [];
|
||||
|
||||
return {
|
||||
at: databaseTimestampToJavascriptTimestamp(row.startTime),
|
||||
type: "calendar",
|
||||
id: row.eventId,
|
||||
url: row.tournamentId
|
||||
? tournamentPage(row.tournamentId)
|
||||
: calendarEventPage(row.eventId),
|
||||
name: row.name,
|
||||
organization: row.organization,
|
||||
authorId: row.authorId,
|
||||
tags: tags.filter((tag) => !EXCLUDED_TAGS.includes(tag)),
|
||||
teamsCount: row.teamsCount,
|
||||
normalizedTeamCount: normalizedTeamCount({
|
||||
teamsCount: row.teamsCount,
|
||||
minMembersPerTeam: row.tournamentSettings?.minMembersPerTeam ?? 4,
|
||||
}),
|
||||
modes: tags.includes("CARDS")
|
||||
? ["TB"]
|
||||
: tags.includes("SR")
|
||||
? ["SR"]
|
||||
: row.mapPickingStyle
|
||||
? modesIncluded(row.mapPickingStyle, row.toSetMapPool)
|
||||
: null,
|
||||
badges: row.badges,
|
||||
logoUrl: row.logoUrl,
|
||||
startTime: row.normalizedStartTime,
|
||||
isRanked: row.tournamentSettings
|
||||
? tournamentIsRanked({
|
||||
isSetAsRanked: row.tournamentSettings.isRanked,
|
||||
startTime: databaseTimestampToDate(row.startTime),
|
||||
minMembersPerTeam: row.tournamentSettings.minMembersPerTeam ?? 4,
|
||||
isTest: row.tournamentSettings.isTest ?? false,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const grouped = R.groupBy(mapped, (row) => row.startTime);
|
||||
const dates = Object.keys(grouped)
|
||||
.map((dbTimestamp) => ({
|
||||
at: databaseTimestampToDate(Number(dbTimestamp)).getTime(),
|
||||
events: grouped[Number(dbTimestamp)].sort(calendarEventSorter),
|
||||
}))
|
||||
.sort((a, b) => a.at - b.at);
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
export type ForShowcase = Unwrapped<typeof forShowcase>;
|
||||
|
||||
export function forShowcase() {
|
||||
return db
|
||||
.selectFrom("Tournament")
|
||||
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"Tournament.id",
|
||||
"Tournament.settings",
|
||||
"CalendarEvent.authorId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEventDate.startTime",
|
||||
withTeamsCount(eb).as("teamsCount"),
|
||||
withLogoUrl(eb).as("logoUrl"),
|
||||
withOrganization(eb).as("organization"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin("User", "TournamentResult.userId", "User.id")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage as TeamAvatar",
|
||||
"AllTeam.avatarImgId",
|
||||
"TeamAvatar.id",
|
||||
)
|
||||
.leftJoin(
|
||||
"UserSubmittedImage as TournamentTeamAvatar",
|
||||
"TournamentTeam.avatarImgId",
|
||||
"TournamentTeamAvatar.id",
|
||||
)
|
||||
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
|
||||
.where("TournamentResult.placement", "=", 1)
|
||||
.select([
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.country",
|
||||
"TournamentTeam.name as teamName",
|
||||
"TeamAvatar.url as teamLogoUrl",
|
||||
"TournamentTeamAvatar.url as pickupAvatarUrl",
|
||||
]),
|
||||
).as("firstPlacers"),
|
||||
])
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where("CalendarEventDate.startTime", ">", databaseTimestampWeekAgo())
|
||||
.orderBy("CalendarEventDate.startTime", "asc")
|
||||
.$narrowType<{ teamsCount: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
function databaseTimestampWeekAgo() {
|
||||
const now = new Date();
|
||||
|
||||
now.setDate(now.getDate() - 7);
|
||||
|
||||
return dateToDatabaseTimestamp(now);
|
||||
}
|
||||
|
||||
export async function findById({
|
||||
id,
|
||||
includeMapPool = false,
|
||||
@@ -136,121 +397,6 @@ export async function findById({
|
||||
};
|
||||
}
|
||||
|
||||
const nthAppearance = sql<number> /* sql */`
|
||||
rank() over (
|
||||
partition by "eventId"
|
||||
order by
|
||||
"startTime" asc
|
||||
)`.as("nthAppearance");
|
||||
export type FindAllBetweenTwoTimestampsItem = Unwrapped<
|
||||
typeof findAllBetweenTwoTimestamps
|
||||
>;
|
||||
export async function findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy = [],
|
||||
onlyTournaments,
|
||||
}: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
tagsToFilterBy?: Array<PersistedCalendarEventTag>;
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
let query = db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.innerJoin("User", "CalendarEvent.authorId", "User.id")
|
||||
.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom("CalendarEventDate")
|
||||
.select(["id", "eventId", "startTime", nthAppearance])
|
||||
.as("CalendarEventRanks"),
|
||||
(join) =>
|
||||
join.onRef("CalendarEventRanks.id", "=", "CalendarEventDate.id"),
|
||||
)
|
||||
.select(({ eb, ref }) => [
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.discordUrl",
|
||||
"CalendarEvent.bracketUrl",
|
||||
"CalendarEvent.tags",
|
||||
"CalendarEvent.tournamentId",
|
||||
"CalendarEventDate.id as eventDateId",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEventDate.startTime",
|
||||
"User.username",
|
||||
"CalendarEventRanks.nthAppearance",
|
||||
tournamentOrganization(ref("CalendarEvent.organizationId")).as(
|
||||
"organization",
|
||||
),
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select(["UserSubmittedImage.url"])
|
||||
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
|
||||
.as("logoUrl"),
|
||||
eb
|
||||
.selectFrom("Tournament")
|
||||
.select("Tournament.settings")
|
||||
.whereRef("Tournament.id", "=", "CalendarEvent.tournamentId")
|
||||
.as("tournamentSettings"),
|
||||
hasBadge,
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("CalendarEventBadge")
|
||||
.innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id")
|
||||
.select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"])
|
||||
.whereRef(
|
||||
"CalendarEventBadge.eventId",
|
||||
"=",
|
||||
"CalendarEventDate.eventId",
|
||||
),
|
||||
).as("badgePrizes"),
|
||||
])
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
">=",
|
||||
dateToDatabaseTimestamp(startTime),
|
||||
)
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
"<=",
|
||||
dateToDatabaseTimestamp(endTime),
|
||||
)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy("CalendarEventDate.startTime", "asc");
|
||||
|
||||
for (const tag of tagsToFilterBy) {
|
||||
query = query.where("CalendarEvent.tags", "like", `%${tag}%`);
|
||||
}
|
||||
|
||||
if (onlyTournaments) {
|
||||
query = query.where("CalendarEvent.tournamentId", "is not", null);
|
||||
}
|
||||
|
||||
const rows = await query.execute();
|
||||
|
||||
return Promise.all(
|
||||
rows
|
||||
.map((row) => ({ ...row, tags: tagsArray(row) }))
|
||||
.map(async (row) => {
|
||||
return {
|
||||
...row,
|
||||
participantCounts: row.tournamentId
|
||||
? await tournamentParticipantCount({
|
||||
tournamentId: row.tournamentId,
|
||||
checkedInOnly:
|
||||
row.startTime < dateToDatabaseTimestamp(new Date()),
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findRecentTournamentsByAuthorId(authorId: number) {
|
||||
return db
|
||||
.selectFrom("CalendarEvent")
|
||||
@@ -280,105 +426,9 @@ function tagsArray(args: {
|
||||
args.tags ? args.tags.split(",") : []
|
||||
) as Array<CalendarEventTag>;
|
||||
|
||||
if (args.hasBadge) tags.unshift("BADGE");
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
async function tournamentParticipantCount({
|
||||
tournamentId,
|
||||
checkedInOnly,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
checkedInOnly: boolean;
|
||||
}) {
|
||||
const rows = await db
|
||||
.selectFrom("TournamentTeam")
|
||||
.leftJoin(
|
||||
"TournamentTeamMember",
|
||||
"TournamentTeam.id",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
)
|
||||
.$if(checkedInOnly, (qb) =>
|
||||
qb.innerJoin("TournamentTeamCheckIn", (join) =>
|
||||
join
|
||||
.onRef(
|
||||
"TournamentTeamCheckIn.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.on("TournamentTeamCheckIn.bracketIdx", "is", null),
|
||||
),
|
||||
)
|
||||
.select(({ fn }) => fn.countAll<number>().as("memberCount"))
|
||||
.where("TournamentTeam.tournamentId", "=", tournamentId)
|
||||
.groupBy("TournamentTeam.id")
|
||||
.execute();
|
||||
|
||||
return {
|
||||
teams: rows.length,
|
||||
players: R.sum(rows.map((row) => row.memberCount)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startTimesOfRange({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
tagsToFilterBy: Array<PersistedCalendarEventTag>;
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
let query = db
|
||||
.selectFrom("CalendarEventDate")
|
||||
.innerJoin("CalendarEvent", "CalendarEvent.id", "CalendarEventDate.eventId")
|
||||
.select(["startTime"])
|
||||
.where("startTime", ">=", dateToDatabaseTimestamp(startTime))
|
||||
.where("startTime", "<=", dateToDatabaseTimestamp(endTime));
|
||||
|
||||
for (const tag of tagsToFilterBy) {
|
||||
query = query.where("CalendarEvent.tags", "like", `%${tag}%`);
|
||||
}
|
||||
|
||||
if (onlyTournaments) {
|
||||
query = query.where("CalendarEvent.tournamentId", "is not", null);
|
||||
}
|
||||
|
||||
const rows = await query.execute();
|
||||
return rows.map((row) => row.startTime);
|
||||
}
|
||||
|
||||
export async function eventsToReport(authorId: number) {
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
"CalendarEvent.id",
|
||||
"CalendarEvent.name",
|
||||
fn.max("CalendarEventDate.startTime").as("startTime"),
|
||||
])
|
||||
.where("CalendarEvent.authorId", "=", authorId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo))
|
||||
.where("startTime", "<=", dateToDatabaseTimestamp(new Date()))
|
||||
.where("CalendarEvent.participantCount", "is", null)
|
||||
.where("CalendarEvent.tournamentId", "is", null)
|
||||
.groupBy("CalendarEvent.id")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({ id: row.id, name: row.name }));
|
||||
}
|
||||
|
||||
export async function findRecentMapPoolsByAuthorId(authorId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("CalendarEvent")
|
||||
|
||||
@@ -5,20 +5,22 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseParams,
|
||||
safeParseRequestFormData,
|
||||
} from "~/utils/remix.server";
|
||||
import { calendarEventPage } from "~/utils/urls";
|
||||
import {
|
||||
reportWinnersActionSchema,
|
||||
reportWinnersParamsSchema,
|
||||
} from "../calendar-schemas";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { reportWinnersActionSchema } from "../calendar-schemas";
|
||||
import { canReportCalendarEventWinners } from "../calendar-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
const parsedParams = reportWinnersParamsSchema.parse(params);
|
||||
export const action: ActionFunction = async (args) => {
|
||||
const user = await requireUserId(args.request);
|
||||
const params = parseParams({
|
||||
params: args.params,
|
||||
schema: idObject,
|
||||
});
|
||||
const parsedInput = await safeParseRequestFormData({
|
||||
request,
|
||||
request: args.request,
|
||||
schema: reportWinnersActionSchema,
|
||||
});
|
||||
|
||||
@@ -29,7 +31,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
}
|
||||
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
await CalendarRepository.findById({ id: params.id }),
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
canReportCalendarEventWinners({
|
||||
@@ -41,7 +43,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
);
|
||||
|
||||
await CalendarRepository.upsertReportedScores({
|
||||
eventId: parsedParams.id,
|
||||
eventId: params.id,
|
||||
participantCount: parsedInput.data.participantCount,
|
||||
results: parsedInput.data.team.map((t) => ({
|
||||
teamName: t.teamName,
|
||||
@@ -53,5 +55,5 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
})),
|
||||
});
|
||||
|
||||
throw redirect(calendarEventPage(parsedParams.id));
|
||||
throw redirect(calendarEventPage(params.id));
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { redirect } from "@remix-run/node";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas";
|
||||
import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
|
||||
35
app/features/calendar/actions/calendar.tsx
Normal file
35
app/features/calendar/actions/calendar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import {
|
||||
parseRequestPayload,
|
||||
parseSafeSearchParams,
|
||||
} from "~/utils/remix.server";
|
||||
import { calendarPage } from "~/utils/urls";
|
||||
import { dayMonthYear } from "~/utils/zod";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: calendarFiltersSearchParamsSchema,
|
||||
});
|
||||
|
||||
await UserRepository.updatePreferences(user.id, {
|
||||
defaultCalendarFilters: data,
|
||||
});
|
||||
|
||||
const parsedSearchParams = parseSafeSearchParams({
|
||||
request,
|
||||
schema: dayMonthYear,
|
||||
});
|
||||
|
||||
return redirect(
|
||||
calendarPage({
|
||||
dayMonthYear: parsedSearchParams.success
|
||||
? parsedSearchParams.data
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/tables";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
|
||||
export const persistedTags = {
|
||||
export const tags = {
|
||||
SPECIAL: {
|
||||
color: "#CE93D8",
|
||||
},
|
||||
@@ -41,7 +41,7 @@ export const persistedTags = {
|
||||
color: "#1ADB1E",
|
||||
},
|
||||
TRIOS: {
|
||||
color: "#571ADB",
|
||||
color: "#B694FF",
|
||||
},
|
||||
S1: {
|
||||
color: "#E5E4E2",
|
||||
@@ -60,13 +60,6 @@ export const persistedTags = {
|
||||
},
|
||||
};
|
||||
|
||||
export const tags = {
|
||||
...persistedTags,
|
||||
BADGE: {
|
||||
color: "#000",
|
||||
},
|
||||
};
|
||||
|
||||
export const CALENDAR_EVENT = {
|
||||
NAME_MIN_LENGTH: 2,
|
||||
NAME_MAX_LENGTH: 100,
|
||||
@@ -76,10 +69,6 @@ export const CALENDAR_EVENT = {
|
||||
BRACKET_URL_MAX_LENGTH: 200,
|
||||
MAX_AMOUNT_OF_DATES: 5,
|
||||
/** Calendar event tag that is persisted in the database */
|
||||
PERSISTED_TAGS: Object.keys(
|
||||
persistedTags,
|
||||
) as Array<PersistedCalendarEventTag>,
|
||||
/** Calendar event tag, both those persisted in the database and those that are computed */
|
||||
TAGS: Object.keys(tags) as Array<CalendarEventTag>,
|
||||
AVATAR_SIZE: 512,
|
||||
};
|
||||
@@ -103,3 +92,14 @@ export const REG_CLOSES_AT_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
export type RegClosesAtOption = (typeof REG_CLOSES_AT_OPTIONS)[number];
|
||||
|
||||
/** How many days are shown at the /calendar page at a time */
|
||||
export const DAYS_SHOWN_AT_A_TIME = 4;
|
||||
|
||||
/** Tags not shown on the tournament cards */
|
||||
export const EXCLUDED_TAGS: Array<CalendarEventTag> = [
|
||||
"CARDS",
|
||||
"SR",
|
||||
"SZ",
|
||||
"TW",
|
||||
];
|
||||
|
||||
142
app/features/calendar/calendar-hooks.ts
Normal file
142
app/features/calendar/calendar-hooks.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react";
|
||||
import { calendarEventSorter } from "~/features/calendar/calendar-utils";
|
||||
import type { CalendarLoaderData } from "~/features/calendar/loaders/calendar.server";
|
||||
|
||||
interface CollapsedEvents {
|
||||
eventsShown: CalendarLoaderData["eventTimes"][number]["events"]["shown"];
|
||||
date: {
|
||||
from: Date;
|
||||
to?: Date;
|
||||
};
|
||||
hiddenShown: boolean;
|
||||
hiddenCount: number;
|
||||
onToggleHidden: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage hiding/showing calendar events. Handles collapsing adjacent times given both of them have only hidden events.
|
||||
* E.g. 10pm with all hidden and 11pm with all hidden should be shown in one section as 10pm - 11pm to the user.
|
||||
*
|
||||
* @param eventTimes - Array of event times as returned by the calendar data loader.
|
||||
*/
|
||||
export function useCollapsableEvents(
|
||||
eventTimes: CalendarLoaderData["eventTimes"],
|
||||
) {
|
||||
const [collapsingDisabled, setCollapsingDisabled] = React.useState(false);
|
||||
const [shownEventTimes, setShownEventTimes] = React.useState(
|
||||
new Set<number>(),
|
||||
);
|
||||
|
||||
const eventsResult: Array<CollapsedEvents> = [];
|
||||
const hiddenTimes: Set<number> = new Set();
|
||||
|
||||
for (const [i, eventTime] of eventTimes.entries()) {
|
||||
if (hiddenTimes.has(eventTime.at)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { containedTimes, date } = !collapsingDisabled
|
||||
? resolveCollapsedDateRange(eventTimes, i)
|
||||
: {
|
||||
containedTimes: new Set<number>().add(eventTime.at),
|
||||
date: {
|
||||
from: new Date(eventTime.at),
|
||||
to: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
for (const time of containedTimes) {
|
||||
hiddenTimes.add(time);
|
||||
}
|
||||
|
||||
const hiddenShown = shownEventTimes.has(eventTime.at);
|
||||
|
||||
eventsResult.push({
|
||||
eventsShown: hiddenShown
|
||||
? [...eventTime.events.shown, ...eventTime.events.hidden].sort(
|
||||
calendarEventSorter,
|
||||
)
|
||||
: eventTime.events.shown,
|
||||
date,
|
||||
hiddenShown,
|
||||
onToggleHidden: () => {
|
||||
// if we clicked a range section, uncollapse it
|
||||
if (containedTimes.size > 1) {
|
||||
setCollapsingDisabled(true);
|
||||
}
|
||||
setShownEventTimes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(eventTime.at)) {
|
||||
for (const time of containedTimes) {
|
||||
newSet.delete(time);
|
||||
}
|
||||
} else {
|
||||
for (const time of containedTimes) {
|
||||
newSet.add(time);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
hiddenCount: !date.to
|
||||
? eventTime.events.hidden.length
|
||||
: eventTimes.reduce((acc, cur) => {
|
||||
if (cur.at < eventTime.at || cur.at > date.to.getTime()) {
|
||||
return acc;
|
||||
}
|
||||
return acc + cur.events.hidden.length;
|
||||
}, 0),
|
||||
});
|
||||
}
|
||||
|
||||
return eventsResult;
|
||||
}
|
||||
|
||||
function resolveCollapsedDateRange(
|
||||
eventTimes: CalendarLoaderData["eventTimes"],
|
||||
forIndex: number,
|
||||
) {
|
||||
if (eventTimes[forIndex].events.shown.length > 0) {
|
||||
return {
|
||||
containedTimes: new Set<number>().add(eventTimes[forIndex].at),
|
||||
date: {
|
||||
from: new Date(eventTimes[forIndex].at),
|
||||
to: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const containedTimes: Set<number> = new Set();
|
||||
let to: Date | undefined;
|
||||
|
||||
for (let j = forIndex + 1; j < eventTimes.length; j++) {
|
||||
const eventTime = eventTimes[j];
|
||||
|
||||
if (eventTime.events.shown.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
to = new Date(eventTime.at);
|
||||
containedTimes.add(eventTime.at);
|
||||
}
|
||||
|
||||
containedTimes.add(eventTimes[forIndex].at);
|
||||
|
||||
if (!to) {
|
||||
return {
|
||||
containedTimes,
|
||||
date: {
|
||||
from: new Date(eventTimes[forIndex].at),
|
||||
to: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
containedTimes,
|
||||
date: {
|
||||
from: new Date(eventTimes[forIndex].at),
|
||||
to,
|
||||
},
|
||||
};
|
||||
}
|
||||
123
app/features/calendar/calendar-schemas.server.ts
Normal file
123
app/features/calendar/calendar-schemas.server.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { z } from "zod";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import "~/styles/calendar-new.css";
|
||||
import {
|
||||
bracketProgressionSchema,
|
||||
calendarEventTagSchema,
|
||||
} from "~/features/calendar/calendar-schemas";
|
||||
import {
|
||||
actualNumber,
|
||||
checkboxValueToBoolean,
|
||||
date,
|
||||
falsyToNull,
|
||||
id,
|
||||
processMany,
|
||||
removeDuplicates,
|
||||
safeJSONParse,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
|
||||
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
|
||||
|
||||
export const newCalendarEventActionSchema = z
|
||||
.object({
|
||||
eventToEditId: z.preprocess(actualNumber, id.nullish()),
|
||||
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
|
||||
organizationId: z.preprocess(actualNumber, id.nullish()),
|
||||
name: z
|
||||
.string()
|
||||
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
|
||||
.max(CALENDAR_EVENT.NAME_MAX_LENGTH),
|
||||
description: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(),
|
||||
),
|
||||
rules: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(),
|
||||
),
|
||||
date: z.preprocess(
|
||||
toArray,
|
||||
z
|
||||
.array(
|
||||
z.preprocess(
|
||||
date,
|
||||
z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()),
|
||||
),
|
||||
)
|
||||
.min(1)
|
||||
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES),
|
||||
),
|
||||
bracketUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH)
|
||||
.default("https://sendou.ink"),
|
||||
discordInviteCode: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
tags: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z.array(calendarEventTagSchema).nullable(),
|
||||
),
|
||||
badges: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z.array(id).nullable(),
|
||||
),
|
||||
avatarImgId: id.nullish(),
|
||||
pool: z.string().optional(),
|
||||
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
|
||||
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
|
||||
enableNoScreenToggle: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
requireInGameNames: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(),
|
||||
bracketProgression: bracketProgressionSchema.nullish(),
|
||||
})
|
||||
.refine(
|
||||
async (schema) => {
|
||||
if (schema.eventToEditId) {
|
||||
const eventToEdit = await CalendarRepository.findById({
|
||||
id: schema.eventToEditId,
|
||||
});
|
||||
return schema.date.length === 1 || !eventToEdit?.tournamentId;
|
||||
}
|
||||
return schema.date.length === 1 || !schema.toToolsEnabled;
|
||||
},
|
||||
{
|
||||
message: "Tournament must have exactly one date",
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.toToolsMode !== "ALL") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const maps = schema.pool ? MapPool.toDbList(schema.pool) : [];
|
||||
|
||||
return (
|
||||
maps.length === 4 &&
|
||||
rankedModesShort.every((mode) => maps.some((map) => map.mode === mode))
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"',
|
||||
},
|
||||
);
|
||||
@@ -1,29 +1,95 @@
|
||||
import { z } from "zod";
|
||||
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import "~/styles/calendar-new.css";
|
||||
import {
|
||||
type PersistedCalendarEventTag,
|
||||
TOURNAMENT_STAGE_TYPES,
|
||||
} from "~/db/tables";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import "~/styles/calendar-new.css";
|
||||
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
|
||||
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
|
||||
import {
|
||||
actualNumber,
|
||||
checkboxValueToBoolean,
|
||||
date,
|
||||
falsyToNull,
|
||||
gamesShortSchema,
|
||||
id,
|
||||
processMany,
|
||||
removeDuplicates,
|
||||
modeShortWithSpecial,
|
||||
safeJSONParse,
|
||||
safeSplit,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
|
||||
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
|
||||
import { CALENDAR_EVENT } from "./calendar-constants";
|
||||
import * as CalendarEvent from "./core/CalendarEvent";
|
||||
|
||||
export const calendarEventTagSchema = z
|
||||
.string()
|
||||
.refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag));
|
||||
|
||||
const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10);
|
||||
const calendarFiltersIdsArr = z.array(id).max(10);
|
||||
const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3);
|
||||
const preferredStartTime = z.enum(["ANY", "EU", "NA", "AU"]);
|
||||
const preferredVersus = z
|
||||
.array(z.enum(versusShort))
|
||||
.min(1)
|
||||
.max(versusShort.length);
|
||||
const modeArr = z
|
||||
.array(modeShortWithSpecial)
|
||||
.min(1)
|
||||
.max(modesShortWithSpecial.length);
|
||||
|
||||
export const calendarFiltersSearchParamsSchema = z.object({
|
||||
preferredStartTime: preferredStartTime.catch("ANY"),
|
||||
tagsIncluded: z.array(calendarEventTagSchema).catch([]),
|
||||
tagsExcluded: z.array(calendarEventTagSchema).catch([]),
|
||||
isSendou: z.boolean().catch(false),
|
||||
isRanked: z.boolean().catch(false),
|
||||
orgsIncluded: calendarFiltersPlainStringArr.catch([]),
|
||||
orgsExcluded: calendarFiltersPlainStringArr.catch([]),
|
||||
authorIdsExcluded: calendarFiltersIdsArr.catch([]),
|
||||
games: calendarFilterGamesArr.catch([...gamesShort]),
|
||||
preferredVersus: preferredVersus.catch([...versusShort]),
|
||||
modes: modeArr.catch([...modesShortWithSpecial]),
|
||||
modesExact: z.boolean().catch(false),
|
||||
minTeamCount: z.coerce.number().int().nonnegative().catch(0),
|
||||
});
|
||||
|
||||
export const calendarFiltersFormSchema = z
|
||||
.object({
|
||||
preferredStartTime: preferredStartTime,
|
||||
tagsIncluded: z.array(calendarEventTagSchema),
|
||||
tagsExcluded: z.array(calendarEventTagSchema),
|
||||
isSendou: z.boolean(),
|
||||
isRanked: z.boolean(),
|
||||
orgsIncluded: calendarFiltersPlainStringArr,
|
||||
orgsExcluded: calendarFiltersPlainStringArr,
|
||||
authorIdsExcluded: calendarFiltersIdsArr,
|
||||
games: calendarFilterGamesArr,
|
||||
preferredVersus: preferredVersus,
|
||||
modes: modeArr,
|
||||
modesExact: z.boolean(),
|
||||
minTeamCount: z.coerce.number().int().nonnegative(),
|
||||
})
|
||||
.superRefine((filters, ctx) => {
|
||||
if (
|
||||
filters.tagsIncluded.some((tag) => filters.tagsExcluded.includes(tag))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
path: ["tagsExcluded"],
|
||||
message: "Can't include and exclude the same tag",
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.orgsIncluded.length > 0 && filters.orgsExcluded.length > 0) {
|
||||
ctx.addIssue({
|
||||
path: ["orgsExcluded"],
|
||||
message: "Can't both include and exclude organizations",
|
||||
code: z.ZodIssueCode.custom,
|
||||
});
|
||||
}
|
||||
});
|
||||
export const calendarFiltersSearchParamsObject = z.object({
|
||||
filters: z
|
||||
.preprocess(safeJSONParse, calendarFiltersSearchParamsSchema)
|
||||
.catch(CalendarEvent.defaultFilters()),
|
||||
});
|
||||
|
||||
const playersSchema = z
|
||||
.array(
|
||||
@@ -86,29 +152,6 @@ export const reportWinnersActionSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const reportWinnersParamsSchema = z.object({
|
||||
id: z.preprocess(actualNumber, id),
|
||||
});
|
||||
|
||||
export const loaderWeekSearchParamsSchema = z.object({
|
||||
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
|
||||
year: z.preprocess(actualNumber, z.number().int()),
|
||||
});
|
||||
|
||||
export const calendarEventTagSchema = z
|
||||
.string()
|
||||
.refine((val) =>
|
||||
CALENDAR_EVENT.PERSISTED_TAGS.includes(val as PersistedCalendarEventTag),
|
||||
);
|
||||
|
||||
export const loaderFilterSearchParamsSchema = z.object({
|
||||
tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)),
|
||||
});
|
||||
|
||||
export const loaderTournamentsOnlySearchParamsSchema = z.object({
|
||||
tournaments: z.literal("true").nullish(),
|
||||
});
|
||||
|
||||
export const bracketProgressionSchema = z.preprocess(
|
||||
safeJSONParse,
|
||||
z
|
||||
@@ -140,104 +183,3 @@ export const bracketProgressionSchema = z.preprocess(
|
||||
"Invalid bracket progression",
|
||||
),
|
||||
);
|
||||
|
||||
export const newCalendarEventActionSchema = z
|
||||
.object({
|
||||
eventToEditId: z.preprocess(actualNumber, id.nullish()),
|
||||
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
|
||||
organizationId: z.preprocess(actualNumber, id.nullish()),
|
||||
name: z
|
||||
.string()
|
||||
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
|
||||
.max(CALENDAR_EVENT.NAME_MAX_LENGTH),
|
||||
description: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(),
|
||||
),
|
||||
rules: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(),
|
||||
),
|
||||
date: z.preprocess(
|
||||
toArray,
|
||||
z
|
||||
.array(
|
||||
z.preprocess(
|
||||
date,
|
||||
z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()),
|
||||
),
|
||||
)
|
||||
.min(1)
|
||||
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES),
|
||||
),
|
||||
bracketUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH)
|
||||
.default("https://sendou.ink"),
|
||||
discordInviteCode: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
tags: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z.array(calendarEventTagSchema).nullable(),
|
||||
),
|
||||
badges: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z.array(id).nullable(),
|
||||
),
|
||||
avatarImgId: id.nullish(),
|
||||
pool: z.string().optional(),
|
||||
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
|
||||
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
|
||||
enableNoScreenToggle: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
|
||||
requireInGameNames: z.preprocess(
|
||||
checkboxValueToBoolean,
|
||||
z.boolean().nullish(),
|
||||
),
|
||||
minMembersPerTeam: z.coerce.number().int().min(1).max(4).nullish(),
|
||||
bracketProgression: bracketProgressionSchema.nullish(),
|
||||
})
|
||||
.refine(
|
||||
async (schema) => {
|
||||
if (schema.eventToEditId) {
|
||||
const eventToEdit = await CalendarRepository.findById({
|
||||
id: schema.eventToEditId,
|
||||
});
|
||||
return schema.date.length === 1 || !eventToEdit?.tournamentId;
|
||||
}
|
||||
return schema.date.length === 1 || !schema.toToolsEnabled;
|
||||
},
|
||||
{
|
||||
message: "Tournament must have exactly one date",
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.toToolsMode !== "ALL") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const maps = schema.pool ? MapPool.toDbList(schema.pool) : [];
|
||||
|
||||
return (
|
||||
maps.length === 4 &&
|
||||
rankedModesShort.every((mode) => maps.some((map) => map.mode === mode))
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,58 @@
|
||||
export interface FollowUpBracket {
|
||||
import type { z } from "zod";
|
||||
import type { CalendarEventTag, Tables } from "~/db/tables";
|
||||
import type { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
|
||||
import type { ModeShortWithSpecial } from "~/modules/in-game-lists/types";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
|
||||
interface CommonEvent {
|
||||
id: number;
|
||||
name: string;
|
||||
placements: Array<number>;
|
||||
teamsCount: number;
|
||||
logoUrl: string | null;
|
||||
url: string;
|
||||
/** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */
|
||||
isRanked: boolean | null;
|
||||
modes: Array<ModeShortWithSpecial> | null;
|
||||
organization: {
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
/** User id of the author of the event */
|
||||
authorId: number;
|
||||
}
|
||||
|
||||
export interface CalendarEvent extends CommonEvent {
|
||||
/** The date of the event in UNIX timestamp (JS format) */
|
||||
at: number;
|
||||
type: "calendar";
|
||||
tags: Array<CalendarEventTag>;
|
||||
/** Used for comparison, teams count where it is taken in account whether the tournament is 4v4, 3v3, 2v2 or 1v1 */
|
||||
normalizedTeamCount: number;
|
||||
/** For multi-day tournaments, which day of the event is this */
|
||||
day?: number;
|
||||
badges: Array<
|
||||
Pick<Tables["Badge"], "id" | "code" | "displayName" | "hue">
|
||||
> | null;
|
||||
}
|
||||
|
||||
export interface ShowcaseCalendarEvent extends CommonEvent {
|
||||
type: "showcase";
|
||||
startTime: number;
|
||||
firstPlacer: {
|
||||
teamName: string;
|
||||
logoUrl: string | null;
|
||||
members: (CommonUser & { country: Tables["User"]["country"] })[];
|
||||
notShownMembersCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface GroupedCalendarEvents {
|
||||
/** The date of the event in UNIX timestamp (JS format) */
|
||||
at: number;
|
||||
events: {
|
||||
shown: CalendarEvent[];
|
||||
hidden: CalendarEvent[];
|
||||
};
|
||||
}
|
||||
|
||||
export type CalendarFilters = z.infer<typeof calendarFiltersSearchParamsSchema>;
|
||||
|
||||
@@ -4,7 +4,12 @@ import { allTruthy } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import type { RegClosesAtOption } from "./calendar-constants";
|
||||
import type { DayMonthYear } from "~/utils/zod";
|
||||
import {
|
||||
DAYS_SHOWN_AT_A_TIME,
|
||||
type RegClosesAtOption,
|
||||
} from "./calendar-constants";
|
||||
import type { CalendarEvent } from "./calendar-types";
|
||||
|
||||
export const calendarEventMinDate = () => new Date(Date.UTC(2015, 4, 28));
|
||||
export const calendarEventMaxDate = () => {
|
||||
@@ -192,3 +197,72 @@ function eventStartedInThePast(
|
||||
databaseTimestampToDate(startTime).getTime() < new Date().getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function daysForCalendar(currentDate?: DayMonthYear) {
|
||||
type DaysArray = Array<DayMonthYear>;
|
||||
|
||||
const previous: DaysArray = [];
|
||||
const shown: DaysArray = [];
|
||||
const next: DaysArray = [];
|
||||
|
||||
const startDate = () =>
|
||||
currentDate
|
||||
? new Date(currentDate.year, currentDate.month, currentDate.day)
|
||||
: new Date();
|
||||
|
||||
const currentDayMonthYear = () => {
|
||||
const now = startDate();
|
||||
|
||||
return {
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
};
|
||||
};
|
||||
|
||||
let now = startDate();
|
||||
|
||||
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
|
||||
shown.push({
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
|
||||
now.setDate(now.getDate() + 1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
|
||||
next.push({
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
|
||||
now.setDate(now.getDate() + 1);
|
||||
}
|
||||
|
||||
now = startDate();
|
||||
|
||||
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
|
||||
now.setDate(now.getDate() - 1);
|
||||
|
||||
previous.push({
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
}
|
||||
previous.reverse();
|
||||
|
||||
return {
|
||||
previous,
|
||||
shown,
|
||||
next,
|
||||
current: currentDayMonthYear(),
|
||||
};
|
||||
}
|
||||
|
||||
export function calendarEventSorter(a: CalendarEvent, b: CalendarEvent) {
|
||||
return b.normalizedTeamCount - a.normalizedTeamCount;
|
||||
}
|
||||
|
||||
216
app/features/calendar/components/FiltersDialog.tsx
Normal file
216
app/features/calendar/components/FiltersDialog.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useFetcher, useSearchParams } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { InputFormField } from "~/components/form/InputFormField";
|
||||
import { InputGroupFormField } from "~/components/form/InputGroupFormField";
|
||||
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
|
||||
import { ToggleFormField } from "~/components/form/ToggleFormField";
|
||||
import { FilterFilledIcon } from "~/components/icons/FilterFilled";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas";
|
||||
import type { CalendarFilters } from "~/features/calendar/calendar-types";
|
||||
import { TagsFormField } from "~/features/calendar/components/TagsFormField";
|
||||
|
||||
export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SendouButton
|
||||
size="small"
|
||||
icon={<FilterFilledIcon />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
data-testid="filter-events-button"
|
||||
>
|
||||
{t("calendar:filter.button")}
|
||||
</SendouButton>
|
||||
<SendouDialog
|
||||
heading={t("calendar:filter.heading")}
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<FiltersForm
|
||||
filters={filters}
|
||||
closeDialog={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</SendouDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TAGS_TO_OMIT: Array<CalendarEventTag> = [
|
||||
"CARDS",
|
||||
"SR",
|
||||
"S1",
|
||||
"S2",
|
||||
"SZ",
|
||||
"TW",
|
||||
"ONES",
|
||||
"DUOS",
|
||||
"TRIOS",
|
||||
] as const;
|
||||
|
||||
function FiltersForm({
|
||||
filters,
|
||||
closeDialog,
|
||||
}: { filters: CalendarFilters; closeDialog: () => void }) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["game-misc", "calendar"]);
|
||||
const methods = useForm({
|
||||
resolver: zodResolver(calendarFiltersFormSchema),
|
||||
defaultValues: filters,
|
||||
});
|
||||
const fetcher = useFetcher<any>();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const filtersToSearchParams = (newFilters: CalendarFilters) => {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("filters", JSON.stringify(newFilters));
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const onApply = React.useCallback(
|
||||
methods.handleSubmit((values) => {
|
||||
filtersToSearchParams(values);
|
||||
closeDialog();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const onApplyAndPersist = React.useCallback(
|
||||
methods.handleSubmit((values) =>
|
||||
fetcher.submit(values, { method: "post", encType: "application/json" }),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<fetcher.Form
|
||||
className="stack md-plus items-start"
|
||||
onSubmit={onApplyAndPersist}
|
||||
>
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="checkbox"
|
||||
label={t("calendar:filter.modes")}
|
||||
name={"modes" as const}
|
||||
values={[
|
||||
{ label: t("game-misc:MODE_LONG_TW"), value: "TW" },
|
||||
{ label: t("game-misc:MODE_LONG_SZ"), value: "SZ" },
|
||||
{ label: t("game-misc:MODE_LONG_TC"), value: "TC" },
|
||||
{ label: t("game-misc:MODE_LONG_RM"), value: "RM" },
|
||||
{ label: t("game-misc:MODE_LONG_CB"), value: "CB" },
|
||||
{ label: t("game-misc:MODE_LONG_SR"), value: "SR" },
|
||||
{ label: t("game-misc:MODE_LONG_TB"), value: "TB" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ToggleFormField<CalendarFilters>
|
||||
label={t("calendar:filter.exactModes")}
|
||||
name={"modesExact" as const}
|
||||
bottomText={t("calendar:filter.exactModesBottom")}
|
||||
/>
|
||||
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="checkbox"
|
||||
label={t("calendar:filter.games")}
|
||||
name={"games" as const}
|
||||
values={[
|
||||
{ label: t("game-misc:GAME_S1"), value: "S1" },
|
||||
{ label: t("game-misc:GAME_S2"), value: "S2" },
|
||||
{ label: t("game-misc:GAME_S3"), value: "S3" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="checkbox"
|
||||
label={t("calendar:filter.vs")}
|
||||
name={"preferredVersus" as const}
|
||||
values={[
|
||||
{ label: "4v4", value: "4v4" },
|
||||
{ label: "3v3", value: "3v3" },
|
||||
{ label: "2v2", value: "2v2" },
|
||||
{ label: "1v1", value: "1v1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="radio"
|
||||
label={t("calendar:filter.startTime")}
|
||||
name={"preferredStartTime" as const}
|
||||
values={[
|
||||
{ label: t("calendar:filter.startTime.any"), value: "ANY" },
|
||||
{ label: t("calendar:filter.startTime.eu"), value: "EU" },
|
||||
{ label: t("calendar:filter.startTime.na"), value: "NA" },
|
||||
{ label: t("calendar:filter.startTime.au"), value: "AU" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<TagsFormField<CalendarFilters>
|
||||
label={t("calendar:filter.tagsIncluded")}
|
||||
name={"tagsIncluded" as const}
|
||||
tagsToOmit={TAGS_TO_OMIT}
|
||||
/>
|
||||
|
||||
<TagsFormField<CalendarFilters>
|
||||
label={t("calendar:filter.tagsExcluded")}
|
||||
name={"tagsExcluded" as const}
|
||||
tagsToOmit={TAGS_TO_OMIT}
|
||||
/>
|
||||
|
||||
<ToggleFormField<CalendarFilters>
|
||||
label={t("calendar:filter.isSendou")}
|
||||
name={"isSendou" as const}
|
||||
/>
|
||||
|
||||
<ToggleFormField<CalendarFilters>
|
||||
label={t("calendar:filter.isRanked")}
|
||||
name={"isRanked" as const}
|
||||
/>
|
||||
|
||||
<InputFormField<CalendarFilters>
|
||||
label={t("calendar:filter.minTeamCount")}
|
||||
type="number"
|
||||
name={"minTeamCount" as const}
|
||||
/>
|
||||
|
||||
<TextArrayFormField<CalendarFilters>
|
||||
label={t("calendar:filter.orgsIncluded")}
|
||||
name={"orgsIncluded" as const}
|
||||
/>
|
||||
|
||||
<TextArrayFormField<CalendarFilters>
|
||||
label={t("calendar:filter.orgsExcluded")}
|
||||
name={"orgsExcluded" as const}
|
||||
/>
|
||||
|
||||
<TextArrayFormField<CalendarFilters>
|
||||
label={t("calendar:filter.authorIdsExcluded")}
|
||||
name={"authorIdsExcluded" as const}
|
||||
bottomText={t("calendar:filter.authorIdsExcludedBottom")}
|
||||
/>
|
||||
|
||||
<div className="stack horizontal md justify-center mt-6 w-full">
|
||||
<SendouButton onPress={() => onApply()}>
|
||||
{t("calendar:filter.apply")}
|
||||
</SendouButton>
|
||||
{user ? (
|
||||
<SubmitButton variant="outlined" state={fetcher.state}>
|
||||
{t("calendar:filter.applyAndDefault")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,35 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { Button } from "~/components/Button";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import type * as CalendarRepository from "../CalendarRepository.server";
|
||||
import { tags as allTags } from "../calendar-constants";
|
||||
|
||||
export function Tags({
|
||||
tags,
|
||||
badges,
|
||||
onDelete,
|
||||
tournamentRankedStatus,
|
||||
small = false,
|
||||
centered = false,
|
||||
}: {
|
||||
tags: Array<CalendarEventTag>;
|
||||
badges?: CalendarRepository.FindAllBetweenTwoTimestampsItem["badgePrizes"];
|
||||
tournamentRankedStatus?: "RANKED" | "UNRANKED";
|
||||
small?: boolean;
|
||||
centered?: boolean;
|
||||
|
||||
/** Called when tag delete button clicked. If undefined delete buttons won't be shown. */
|
||||
onDelete?: (tag: CalendarEventTag) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (tags.length === 0 && !tournamentRankedStatus) return null;
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="calendar__event__tags">
|
||||
{tournamentRankedStatus === "RANKED" ? (
|
||||
<li className="calendar__event__ranked-tag">Ranked</li>
|
||||
) : null}
|
||||
{tournamentRankedStatus === "UNRANKED" ? (
|
||||
<li className="calendar__event__unranked-tag">Unranked</li>
|
||||
) : null}
|
||||
<ul className={clsx("calendar__event__tags", { small, centered })}>
|
||||
{tags.map((tag) => (
|
||||
<React.Fragment key={tag}>
|
||||
<li
|
||||
style={{ backgroundColor: allTags[tag].color }}
|
||||
className={clsx("calendar__event__tag", {
|
||||
"calendar__event__badge-tag": tag === "BADGE",
|
||||
})}
|
||||
className="calendar__event__tag"
|
||||
>
|
||||
{t(`tag.name.${tag}`)}
|
||||
{onDelete && (
|
||||
@@ -52,13 +42,6 @@ export function Tags({
|
||||
size="tiny"
|
||||
/>
|
||||
)}
|
||||
{tag === "BADGE" && badges && (
|
||||
<div className="calendar__event__tag-badges">
|
||||
{badges.map((badge) => (
|
||||
<Badge key={badge.id} badge={badge} size={20} isAnimated />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
34
app/features/calendar/components/TagsFormField.module.css
Normal file
34
app/features/calendar/components/TagsFormField.module.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.tagGroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.tag {
|
||||
border: 2px solid var(--tag-color);
|
||||
border-radius: var(--rounded-sm);
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--semi-bold);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.tag[data-selected] {
|
||||
background-color: var(--tag-color) !important;
|
||||
color: var(--black-text);
|
||||
}
|
||||
|
||||
.tag[data-focus-visible] {
|
||||
outline: 2px solid var(--tag-color);
|
||||
outline-offset: 1px;
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.tag[data-hovered] {
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
101
app/features/calendar/components/TagsFormField.tsx
Normal file
101
app/features/calendar/components/TagsFormField.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
import { Tag, TagGroup, TagList } from "react-aria-components";
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import { CALENDAR_EVENT } from "~/features/calendar/calendar-constants";
|
||||
import { tags as allTags } from "../calendar-constants";
|
||||
|
||||
import styles from "./TagsFormField.module.css";
|
||||
|
||||
export function TagsFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
tagsToOmit,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
tagsToOmit?: Array<CalendarEventTag>;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={name}
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<SelectableTags
|
||||
selectedTags={value}
|
||||
onSelectionChange={onChange}
|
||||
tagsToOmit={tagsToOmit}
|
||||
ref={ref}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SelectableTags = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
selectedTags: Array<CalendarEventTag>;
|
||||
tagsToOmit?: Array<CalendarEventTag>;
|
||||
onSelectionChange: (selectedTags: Array<CalendarEventTag>) => void;
|
||||
}
|
||||
>(({ selectedTags, tagsToOmit, onSelectionChange }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableTags = tagsToOmit
|
||||
? CALENDAR_EVENT.TAGS.filter((tag) => !tagsToOmit?.includes(tag))
|
||||
: CALENDAR_EVENT.TAGS;
|
||||
|
||||
return (
|
||||
<TagGroup
|
||||
className={styles.tagGroup}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedTags}
|
||||
onSelectionChange={(newSelection) =>
|
||||
onSelectionChange(Array.from(newSelection) as CalendarEventTag[])
|
||||
}
|
||||
aria-label="Select tags"
|
||||
ref={ref}
|
||||
>
|
||||
<TagList className={styles.tagList}>
|
||||
{availableTags.map((tag) => {
|
||||
return (
|
||||
<Tag
|
||||
key={tag}
|
||||
id={tag}
|
||||
className={styles.tag}
|
||||
style={{ "--tag-color": allTags[tag].color }}
|
||||
>
|
||||
{t(`tag.name.${tag}`)}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</TagList>
|
||||
</TagGroup>
|
||||
);
|
||||
});
|
||||
153
app/features/calendar/components/TournamentCard.module.css
Normal file
153
app/features/calendar/components/TournamentCard.module.css
Normal file
@@ -0,0 +1,153 @@
|
||||
.container {
|
||||
min-width: var(--card-width);
|
||||
max-width: var(--card-width);
|
||||
height: calc(var(--card-height) + 26px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
--tournament-card-icon-size: 13px;
|
||||
}
|
||||
|
||||
.containerTall {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-lightest);
|
||||
border-radius: var(--rounded);
|
||||
color: var(--text);
|
||||
padding: var(--s-1) var(--s-2) var(--s-2) var(--s-2);
|
||||
height: 100%;
|
||||
min-width: var(--card-width);
|
||||
max-width: var(--card-width);
|
||||
}
|
||||
|
||||
.card:hover .avatarImg {
|
||||
outline: 6px solid var(--theme-transparent);
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
background-color: var(--bg);
|
||||
padding: var(--s-1-5);
|
||||
border-radius: 100%;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.org {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--fonts-xxs);
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text-lighter);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.name {
|
||||
text-align: center;
|
||||
font-weight: var(--semi-bold);
|
||||
font-size: var(--fonts-sm);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 225px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.teamCount {
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--bold);
|
||||
background-color: var(--bg-lightest);
|
||||
border-radius: var(--rounded-sm);
|
||||
width: max-content;
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.teamCount svg {
|
||||
width: var(--tournament-card-icon-size);
|
||||
}
|
||||
|
||||
.modesPillContainer {
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
|
||||
.modesPill {
|
||||
background-color: var(--bg-lightest);
|
||||
border-radius: var(--rounded-sm);
|
||||
width: max-content;
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.pillsContainer {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
justify-content: end;
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--bold);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.pillRanked {
|
||||
background-color: var(--theme-info-transparent);
|
||||
color: var(--theme-info);
|
||||
}
|
||||
|
||||
.pillRanked svg {
|
||||
width: var(--tournament-card-icon-size);
|
||||
}
|
||||
|
||||
.firstPlacers {
|
||||
margin-inline: auto;
|
||||
margin-top: var(--s-5);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.firstPlacersTeamName {
|
||||
max-width: 150px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badgesContainer {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
justify-content: center;
|
||||
background-color: black;
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-1);
|
||||
}
|
||||
|
||||
.badgeNavIcon {
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.badgeDisplay {
|
||||
min-width: initial;
|
||||
min-height: initial;
|
||||
}
|
||||
|
||||
.badgePill {
|
||||
background-color: var(--bg-lightest);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
}
|
||||
207
app/features/calendar/components/TournamentCard.tsx
Normal file
207
app/features/calendar/components/TournamentCard.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { Image, ModeImage } from "~/components/Image";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { TrophyIcon } from "~/components/icons/Trophy";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { navIconUrl, userSubmittedImage } from "~/utils/urls";
|
||||
import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types";
|
||||
import { Tags } from "./Tags";
|
||||
import styles from "./TournamentCard.module.css";
|
||||
|
||||
export function TournamentCard({
|
||||
tournament,
|
||||
className,
|
||||
}: {
|
||||
tournament: CalendarEvent | ShowcaseCalendarEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { i18n } = useTranslation(["front", "common"]);
|
||||
|
||||
const isShowcase = tournament.type === "showcase";
|
||||
const isCalendar = tournament.type === "calendar";
|
||||
const isHostedOnSendouInk = typeof tournament.isRanked === "boolean";
|
||||
|
||||
const time = () => {
|
||||
if (!isShowcase) return null;
|
||||
if (!isMounted) return "Placeholder";
|
||||
|
||||
const date = databaseTimestampToDate(tournament.startTime);
|
||||
return date.toLocaleString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
weekday: "short",
|
||||
minute: date.getMinutes() !== 0 ? "numeric" : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(className, styles.container, {
|
||||
[styles.containerTall]: isShowcase && tournament.firstPlacer,
|
||||
})}
|
||||
data-testid="tournament-card"
|
||||
>
|
||||
<Link to={tournament.url} className={styles.card}>
|
||||
<div className="stack horizontal justify-between">
|
||||
{isHostedOnSendouInk ? (
|
||||
<div className={styles.imgContainer}>
|
||||
<img
|
||||
src={
|
||||
tournament.logoUrl
|
||||
? userSubmittedImage(tournament.logoUrl)
|
||||
: HACKY_resolvePicture(tournament)
|
||||
}
|
||||
width={32}
|
||||
height={32}
|
||||
className={styles.avatarImg}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{tournament.organization ? (
|
||||
<div className={styles.org}>{tournament.organization.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.name, {
|
||||
"mt-3": !isHostedOnSendouInk,
|
||||
"mt-1": isHostedOnSendouInk,
|
||||
})}
|
||||
>
|
||||
{tournament.name}{" "}
|
||||
{isShowcase ? (
|
||||
<time
|
||||
className={clsx(styles.time, {
|
||||
invisible: !isMounted,
|
||||
})}
|
||||
dateTime={databaseTimestampToDate(
|
||||
tournament.startTime,
|
||||
).toISOString()}
|
||||
>
|
||||
{time()}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
{isCalendar ? (
|
||||
<div className="stack sm items-center my-2">
|
||||
<Tags tags={tournament.tags} small centered />
|
||||
</div>
|
||||
) : null}
|
||||
{isShowcase && tournament.firstPlacer ? (
|
||||
<TournamentFirstPlacers firstPlacer={tournament.firstPlacer} />
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
{tournament.modes ? <ModesPill modes={tournament.modes} /> : null}
|
||||
{isHostedOnSendouInk ? (
|
||||
<div className={styles.pillsContainer}>
|
||||
{tournament.isRanked ? (
|
||||
<div className={clsx(styles.pill, styles.pillRanked)}>
|
||||
<TrophyIcon title="Ranked (impacts this seasons SP)" />
|
||||
</div>
|
||||
) : null}
|
||||
{isCalendar && tournament.badges && tournament.badges.length > 0 ? (
|
||||
<BadgePrizesPill badges={tournament.badges} />
|
||||
) : null}
|
||||
<div className={styles.teamCount}>
|
||||
<UsersIcon /> {tournament.teamsCount}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentFirstPlacers({
|
||||
firstPlacer,
|
||||
}: {
|
||||
firstPlacer: NonNullable<ShowcaseCalendarEvent["firstPlacer"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["front"]);
|
||||
|
||||
return (
|
||||
<div className={styles.firstPlacers}>
|
||||
<div className="stack xs horizontal items-center text-xs">
|
||||
{firstPlacer.logoUrl ? (
|
||||
<img
|
||||
src={userSubmittedImage(firstPlacer.logoUrl)}
|
||||
alt=""
|
||||
width={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : null}{" "}
|
||||
<div className="stack items-start">
|
||||
<span className={styles.firstPlacersTeamName}>
|
||||
{firstPlacer.teamName}
|
||||
</span>
|
||||
<div className="text-xxxs text-lighter font-bold text-uppercase">
|
||||
{t("front:showcase.card.winner")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xxs stack items-start mt-1">
|
||||
{firstPlacer.members.map((member) => (
|
||||
<div key={member.id} className="stack horizontal xs items-center">
|
||||
{member.country ? <Flag tiny countryCode={member.country} /> : null}
|
||||
{member.username}{" "}
|
||||
</div>
|
||||
))}
|
||||
{firstPlacer.notShownMembersCount > 0 ? (
|
||||
<div className="font-bold text-lighter">
|
||||
+{firstPlacer.notShownMembersCount}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModesPill({ modes }: { modes: NonNullable<CalendarEvent["modes"]> }) {
|
||||
const size = 16;
|
||||
|
||||
return (
|
||||
<div className={styles.modesPillContainer}>
|
||||
<div className={styles.modesPill}>
|
||||
{modes.map((mode) => (
|
||||
<ModeImage key={mode} mode={mode} size={size} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgePrizesPill({
|
||||
badges,
|
||||
}: { badges: NonNullable<CalendarEvent["badges"]> }) {
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal" className={styles.badgePill}>
|
||||
<Image
|
||||
size={16}
|
||||
path={navIconUrl("badges")}
|
||||
alt="Badge prizes"
|
||||
className={styles.badgeNavIcon}
|
||||
/>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<BadgeDisplay
|
||||
badges={badges}
|
||||
showText={false}
|
||||
className={styles.badgeDisplay}
|
||||
/>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
275
app/features/calendar/core/CalendarEvent.test.ts
Normal file
275
app/features/calendar/core/CalendarEvent.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CalendarEvent as CalendarEventType,
|
||||
CalendarFilters,
|
||||
} from "../calendar-types";
|
||||
import * as CalendarEvent from "./CalendarEvent";
|
||||
|
||||
function makeEvent(
|
||||
overrides: Partial<CalendarEventType> = {},
|
||||
): CalendarEventType {
|
||||
return {
|
||||
at: new Date().getTime(),
|
||||
id: 1,
|
||||
isRanked: null,
|
||||
tags: [],
|
||||
modes: ["SZ"],
|
||||
teamsCount: 2,
|
||||
organization: null,
|
||||
authorId: 1,
|
||||
type: "calendar",
|
||||
normalizedTeamCount: 0,
|
||||
badges: [],
|
||||
logoUrl: null,
|
||||
name: "",
|
||||
url: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CalendarEvent.applyFilters", () => {
|
||||
it("returns all events as shown with default filters", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [makeEvent({ id: 1 }), makeEvent({ id: 2 })],
|
||||
},
|
||||
];
|
||||
const result = CalendarEvent.applyFilters(
|
||||
events,
|
||||
CalendarEvent.defaultFilters(),
|
||||
);
|
||||
expect(result[0].events.shown).toHaveLength(2);
|
||||
expect(result[0].events.hidden).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters by isRanked", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, isRanked: true }),
|
||||
makeEvent({ id: 2, isRanked: false }),
|
||||
makeEvent({ id: 3, isRanked: null }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
isRanked: true,
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown).toHaveLength(1);
|
||||
expect(result[0].events.shown[0].id).toBe(1);
|
||||
expect(result[0].events.hidden).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by tagsIncluded", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, tags: ["MONEY", "ART"] }),
|
||||
makeEvent({ id: 2, tags: ["LOW"] }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
tagsIncluded: ["MONEY"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown).toHaveLength(1);
|
||||
expect(result[0].events.shown[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it("filters by tagsExcluded", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, tags: ["MONEY"] }),
|
||||
makeEvent({ id: 2, tags: ["ART"] }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
tagsExcluded: ["MONEY"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown).toHaveLength(1);
|
||||
expect(result[0].events.shown[0].id).toBe(2);
|
||||
});
|
||||
|
||||
it("filters by games", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, tags: ["S1"] }),
|
||||
makeEvent({ id: 2, tags: ["S2"] }),
|
||||
makeEvent({ id: 3, tags: [] }), // S3
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
games: ["S1"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown).toHaveLength(1);
|
||||
expect(result[0].events.shown[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it("filters by preferredVersus", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, tags: ["ONES"] }),
|
||||
makeEvent({ id: 2, tags: ["DUOS"] }),
|
||||
makeEvent({ id: 3, tags: ["TRIOS"] }),
|
||||
makeEvent({ id: 4, tags: [] }), // 4v4
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
preferredVersus: ["1v1", "2v2"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("filters by modes (not exact)", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, modes: ["SZ"] }),
|
||||
makeEvent({ id: 2, modes: ["TC"] }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
modes: ["SZ"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("filters by modes (exact)", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, modes: ["SZ", "TC"] }),
|
||||
makeEvent({ id: 2, modes: ["SZ"] }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
modes: ["SZ"],
|
||||
modesExact: true,
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("filters by minTeamCount", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, teamsCount: 2 }),
|
||||
makeEvent({ id: 2, teamsCount: 4 }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
minTeamCount: 3,
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("filters by orgsIncluded", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, organization: { name: "OrgA" } as any }),
|
||||
makeEvent({ id: 2, organization: { name: "OrgB" } as any }),
|
||||
makeEvent({ id: 3, organization: undefined }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
orgsIncluded: ["OrgA"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("filters by orgsExcluded", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, organization: { name: "OrgA" } as any }),
|
||||
makeEvent({ id: 2, organization: { name: "OrgB" } as any }),
|
||||
makeEvent({ id: 3, organization: undefined }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
orgsExcluded: ["OrgA"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("filters by authorIdsExcluded", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, authorId: 1 }),
|
||||
makeEvent({ id: 2, authorId: 2 }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
authorIdsExcluded: [1],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("filters by combining two different filters", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, tags: ["MONEY"], isRanked: true }),
|
||||
makeEvent({ id: 2, tags: ["LOW"], isRanked: false }),
|
||||
makeEvent({ id: 3, tags: ["MONEY"], isRanked: false }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
tagsIncluded: ["MONEY"],
|
||||
isRanked: true,
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
303
app/features/calendar/core/CalendarEvent.ts
Normal file
303
app/features/calendar/core/CalendarEvent.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import { isWeekend } from "date-fns";
|
||||
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
|
||||
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
|
||||
import { assertType } from "~/utils/types";
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarFilters,
|
||||
GroupedCalendarEvents,
|
||||
} from "../calendar-types";
|
||||
|
||||
const FILTERS_KEYS = [
|
||||
"preferredStartTime",
|
||||
"tagsIncluded",
|
||||
"tagsExcluded",
|
||||
"isSendou",
|
||||
"isRanked",
|
||||
"games",
|
||||
"orgsIncluded",
|
||||
"orgsExcluded",
|
||||
"authorIdsExcluded",
|
||||
"modes",
|
||||
"modesExact",
|
||||
"minTeamCount",
|
||||
"preferredVersus",
|
||||
] as const;
|
||||
|
||||
assertType<(typeof FILTERS_KEYS)[number], keyof CalendarFilters>();
|
||||
assertType<keyof CalendarFilters, (typeof FILTERS_KEYS)[number]>();
|
||||
|
||||
/**
|
||||
* Returns the default empty state of filter settings for calendar events.
|
||||
*/
|
||||
export function defaultFilters(): CalendarFilters {
|
||||
return {
|
||||
preferredStartTime: "ANY",
|
||||
tagsIncluded: [],
|
||||
tagsExcluded: [],
|
||||
isSendou: false,
|
||||
isRanked: false,
|
||||
games: [...gamesShort],
|
||||
modes: [...modesShortWithSpecial],
|
||||
preferredVersus: [...versusShort],
|
||||
modesExact: false,
|
||||
orgsIncluded: [],
|
||||
orgsExcluded: [],
|
||||
authorIdsExcluded: [],
|
||||
minTeamCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultFiltersString = filtersToString(defaultFilters());
|
||||
|
||||
/**
|
||||
* Determines whether the provided calendar filters match the default filter settings.
|
||||
*/
|
||||
export function isDefaultFilters(filters: CalendarFilters): boolean {
|
||||
return filtersToString(filters) === defaultFiltersString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the given calendar filters object into a string representation to be used as e.g. React key.
|
||||
*/
|
||||
export function filtersToString(filters: CalendarFilters): string {
|
||||
let result = "";
|
||||
|
||||
for (const key of FILTERS_KEYS) {
|
||||
result += `${key}-${filters[key]};`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the provided calendar filters to a list of grouped calendar events.
|
||||
*
|
||||
* Returns a new array where each group contains separated lists of shown and hidden events. Event is shown if it matches all set filters.
|
||||
*/
|
||||
export function applyFilters(
|
||||
events: {
|
||||
at: number;
|
||||
events: Array<CalendarEvent>;
|
||||
}[],
|
||||
filters: CalendarFilters,
|
||||
): Array<GroupedCalendarEvents> {
|
||||
return events.map((eventTime) => {
|
||||
const shown: CalendarEvent[] = [];
|
||||
const hidden: CalendarEvent[] = [];
|
||||
|
||||
for (const calendarEvent of eventTime.events) {
|
||||
let isHidden = false;
|
||||
for (const key of FILTERS_KEYS) {
|
||||
if (!matchesFilter(calendarEvent, eventTime.at, key, filters)) {
|
||||
isHidden = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isHidden) {
|
||||
hidden.push(calendarEvent);
|
||||
continue;
|
||||
}
|
||||
|
||||
shown.push(calendarEvent);
|
||||
}
|
||||
|
||||
return {
|
||||
at: eventTime.at,
|
||||
events: {
|
||||
shown,
|
||||
hidden,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function matchesFilter(
|
||||
event: CalendarEvent,
|
||||
startTime: number,
|
||||
key: keyof CalendarFilters,
|
||||
filters: CalendarFilters,
|
||||
): boolean {
|
||||
switch (key) {
|
||||
case "preferredStartTime": {
|
||||
const preferredStartTime = filters[key];
|
||||
if (preferredStartTime === "ANY") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const timeZone =
|
||||
preferredStartTime === "EU"
|
||||
? "Europe/Paris"
|
||||
: preferredStartTime === "NA"
|
||||
? "America/Winnipeg"
|
||||
: "Australia/Perth";
|
||||
|
||||
const tzDate = new TZDate(startTime, timeZone);
|
||||
|
||||
if (isWeekend(tzDate)) {
|
||||
return tzDate.getHours() >= 10 && tzDate.getHours() <= 24;
|
||||
}
|
||||
|
||||
return tzDate.getHours() >= 16 && tzDate.getHours() <= 23;
|
||||
}
|
||||
case "isSendou": {
|
||||
if (filters[key] !== true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return event.isRanked !== null;
|
||||
}
|
||||
case "isRanked": {
|
||||
if (filters[key] !== true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return event.isRanked === true;
|
||||
}
|
||||
case "tagsIncluded": {
|
||||
const tags = filters[key];
|
||||
if (tags.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return event.tags.some((tag) => tags.includes(tag));
|
||||
}
|
||||
case "tagsExcluded": {
|
||||
const tags = filters[key];
|
||||
if (tags.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !event.tags.some((tag) => tags.includes(tag));
|
||||
}
|
||||
case "games": {
|
||||
const games = filters[key];
|
||||
if (!games.length || games.length === gamesShort.length) {
|
||||
return true;
|
||||
}
|
||||
const isSplatoonOne = event.tags.includes("S1");
|
||||
const isSplatoonTwo = event.tags.includes("S2");
|
||||
const isSplatoonThree = !isSplatoonOne && !isSplatoonTwo;
|
||||
|
||||
for (const game of games) {
|
||||
if (game === "S1" && isSplatoonOne) {
|
||||
return true;
|
||||
}
|
||||
if (game === "S2" && isSplatoonTwo) {
|
||||
return true;
|
||||
}
|
||||
if (game === "S3" && isSplatoonThree) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
case "preferredVersus": {
|
||||
const preferredVersus = filters[key];
|
||||
if (
|
||||
!preferredVersus.length ||
|
||||
preferredVersus.length === versusShort.length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const eventType = event.tags.find(
|
||||
(tag) => tag === "ONES" || tag === "DUOS" || tag === "TRIOS",
|
||||
);
|
||||
|
||||
if (eventType === "ONES") {
|
||||
return preferredVersus.includes("1v1");
|
||||
}
|
||||
|
||||
if (eventType === "DUOS") {
|
||||
return preferredVersus.includes("2v2");
|
||||
}
|
||||
|
||||
if (eventType === "TRIOS") {
|
||||
return preferredVersus.includes("3v3");
|
||||
}
|
||||
|
||||
return preferredVersus.includes("4v4");
|
||||
}
|
||||
case "modes": {
|
||||
const modes = filters[key];
|
||||
if (!modes.length || modes.length === modesShortWithSpecial.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.modes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.modesExact) {
|
||||
return (
|
||||
event.modes.length === modes.length &&
|
||||
event.modes.every((mode) => modes.includes(mode))
|
||||
);
|
||||
}
|
||||
|
||||
return event.modes.some((mode) => modes.includes(mode));
|
||||
}
|
||||
case "modesExact": {
|
||||
// handled in the modes filter
|
||||
return true;
|
||||
}
|
||||
case "minTeamCount": {
|
||||
const minTeamCount = filters[key];
|
||||
if (minTeamCount === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return event.teamsCount >= minTeamCount;
|
||||
}
|
||||
case "orgsIncluded": {
|
||||
const orgsIncluded = filters[key];
|
||||
if (orgsIncluded.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const org = event.organization;
|
||||
|
||||
if (!org) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return orgsIncluded.some((orgName) =>
|
||||
orgNameMatches({ orgName: org.name, value: orgName }),
|
||||
);
|
||||
}
|
||||
case "orgsExcluded": {
|
||||
const orgsExcluded = filters[key];
|
||||
if (orgsExcluded.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const org = event.organization;
|
||||
|
||||
if (!org) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !orgsExcluded.some((orgName) =>
|
||||
orgNameMatches({ orgName: org.name, value: orgName }),
|
||||
);
|
||||
}
|
||||
case "authorIdsExcluded": {
|
||||
const authorIds = filters[key];
|
||||
if (authorIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !authorIds.some((id) => event.authorId === id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function orgNameMatches({
|
||||
orgName,
|
||||
value,
|
||||
}: { orgName: string; value: string }) {
|
||||
return orgName.trim().toLowerCase() === value.trim().toLowerCase();
|
||||
}
|
||||
51
app/features/calendar/core/ICal.server.ts
Normal file
51
app/features/calendar/core/ICal.server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as ics from "ics";
|
||||
import type { CalendarEvent } from "~/features/calendar/calendar-types";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { SENDOU_INK_BASE_URL } from "~/utils/urls";
|
||||
|
||||
export async function getICalendar(events: Array<CalendarEvent>) {
|
||||
// ical doesnt allow calendars with no events
|
||||
if (events.length === 0) {
|
||||
logger.warn("Could not construct ical feed, no events within time period");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { error, value } = eventsAsICal(events);
|
||||
|
||||
if (error) {
|
||||
logger.error(`Error constructing ical feed: ${error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
logger.error("Error constructing ical feed: no value returned");
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function eventsAsICal(events: Array<CalendarEvent>): ics.ReturnObject {
|
||||
return ics.createEvents(events.map(eventInfoAsICalEvent));
|
||||
}
|
||||
|
||||
function eventInfoAsICalEvent(event: CalendarEvent): ics.EventAttributes {
|
||||
const startDate = new Date(event.at);
|
||||
const eventLink = `${SENDOU_INK_BASE_URL}/${event.url}`;
|
||||
|
||||
return {
|
||||
title: event.name,
|
||||
start: [
|
||||
startDate.getUTCFullYear(),
|
||||
startDate.getUTCMonth() + 1,
|
||||
startDate.getUTCDate(),
|
||||
startDate.getUTCHours(),
|
||||
startDate.getUTCMinutes(),
|
||||
],
|
||||
startInputType: "utc",
|
||||
duration: { hours: 3 }, // arbitrary length
|
||||
url: eventLink,
|
||||
categories: event.tags,
|
||||
productId: "sendou.ink/calendar",
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import * as ics from "ics";
|
||||
import type { PersistedCalendarEventTag } from "~/db/tables";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { CALENDAR_PAGE, SENDOU_INK_BASE_URL } from "~/utils/urls";
|
||||
import {
|
||||
type FindAllBetweenTwoTimestampsItem,
|
||||
findAllBetweenTwoTimestamps,
|
||||
} from "../CalendarRepository.server";
|
||||
|
||||
export async function getICalendar(
|
||||
{
|
||||
tagsFilter,
|
||||
tournamentsFilter,
|
||||
}: {
|
||||
tagsFilter: Array<PersistedCalendarEventTag>;
|
||||
tournamentsFilter: boolean;
|
||||
} = { tagsFilter: [], tournamentsFilter: false },
|
||||
): Promise<string | null> {
|
||||
const startTime = new Date();
|
||||
const endTime = new Date(startTime);
|
||||
|
||||
// get all events over the next month, might be good to make this an parameter in the future
|
||||
endTime.setDate(startTime.getDate() + 30);
|
||||
|
||||
// handle timezone mismatch between server and client
|
||||
startTime.setHours(startTime.getHours() - 12);
|
||||
endTime.setHours(endTime.getHours() + 12);
|
||||
|
||||
const events = await findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy: tagsFilter,
|
||||
onlyTournaments: tournamentsFilter,
|
||||
});
|
||||
|
||||
// ical doesnt allow calendars with no events
|
||||
if (events.length === 0) {
|
||||
logger.warn("Could not construct ical feed, no events within time period");
|
||||
return null;
|
||||
}
|
||||
|
||||
const { error, value } = eventsAsICal(events);
|
||||
|
||||
if (error) {
|
||||
logger.error(`Error constructing ical feed: ${error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as string;
|
||||
}
|
||||
|
||||
export function eventsAsICal(
|
||||
events: Array<FindAllBetweenTwoTimestampsItem>,
|
||||
): ics.ReturnObject {
|
||||
return ics.createEvents(events.map(eventInfoAsICalEvent));
|
||||
}
|
||||
|
||||
export function eventInfoAsICalEvent(
|
||||
event: FindAllBetweenTwoTimestampsItem,
|
||||
): ics.EventAttributes {
|
||||
const startDate = databaseTimestampToDate(event.startTime);
|
||||
const eventLink = `${SENDOU_INK_BASE_URL}${CALENDAR_PAGE}/${event.eventId}`;
|
||||
const tags = event.tags;
|
||||
|
||||
return {
|
||||
title: event.name,
|
||||
start: [
|
||||
startDate.getUTCFullYear(),
|
||||
startDate.getUTCMonth() + 1,
|
||||
startDate.getUTCDate(),
|
||||
startDate.getUTCHours(),
|
||||
startDate.getUTCMinutes(),
|
||||
],
|
||||
startInputType: "utc",
|
||||
duration: { hours: 3 }, // arbitrary length
|
||||
url: eventLink,
|
||||
categories: tags,
|
||||
productId: "sendou.ink/calendar",
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { notFoundIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server";
|
||||
import { reportWinnersParamsSchema } from "../calendar-schemas";
|
||||
import {
|
||||
notFoundIfFalsy,
|
||||
parseParams,
|
||||
unauthorizedIfFalsy,
|
||||
} from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { canReportCalendarEventWinners } from "../calendar-utils";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parsedParams = reportWinnersParamsSchema.parse(params);
|
||||
const user = await requireUserId(request);
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const params = parseParams({
|
||||
params: args.params,
|
||||
schema: idObject,
|
||||
});
|
||||
const user = await requireUserId(args.request);
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
await CalendarRepository.findById({ id: params.id }),
|
||||
);
|
||||
|
||||
unauthorizedIfFalsy(
|
||||
@@ -23,6 +30,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
return {
|
||||
name: event.name,
|
||||
participantCount: event.participantCount,
|
||||
winners: await CalendarRepository.findResultsByEventId(parsedParams.id),
|
||||
winners: await CalendarRepository.findResultsByEventId(params.id),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,14 +28,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
if (!event) return;
|
||||
|
||||
// special tags that are added automatically
|
||||
const tags = event?.tags?.filter((tag) => tag !== "BADGE");
|
||||
|
||||
if (!event?.tournamentId) return { ...event, tags, tournament: null };
|
||||
if (!event?.tournamentId) return { ...event, tournament: null };
|
||||
|
||||
return {
|
||||
...event,
|
||||
tags,
|
||||
tournament: await tournamentData({
|
||||
tournamentId: event.tournamentId,
|
||||
user,
|
||||
|
||||
@@ -1,105 +1,71 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { addMonths, subMonths } from "date-fns";
|
||||
import type { PersistedCalendarEventTag } from "~/db/tables";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import type { UserPreferences } from "~/db/tables";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
|
||||
import {
|
||||
dateToThisWeeksMonday,
|
||||
dateToThisWeeksSunday,
|
||||
dateToWeekNumber,
|
||||
weekNumberToDate,
|
||||
} from "~/utils/dates";
|
||||
calendarFiltersSearchParamsObject,
|
||||
calendarFiltersSearchParamsSchema,
|
||||
} from "~/features/calendar/calendar-schemas";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { parseSafeSearchParams, parseSearchParams } from "~/utils/remix.server";
|
||||
import { dayMonthYear } from "~/utils/zod";
|
||||
import * as CalendarRepository from "../CalendarRepository.server";
|
||||
import {
|
||||
loaderFilterSearchParamsSchema,
|
||||
loaderTournamentsOnlySearchParamsSchema,
|
||||
loaderWeekSearchParamsSchema,
|
||||
} from "../calendar-schemas";
|
||||
import { closeByWeeks } from "../calendar-utils";
|
||||
import * as CalendarEvent from "../core/CalendarEvent";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const url = new URL(request.url);
|
||||
export type CalendarLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
// separate from tags parse so they can fail independently
|
||||
const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({
|
||||
year: url.searchParams.get("year"),
|
||||
week: url.searchParams.get("week"),
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const user = await getUser(args.request);
|
||||
const parsed = parseSafeSearchParams({
|
||||
request: args.request,
|
||||
schema: dayMonthYear,
|
||||
});
|
||||
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
|
||||
tags: url.searchParams.get("tags"),
|
||||
|
||||
const date = parsed.success
|
||||
? new Date(
|
||||
Date.UTC(parsed.data.year, parsed.data.month, parsed.data.day),
|
||||
).getTime()
|
||||
: Date.now();
|
||||
|
||||
const twentyFourHoursAgo = date - 24 * 60 * 60 * 1000;
|
||||
const fiveDaysFromNow = date + DAYS_SHOWN_AT_A_TIME * 24 * 60 * 60 * 1000;
|
||||
|
||||
const events = await CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime: new Date(twentyFourHoursAgo),
|
||||
endTime: new Date(fiveDaysFromNow),
|
||||
});
|
||||
const parsedTournamentsOnlyParams =
|
||||
loaderTournamentsOnlySearchParamsSchema.safeParse({
|
||||
tournaments: url.searchParams.get("tournaments"),
|
||||
});
|
||||
|
||||
const mondayDate = dateToThisWeeksMonday(new Date());
|
||||
const sundayDate = dateToThisWeeksSunday(new Date());
|
||||
const currentWeek = dateToWeekNumber(mondayDate);
|
||||
|
||||
const displayedWeek = parsedWeekParams.success
|
||||
? parsedWeekParams.data.week
|
||||
: currentWeek;
|
||||
const displayedYear = parsedWeekParams.success
|
||||
? parsedWeekParams.data.year
|
||||
: currentWeek === 1 // handle first week of the year special case
|
||||
? sundayDate.getFullYear()
|
||||
: mondayDate.getFullYear();
|
||||
const tagsToFilterBy = parsedFilterParams.success
|
||||
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
|
||||
: [];
|
||||
const onlyTournaments = parsedTournamentsOnlyParams.success
|
||||
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
|
||||
: false;
|
||||
const filters = resolveFilters(args.request, user?.preferences);
|
||||
const filtered = CalendarEvent.applyFilters(events, filters);
|
||||
|
||||
return {
|
||||
currentWeek,
|
||||
displayedWeek,
|
||||
currentDay: new Date().getDay(),
|
||||
nearbyStartTimes: await CalendarRepository.startTimesOfRange({
|
||||
startTime: subMonths(
|
||||
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
|
||||
1,
|
||||
),
|
||||
endTime: addMonths(
|
||||
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
|
||||
1,
|
||||
),
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}),
|
||||
weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }),
|
||||
events: await fetchEventsOfWeek({
|
||||
week: displayedWeek,
|
||||
year: displayedYear,
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}),
|
||||
eventsToReport: user
|
||||
? await CalendarRepository.eventsToReport(user.id)
|
||||
: [],
|
||||
eventTimes: filtered,
|
||||
dateViewed: parsed.success ? parsed.data : undefined,
|
||||
filters,
|
||||
};
|
||||
};
|
||||
|
||||
function fetchEventsOfWeek(args: {
|
||||
week: number;
|
||||
year: number;
|
||||
tagsToFilterBy: PersistedCalendarEventTag[];
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
const startTime = weekNumberToDate(args);
|
||||
function resolveFilters(
|
||||
request: Request,
|
||||
preferences?: UserPreferences | null,
|
||||
) {
|
||||
const parsed = parseSearchParams({
|
||||
request,
|
||||
schema: calendarFiltersSearchParamsObject,
|
||||
}).filters;
|
||||
|
||||
const endTime = new Date(startTime);
|
||||
endTime.setDate(endTime.getDate() + 7);
|
||||
if (!CalendarEvent.isDefaultFilters(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// handle timezone mismatch between server and client
|
||||
startTime.setHours(startTime.getHours() - 12);
|
||||
endTime.setHours(endTime.getHours() + 12);
|
||||
if (preferences?.defaultCalendarFilters) {
|
||||
// make sure the saved values still match current reality
|
||||
const parsedDefault = calendarFiltersSearchParamsSchema.parse(
|
||||
preferences.defaultCalendarFilters,
|
||||
);
|
||||
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy: args.tagsToFilterBy,
|
||||
onlyTournaments: args.onlyTournaments,
|
||||
});
|
||||
return parsedDefault;
|
||||
}
|
||||
|
||||
return CalendarEvent.defaultFilters();
|
||||
}
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import type { PersistedCalendarEventTag } from "~/db/tables";
|
||||
import {
|
||||
loaderFilterSearchParamsSchema,
|
||||
loaderTournamentsOnlySearchParamsSchema,
|
||||
} from "../calendar-schemas";
|
||||
import * as ical from "../core/ical";
|
||||
import { parseSearchParams } from "~/utils/remix.server";
|
||||
import * as CalendarRepository from "../CalendarRepository.server";
|
||||
import { calendarFiltersSearchParamsObject } from "../calendar-schemas";
|
||||
import * as CalendarEvent from "../core/CalendarEvent";
|
||||
import * as ICal from "../core/ICal.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const filters = parseSearchParams({
|
||||
request,
|
||||
schema: calendarFiltersSearchParamsObject,
|
||||
}).filters;
|
||||
|
||||
// allows limiting calendar events to specific tags
|
||||
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
|
||||
tags: url.searchParams.get("tags"),
|
||||
const startTime = new Date();
|
||||
const endTime = new Date(startTime);
|
||||
|
||||
// get all events over the two weeks, might be good to make this an parameter in the future
|
||||
endTime.setDate(startTime.getDate() + 14);
|
||||
|
||||
// handle timezone mismatch between server and client
|
||||
startTime.setHours(startTime.getHours() - 12);
|
||||
endTime.setHours(endTime.getHours() + 12);
|
||||
|
||||
const events = await CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
const parsedTournamentsOnlyParams =
|
||||
loaderTournamentsOnlySearchParamsSchema.safeParse({
|
||||
tournaments: url.searchParams.get("tournaments"),
|
||||
});
|
||||
|
||||
const tagsToFilterBy = parsedFilterParams.success
|
||||
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
|
||||
: [];
|
||||
const onlyTournaments = parsedTournamentsOnlyParams.success
|
||||
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
|
||||
: false;
|
||||
const filtered = CalendarEvent.applyFilters(events, filters);
|
||||
|
||||
const iCalData = await ical.getICalendar({
|
||||
tagsFilter: tagsToFilterBy,
|
||||
tournamentsFilter: onlyTournaments,
|
||||
});
|
||||
const iCalData = await ICal.getICalendar(
|
||||
filtered.flatMap((eventTime) => eventTime.events.shown),
|
||||
);
|
||||
|
||||
if (iCalData === null) {
|
||||
return new Response(null, { status: 204 });
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function CalendarEventPage() {
|
||||
<div className="stack md">
|
||||
<div className="stack xs">
|
||||
<h2>{data.event.name}</h2>
|
||||
<Tags tags={data.event.tags} badges={data.event.badgePrizes} />
|
||||
<Tags tags={data.event.tags} />
|
||||
</div>
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
{data.event.discordUrl ? (
|
||||
|
||||
127
app/features/calendar/routes/calendar.module.css
Normal file
127
app/features/calendar/routes/calendar.module.css
Normal file
@@ -0,0 +1,127 @@
|
||||
.buttonsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-6);
|
||||
align-items: start;
|
||||
flex-wrap: wrap-reverse;
|
||||
}
|
||||
|
||||
.navigateButtonsContainer {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navigateButton {
|
||||
all: unset;
|
||||
background-color: var(--bg-lightest);
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: var(--bold);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
font-size: var(--fonts-xs);
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.navigateButton:focus-visible {
|
||||
outline: 2px solid var(--theme);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
.navigateButton {
|
||||
flex: initial;
|
||||
}
|
||||
|
||||
.navigateButtonsContainer {
|
||||
width: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.navigateButton:not(.navigateArrowButton) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navigateButton svg {
|
||||
min-width: 1.75rem;
|
||||
max-width: 1.75rem;
|
||||
min-width: 1.75rem;
|
||||
max-height: 1.75rem;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.navigateArrowButton svg {
|
||||
margin-inline-start: -5px;
|
||||
}
|
||||
|
||||
.majorLink {
|
||||
all: unset;
|
||||
background-color: orange;
|
||||
border: 2px solid darkorange;
|
||||
color: rgb(49, 49, 49);
|
||||
font-weight: var(--bold);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
font-size: var(--fonts-xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.columnsContainer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns-count), 225px);
|
||||
gap: var(--s-14);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dayHeader {
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--rounded-sm);
|
||||
background-color: var(--bg-lighter);
|
||||
padding: var(--s-2);
|
||||
text-align: center;
|
||||
font-size: var(--fonts-md);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.dayHeaderToday {
|
||||
background-color: var(--bg-lightest);
|
||||
border: 2px solid var(--theme-transparent);
|
||||
}
|
||||
|
||||
.dayHeaderWeekday {
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.dayEvents {
|
||||
margin-block-start: var(--s-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.clockHeader {
|
||||
font-weight: var(--semi-bold);
|
||||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
||||
.clockHeaderDivider {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: var(--theme-transparent);
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.hiddenEventsButton svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
background-color: initial;
|
||||
padding: var(--s-2);
|
||||
border: none;
|
||||
}
|
||||
@@ -584,9 +584,9 @@ function TagsAdder() {
|
||||
const [tags, setTags] = React.useState(baseEvent?.tags ?? []);
|
||||
const id = React.useId();
|
||||
|
||||
const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter(
|
||||
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
|
||||
(tag) => !tags.includes(tag),
|
||||
);
|
||||
).filter((tag) => tag !== "SZ" && tag !== "TW"); // TODO: these are now added automatically, remove in migration?
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
@@ -601,7 +601,6 @@ function TagsAdder() {
|
||||
id={id}
|
||||
className="calendar-new__select"
|
||||
onChange={(e) =>
|
||||
// @ts-expect-error TODO: fix this (5.5 version)
|
||||
setTags([...tags, e.target.value as CalendarEventTag])
|
||||
}
|
||||
>
|
||||
@@ -612,7 +611,6 @@ function TagsAdder() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FormMessage type="info">{t("calendar:forms.tags.info")}</FormMessage>
|
||||
</div>
|
||||
<Tags
|
||||
tags={tags}
|
||||
|
||||
@@ -1,87 +1,58 @@
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import {
|
||||
Link,
|
||||
createSearchParams,
|
||||
useLoaderData,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { Link, useLoaderData, useNavigate } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { Flipped, Flipper } from "react-flip-toolkit";
|
||||
import type * as React from "react";
|
||||
import type { DateValue } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { ClipboardIcon } from "~/components/icons/Clipboard";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToWeekNumber,
|
||||
dayToWeekStartsAtMondayDay,
|
||||
getWeekStartsAtMondayDay,
|
||||
weekNumberToDate,
|
||||
} from "~/utils/dates";
|
||||
SendouButton,
|
||||
type SendouButtonProps,
|
||||
} from "~/components/elements/Button";
|
||||
import { SendouCalendar } from "~/components/elements/Calendar";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
|
||||
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
||||
import { CalendarIcon } from "~/components/icons/Calendar";
|
||||
import { EyeIcon } from "~/components/icons/Eye";
|
||||
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
|
||||
import { LinkIcon } from "~/components/icons/Link";
|
||||
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
|
||||
import { useCollapsableEvents } from "~/features/calendar/calendar-hooks";
|
||||
import { dayMonthYearToDateValue } from "~/utils/dates";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import {
|
||||
CALENDAR_PAGE,
|
||||
calendarReportWinnersPage,
|
||||
calendarIcalFeed,
|
||||
calendarPage,
|
||||
navIconUrl,
|
||||
resolveBaseUrl,
|
||||
tournamentOrganizationPage,
|
||||
tournamentPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { Label } from "../../../components/Label";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { CALENDAR_EVENT } from "../calendar-constants";
|
||||
import { Tags } from "../components/Tags";
|
||||
import type { DayMonthYear } from "~/utils/zod";
|
||||
import { daysForCalendar } from "../calendar-utils";
|
||||
import { FiltersDialog } from "../components/FiltersDialog";
|
||||
import { TournamentCard } from "../components/TournamentCard";
|
||||
import * as CalendarEvent from "../core/CalendarEvent";
|
||||
|
||||
import { loader } from "../loaders/calendar.server";
|
||||
export { loader };
|
||||
import { action } from "../actions/calendar";
|
||||
import { type CalendarLoaderData, loader } from "../loaders/calendar.server";
|
||||
export { action, loader };
|
||||
|
||||
import "~/styles/calendar.css";
|
||||
import styles from "./calendar.module.css";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
const events = data.events.slice().sort((a, b) => {
|
||||
const aParticipants = a.participantCounts?.teams ?? 0;
|
||||
const bParticipants = b.participantCounts?.teams ?? 0;
|
||||
|
||||
if (aParticipants > bParticipants) return -1;
|
||||
if (aParticipants < bParticipants) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return metaTags({
|
||||
title: "Calendar",
|
||||
ogTitle: "Splatoon competitive event calendar",
|
||||
location: args.location,
|
||||
description: `${data.events.length} events on sendou.ink happening during week ${
|
||||
data.displayedWeek
|
||||
} including ${joinListToNaturalString(
|
||||
events.slice(0, 3).map((e) => e.name),
|
||||
)}`,
|
||||
description:
|
||||
"Browser Splatoon competitive tournaments and events both local and online. Events for players of all skill levels from newcomer to pro.",
|
||||
});
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "calendar",
|
||||
i18n: ["calendar", "front"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("calendar"),
|
||||
href: CALENDAR_PAGE,
|
||||
@@ -90,608 +61,268 @@ export const handle: SendouRouteHandle = {
|
||||
};
|
||||
|
||||
export default function CalendarPage() {
|
||||
const { t } = useTranslation("calendar");
|
||||
const { t } = useTranslation(["calendar", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
// we don't know which events are starting in user's time zone on server
|
||||
// so that's why this calculation is not in the loader
|
||||
const thisWeeksEvents = isMounted
|
||||
? data.events.filter(
|
||||
(event) =>
|
||||
dateToWeekNumber(
|
||||
dateToSixHoursAgo(databaseTimestampToDate(event.startTime)),
|
||||
) === data.displayedWeek,
|
||||
)
|
||||
: data.events;
|
||||
const { previous, shown, next, current } = daysForCalendar(data.dateViewed);
|
||||
|
||||
return (
|
||||
<Main classNameOverwrite="stack lg main layout__main">
|
||||
<WeekLinks />
|
||||
<EventsToReport />
|
||||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<div className="stack sm">
|
||||
<TagsFilter />
|
||||
<ICalLink />
|
||||
</div>
|
||||
<OnSendouInkToggle />
|
||||
<Main bigger className="stack lg">
|
||||
<div className={styles.buttonsContainer}>
|
||||
<div className={styles.navigateButtonsContainer}>
|
||||
<NavigateButton
|
||||
icon={<ArrowLeftIcon />}
|
||||
daysInterval={previous}
|
||||
filters={data.filters}
|
||||
>
|
||||
{t("common:actions.previous")}
|
||||
</NavigateButton>
|
||||
<NavigateButton
|
||||
icon={<ArrowRightIcon />}
|
||||
daysInterval={next}
|
||||
filters={data.filters}
|
||||
>
|
||||
{t("common:actions.next")}
|
||||
</NavigateButton>
|
||||
<CalendarDatePicker
|
||||
dayMonthYear={current}
|
||||
filters={data.filters}
|
||||
key={JSON.stringify(current)}
|
||||
/>
|
||||
</div>
|
||||
{isMounted ? (
|
||||
<>
|
||||
{thisWeeksEvents.length > 0 ? (
|
||||
<>
|
||||
<EventsList events={thisWeeksEvents} />
|
||||
<div className="calendar__time-zone-info">
|
||||
{t("inYourTimeZone")}{" "}
|
||||
{Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h2 className="calendar__no-events">{t("noEvents")}</h2>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="calendar__placeholder" />
|
||||
)}
|
||||
<div className="stack sm horizontal ml-auto">
|
||||
<CopyToClipboardPopover
|
||||
trigger={
|
||||
<SendouButton icon={<LinkIcon />} size="small" variant="outlined">
|
||||
{t("calendar:icalFeed")}
|
||||
</SendouButton>
|
||||
}
|
||||
url={calendarIcalFeed(data.filters)}
|
||||
/>
|
||||
<FiltersDialog
|
||||
key={CalendarEvent.filtersToString(data.filters)}
|
||||
filters={data.filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={styles.columnsContainer}
|
||||
style={{ "--columns-count": DAYS_SHOWN_AT_A_TIME }}
|
||||
>
|
||||
{shown.map((date) => (
|
||||
<DayEventsColumn
|
||||
key={`${date.month}-${date.day}`}
|
||||
date={date.day}
|
||||
month={date.month}
|
||||
eventTimes={data.eventTimes.filter((event) => {
|
||||
const eventDate = new Date(event.at);
|
||||
|
||||
return (
|
||||
eventDate.getDate() === date.day &&
|
||||
eventDate.getMonth() === date.month
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekLinks() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
const [searchParams] = useSearchParams();
|
||||
function NavigateButton({
|
||||
icon,
|
||||
children,
|
||||
daysInterval,
|
||||
filters,
|
||||
}: {
|
||||
icon: SendouButtonProps["icon"];
|
||||
children: React.ReactNode;
|
||||
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
|
||||
filters?: CalendarLoaderData["filters"];
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const lowestDate = daysInterval[0];
|
||||
const highestDate = daysInterval[daysInterval.length - 1];
|
||||
|
||||
const eventCounts = isMounted
|
||||
? getEventsCountPerWeek(data.nearbyStartTimes)
|
||||
: null;
|
||||
|
||||
const linkTo = (args: { week: number; year: number }) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("week", String(args.week));
|
||||
params.set("year", String(args.year));
|
||||
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
const dateToString = (
|
||||
day: ReturnType<typeof daysForCalendar>["shown"][number],
|
||||
) =>
|
||||
new Date(new Date().getFullYear(), day.month, day.day).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Flipper flipKey={data.weeks.map(({ number }) => number).join("")}>
|
||||
<div className="flex justify-center">
|
||||
<div className="calendar__weeks">
|
||||
{data.weeks.map((week, i) => {
|
||||
const hidden = [
|
||||
0,
|
||||
1,
|
||||
data.weeks.length - 2,
|
||||
data.weeks.length - 1,
|
||||
].includes(i);
|
||||
|
||||
const isCurrentWeek = i === 4;
|
||||
|
||||
return (
|
||||
<Flipped key={week.number} flipId={week.number}>
|
||||
<Link
|
||||
to={linkTo({ week: week.number, year: week.year })}
|
||||
className={clsx("calendar__week", { invisible: hidden })}
|
||||
aria-hidden={hidden}
|
||||
tabIndex={hidden || isCurrentWeek ? -1 : 0}
|
||||
onClick={(e) => isCurrentWeek && e.preventDefault()}
|
||||
>
|
||||
<>
|
||||
<WeekLinkTitle week={week} />
|
||||
<div
|
||||
className={clsx("calendar__event-count", {
|
||||
invisible: !eventCounts,
|
||||
})}
|
||||
>
|
||||
×{eventCounts?.get(week.number) ?? 0}
|
||||
</div>
|
||||
</>
|
||||
</Link>
|
||||
</Flipped>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
to={calendarPage({ filters, dayMonthYear: lowestDate })}
|
||||
className={clsx(styles.navigateButton, styles.navigateArrowButton)}
|
||||
data-testid="calendar-navigate-button"
|
||||
>
|
||||
{icon}
|
||||
<div>
|
||||
<div>{children}</div>
|
||||
<div className="text-xxs text-lighter">
|
||||
{dateToString(lowestDate)} - {dateToString(highestDate)}
|
||||
</div>
|
||||
</div>
|
||||
</Flipper>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekLinkTitle({
|
||||
week,
|
||||
function CalendarDatePicker({
|
||||
dayMonthYear,
|
||||
filters,
|
||||
}: { dayMonthYear: DayMonthYear; filters?: CalendarLoaderData["filters"] }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onChange = (date: DateValue) => {
|
||||
navigate(
|
||||
calendarPage({
|
||||
filters,
|
||||
dayMonthYear: {
|
||||
day: date.day,
|
||||
month: date.month - 1,
|
||||
year: date.year,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
className={styles.navigateButton}
|
||||
icon={<CalendarIcon />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SendouCalendar
|
||||
className={styles.calendar}
|
||||
value={dayMonthYearToDateValue(dayMonthYear)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function DayEventsColumn({
|
||||
date,
|
||||
month,
|
||||
eventTimes,
|
||||
}: {
|
||||
week: Unpacked<SerializeFrom<typeof loader>["weeks"]>;
|
||||
date: number;
|
||||
month: number;
|
||||
eventTimes: CalendarLoaderData["eventTimes"];
|
||||
}) {
|
||||
const { t } = useTranslation("calendar");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const eventTimesCollapsed = useCollapsableEvents(eventTimes);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DayHeader date={date} month={month} />
|
||||
<div className={styles.dayEvents}>
|
||||
{eventTimesCollapsed.map((eventTime, i) => {
|
||||
return (
|
||||
<div key={eventTime.date.from.getTime()} className="stack md">
|
||||
<ClockHeader
|
||||
date={eventTime.date.from}
|
||||
toDate={eventTime.date.to}
|
||||
hiddenEventsCount={eventTime.hiddenCount}
|
||||
hiddenShown={eventTime.hiddenShown}
|
||||
onToggleHidden={eventTime.onToggleHidden}
|
||||
className={i !== 0 ? "mt-4" : undefined}
|
||||
/>
|
||||
{eventTime.eventsShown.map((event) => (
|
||||
<TournamentCard key={event.id} tournament={event} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayHeader(props: { date: number; month: number }) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const isSameYear = week.year === new Date().getFullYear();
|
||||
const relativeWeekIdentifier =
|
||||
week.number === data.currentWeek && isSameYear
|
||||
? t("week.this")
|
||||
: week.number - data.currentWeek === 1 && isSameYear
|
||||
? t("week.next")
|
||||
: week.number - data.currentWeek === -1 && isSameYear
|
||||
? t("week.last")
|
||||
: null;
|
||||
|
||||
if (relativeWeekIdentifier) {
|
||||
return (
|
||||
<div className="calendar__week__relative">
|
||||
<div>{relativeWeekIdentifier}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const date = new Date(new Date().getFullYear(), props.month, props.date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{weekNumberToDate({
|
||||
week: week.number,
|
||||
year: week.year,
|
||||
}).toLocaleDateString(i18n.language, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</div>
|
||||
<div className="calendar__week__dash">-</div>
|
||||
<div>
|
||||
{weekNumberToDate({
|
||||
week: week.number,
|
||||
year: week.year,
|
||||
position: "end",
|
||||
}).toLocaleDateString(i18n.language, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEventsCountPerWeek(
|
||||
startTimes: SerializeFrom<typeof loader>["nearbyStartTimes"],
|
||||
) {
|
||||
const result = new Map<number, number>();
|
||||
|
||||
for (const startTime of startTimes) {
|
||||
const week = dateToWeekNumber(databaseTimestampToDate(startTime));
|
||||
const previousCount = result.get(week) ?? 0;
|
||||
result.set(week, previousCount + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function EventsToReport() {
|
||||
const { t } = useTranslation("calendar");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.eventsToReport.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Alert textClassName="calendar__events-to-report">
|
||||
{t("reportResults")}{" "}
|
||||
{data.eventsToReport.map((event, i) => (
|
||||
<React.Fragment key={event.id}>
|
||||
<Link to={calendarReportWinnersPage(event.id)}>{event.name}</Link>
|
||||
{i === data.eventsToReport.length - 1 ? "" : ", "}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsFilter() {
|
||||
const { t } = useTranslation(["calendar", "common"]);
|
||||
const id = React.useId();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const tagsToFilterBy = (searchParams
|
||||
.get("tags")
|
||||
?.split(",")
|
||||
.filter((tag) => CALENDAR_EVENT.TAGS.includes(tag as CalendarEventTag)) ??
|
||||
[]) as CalendarEventTag[];
|
||||
const setTagsToFilterBy = (tags: CalendarEventTag[]) => {
|
||||
setSearchParams((params) => {
|
||||
if (tags.length === 0) {
|
||||
params.delete("tags");
|
||||
return params;
|
||||
}
|
||||
|
||||
params.set("tags", tags.join(","));
|
||||
return params;
|
||||
});
|
||||
};
|
||||
|
||||
const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter(
|
||||
(tag) => !tagsToFilterBy.includes(tag),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
<div>
|
||||
<label htmlFor={id}>{t("calendar:tag.filter.label")}</label>
|
||||
<select
|
||||
id={id}
|
||||
className="w-max"
|
||||
onChange={(e) =>
|
||||
setTagsToFilterBy([
|
||||
...tagsToFilterBy,
|
||||
e.target.value as CalendarEventTag,
|
||||
])
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{tagsForSelect.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{t(`common:tag.name.${tag}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Tags
|
||||
tags={tagsToFilterBy}
|
||||
onDelete={(tagToDelete) =>
|
||||
setTagsToFilterBy(tagsToFilterBy.filter((tag) => tag !== tagToDelete))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ICalLink() {
|
||||
const [searchParams, _] = useSearchParams();
|
||||
const [state, copyToClipboard] = useCopyToClipboard();
|
||||
const [copySuccess, setCopySuccess] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!state.value) return;
|
||||
|
||||
setCopySuccess(true);
|
||||
const timeout = setTimeout(() => setCopySuccess(false), 2000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [state]);
|
||||
|
||||
const filteredTags = (
|
||||
searchParams
|
||||
.get("tags")
|
||||
?.split(",")
|
||||
.filter((tag) => CALENDAR_EVENT.TAGS.includes(tag as CalendarEventTag)) ??
|
||||
[]
|
||||
).join();
|
||||
|
||||
const onlyTournaments = searchParams.get("tournaments") === "true";
|
||||
|
||||
const params = createSearchParams();
|
||||
|
||||
if (filteredTags.length > 0) params.append("tags", filteredTags);
|
||||
if (onlyTournaments) params.append("tournaments", "true");
|
||||
|
||||
const icalURL = `https://sendou.ink/calendar.ics${params.size > 0 ? `?${params.toString()}` : ""}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="icalAddress">iCalendar</label>
|
||||
<div className="stack horizontal sm items-center">
|
||||
<input type="text" readOnly value={icalURL} id="icalAddress" />
|
||||
<SendouButton
|
||||
variant={copySuccess ? "outlined-success" : "outlined"}
|
||||
onPress={() => copyToClipboard(icalURL)}
|
||||
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
|
||||
aria-label="Copy to clipboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnSendouInkToggle() {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const onlyTournaments = searchParams.get("tournaments") === "true";
|
||||
|
||||
const setOnlyTournaments = (value: boolean) => {
|
||||
setSearchParams((params) => {
|
||||
if (value) {
|
||||
params.set("tournaments", "true");
|
||||
} else {
|
||||
params.delete("tournaments");
|
||||
}
|
||||
|
||||
return params;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack horizontal justify-end">
|
||||
<div className="stack items-end">
|
||||
<Label htmlFor="onlyTournaments">
|
||||
{t("calendar:tournament.filter.label")}
|
||||
</Label>
|
||||
<SendouSwitch
|
||||
id="onlyTournaments"
|
||||
isSelected={onlyTournaments}
|
||||
onChange={setOnlyTournaments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsList({
|
||||
events,
|
||||
}: {
|
||||
events: SerializeFrom<typeof loader>["events"];
|
||||
}) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t, i18n } = useTranslation("calendar");
|
||||
|
||||
const sortPastEventsLast = data.currentWeek === data.displayedWeek;
|
||||
|
||||
const eventsGrouped = eventsGroupedByDay(events);
|
||||
if (sortPastEventsLast) {
|
||||
eventsGrouped.sort(
|
||||
pastEventsLast(dayToWeekStartsAtMondayDay(data.currentDay)),
|
||||
);
|
||||
}
|
||||
|
||||
let dividerRendered = false;
|
||||
return (
|
||||
<div className="calendar__events-container">
|
||||
{eventsGrouped.map(([daysDate, events]) => {
|
||||
const renderDivider =
|
||||
sortPastEventsLast &&
|
||||
!dividerRendered &&
|
||||
getWeekStartsAtMondayDay(daysDate) <
|
||||
dayToWeekStartsAtMondayDay(data.currentDay);
|
||||
if (renderDivider) {
|
||||
dividerRendered = true;
|
||||
}
|
||||
|
||||
const sectionWeekday = daysDate.toLocaleString(i18n.language, {
|
||||
weekday: "short",
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment key={daysDate.getTime()}>
|
||||
<div className="calendar__event__date-container">
|
||||
{renderDivider ? (
|
||||
<Divider className="calendar__event__divider">
|
||||
{t("pastEvents.dividerText")}
|
||||
</Divider>
|
||||
) : null}
|
||||
<div className="calendar__event__date">
|
||||
{daysDate.toLocaleDateString(i18n.language, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack md">
|
||||
{events.map((calendarEvent) => {
|
||||
const eventWeekday = databaseTimestampToDate(
|
||||
calendarEvent.startTime,
|
||||
).toLocaleString(i18n.language, {
|
||||
weekday: "short",
|
||||
});
|
||||
|
||||
const isOneVsOne =
|
||||
calendarEvent.tournamentSettings?.minMembersPerTeam === 1;
|
||||
|
||||
const startTimeDate = databaseTimestampToDate(
|
||||
calendarEvent.startTime,
|
||||
);
|
||||
const tournamentRankedStatus = () => {
|
||||
if (!calendarEvent.tournamentSettings) return undefined;
|
||||
if (!Seasons.current(startTimeDate)) return undefined;
|
||||
|
||||
return calendarEvent.tournamentSettings.isRanked &&
|
||||
(!calendarEvent.tournamentSettings.minMembersPerTeam ||
|
||||
calendarEvent.tournamentSettings.minMembersPerTeam === 4)
|
||||
? "RANKED"
|
||||
: "UNRANKED";
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
key={calendarEvent.eventDateId}
|
||||
className="calendar__event stack md"
|
||||
>
|
||||
<div className="stack sm">
|
||||
<div className="calendar__event__top-info-container">
|
||||
<time
|
||||
dateTime={databaseTimestampToDate(
|
||||
calendarEvent.startTime,
|
||||
).toISOString()}
|
||||
className="calendar__event__time"
|
||||
>
|
||||
{startTimeDate.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
</time>
|
||||
{calendarEvent.organization ? (
|
||||
<Link
|
||||
to={tournamentOrganizationPage({
|
||||
organizationSlug: calendarEvent.organization.slug,
|
||||
})}
|
||||
className="stack horizontal xs items-center text-xs text-main-forced"
|
||||
>
|
||||
<Avatar
|
||||
url={
|
||||
calendarEvent.organization.avatarUrl
|
||||
? userSubmittedImage(
|
||||
calendarEvent.organization.avatarUrl,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
size="xxs"
|
||||
/>
|
||||
{calendarEvent.organization.name}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="calendar__event__author">
|
||||
{t("from", {
|
||||
author: calendarEvent.username,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{sectionWeekday !== eventWeekday ? (
|
||||
<div className="text-xxs font-bold text-theme-secondary ml-auto">
|
||||
{eventWeekday}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stack xs">
|
||||
<div className="stack horizontal sm-plus items-center">
|
||||
{calendarEvent.tournamentId ? (
|
||||
<img
|
||||
src={
|
||||
calendarEvent.logoUrl
|
||||
? userSubmittedImage(calendarEvent.logoUrl)
|
||||
: HACKY_resolvePicture({
|
||||
name: calendarEvent.name,
|
||||
})
|
||||
}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="calendar__event-logo"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<Link
|
||||
to={
|
||||
calendarEvent.tournamentId
|
||||
? tournamentPage(calendarEvent.tournamentId)
|
||||
: String(calendarEvent.eventId)
|
||||
}
|
||||
>
|
||||
<h2 className="calendar__event__title">
|
||||
{calendarEvent.name}{" "}
|
||||
{calendarEvent.nthAppearance > 1 ? (
|
||||
<span className="calendar__event__day">
|
||||
{t("day", {
|
||||
number: calendarEvent.nthAppearance,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
</Link>
|
||||
{calendarEvent.participantCounts &&
|
||||
calendarEvent.participantCounts.teams > 0 ? (
|
||||
<div className="calendar__event__participant-counts">
|
||||
<UsersIcon />{" "}
|
||||
{!isOneVsOne ? (
|
||||
<>
|
||||
{t("count.teams", {
|
||||
count:
|
||||
calendarEvent.participantCounts.teams,
|
||||
})}{" "}
|
||||
/{" "}
|
||||
</>
|
||||
) : null}
|
||||
{t("count.players", {
|
||||
count:
|
||||
calendarEvent.participantCounts.players,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Tags
|
||||
tags={calendarEvent.tags}
|
||||
badges={calendarEvent.badgePrizes}
|
||||
tournamentRankedStatus={tournamentRankedStatus()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="calendar__event__bottom-info-container">
|
||||
{calendarEvent.discordUrl ? (
|
||||
<LinkButton
|
||||
to={calendarEvent.discordUrl}
|
||||
variant="outlined"
|
||||
size="tiny"
|
||||
isExternal
|
||||
>
|
||||
Discord
|
||||
</LinkButton>
|
||||
) : null}
|
||||
{!calendarEvent.tournamentId ? (
|
||||
<LinkButton
|
||||
to={calendarEvent.bracketUrl}
|
||||
variant="outlined"
|
||||
size="tiny"
|
||||
isExternal
|
||||
>
|
||||
{resolveBaseUrl(calendarEvent.bracketUrl)}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
<div
|
||||
className={clsx(styles.dayHeader, {
|
||||
[styles.dayHeaderToday]: isToday,
|
||||
})}
|
||||
data-testid={isToday ? "today-header" : undefined}
|
||||
>
|
||||
{date.toLocaleDateString(i18n.language, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
<div className={styles.dayHeaderWeekday}>
|
||||
{date.toLocaleDateString(i18n.language, {
|
||||
weekday: "long",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Goal with this is to make events that start during the night for EU (NA events)
|
||||
// grouped up with the previous day. Otherwise you have a past event showing at the
|
||||
// top of the page for the whole following day for EU.
|
||||
function dateToSixHoursAgo(date: Date) {
|
||||
const sixHoursAgo = new Date(date);
|
||||
sixHoursAgo.setHours(sixHoursAgo.getHours() - 6);
|
||||
return sixHoursAgo;
|
||||
}
|
||||
|
||||
type EventsGrouped = [Date, SerializeFrom<typeof loader>["events"]];
|
||||
function eventsGroupedByDay(events: SerializeFrom<typeof loader>["events"]) {
|
||||
const result: EventsGrouped[] = [];
|
||||
|
||||
for (const calendarEvent of events) {
|
||||
const previousIterationEvents = result[result.length - 1] ?? null;
|
||||
|
||||
const eventsDate = dateToSixHoursAgo(
|
||||
databaseTimestampToDate(calendarEvent.startTime),
|
||||
);
|
||||
|
||||
if (
|
||||
!previousIterationEvents ||
|
||||
previousIterationEvents[0].getDay() !== eventsDate.getDay()
|
||||
) {
|
||||
result.push([eventsDate, [calendarEvent]]);
|
||||
} else {
|
||||
previousIterationEvents[1].push(calendarEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function pastEventsLast(currentDay: number) {
|
||||
return (a: EventsGrouped, b: EventsGrouped) => {
|
||||
const aDay = getWeekStartsAtMondayDay(a[0]);
|
||||
const bDay = getWeekStartsAtMondayDay(b[0]);
|
||||
|
||||
if (aDay < currentDay && bDay >= currentDay) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (aDay >= currentDay && bDay < currentDay) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
function ClockHeader({
|
||||
date,
|
||||
toDate,
|
||||
hiddenEventsCount = 0,
|
||||
onToggleHidden,
|
||||
hiddenShown,
|
||||
className,
|
||||
}: {
|
||||
date: Date;
|
||||
toDate?: Date;
|
||||
hiddenEventsCount?: number;
|
||||
onToggleHidden: () => void;
|
||||
hiddenShown: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const isInThePast = (toDate ?? date).getTime() < Date.now();
|
||||
|
||||
return (
|
||||
<div className={clsx(className, styles.clockHeader)}>
|
||||
<div className="stack horizontal justify-between">
|
||||
<span
|
||||
className={clsx({
|
||||
"text-lighter italic": isInThePast,
|
||||
})}
|
||||
>
|
||||
{date.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{toDate
|
||||
? ` - ${toDate.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}`
|
||||
: ""}
|
||||
</span>
|
||||
{hiddenEventsCount > 0 ? (
|
||||
<SendouButton
|
||||
icon={hiddenShown ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
onClick={onToggleHidden}
|
||||
variant="minimal"
|
||||
className={styles.hiddenEventsButton}
|
||||
data-testid="hidden-events-button"
|
||||
>
|
||||
{hiddenEventsCount}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.clockHeaderDivider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cachified from "@epic-web/cachified";
|
||||
import { TWO_HOURS_IN_MS } from "~/constants";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
@@ -9,36 +9,18 @@ import {
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { tournamentPage } from "~/utils/urls";
|
||||
|
||||
interface ShowcaseTournamentCollection {
|
||||
participatingFor: ShowcaseTournament[];
|
||||
organizingFor: ShowcaseTournament[];
|
||||
showcase: ShowcaseTournament[];
|
||||
results: ShowcaseTournament[];
|
||||
}
|
||||
|
||||
export interface ShowcaseTournament {
|
||||
id: number;
|
||||
name: string;
|
||||
startTime: number;
|
||||
teamsCount: number;
|
||||
isRanked: boolean;
|
||||
logoUrl: string | null;
|
||||
organization: {
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
firstPlacer: {
|
||||
teamName: string;
|
||||
logoUrl: string | null;
|
||||
members: (CommonUser & { country: Tables["User"]["country"] })[];
|
||||
notShownMembersCount: number;
|
||||
} | null;
|
||||
participatingFor: ShowcaseCalendarEvent[];
|
||||
organizingFor: ShowcaseCalendarEvent[];
|
||||
showcase: ShowcaseCalendarEvent[];
|
||||
results: ShowcaseCalendarEvent[];
|
||||
}
|
||||
|
||||
interface ParticipationInfo {
|
||||
participants: Set<ShowcaseTournament["id"]>;
|
||||
organizers: Set<ShowcaseTournament["id"]>;
|
||||
participants: Set<ShowcaseCalendarEvent["id"]>;
|
||||
organizers: Set<ShowcaseCalendarEvent["id"]>;
|
||||
}
|
||||
|
||||
export async function frontPageTournamentsByUserId(
|
||||
@@ -152,7 +134,7 @@ export function updateCachedTournamentTeamCount({
|
||||
|
||||
async function cachedParticipationInfo(
|
||||
userId: number | null,
|
||||
tournaments: ShowcaseTournament[],
|
||||
tournaments: ShowcaseCalendarEvent[],
|
||||
): Promise<ParticipationInfo> {
|
||||
if (!userId) {
|
||||
return emptyParticipationInfo();
|
||||
@@ -188,7 +170,7 @@ async function cachedTournaments() {
|
||||
});
|
||||
}
|
||||
|
||||
function deleteExtraResults(tournaments: ShowcaseTournament[]) {
|
||||
function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
|
||||
const nonResults = tournaments.filter(
|
||||
(tournament) => !tournament.firstPlacer,
|
||||
);
|
||||
@@ -216,8 +198,8 @@ function deleteExtraResults(tournaments: ShowcaseTournament[]) {
|
||||
}
|
||||
|
||||
function resolveShowcaseTournaments(
|
||||
tournaments: ShowcaseTournament[],
|
||||
): ShowcaseTournament[] {
|
||||
tournaments: ShowcaseCalendarEvent[],
|
||||
): ShowcaseCalendarEvent[] {
|
||||
const happeningDuringNextWeek = tournaments.filter(
|
||||
(tournament) =>
|
||||
tournament.startTime > databaseTimestampSixHoursAgo() &&
|
||||
@@ -237,7 +219,7 @@ function resolveShowcaseTournaments(
|
||||
}
|
||||
|
||||
async function tournamentsToParticipationInfoMap(
|
||||
tournaments: ShowcaseTournament[],
|
||||
tournaments: ShowcaseCalendarEvent[],
|
||||
): Promise<Map<CommonUser["id"], ParticipationInfo>> {
|
||||
const tournamentIds = tournaments.map((tournament) => tournament.id);
|
||||
const tournamentsWithUsers =
|
||||
@@ -284,9 +266,12 @@ const MEMBERS_TO_SHOW = 5;
|
||||
|
||||
function mapTournamentFromDB(
|
||||
tournament: TournamentRepository.ForShowcase,
|
||||
): ShowcaseTournament {
|
||||
): ShowcaseCalendarEvent {
|
||||
return {
|
||||
type: "showcase",
|
||||
url: tournamentPage(tournament.id),
|
||||
id: tournament.id,
|
||||
authorId: tournament.authorId,
|
||||
name: tournament.name,
|
||||
startTime: tournament.startTime,
|
||||
teamsCount: tournament.teamsCount,
|
||||
@@ -303,6 +288,7 @@ function mapTournamentFromDB(
|
||||
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
||||
isTest: tournament.settings.isTest ?? false,
|
||||
}),
|
||||
modes: null, // no need to show modes for front page, maybe could in the future?
|
||||
firstPlacer:
|
||||
tournament.firstPlacers.length > 0
|
||||
? {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { NewTabs } from "~/components/NewTabs";
|
||||
@@ -20,11 +19,11 @@ import { SearchIcon } from "~/components/icons/Search";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { navItems } from "~/components/layout/nav-items";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
|
||||
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
|
||||
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
BLANK_IMAGE_URL,
|
||||
@@ -35,10 +34,7 @@ import {
|
||||
leaderboardsPage,
|
||||
navIconUrl,
|
||||
sqHeaderGuyImageUrl,
|
||||
tournamentPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import type * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
|
||||
|
||||
import { type LeaderboardEntry, loader } from "../loaders/index.server";
|
||||
export { loader };
|
||||
@@ -239,7 +235,7 @@ function TournamentCards() {
|
||||
|
||||
function ShowcaseTournamentScroller({
|
||||
tournaments,
|
||||
}: { tournaments: ShowcaseTournaments.ShowcaseTournament[] }) {
|
||||
}: { tournaments: ShowcaseCalendarEvent[] }) {
|
||||
return (
|
||||
<div className="front__tournament-cards">
|
||||
<div className="front__tournament-cards__spacer overflow-x-scroll">
|
||||
@@ -247,7 +243,7 @@ function ShowcaseTournamentScroller({
|
||||
<TournamentCard
|
||||
key={tournament.id}
|
||||
tournament={tournament}
|
||||
topSpaced
|
||||
className="mt-4"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -270,141 +266,6 @@ function AllTournamentsLinkCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentCard({
|
||||
tournament,
|
||||
topSpaced,
|
||||
}: {
|
||||
tournament: ShowcaseTournaments.ShowcaseTournament;
|
||||
topSpaced?: boolean;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { t, i18n } = useTranslation(["front", "common"]);
|
||||
|
||||
const time = () => {
|
||||
if (!isMounted) return "Placeholder";
|
||||
|
||||
const date = databaseTimestampToDate(tournament.startTime);
|
||||
return date.toLocaleString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
weekday: "short",
|
||||
minute: date.getMinutes() !== 0 ? "numeric" : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("front__tournament-card__container", {
|
||||
"front__tournament-card__container__tall": tournament.firstPlacer,
|
||||
"mt-4": topSpaced,
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
to={tournamentPage(tournament.id)}
|
||||
className="front__tournament-card"
|
||||
>
|
||||
<div className="stack horizontal justify-between">
|
||||
<div className="front__tournament-card__img-container">
|
||||
<img
|
||||
src={
|
||||
tournament.logoUrl
|
||||
? userSubmittedImage(tournament.logoUrl)
|
||||
: HACKY_resolvePicture(tournament)
|
||||
}
|
||||
width={32}
|
||||
height={32}
|
||||
className="front__tournament-card__tournament-avatar-img"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
{tournament.organization ? (
|
||||
<div className="front__tournament-card__org">
|
||||
{tournament.organization.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="front__tournament-card__name">
|
||||
{tournament.name}{" "}
|
||||
<time
|
||||
className={clsx("front__tournament-card__time", {
|
||||
invisible: !isMounted,
|
||||
})}
|
||||
dateTime={databaseTimestampToDate(
|
||||
tournament.startTime,
|
||||
).toISOString()}
|
||||
>
|
||||
{time()}
|
||||
</time>
|
||||
</div>
|
||||
{tournament.firstPlacer ? (
|
||||
<TournamentFirstPlacers firstPlacer={tournament.firstPlacer} />
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="stack horizontal xxs justify-end">
|
||||
<div className="front__tournament-card__team-count">
|
||||
<UsersIcon /> {tournament.teamsCount}
|
||||
</div>
|
||||
{tournament.isRanked ? (
|
||||
<div className="front__tournament-card__tag front__tournament-card__ranked">
|
||||
{t("front:showcase.card.ranked")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="front__tournament-card__tag front__tournament-card__unranked">
|
||||
{t("front:showcase.card.unranked")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentFirstPlacers({
|
||||
firstPlacer,
|
||||
}: {
|
||||
firstPlacer: NonNullable<
|
||||
ShowcaseTournaments.ShowcaseTournament["firstPlacer"]
|
||||
>;
|
||||
}) {
|
||||
const { t } = useTranslation(["front"]);
|
||||
|
||||
return (
|
||||
<div className="front__tournament-card__first-placers">
|
||||
<div className="stack xs horizontal items-center text-xs">
|
||||
{firstPlacer.logoUrl ? (
|
||||
<img
|
||||
src={userSubmittedImage(firstPlacer.logoUrl)}
|
||||
alt=""
|
||||
width={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : null}{" "}
|
||||
<div className="stack items-start">
|
||||
<span className="front__tournament-card__first-placers__team-name">
|
||||
{firstPlacer.teamName}
|
||||
</span>
|
||||
<div className="text-xxxs text-lighter font-bold text-uppercase">
|
||||
{t("front:showcase.card.winner")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xxs stack items-start mt-1">
|
||||
{firstPlacer.members.map((member) => (
|
||||
<div key={member.id} className="stack horizontal xs items-center">
|
||||
{member.country ? <Flag tiny countryCode={member.country} /> : null}
|
||||
{member.username}{" "}
|
||||
</div>
|
||||
))}
|
||||
{firstPlacer.notShownMembersCount > 0 ? (
|
||||
<div className="font-bold text-lighter">
|
||||
+{firstPlacer.notShownMembersCount}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultHighlights() {
|
||||
const { t } = useTranslation(["front"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { z } from "zod";
|
||||
import { Label } from "~/components/Label";
|
||||
import { DateTimeFormField } from "~/components/form/DateTimeFormField";
|
||||
import { MyForm } from "~/components/form/MyForm";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
@@ -35,7 +35,7 @@ export default function NewScrimPage() {
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<MyForm
|
||||
<SendouForm
|
||||
schema={scrimsNewActionSchema}
|
||||
heading={t("scrims:forms.title")}
|
||||
defaultValues={{
|
||||
@@ -77,7 +77,7 @@ export default function NewScrimPage() {
|
||||
name="postText"
|
||||
maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
|
||||
/>
|
||||
</MyForm>
|
||||
</SendouForm>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Table } from "~/components/Table";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { MyForm } from "~/components/form/MyForm";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
|
||||
import { SpeechBubbleIcon } from "~/components/icons/SpeechBubble";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
@@ -186,7 +186,7 @@ function RequestScrimModal({
|
||||
|
||||
return (
|
||||
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
|
||||
<MyForm
|
||||
<SendouForm
|
||||
schema={newRequestSchema}
|
||||
defaultValues={{
|
||||
_action: "NEW_REQUEST",
|
||||
@@ -211,7 +211,7 @@ function RequestScrimModal({
|
||||
) : null}
|
||||
<Divider />
|
||||
<WithFormField usersTeams={data.teams} />
|
||||
</MyForm>
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as R from "remeda";
|
||||
import type {
|
||||
Tables,
|
||||
TournamentStage,
|
||||
@@ -10,11 +9,13 @@ import {
|
||||
LEAGUES,
|
||||
TOURNAMENT,
|
||||
} from "~/features/tournament/tournament-constants";
|
||||
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
|
||||
import {
|
||||
modesIncluded,
|
||||
tournamentIsRanked,
|
||||
} from "~/features/tournament/tournament-utils";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import type { Match, Stage } from "~/modules/brackets-model";
|
||||
import type { ModeShort } from "~/modules/in-game-lists";
|
||||
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import {
|
||||
databaseTimestampNow,
|
||||
@@ -728,32 +729,7 @@ export class Tournament {
|
||||
}
|
||||
|
||||
get modesIncluded(): ModeShort[] {
|
||||
switch (this.ctx.mapPickingStyle) {
|
||||
case "AUTO_SZ": {
|
||||
return ["SZ"];
|
||||
}
|
||||
case "AUTO_TC": {
|
||||
return ["TC"];
|
||||
}
|
||||
case "AUTO_RM": {
|
||||
return ["RM"];
|
||||
}
|
||||
case "AUTO_CB": {
|
||||
return ["CB"];
|
||||
}
|
||||
default: {
|
||||
const pickedModes = R.unique(
|
||||
this.ctx.toSetMapPool.map((map) => map.mode),
|
||||
);
|
||||
if (pickedModes.length === 0) {
|
||||
return [...rankedModesShort];
|
||||
}
|
||||
|
||||
return pickedModes.sort(
|
||||
(a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
|
||||
);
|
||||
}
|
||||
}
|
||||
return modesIncluded(this.ctx.mapPickingStyle, this.ctx.toSetMapPool);
|
||||
}
|
||||
|
||||
tournamentTeamLogoSrc(team: TournamentDataTeam) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Tables, TournamentRoundMaps } from "~/db/tables";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||
import { modesIncluded } from "~/features/tournament/tournament-utils";
|
||||
import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils";
|
||||
import type { Round } from "~/modules/brackets-model";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
@@ -132,7 +132,7 @@ export function resolveFreshTeamPickedMapList(
|
||||
if (count() === 1) {
|
||||
return starterMap({
|
||||
seed: String(args.matchId),
|
||||
modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
|
||||
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
|
||||
tiebreakerMaps: new MapPool(tieBreakerMapPool),
|
||||
teams: [
|
||||
{
|
||||
@@ -151,7 +151,7 @@ export function resolveFreshTeamPickedMapList(
|
||||
return createTournamentMapList({
|
||||
count: count(),
|
||||
seed: String(args.matchId),
|
||||
modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
|
||||
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
|
||||
tiebreakerMaps: new MapPool(tieBreakerMapPool),
|
||||
teams: [
|
||||
{
|
||||
@@ -173,7 +173,7 @@ export function resolveFreshTeamPickedMapList(
|
||||
return createTournamentMapList({
|
||||
count: count(),
|
||||
seed: String(args.matchId),
|
||||
modesIncluded: modesIncluded({ mapPickingStyle: args.mapPickingStyle }),
|
||||
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
|
||||
tiebreakerMaps: new MapPool(tieBreakerMapPool),
|
||||
teams: [
|
||||
{
|
||||
|
||||
@@ -7,11 +7,11 @@ import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { AddFieldButton } from "~/components/form/AddFieldButton";
|
||||
import { FormFieldset } from "~/components/form/FormFieldset";
|
||||
import { MyForm } from "~/components/form/MyForm";
|
||||
import { InputFormField } from "~/components/form/InputFormField";
|
||||
import { SelectFormField } from "~/components/form/SelectFormField";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
|
||||
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
|
||||
import { TextFormField } from "~/components/form/TextFormField";
|
||||
import { ToggleFormField } from "~/components/form/ToggleFormField";
|
||||
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
|
||||
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
|
||||
@@ -44,7 +44,7 @@ export default function TournamentOrganizationEditPage() {
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<MyForm
|
||||
<SendouForm
|
||||
heading={t("org:edit.form.title")}
|
||||
schema={organizationEditSchema}
|
||||
defaultValues={{
|
||||
@@ -74,9 +74,12 @@ export default function TournamentOrganizationEditPage() {
|
||||
{t("org:edit.form.uploadLogo")}
|
||||
</Link>
|
||||
|
||||
<TextFormField<FormFields> label={t("common:forms.name")} name="name" />
|
||||
<InputFormField<FormFields>
|
||||
label={t("common:forms.name")}
|
||||
name="name"
|
||||
/>
|
||||
|
||||
<TextAreaFormField<typeof organizationEditSchema>
|
||||
<TextAreaFormField<FormFields>
|
||||
label={t("common:forms.description")}
|
||||
name="description"
|
||||
maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH}
|
||||
@@ -84,16 +87,16 @@ export default function TournamentOrganizationEditPage() {
|
||||
|
||||
<MembersFormField />
|
||||
|
||||
<TextArrayFormField<typeof organizationEditSchema>
|
||||
<TextArrayFormField<FormFields>
|
||||
label={t("org:edit.form.socialLinks.title")}
|
||||
name="socials"
|
||||
defaultFieldValue=""
|
||||
format="object"
|
||||
/>
|
||||
|
||||
<SeriesFormField />
|
||||
|
||||
<BadgesFormField />
|
||||
</MyForm>
|
||||
</SendouForm>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +162,7 @@ function MemberFieldset({
|
||||
}))}
|
||||
/>
|
||||
|
||||
<TextFormField<FormFields>
|
||||
<InputFormField<FormFields>
|
||||
label={t("org:edit.form.members.roleDisplayName.title")}
|
||||
name={`members.${idx}.roleDisplayName` as const}
|
||||
/>
|
||||
@@ -213,7 +216,7 @@ function SeriesFieldset({
|
||||
clearErrors("series");
|
||||
}}
|
||||
>
|
||||
<TextFormField<FormFields>
|
||||
<InputFormField<FormFields>
|
||||
label={t("org:edit.form.series.seriesName.title")}
|
||||
name={`series.${idx}.name` as const}
|
||||
/>
|
||||
|
||||
@@ -452,6 +452,7 @@ export function forShowcase() {
|
||||
.select((eb) => [
|
||||
"Tournament.id",
|
||||
"Tournament.settings",
|
||||
"CalendarEvent.authorId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEventDate.startTime",
|
||||
eb
|
||||
@@ -538,6 +539,36 @@ function databaseTimestampWeekAgo() {
|
||||
return dateToDatabaseTimestamp(now);
|
||||
}
|
||||
|
||||
export function findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.innerJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.select(["Tournament.id as tournamentId"])
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
">=",
|
||||
dateToDatabaseTimestamp(startTime),
|
||||
)
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
"<=",
|
||||
dateToDatabaseTimestamp(endTime),
|
||||
)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function topThreeResultsByTournamentId(tournamentId: number) {
|
||||
return db
|
||||
.selectFrom("TournamentResult")
|
||||
|
||||
@@ -14,7 +14,6 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseFormData,
|
||||
parseParams,
|
||||
uploadImageIfSubmitted,
|
||||
@@ -25,7 +24,6 @@ import { idObject } from "~/utils/zod";
|
||||
import { checkIn } from "../queries/checkIn.server";
|
||||
import { deleteTeam } from "../queries/deleteTeam.server";
|
||||
import deleteTeamMember from "../queries/deleteTeamMember.server";
|
||||
import { findByIdentifier } from "../queries/findByIdentifier.server";
|
||||
import { findOwnTournamentTeam } from "../queries/findOwnTournamentTeam.server";
|
||||
import { joinTeam } from "../queries/joinLeaveTeam.server";
|
||||
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
|
||||
@@ -52,7 +50,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
schema: idObject,
|
||||
});
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
|
||||
|
||||
errorToastIfFalsy(
|
||||
!tournament.hasStarted,
|
||||
@@ -198,7 +195,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
errorToastIfFalsy(
|
||||
validateCounterPickMapPool(
|
||||
mapPool,
|
||||
isOneModeTournamentOf(event),
|
||||
isOneModeTournamentOf(
|
||||
tournament.ctx.mapPickingStyle,
|
||||
tournament.ctx.toSetMapPool,
|
||||
),
|
||||
tournament.ctx.tieBreakerMapPool,
|
||||
) === "VALID",
|
||||
"Invalid map pool",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as R from "remeda";
|
||||
import { INVITE_CODE_LENGTH } from "~/constants";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { weekNumberToDate } from "~/utils/dates";
|
||||
import { tournamentLogoUrl } from "~/utils/urls";
|
||||
import type { Tables, TournamentStageSettings } from "../../db/tables";
|
||||
@@ -13,33 +14,46 @@ import type { TournamentData } from "../tournament-bracket/core/Tournament.serve
|
||||
import type { PlayedSet } from "./core/sets.server";
|
||||
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
|
||||
|
||||
const mapPickingStyleToModeRecord = {
|
||||
AUTO_SZ: ["SZ"],
|
||||
AUTO_TC: ["TC"],
|
||||
AUTO_RM: ["RM"],
|
||||
AUTO_CB: ["CB"],
|
||||
AUTO_ALL: rankedModesShort,
|
||||
} as const;
|
||||
|
||||
export const mapPickingStyleToModes = (
|
||||
mapPickingStyle: Exclude<Tables["Tournament"]["mapPickingStyle"], "TO">,
|
||||
) => {
|
||||
return mapPickingStyleToModeRecord[mapPickingStyle].slice();
|
||||
};
|
||||
|
||||
export function modesIncluded(
|
||||
tournament: Pick<Tables["Tournament"], "mapPickingStyle">,
|
||||
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"],
|
||||
toSetMapPool: Array<{ mode: ModeShort }>,
|
||||
): ModeShort[] {
|
||||
switch (tournament.mapPickingStyle) {
|
||||
case "AUTO_SZ": {
|
||||
return ["SZ"];
|
||||
}
|
||||
case "AUTO_TC": {
|
||||
return ["TC"];
|
||||
}
|
||||
case "AUTO_RM": {
|
||||
return ["RM"];
|
||||
}
|
||||
case "AUTO_CB": {
|
||||
return ["CB"];
|
||||
}
|
||||
default: {
|
||||
return [...rankedModesShort];
|
||||
}
|
||||
if (mapPickingStyle !== "TO") {
|
||||
return mapPickingStyleToModes(mapPickingStyle);
|
||||
}
|
||||
|
||||
const pickedModes = R.unique(toSetMapPool.map((map) => map.mode));
|
||||
|
||||
// fallback
|
||||
if (pickedModes.length === 0) {
|
||||
return [...rankedModesShort];
|
||||
}
|
||||
|
||||
return pickedModes.sort(
|
||||
(a, b) => modesShort.indexOf(a) - modesShort.indexOf(b),
|
||||
);
|
||||
}
|
||||
|
||||
export function isOneModeTournamentOf(
|
||||
tournament: Pick<Tables["Tournament"], "mapPickingStyle">,
|
||||
mapPickingStyle: Tables["Tournament"]["mapPickingStyle"],
|
||||
toSetMapPool: Array<{ mode: ModeShort }>,
|
||||
) {
|
||||
return modesIncluded(tournament).length === 1
|
||||
? modesIncluded(tournament)[0]
|
||||
return modesIncluded(mapPickingStyle, toSetMapPool).length === 1
|
||||
? modesIncluded(mapPickingStyle, toSetMapPool)[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -370,3 +384,10 @@ export function validateCanJoinTeam({
|
||||
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
export function normalizedTeamCount({
|
||||
teamsCount,
|
||||
minMembersPerTeam,
|
||||
}: { teamsCount: number; minMembersPerTeam: number }) {
|
||||
return teamsCount * minMembersPerTeam;
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ function ExtraInfos() {
|
||||
|
||||
return (
|
||||
<div className="u__extra-infos">
|
||||
<div className="u__extra-info">#{data.user.id}</div>
|
||||
{data.user.discordUniqueName && (
|
||||
<div className="u__extra-info">
|
||||
<span className="u__extra-info__heading">
|
||||
|
||||
@@ -27,9 +27,9 @@ import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { Alert } from "../../../components/Alert";
|
||||
import { DateFormField } from "../../../components/form/DateFormField";
|
||||
import { MyForm } from "../../../components/form/MyForm";
|
||||
import { InputFormField } from "../../../components/form/InputFormField";
|
||||
import { SelectFormField } from "../../../components/form/SelectFormField";
|
||||
import { TextFormField } from "../../../components/form/TextFormField";
|
||||
import { SendouForm } from "../../../components/form/SendouForm";
|
||||
import { videoMatchTypes } from "../vods-constants";
|
||||
import { videoInputSchema } from "../vods-schemas";
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function NewVodPage() {
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<MyForm
|
||||
<SendouForm
|
||||
heading={
|
||||
data.vodToEdit
|
||||
? t("vods:forms.title.edit")
|
||||
@@ -83,7 +83,7 @@ export default function NewVodPage() {
|
||||
}
|
||||
>
|
||||
<FormFields />
|
||||
</MyForm>
|
||||
</SendouForm>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ function FormFields() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextFormField<VodFormFields>
|
||||
<InputFormField<VodFormFields>
|
||||
label={t("vods:forms.title.youtubeUrl")}
|
||||
name="video.youtubeUrl"
|
||||
placeholder="https://www.youtube.com/watch?v=-dQ6JsVIKdY"
|
||||
@@ -104,7 +104,7 @@ function FormFields() {
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<TextFormField<VodFormFields>
|
||||
<InputFormField<VodFormFields>
|
||||
label={t("vods:forms.title.videoTitle")}
|
||||
name="video.title"
|
||||
placeholder="[SCL 47] (Grand Finals) Team Olive vs. Kraken Paradise"
|
||||
@@ -278,7 +278,7 @@ function MatchesFieldset({
|
||||
{canRemove ? <RemoveFieldButton onClick={() => remove(idx)} /> : null}
|
||||
</div>
|
||||
|
||||
<TextFormField<VodFormFields>
|
||||
<InputFormField<VodFormFields>
|
||||
required
|
||||
label={t("vods:forms.title.startTimestamp")}
|
||||
name={`video.matches.${idx}.startsAt`}
|
||||
|
||||
2
app/modules/in-game-lists/games.ts
Normal file
2
app/modules/in-game-lists/games.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const gamesShort = ["S1", "S2", "S3"] as const;
|
||||
export const versusShort = ["1v1", "2v2", "3v3", "4v4"] as const;
|
||||
@@ -1,4 +1,4 @@
|
||||
export { modes, modesShort } from "./modes";
|
||||
export { modesShort } from "./modes";
|
||||
export {
|
||||
weaponCategories,
|
||||
mainWeaponIds,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { RankedModeShort } from "./types";
|
||||
|
||||
export const modes = [
|
||||
{ short: "TW" },
|
||||
{ short: "SZ" },
|
||||
{ short: "TC" },
|
||||
{ short: "RM" },
|
||||
{ short: "CB" },
|
||||
] as const;
|
||||
|
||||
export const modesShort = modes.map((mode) => mode.short);
|
||||
export const modesShort = ["TW", "SZ", "TC", "RM", "CB"] as const;
|
||||
export const rankedModesShort = modesShort.slice(1) as RankedModeShort[];
|
||||
|
||||
export const modesShortWithSpecial = [...modesShort, "TB", "SR"] as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { abilities } from "./abilities";
|
||||
import type { modes } from "./modes";
|
||||
import type { modesShort, modesShortWithSpecial } from "./modes";
|
||||
import type { stageIds } from "./stage-ids";
|
||||
import type {
|
||||
mainWeaponIds,
|
||||
@@ -7,8 +7,9 @@ import type {
|
||||
subWeaponIds,
|
||||
} from "./weapon-ids";
|
||||
|
||||
export type ModeShort = (typeof modes)[number]["short"];
|
||||
export type RankedModeShort = "SZ" | "TC" | "RM" | "CB";
|
||||
export type ModeShort = (typeof modesShort)[number];
|
||||
export type ModeShortWithSpecial = (typeof modesShortWithSpecial)[number];
|
||||
export type RankedModeShort = Exclude<ModeShort, "TW">;
|
||||
|
||||
export type StageId = (typeof stageIds)[number];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as CalendarRepository from "../features/calendar/CalendarRepository.server";
|
||||
import { notify } from "../features/notifications/core/notify.server";
|
||||
import { tournamentDataCached } from "../features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Routine } from "./routine.server";
|
||||
|
||||
@@ -9,10 +9,9 @@ export const NotifyCheckInStartRoutine = new Routine({
|
||||
func: async () => {
|
||||
const now = new Date();
|
||||
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
|
||||
const tournaments = await CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
const tournaments = await TournamentRepository.findAllBetweenTwoTimestamps({
|
||||
startTime: now,
|
||||
endTime: oneHourFromNow,
|
||||
onlyTournaments: true,
|
||||
});
|
||||
|
||||
for (const { tournamentId } of tournaments) {
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
.calendar__placeholder {
|
||||
height: 48rem;
|
||||
}
|
||||
|
||||
.calendar__weeks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
overflow-x: hidden;
|
||||
padding-inline: var(--s-2-5);
|
||||
|
||||
--full-size-week-height: 6.75rem;
|
||||
--full-size-week-width: 5rem;
|
||||
}
|
||||
|
||||
.calendar__week {
|
||||
display: flex;
|
||||
min-width: calc(var(--full-size-week-width) - 2rem);
|
||||
height: auto;
|
||||
min-height: calc(var(--full-size-week-height) - 2rem);
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-1-5);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--theme-very-transparent);
|
||||
cursor: pointer;
|
||||
font-size: var(--fonts-xxxs);
|
||||
font-weight: var(--bold);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar__week:hover {
|
||||
background-color: var(--theme-transparent);
|
||||
}
|
||||
|
||||
.calendar__week:focus {
|
||||
outline: 2px solid var(--theme);
|
||||
}
|
||||
|
||||
.calendar__event-count {
|
||||
font-size: var(--fonts-xxxs);
|
||||
font-weight: var(--body);
|
||||
}
|
||||
|
||||
.calendar__week:nth-child(1),
|
||||
.calendar__week:nth-child(2),
|
||||
.calendar__week:nth-child(8),
|
||||
.calendar__week:nth-child(9) {
|
||||
min-width: calc(var(--full-size-week-width) - 2.5rem);
|
||||
height: calc(var(--full-size-week-height) - 2.5rem);
|
||||
cursor: initial;
|
||||
font-size: var(--fonts-xxxxs);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.calendar__week:nth-child(5) {
|
||||
min-width: var(--full-size-week-width);
|
||||
min-height: var(--full-size-week-height);
|
||||
color: var(--text);
|
||||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
||||
.calendar__week:nth-child(5) > .calendar__event-count {
|
||||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
||||
.calendar__week:nth-child(4),
|
||||
.calendar__week:nth-child(6) {
|
||||
min-width: calc(var(--full-size-week-width) - 1rem);
|
||||
min-height: calc(var(--full-size-week-height) - 1rem);
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.calendar__week:nth-child(4) > .calendar__event-count,
|
||||
.calendar__week:nth-child(6) > .calendar__event-count {
|
||||
font-size: var(--fonts-xxs);
|
||||
}
|
||||
|
||||
.calendar__week__relative {
|
||||
max-width: 3rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.calendar__week__dash {
|
||||
line-height: 0.8;
|
||||
}
|
||||
|
||||
.calendar__events-to-report {
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.calendar__events-container {
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.calendar__event {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--s-3);
|
||||
font-size: var(--fonts-lg);
|
||||
padding-inline: var(--s-4);
|
||||
}
|
||||
|
||||
.calendar__event + .calendar__event {
|
||||
border-top: 2px solid var(--divider);
|
||||
}
|
||||
|
||||
.calendar__event:last-child {
|
||||
padding-block-end: var(--s-4);
|
||||
}
|
||||
|
||||
.calendar__event__date-container {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.calendar__event__divider {
|
||||
padding-block-start: 40px;
|
||||
}
|
||||
|
||||
.calendar__event__date {
|
||||
font-weight: var(--semi-bold);
|
||||
padding-block-start: var(--s-6);
|
||||
}
|
||||
|
||||
.calendar__event__time {
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.calendar__event__top-info-container {
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
align-items: center;
|
||||
font-size: var(--fonts-sm);
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.calendar__event__author {
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.calendar__event__title {
|
||||
color: var(--text);
|
||||
font-size: var(--fonts-xl);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.calendar__event__day {
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.calendar__event__participant-counts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
gap: var(--s-2);
|
||||
margin-block-end: var(--s-1);
|
||||
}
|
||||
|
||||
.calendar__event__participant-counts > svg {
|
||||
width: 1rem;
|
||||
margin-block-end: 1px;
|
||||
}
|
||||
|
||||
.calendar__event__bottom-info-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--fonts-sm);
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.calendar__no-events {
|
||||
color: var(--text-lighter);
|
||||
padding-block: var(--s-16);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar__time-zone-info {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
padding-block-end: var(--s-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar__event-logo {
|
||||
border-radius: 100%;
|
||||
min-width: 40px;
|
||||
}
|
||||
@@ -712,6 +712,18 @@ abbr[title] {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calendar__event__tags.small {
|
||||
font-size: var(--fonts-xxxs);
|
||||
}
|
||||
|
||||
.calendar__event__tags.centered {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar__event__tags.small > li {
|
||||
padding: 0 var(--s-1);
|
||||
}
|
||||
|
||||
.calendar__event__tags > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -724,20 +736,6 @@ abbr[title] {
|
||||
color: var(--black-text);
|
||||
}
|
||||
|
||||
.calendar__event__badge-tag {
|
||||
color: var(--badge-text);
|
||||
}
|
||||
|
||||
.calendar__event__ranked-tag {
|
||||
background-color: var(--theme-info-transparent);
|
||||
color: var(--theme-info);
|
||||
}
|
||||
|
||||
.calendar__event__unranked-tag {
|
||||
background-color: var(--theme-success-transparent);
|
||||
color: var(--theme-success);
|
||||
}
|
||||
|
||||
.calendar__event__tag-delete-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -195,114 +195,6 @@
|
||||
background-color: var(--bg-lightest);
|
||||
}
|
||||
|
||||
.front__tournament-card__container {
|
||||
min-width: var(--card-width);
|
||||
max-width: var(--card-width);
|
||||
height: calc(var(--card-height) + 22px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.front__tournament-card__container__tall {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.front__tournament-card {
|
||||
background-color: var(--bg-lightest);
|
||||
border-radius: var(--rounded);
|
||||
color: var(--text);
|
||||
padding: var(--s-1) var(--s-2) var(--s-2) var(--s-2);
|
||||
height: 100%;
|
||||
min-width: var(--card-width);
|
||||
max-width: var(--card-width);
|
||||
}
|
||||
|
||||
.front__tournament-card:hover .front__tournament-card__tournament-avatar-img {
|
||||
outline: 6px solid var(--theme-transparent);
|
||||
}
|
||||
|
||||
.front__tournament-card__img-container {
|
||||
background-color: var(--bg);
|
||||
padding: var(--s-1-5);
|
||||
border-radius: 100%;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.front__tournament-card__tournament-avatar-img {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.front__tournament-card__org {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--fonts-xxs);
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.front__tournament-card__time {
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text-lighter);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.front__tournament-card__name {
|
||||
text-align: center;
|
||||
font-weight: var(--semi-bold);
|
||||
font-size: var(--fonts-sm);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 225px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.front__tournament-card__team-count {
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--bold);
|
||||
background-color: var(--bg-lightest);
|
||||
border-radius: var(--rounded-sm);
|
||||
width: max-content;
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.front__tournament-card__team-count svg {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.front__tournament-card__tag {
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--bold);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
}
|
||||
|
||||
.front__tournament-card__ranked {
|
||||
background-color: var(--theme-info-transparent);
|
||||
color: var(--theme-info);
|
||||
}
|
||||
|
||||
.front__tournament-card__unranked {
|
||||
background-color: var(--theme-success-transparent);
|
||||
color: var(--theme-success);
|
||||
}
|
||||
|
||||
.front__tournament-card__first-placers {
|
||||
margin-inline: auto;
|
||||
margin-top: var(--s-5);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.front__tournament-card__first-placers__team-name {
|
||||
max-width: 150px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.front__result-highlights {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
||||
@@ -402,7 +402,6 @@
|
||||
|
||||
.layout__overlay-nav__dialog {
|
||||
padding-block: var(--s-12) !important;
|
||||
padding-block-end: var(--s-32) !important;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import { getWeek } from "date-fns";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
import type { DayMonthYear } from "./zod";
|
||||
|
||||
export function databaseTimestampToDate(timestamp: number) {
|
||||
return new Date(timestamp * 1000);
|
||||
return new Date(databaseTimestampToJavascriptTimestamp(timestamp));
|
||||
}
|
||||
|
||||
export function databaseTimestampToJavascriptTimestamp(timestamp: number) {
|
||||
return timestamp * 1000;
|
||||
}
|
||||
|
||||
export function dateToDatabaseTimestamp(date: Date) {
|
||||
@@ -21,6 +26,15 @@ export function dayMonthYearToDate({ day, month, year }: DayMonthYear) {
|
||||
return new Date(Date.UTC(year, month, day, 12));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date represented by day, month, and year into a DateValue object (used by react-aria-components), noon UTC.
|
||||
*/
|
||||
export function dayMonthYearToDateValue({ day, month, year }: DayMonthYear) {
|
||||
const isoString = dateToYYYYMMDD(new Date(Date.UTC(year, month, day, 12)));
|
||||
|
||||
return parseDate(isoString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date represented by day, month, and year into a database timestamp, noon UTC.
|
||||
*/
|
||||
|
||||
@@ -59,6 +59,11 @@ export async function selectComboboxValue({
|
||||
/** page.goto that waits for the page to be hydrated before proceeding */
|
||||
export async function navigate({ page, url }: { page: Page; url: string }) {
|
||||
await page.goto(url);
|
||||
await expectIsHydrated(page);
|
||||
}
|
||||
|
||||
/** Waits and expects the page to be hydrated (click handlers etc. ready for testing) */
|
||||
export async function expectIsHydrated(page: Page) {
|
||||
await expect(page.getByTestId("hydrated")).toHaveCount(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Asserts that a code path is unreachable by accepting a value of type `never`.
|
||||
* This function is useful for exhaustive checks in switch statements or discriminated unions.
|
||||
* If called, it throws an error with a message containing the unexpected value.
|
||||
*
|
||||
* @param x - The value that should never occur (of type `never`).
|
||||
* @throws {Error} Throws an error indicating an unexpected value was encountered.
|
||||
*/
|
||||
export function assertUnreachable(x: never): never {
|
||||
throw new Error(
|
||||
`Didn't expect to get here. Unexpected value: ${JSON.stringify(x)}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GearType, Preference, Tables } from "~/db/tables";
|
||||
import type { ArtSource } from "~/features/art/art-types";
|
||||
import type { AuthErrorCode } from "~/features/auth/core/errors";
|
||||
import { serializeBuild } from "~/features/build-analyzer";
|
||||
import type { CalendarFilters } from "~/features/calendar/calendar-types";
|
||||
import type { StageBackgroundStyle } from "~/features/map-planner";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants";
|
||||
@@ -12,10 +13,12 @@ import type {
|
||||
AbilityWithUnknown,
|
||||
BuildAbilitiesTupleWithUnknown,
|
||||
MainWeaponId,
|
||||
ModeShortWithSpecial,
|
||||
SpecialWeaponId,
|
||||
StageId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { DayMonthYear } from "~/utils/zod";
|
||||
|
||||
const staticAssetsUrl = ({
|
||||
folder,
|
||||
@@ -262,6 +265,31 @@ export const weaponBuildStatsPage = (weaponSlug: string) =>
|
||||
export const weaponBuildPopularPage = (weaponSlug: string) =>
|
||||
`${weaponBuildPage(weaponSlug)}/popular`;
|
||||
|
||||
export const calendarPage = (args?: {
|
||||
filters?: CalendarFilters;
|
||||
dayMonthYear?: DayMonthYear;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (args?.filters) {
|
||||
params.set("filters", JSON.stringify(args.filters));
|
||||
}
|
||||
if (args?.dayMonthYear) {
|
||||
params.set("day", String(args.dayMonthYear.day));
|
||||
params.set("month", String(args.dayMonthYear.month));
|
||||
params.set("year", String(args.dayMonthYear.year));
|
||||
}
|
||||
|
||||
return `${CALENDAR_PAGE}${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
};
|
||||
|
||||
export const calendarIcalFeed = (filters?: CalendarFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters) {
|
||||
params.set("filters", JSON.stringify(filters));
|
||||
}
|
||||
return `${SENDOU_INK_BASE_URL}/calendar.ics${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
};
|
||||
|
||||
export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`;
|
||||
export const calendarEditPage = (eventId?: number) =>
|
||||
`/calendar/new${eventId ? `?eventId=${eventId}` : ""}`;
|
||||
@@ -452,7 +480,7 @@ export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) =>
|
||||
`/static-assets/img/special-weapons/${specialWeaponSplId}`;
|
||||
export const abilityImageUrl = (ability: AbilityWithUnknown) =>
|
||||
`/static-assets/img/abilities/${ability}`;
|
||||
export const modeImageUrl = (mode: ModeShort) =>
|
||||
export const modeImageUrl = (mode: ModeShortWithSpecial) =>
|
||||
`/static-assets/img/modes/${mode}`;
|
||||
export const stageImageUrl = (stageId: StageId) =>
|
||||
`/static-assets/img/stages/${stageId}`;
|
||||
|
||||
@@ -106,6 +106,17 @@ export const qWeapon = z.object({
|
||||
});
|
||||
|
||||
export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]);
|
||||
export const modeShortWithSpecial = z.enum([
|
||||
"TW",
|
||||
"SZ",
|
||||
"TC",
|
||||
"RM",
|
||||
"CB",
|
||||
"SR",
|
||||
"TB",
|
||||
]);
|
||||
|
||||
export const gamesShortSchema = z.enum(["S1", "S2", "S3"]);
|
||||
|
||||
export const stageId = z.preprocess(actualNumber, numericEnum(stageIds));
|
||||
|
||||
@@ -334,9 +345,9 @@ export function numericEnum<TValues extends readonly number[]>(
|
||||
}
|
||||
|
||||
export const dayMonthYear = z.object({
|
||||
day: z.number().int().min(1).max(31),
|
||||
month: z.number().int().min(0).max(11),
|
||||
year: z.number().int().min(2015).max(2100),
|
||||
day: z.coerce.number().int().min(1).max(31),
|
||||
month: z.coerce.number().int().min(0).max(11),
|
||||
year: z.coerce.number().int().min(2015).max(2100),
|
||||
});
|
||||
|
||||
export type DayMonthYear = z.infer<typeof dayMonthYear>;
|
||||
|
||||
@@ -34,7 +34,7 @@ test.describe("Badges", () => {
|
||||
await expect(page).toHaveURL(badgePage(1));
|
||||
|
||||
await page.getByTestId("notifications-button").click();
|
||||
await page.getByText("See all").click();
|
||||
await page.getByTestId("notifications-see-all-button").click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Notifications" }),
|
||||
|
||||
104
e2e/calendar.spec.ts
Normal file
104
e2e/calendar.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import {
|
||||
expectIsHydrated,
|
||||
impersonate,
|
||||
isNotVisible,
|
||||
navigate,
|
||||
seed,
|
||||
} from "~/utils/playwright";
|
||||
import { calendarPage } from "~/utils/urls";
|
||||
|
||||
const SENDOU_INK_TOURNAMENTS_COUNT = 6;
|
||||
|
||||
test.describe("Calendar", () => {
|
||||
test("applies filters and operates hidden events toggle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seed(page);
|
||||
await navigate({
|
||||
page,
|
||||
url: calendarPage(),
|
||||
});
|
||||
|
||||
await page.getByTestId("filter-events-button").click();
|
||||
await page.getByText("Only events hosted on sendou.ink").click();
|
||||
|
||||
await page.getByText("Apply", { exact: true }).click();
|
||||
|
||||
const tournamentCardLocator = page.getByTestId("tournament-card");
|
||||
const hiddenEventsToggleButtonLocator = page.getByTestId(
|
||||
"hidden-events-button",
|
||||
);
|
||||
|
||||
await expect(tournamentCardLocator).toHaveCount(
|
||||
SENDOU_INK_TOURNAMENTS_COUNT,
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await expectIsHydrated(page);
|
||||
|
||||
// remembers selection via search params
|
||||
await expect(tournamentCardLocator).toHaveCount(
|
||||
SENDOU_INK_TOURNAMENTS_COUNT,
|
||||
);
|
||||
|
||||
await hiddenEventsToggleButtonLocator.first().click();
|
||||
|
||||
await expect
|
||||
.poll(() => tournamentCardLocator.count())
|
||||
.toBeGreaterThan(SENDOU_INK_TOURNAMENTS_COUNT);
|
||||
|
||||
const countAfterToggle = await tournamentCardLocator.count();
|
||||
|
||||
await hiddenEventsToggleButtonLocator.first().click();
|
||||
await expect
|
||||
.poll(() => tournamentCardLocator.count())
|
||||
.toBeLessThan(countAfterToggle); // not SENDOU_INK_TOURNAMENTS_COUNT as it's possible we untoggle more than one tournament
|
||||
});
|
||||
|
||||
test("sets default filters", async ({ page }) => {
|
||||
await seed(page);
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
await navigate({
|
||||
page,
|
||||
url: calendarPage(),
|
||||
});
|
||||
|
||||
const hiddenEventsToggleButtonLocator = page.getByTestId(
|
||||
"hidden-events-button",
|
||||
);
|
||||
|
||||
await isNotVisible(hiddenEventsToggleButtonLocator);
|
||||
|
||||
await page.getByTestId("filter-events-button").click();
|
||||
await page.getByText("Only ranked events").click();
|
||||
|
||||
await page.getByText("Apply & make default", { exact: true }).click();
|
||||
|
||||
await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible();
|
||||
|
||||
await navigate({
|
||||
page,
|
||||
url: calendarPage(),
|
||||
});
|
||||
|
||||
// remembers selection via user preferences
|
||||
await expect(hiddenEventsToggleButtonLocator.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigates view more buttons", async ({ page }) => {
|
||||
await seed(page);
|
||||
await navigate({
|
||||
page,
|
||||
url: calendarPage(),
|
||||
});
|
||||
|
||||
await page.getByTestId("calendar-navigate-button").first().click();
|
||||
|
||||
await isNotVisible(page.getByTestId("today-header"));
|
||||
|
||||
await page.getByTestId("calendar-navigate-button").nth(1).click();
|
||||
await expect(page.getByTestId("today-header")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "Discordserver-invitations-URL",
|
||||
"forms.tags": "Tags",
|
||||
"forms.tags.placeholder": "Vælg et tag",
|
||||
"forms.tags.info": "\"Præmiemærker\" tag tilføjes automatisk, hvis den er anvendelig",
|
||||
"forms.badges": "Præmiemærker",
|
||||
"forms.badges.placeholder": "Vælg et premiemærke",
|
||||
"forms.mapPool": "Banepulje",
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
|
||||
"errors.genericReload": "Noget gik galt. Prøv at genindlæse siden.",
|
||||
|
||||
"tag.name.BADGE": "Premiemærker",
|
||||
"tag.name.SPECIAL": "Særregler",
|
||||
"tag.name.ART": "Kunstpræmier",
|
||||
"tag.name.MONEY": "Pengepræmier",
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"forms.discordInvite": "Discord-Server Einladungs-URL",
|
||||
"forms.tags": "Tags",
|
||||
"forms.tags.placeholder": "Wähle einen Tag",
|
||||
"forms.tags.info": "\"Abzeichen-Preis\"-Tag wird automatisch hinzugefügt (falls anwendbar)",
|
||||
"forms.badges": "Abzeichen-Preis",
|
||||
"forms.badges.placeholder": "Wähle ein Abzeichen für das Event",
|
||||
"forms.mapPool": "Arenen-Pool",
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
|
||||
"errors.genericReload": "Etwas ist schiefgegangen. Versuche die Seite neu zu laden.",
|
||||
|
||||
"tag.name.BADGE": "Abzeichen-Preise",
|
||||
"tag.name.SPECIAL": "Spezielle Regeln",
|
||||
"tag.name.ART": "Kunst-Preise",
|
||||
"tag.name.MONEY": "Geld-Preise",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"inYourTimeZone": "Times in your local time zone:",
|
||||
"addNew": "Add new",
|
||||
"noEvents": "No events for the selected week",
|
||||
"reportResults": "You can report results:",
|
||||
"day": "Day {{number}}",
|
||||
"actions.reportWinners": "Report winners",
|
||||
"actions.delete": "Delete event",
|
||||
@@ -12,8 +9,6 @@
|
||||
"members": "Members",
|
||||
"results": "Results",
|
||||
"createMapList": "Create map list",
|
||||
"from": "From {{author}}",
|
||||
"pastEvents.dividerText": "Past events",
|
||||
"count.teams_one": "{{count}} team",
|
||||
"count.players_one": "{{count}} player",
|
||||
"count.teams_other": "{{count}} teams",
|
||||
@@ -24,7 +19,6 @@
|
||||
"forms.discordInvite": "Discord server invite URL",
|
||||
"forms.tags": "Tags",
|
||||
"forms.tags.placeholder": "Choose a tag",
|
||||
"forms.tags.info": "\"Badge prizes\" tag is added automatically if applicable",
|
||||
"forms.badges": "Badge prizes",
|
||||
"forms.badges.placeholder": "Choose a badge prize",
|
||||
"forms.mapPool": "Map pool",
|
||||
@@ -47,11 +41,6 @@
|
||||
"forms.errors.duplicatePlayer": "Can't have the same player twice in the same team.",
|
||||
"forms.errors.emptyTeam": "Each team must have at least one player.",
|
||||
|
||||
"week.this": "This Week",
|
||||
"week.next": "Next Week",
|
||||
"week.last": "Last Week",
|
||||
|
||||
"tag.desc.BADGE": "Winner of this event gets a sendou.ink badge.",
|
||||
"tag.desc.SPECIAL": "Ruleset that differs from the standard e.g. limited what weapons can be used.",
|
||||
"tag.desc.ART": "You can win art by playing in this tournament.",
|
||||
"tag.desc.MONEY": "You can win money by playing in this tournament.",
|
||||
@@ -66,7 +55,34 @@
|
||||
"tag.desc.S2": "The game played is Splatoon 2.",
|
||||
"tag.desc.SR": "Salmon Run event.",
|
||||
"tag.desc.CARDS": "Tableturf Battle event.",
|
||||
"tag.filter.label": "Filter by tags",
|
||||
|
||||
"tournament.filter.label": "Hosted on sendou.ink"
|
||||
"icalFeed": "iCal feed",
|
||||
|
||||
"filter.button": "Filter",
|
||||
"filter.heading": "Filter calendar events",
|
||||
"filter.modes": "Modes",
|
||||
"filter.exactModes": "Exact modes",
|
||||
"filter.exactModesBottom": "Only show events that match all selected modes",
|
||||
"filter.games": "Games",
|
||||
"filter.vs": "Vs.",
|
||||
"filter.vs.4v4": "4v4",
|
||||
"filter.vs.3v3": "3v3",
|
||||
"filter.vs.2v2": "2v2",
|
||||
"filter.vs.1v1": "1v1",
|
||||
"filter.startTime": "Start time",
|
||||
"filter.startTime.any": "Any",
|
||||
"filter.startTime.eu": "Europe friendly",
|
||||
"filter.startTime.na": "Americas friendly",
|
||||
"filter.startTime.au": "AU/NZ friendly",
|
||||
"filter.tagsIncluded": "Tags included",
|
||||
"filter.tagsExcluded": "Tags excluded",
|
||||
"filter.isSendou": "Only events hosted on sendou.ink",
|
||||
"filter.isRanked": "Only ranked events",
|
||||
"filter.minTeamCount": "Minimum team count",
|
||||
"filter.orgsIncluded": "Visible organizations",
|
||||
"filter.orgsExcluded": "Hidden organizations",
|
||||
"filter.authorIdsExcluded": "Authors excluded",
|
||||
"filter.authorIdsExcludedBottom": "You can find a user's id on their profile page",
|
||||
"filter.apply": "Apply",
|
||||
"filter.applyAndDefault": "Apply & make default"
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
"actions.enable": "Enable",
|
||||
"actions.disable": "Disable",
|
||||
"actions.accept": "Accept",
|
||||
"actions.next": "Next",
|
||||
"actions.previous": "Previous",
|
||||
|
||||
"noResults": "No results",
|
||||
|
||||
@@ -165,7 +167,6 @@
|
||||
|
||||
"errors.genericReload": "Something went wrong. Try reloading the page.",
|
||||
|
||||
"tag.name.BADGE": "Badge prizes",
|
||||
"tag.name.SPECIAL": "Special rules",
|
||||
"tag.name.ART": "Art prizes",
|
||||
"tag.name.MONEY": "Money prizes",
|
||||
|
||||
@@ -58,5 +58,10 @@
|
||||
"MODE_LONG_SZ": "Splat Zones",
|
||||
"MODE_LONG_TC": "Tower Control",
|
||||
"MODE_LONG_RM": "Rainmaker",
|
||||
"MODE_LONG_CB": "Clam Blitz"
|
||||
"MODE_LONG_CB": "Clam Blitz",
|
||||
"MODE_LONG_SR": "Salmon Run",
|
||||
"MODE_LONG_TB": "Tableturf Battle",
|
||||
"GAME_S1": "Splatoon 1",
|
||||
"GAME_S2": "Splatoon 2",
|
||||
"GAME_S3": "Splatoon 3"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "Enlace de invitación a Discord",
|
||||
"forms.tags": "Etiquetas",
|
||||
"forms.tags.placeholder": "Elegir etiquetas",
|
||||
"forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.",
|
||||
"forms.badges": "Premios de insignia",
|
||||
"forms.badges.placeholder": "Elige un premio de insignia",
|
||||
"forms.mapPool": "Grupo de mapas",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
|
||||
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
|
||||
|
||||
"tag.name.BADGE": "Premios de insignia",
|
||||
"tag.name.SPECIAL": "Reglas especiales",
|
||||
"tag.name.ART": "Premios de arte",
|
||||
"tag.name.MONEY": "Premios de dinero",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "Enlace de invitación a Discord",
|
||||
"forms.tags": "Etiquetas",
|
||||
"forms.tags.placeholder": "Elegir etiquetas",
|
||||
"forms.tags.info": "Etiqueta \"Premios de insignia\" se agrega automáticamente si corresponde.",
|
||||
"forms.badges": "Premios de insignia",
|
||||
"forms.badges.placeholder": "Elige un premio de insignia",
|
||||
"forms.mapPool": "Grupo de mapas",
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
|
||||
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
|
||||
|
||||
"tag.name.BADGE": "Premios de insignia",
|
||||
"tag.name.SPECIAL": "Reglas especiales",
|
||||
"tag.name.ART": "Premios de arte",
|
||||
"tag.name.MONEY": "Premios de dinero",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "Lien d'invitation du Discord",
|
||||
"forms.tags": "Tags",
|
||||
"forms.tags.placeholder": "Choisir un tag",
|
||||
"forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible",
|
||||
"forms.badges": "Badge à gagner",
|
||||
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
|
||||
"forms.mapPool": "Stages disponibles",
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
|
||||
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
|
||||
|
||||
"tag.name.BADGE": "Badge à gagner",
|
||||
"tag.name.SPECIAL": "Règles spéciales",
|
||||
"tag.name.ART": "Illustration à gagner",
|
||||
"tag.name.MONEY": "Argent à gagner",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "Lien d'invitation du Discord",
|
||||
"forms.tags": "Tags",
|
||||
"forms.tags.placeholder": "Choisir un tag",
|
||||
"forms.tags.info": "Le tag \"Badge à gagner\" est ajouté automatiquement si le tournoi est éligible",
|
||||
"forms.badges": "Badge à gagner",
|
||||
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
|
||||
"forms.mapPool": "Stages disponibles",
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
|
||||
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
|
||||
|
||||
"tag.name.BADGE": "Badge à gagner",
|
||||
"tag.name.SPECIAL": "Règles spéciales",
|
||||
"tag.name.ART": "Illustration à gagner",
|
||||
"tag.name.MONEY": "Argent à gagner",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "כתובת URL הזמנה לשרת Discord",
|
||||
"forms.tags": "תגים",
|
||||
"forms.tags.placeholder": "בחירת תג",
|
||||
"forms.tags.info": "\"Badge prizes\" תג נוסף אוטומטית אם רלוונטי",
|
||||
"forms.badges": "פרסי תגים",
|
||||
"forms.badges.placeholder": "בחירת פרס תג",
|
||||
"forms.mapPool": "מאגר מפות",
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
|
||||
"errors.genericReload": "משהו השתבש. נסו לטעון מחדש את העמוד.",
|
||||
|
||||
"tag.name.BADGE": "פרסי תג",
|
||||
"tag.name.SPECIAL": "חוקים מיוחדים",
|
||||
"tag.name.ART": "פרסי ציור",
|
||||
"tag.name.MONEY": "פרסים בכסף",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "URL invito server Discord",
|
||||
"forms.tags": "Tag",
|
||||
"forms.tags.placeholder": "Scegli un tag",
|
||||
"forms.tags.info": "Il tag \"Badge prizes\" è aggiunto automaticamente se necessario",
|
||||
"forms.badges": "Medaglie in palio",
|
||||
"forms.badges.placeholder": "Scegli una medaglia come premio",
|
||||
"forms.mapPool": "Pool di scenari",
|
||||
|
||||
@@ -151,7 +151,6 @@
|
||||
|
||||
"errors.genericReload": "Qualcosa è andato storto. Prova a ricaricare la pagina.",
|
||||
|
||||
"tag.name.BADGE": "Premi medaglie",
|
||||
"tag.name.SPECIAL": "Regole speciali",
|
||||
"tag.name.ART": "Premi artistici",
|
||||
"tag.name.MONEY": "Premi monetari",
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"forms.discordInvite": "Discord サーバー招待 URL",
|
||||
"forms.tags": "タグ",
|
||||
"forms.tags.placeholder": "タグを選択",
|
||||
"forms.tags.info": "\"バッジプライズ\" タグは可能な場合に自動的に適用されます",
|
||||
"forms.badges": "バッジプライズ",
|
||||
"forms.badges.placeholder": "バッジプライズを選択する",
|
||||
"forms.mapPool": "ステージプール",
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
|
||||
"errors.genericReload": "エラーが発生しました。ページを再読込してください。",
|
||||
|
||||
"tag.name.BADGE": "バッジプライズ",
|
||||
"tag.name.SPECIAL": "特別ルール",
|
||||
"tag.name.ART": "イラスト賞品",
|
||||
"tag.name.MONEY": "賞金",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user