mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-11 05:36:10 -05:00
Filter calendar events by tags (#1972)
* backend * Tournament docs only visible when adding tournament * Progress * Finish? * Fix
This commit is contained in:
@@ -33,7 +33,7 @@ import { mySlugify } from "~/utils/urls";
|
||||
import type { SeedVariation } from "~/features/api-private/routes/seed";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { tags } from "~/features/calendar/calendar-constants";
|
||||
import { persistedTags } from "~/features/calendar/calendar-constants";
|
||||
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
|
||||
import { TIMEZONES } from "~/features/lfg/lfg-constants";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
@@ -698,9 +698,7 @@ function calendarEvents() {
|
||||
const userIds = userIdsInRandomOrder();
|
||||
|
||||
for (let id = 1; id <= AMOUNT_OF_CALENDAR_EVENTS; id++) {
|
||||
const shuffledTags = shuffle(Object.keys(tags)).filter(
|
||||
(tag) => tag !== "BADGE",
|
||||
);
|
||||
const shuffledTags = shuffle(Object.keys(persistedTags));
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { tags } from "~/features/calendar/calendar-constants";
|
||||
import type {
|
||||
persistedTags,
|
||||
tags,
|
||||
} from "~/features/calendar/calendar-constants";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import type { TEAM_MEMBER_ROLES } from "~/features/team";
|
||||
import type {
|
||||
@@ -135,6 +138,7 @@ export interface CalendarEvent {
|
||||
tournamentId: number | null;
|
||||
}
|
||||
|
||||
export type PersistedCalendarEventTag = keyof typeof persistedTags;
|
||||
export type CalendarEventTag = keyof typeof tags;
|
||||
|
||||
export interface CalendarEventDate {
|
||||
|
||||
@@ -44,5 +44,10 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
|
||||
const endTime = new Date(startTime);
|
||||
endTime.setDate(endTime.getDate() + 7);
|
||||
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({ startTime, endTime });
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy: [],
|
||||
onlyTournaments: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables, TournamentSettings } from "~/db/tables";
|
||||
import type { CalendarEventTag } from "~/db/types";
|
||||
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/types";
|
||||
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";
|
||||
@@ -143,11 +143,15 @@ export type FindAllBetweenTwoTimestampsItem = Unwrapped<
|
||||
export async function findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
tagsToFilterBy: Array<PersistedCalendarEventTag>;
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
const rows = await db
|
||||
let query = db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
@@ -211,8 +215,17 @@ export async function findAllBetweenTwoTimestamps({
|
||||
"<=",
|
||||
dateToDatabaseTimestamp(endTime),
|
||||
)
|
||||
.orderBy("CalendarEventDate.startTime", "asc")
|
||||
.execute();
|
||||
.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
|
||||
@@ -305,17 +318,30 @@ async function tournamentParticipantCount({
|
||||
export async function startTimesOfRange({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
tagsToFilterBy: Array<PersistedCalendarEventTag>;
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
const rows = await db
|
||||
let query = db
|
||||
.selectFrom("CalendarEventDate")
|
||||
.innerJoin("CalendarEvent", "CalendarEvent.id", "CalendarEventDate.eventId")
|
||||
.select(["startTime"])
|
||||
.where("startTime", ">=", dateToDatabaseTimestamp(startTime))
|
||||
.where("startTime", "<=", dateToDatabaseTimestamp(endTime))
|
||||
.execute();
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { TOURNAMENT_STAGE_TYPES } from "~/db/tables";
|
||||
import type { CalendarEventTag } from "~/db/types";
|
||||
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/types";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
@@ -212,6 +212,12 @@ export const bracketProgressionSchema = z.preprocess(
|
||||
),
|
||||
);
|
||||
|
||||
export const calendarEventTagSchema = z
|
||||
.string()
|
||||
.refine((val) =>
|
||||
CALENDAR_EVENT.PERSISTED_TAGS.includes(val as PersistedCalendarEventTag),
|
||||
);
|
||||
|
||||
export const newCalendarEventActionSchema = z
|
||||
.object({
|
||||
eventToEditId: z.preprocess(actualNumber, id.nullish()),
|
||||
@@ -252,15 +258,7 @@ export const newCalendarEventActionSchema = z
|
||||
),
|
||||
tags: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.refine((val) =>
|
||||
CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag),
|
||||
),
|
||||
)
|
||||
.nullable(),
|
||||
z.array(calendarEventTagSchema).nullable(),
|
||||
),
|
||||
badges: z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { CalendarEventTag } from "~/db/types";
|
||||
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/types";
|
||||
|
||||
export const tags = {
|
||||
BADGE: {
|
||||
color: "#000",
|
||||
},
|
||||
export const persistedTags = {
|
||||
SPECIAL: {
|
||||
color: "#CE93D8",
|
||||
},
|
||||
@@ -60,6 +57,13 @@ export const tags = {
|
||||
},
|
||||
};
|
||||
|
||||
export const tags = {
|
||||
...persistedTags,
|
||||
BADGE: {
|
||||
color: "#000",
|
||||
},
|
||||
};
|
||||
|
||||
export const CALENDAR_EVENT = {
|
||||
NAME_MIN_LENGTH: 2,
|
||||
NAME_MAX_LENGTH: 100,
|
||||
@@ -68,6 +72,11 @@ export const CALENDAR_EVENT = {
|
||||
DISCORD_INVITE_CODE_MAX_LENGTH: 50,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -110,15 +110,17 @@ export default function CalendarNewEventPage() {
|
||||
<h1 className="text-lg">
|
||||
{data.isAddingTournament ? "New tournament" : "New calendar event"}
|
||||
</h1>
|
||||
<a
|
||||
href={CREATING_TOURNAMENT_DOC_LINK}
|
||||
className="text-lg text-bold"
|
||||
title="Documentation about creating tournaments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
?
|
||||
</a>
|
||||
{data.isAddingTournament ? (
|
||||
<a
|
||||
href={CREATING_TOURNAMENT_DOC_LINK}
|
||||
className="text-lg text-bold"
|
||||
title="Documentation about creating tournaments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
?
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{data.isAddingTournament ? <TemplateTournamentForm /> : null}
|
||||
<EventForm key={baseEvent?.eventId} />
|
||||
@@ -572,9 +574,8 @@ function TagsAdder() {
|
||||
const [tags, setTags] = React.useState(baseEvent?.tags ?? []);
|
||||
const id = React.useId();
|
||||
|
||||
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
|
||||
// @ts-expect-error TODO: fix this (5.5 version)
|
||||
(tag) => !tags.includes(tag) && tag !== "BADGE",
|
||||
const tagsForSelect = CALENDAR_EVENT.PERSISTED_TAGS.filter(
|
||||
(tag) => !tags.includes(tag),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { addDays, addMonths, subDays, subMonths } from "date-fns";
|
||||
import React from "react";
|
||||
@@ -14,15 +14,12 @@ import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import {
|
||||
@@ -45,8 +42,16 @@ import {
|
||||
tournamentPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { actualNumber } from "~/utils/zod";
|
||||
import { actualNumber, safeSplit } from "~/utils/zod";
|
||||
import { Label } from "../../../components/Label";
|
||||
import { Toggle } from "../../../components/Toggle";
|
||||
import type {
|
||||
CalendarEventTag,
|
||||
PersistedCalendarEventTag,
|
||||
} from "../../../db/types";
|
||||
import * as CalendarRepository from "../CalendarRepository.server";
|
||||
import { calendarEventTagSchema } from "../actions/calendar.new.server";
|
||||
import { CALENDAR_EVENT } from "../calendar-constants";
|
||||
import { Tags } from "../components/Tags";
|
||||
|
||||
import "~/styles/calendar.css";
|
||||
@@ -78,29 +83,52 @@ export const handle: SendouRouteHandle = {
|
||||
}),
|
||||
};
|
||||
|
||||
const loaderSearchParamsSchema = z.object({
|
||||
const loaderWeekSearchParamsSchema = z.object({
|
||||
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
|
||||
year: z.preprocess(actualNumber, z.number().int()),
|
||||
});
|
||||
|
||||
const loaderFilterSearchParamsSchema = z.object({
|
||||
tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)),
|
||||
});
|
||||
|
||||
const loaderTournamentsOnlySearchParamsSchema = z.object({
|
||||
tournaments: z.literal("true").nullish(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const t = await i18next.getFixedT(request);
|
||||
const url = new URL(request.url);
|
||||
const parsedParams = loaderSearchParamsSchema.safeParse({
|
||||
|
||||
// separate from tags parse so they can fail independently
|
||||
const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({
|
||||
year: url.searchParams.get("year"),
|
||||
week: url.searchParams.get("week"),
|
||||
});
|
||||
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
|
||||
tags: url.searchParams.get("tags"),
|
||||
});
|
||||
const parsedTournamentsOnlyParams =
|
||||
loaderTournamentsOnlySearchParamsSchema.safeParse({
|
||||
tournaments: url.searchParams.get("tournaments"),
|
||||
});
|
||||
|
||||
const mondayDate = dateToThisWeeksMonday(new Date());
|
||||
const currentWeek = dateToWeekNumber(mondayDate);
|
||||
|
||||
const displayedWeek = parsedParams.success
|
||||
? parsedParams.data.week
|
||||
const displayedWeek = parsedWeekParams.success
|
||||
? parsedWeekParams.data.week
|
||||
: currentWeek;
|
||||
const displayedYear = parsedParams.success
|
||||
? parsedParams.data.year
|
||||
const displayedYear = parsedWeekParams.success
|
||||
? parsedWeekParams.data.year
|
||||
: mondayDate.getFullYear();
|
||||
const tagsToFilterBy = parsedFilterParams.success
|
||||
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
|
||||
: [];
|
||||
const onlyTournaments = parsedTournamentsOnlyParams.success
|
||||
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
|
||||
: false;
|
||||
|
||||
return {
|
||||
currentWeek,
|
||||
@@ -115,11 +143,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
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)
|
||||
@@ -144,7 +176,12 @@ function closeByWeeks(args: { week: number; year: number }) {
|
||||
});
|
||||
}
|
||||
|
||||
function fetchEventsOfWeek(args: { week: number; year: number }) {
|
||||
function fetchEventsOfWeek(args: {
|
||||
week: number;
|
||||
year: number;
|
||||
tagsToFilterBy: PersistedCalendarEventTag[];
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
const startTime = weekNumberToDate(args);
|
||||
|
||||
const endTime = new Date(startTime);
|
||||
@@ -154,51 +191,38 @@ function fetchEventsOfWeek(args: { week: number; year: number }) {
|
||||
startTime.setHours(startTime.getHours() - 12);
|
||||
endTime.setHours(endTime.getHours() + 12);
|
||||
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({ startTime, endTime });
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy: args.tagsToFilterBy,
|
||||
onlyTournaments: args.onlyTournaments,
|
||||
});
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const { t } = useTranslation("calendar");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
const [onlySendouInkEvents, setOnlySendouInkEvents] = useSearchParamState({
|
||||
defaultValue: false,
|
||||
name: "tournaments",
|
||||
revive: (val) => val === "true",
|
||||
});
|
||||
|
||||
const filteredEvents = onlySendouInkEvents
|
||||
? data.events.filter((event) => event.tournamentId)
|
||||
: data.events;
|
||||
|
||||
// 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
|
||||
? filteredEvents.filter(
|
||||
? data.events.filter(
|
||||
(event) =>
|
||||
dateToWeekNumber(
|
||||
dateToSixHoursAgo(databaseTimestampToDate(event.startTime)),
|
||||
) === data.displayedWeek,
|
||||
)
|
||||
: filteredEvents;
|
||||
: data.events;
|
||||
|
||||
return (
|
||||
<Main classNameOverwrite="stack lg main layout__main">
|
||||
<WeekLinks />
|
||||
<EventsToReport />
|
||||
<div>
|
||||
<div className="stack horizontal justify-end">
|
||||
<div className="stack horizontal sm items-center">
|
||||
<Toggle
|
||||
id="onlySendouInk"
|
||||
tiny
|
||||
checked={onlySendouInkEvents}
|
||||
setChecked={setOnlySendouInkEvents}
|
||||
/>
|
||||
<Label spaced={false} htmlFor="onlySendouInk">
|
||||
Only sendou.ink events
|
||||
</Label>
|
||||
</div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<TagsFilter />
|
||||
<OnSendouInkToggle />
|
||||
</div>
|
||||
{isMounted ? (
|
||||
<>
|
||||
@@ -225,11 +249,20 @@ export default function CalendarPage() {
|
||||
function WeekLinks() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
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()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flipper flipKey={data.weeks.map(({ number }) => number).join("")}>
|
||||
<div className="flex justify-center">
|
||||
@@ -247,7 +280,7 @@ function WeekLinks() {
|
||||
return (
|
||||
<Flipped key={week.number} flipId={week.number}>
|
||||
<Link
|
||||
to={`?week=${week.number}&year=${week.year}`}
|
||||
to={linkTo({ week: week.number, year: week.year })}
|
||||
className={clsx("calendar__week", { invisible: hidden })}
|
||||
aria-hidden={hidden}
|
||||
tabIndex={hidden || isCurrentWeek ? -1 : 0}
|
||||
@@ -359,6 +392,98 @@ function EventsToReport() {
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
<Toggle
|
||||
id="onlyTournaments"
|
||||
checked={onlyTournaments}
|
||||
setChecked={setOnlyTournaments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsList({
|
||||
events,
|
||||
}: {
|
||||
|
||||
@@ -97,6 +97,21 @@ export function safeJSONParse(value: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely splits a string by a specified delimiter as Zod preprocess function.
|
||||
*
|
||||
* @param splitBy - The delimiter to split the string by. Defaults to a comma (",").
|
||||
* @returns A function that takes a value and returns the split string if the value is a string,
|
||||
* otherwise returns the original value.
|
||||
*/
|
||||
export const safeSplit =
|
||||
(splitBy = ",") =>
|
||||
(value: unknown): unknown => {
|
||||
if (typeof value !== "string") return value;
|
||||
|
||||
return value.split(splitBy);
|
||||
};
|
||||
|
||||
export function falsyToNull(value: unknown): unknown {
|
||||
if (value) return value;
|
||||
|
||||
|
||||
@@ -65,5 +65,8 @@
|
||||
"tag.desc.S1": "The game played is Splatoon 1.",
|
||||
"tag.desc.S2": "The game played is Splatoon 2.",
|
||||
"tag.desc.SR": "Salmon Run event.",
|
||||
"tag.desc.CARDS": "Tableturf Battle event."
|
||||
"tag.desc.CARDS": "Tableturf Battle event.",
|
||||
"tag.filter.label": "Filter by tags",
|
||||
|
||||
"tournament.filter.label": "Hosted on sendou.ink"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user