mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 09:20:24 -05:00
Planned to be replaced with Playwright maybe? Just removing in the meanwhile so they don't confuse people. Or that people won't accidentally develop new.
553 lines
15 KiB
TypeScript
553 lines
15 KiB
TypeScript
import type { SerializeFrom } from "@remix-run/node";
|
|
import {
|
|
json,
|
|
redirect,
|
|
type ActionFunction,
|
|
type LinksFunction,
|
|
type LoaderArgs,
|
|
type MetaFunction,
|
|
} from "@remix-run/node";
|
|
import { Form, useLoaderData } from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { z } from "zod";
|
|
import { Badge } from "~/components/Badge";
|
|
import { Button } from "~/components/Button";
|
|
import { DateInput } from "~/components/DateInput";
|
|
import { FormMessage } from "~/components/FormMessage";
|
|
import { TrashIcon } from "~/components/icons/Trash";
|
|
import { Input } from "~/components/Input";
|
|
import { Label } from "~/components/Label";
|
|
import { Main } from "~/components/Main";
|
|
import { CALENDAR_EVENT } from "~/constants";
|
|
import { db } from "~/db";
|
|
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import { requireUser } from "~/modules/auth";
|
|
import { i18next } from "~/modules/i18n";
|
|
import { MapPool } from "~/modules/map-pool-serializer";
|
|
import { canEditCalendarEvent } from "~/permissions";
|
|
import calendarNewStyles from "~/styles/calendar-new.css";
|
|
import mapsStyles from "~/styles/maps.css";
|
|
import {
|
|
databaseTimestampToDate,
|
|
dateToDatabaseTimestamp,
|
|
} from "~/utils/dates";
|
|
import {
|
|
badRequestIfFalsy,
|
|
parseRequestFormData,
|
|
validate,
|
|
type SendouRouteHandle,
|
|
} from "~/utils/remix";
|
|
import { makeTitle } from "~/utils/strings";
|
|
import { calendarEventPage } from "~/utils/urls";
|
|
import {
|
|
actualNumber,
|
|
date,
|
|
falsyToNull,
|
|
id,
|
|
processMany,
|
|
removeDuplicates,
|
|
safeJSONParse,
|
|
toArray,
|
|
} from "~/utils/zod";
|
|
import { MapPoolSelector } from "~/components/MapPoolSelector";
|
|
import { Tags } from "./components/Tags";
|
|
|
|
const MIN_DATE = new Date(Date.UTC(2015, 4, 28));
|
|
|
|
const MAX_DATE = new Date();
|
|
MAX_DATE.setFullYear(MAX_DATE.getFullYear() + 1);
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [
|
|
{ rel: "stylesheet", href: calendarNewStyles },
|
|
{ rel: "stylesheet", href: mapsStyles },
|
|
];
|
|
};
|
|
|
|
export const meta: MetaFunction = (args) => {
|
|
const data = args.data as SerializeFrom<typeof loader> | null;
|
|
|
|
if (!data) return {};
|
|
|
|
return {
|
|
title: data.title,
|
|
};
|
|
};
|
|
|
|
const newCalendarEventActionSchema = z.object({
|
|
eventToEditId: 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()
|
|
),
|
|
date: z.preprocess(
|
|
toArray,
|
|
z
|
|
.array(z.preprocess(date, z.date().min(MIN_DATE).max(MAX_DATE)))
|
|
.min(1)
|
|
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES)
|
|
),
|
|
bracketUrl: z.string().url().max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH),
|
|
discordInviteCode: z.preprocess(
|
|
falsyToNull,
|
|
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable()
|
|
),
|
|
tags: z.preprocess(
|
|
processMany(safeJSONParse, removeDuplicates),
|
|
z
|
|
.array(
|
|
z
|
|
.string()
|
|
.refine((val) =>
|
|
CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)
|
|
)
|
|
)
|
|
.nullable()
|
|
),
|
|
badges: z.preprocess(
|
|
processMany(safeJSONParse, removeDuplicates),
|
|
z.array(id).nullable()
|
|
),
|
|
pool: z.string().optional(),
|
|
});
|
|
|
|
export const action: ActionFunction = async ({ request }) => {
|
|
const user = await requireUser(request);
|
|
const data = await parseRequestFormData({
|
|
request,
|
|
schema: newCalendarEventActionSchema,
|
|
});
|
|
|
|
const commonArgs = {
|
|
name: data.name,
|
|
description: data.description,
|
|
startTimes: data.date.map((date) => dateToDatabaseTimestamp(date)),
|
|
bracketUrl: data.bracketUrl,
|
|
discordInviteCode: data.discordInviteCode,
|
|
tags: data.tags
|
|
? data.tags
|
|
.sort(
|
|
(a, b) =>
|
|
CALENDAR_EVENT.TAGS.indexOf(a as CalendarEventTag) -
|
|
CALENDAR_EVENT.TAGS.indexOf(b as CalendarEventTag)
|
|
)
|
|
.join(",")
|
|
: data.tags,
|
|
badges: data.badges ?? [],
|
|
};
|
|
|
|
const deserializedMaps = (() => {
|
|
if (!data.pool) return;
|
|
|
|
return MapPool.toDbList(data.pool);
|
|
})();
|
|
|
|
if (data.eventToEditId) {
|
|
const eventToEdit = badRequestIfFalsy(
|
|
db.calendarEvents.findById(data.eventToEditId)
|
|
);
|
|
validate(canEditCalendarEvent({ user, event: eventToEdit }), 401);
|
|
|
|
db.calendarEvents.update({
|
|
eventId: data.eventToEditId,
|
|
mapPoolMaps: deserializedMaps,
|
|
...commonArgs,
|
|
});
|
|
|
|
return redirect(calendarEventPage(data.eventToEditId));
|
|
} else {
|
|
const createdEventId = db.calendarEvents.create({
|
|
authorId: user.id,
|
|
mapPoolMaps: deserializedMaps,
|
|
...commonArgs,
|
|
});
|
|
|
|
return redirect(calendarEventPage(createdEventId));
|
|
}
|
|
};
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: "calendar",
|
|
};
|
|
|
|
export const loader = async ({ request }: LoaderArgs) => {
|
|
const t = await i18next.getFixedT(request);
|
|
const user = await requireUser(request);
|
|
const url = new URL(request.url);
|
|
|
|
const eventId = Number(url.searchParams.get("eventId"));
|
|
const eventToEdit = Number.isNaN(eventId)
|
|
? undefined
|
|
: db.calendarEvents.findById(eventId);
|
|
|
|
const canEditEvent =
|
|
eventToEdit && canEditCalendarEvent({ user, event: eventToEdit });
|
|
|
|
return json({
|
|
managedBadges: db.badges.managedByUserId(user.id),
|
|
recentEventsWithMapPools: db.calendarEvents.findRecentMapPoolsByAuthorId(
|
|
user.id
|
|
),
|
|
eventToEdit: canEditEvent
|
|
? {
|
|
...eventToEdit,
|
|
// "BADGE" tag is special and can't be edited like other tags
|
|
tags: eventToEdit.tags.filter((tag) => tag !== "BADGE"),
|
|
badges: db.calendarEvents.findBadgesByEventId(eventId),
|
|
mapPool: db.calendarEvents.findMapPoolByEventId(eventId),
|
|
}
|
|
: undefined,
|
|
title: makeTitle([canEditEvent ? "Edit" : "New", t("pages.calendar")]),
|
|
});
|
|
};
|
|
|
|
export default function CalendarNewEventPage() {
|
|
const { t } = useTranslation();
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<Main className="calendar-new__container">
|
|
<Form className="stack md items-start" method="post">
|
|
{eventToEdit && (
|
|
<input
|
|
type="hidden"
|
|
name="eventToEditId"
|
|
value={eventToEdit.eventId}
|
|
/>
|
|
)}
|
|
<NameInput />
|
|
<DescriptionTextarea />
|
|
<DatesInput />
|
|
<BracketUrlInput />
|
|
<DiscordLinkInput />
|
|
<TagsAdder />
|
|
<BadgesAdder />
|
|
<MapPoolSection />
|
|
<Button type="submit" className="mt-4">
|
|
{t("actions.submit")}
|
|
</Button>
|
|
</Form>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
function NameInput() {
|
|
const { t } = useTranslation();
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="name" required>
|
|
{t("forms.name")}
|
|
</Label>
|
|
<input
|
|
name="name"
|
|
required
|
|
minLength={CALENDAR_EVENT.NAME_MIN_LENGTH}
|
|
maxLength={CALENDAR_EVENT.NAME_MAX_LENGTH}
|
|
defaultValue={eventToEdit?.name}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DescriptionTextarea() {
|
|
const { t } = useTranslation();
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
const [value, setValue] = React.useState(eventToEdit?.description ?? "");
|
|
|
|
return (
|
|
<div>
|
|
<Label
|
|
htmlFor="description"
|
|
valueLimits={{
|
|
current: value.length,
|
|
max: CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH,
|
|
}}
|
|
>
|
|
{t("forms.description")}
|
|
</Label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
maxLength={CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DatesInput() {
|
|
const { t } = useTranslation(["common", "calendar"]);
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
const [datesCount, setDatesCount] = React.useState(
|
|
eventToEdit?.startTimes.length ?? 1
|
|
);
|
|
const isMounted = useIsMounted();
|
|
|
|
const usersTimeZone = isMounted
|
|
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
: "";
|
|
|
|
return (
|
|
<div className="stack md items-start">
|
|
<div>
|
|
<Label htmlFor="date" required>
|
|
{t("calendar:forms.dates")}
|
|
</Label>
|
|
<div className="stack sm">
|
|
{new Array(datesCount).fill(null).map((_, i) => {
|
|
const defaultStartTime = eventToEdit?.startTimes[i];
|
|
|
|
return (
|
|
<div key={i} className="stack horizontal sm items-center">
|
|
<DateInput
|
|
id="date"
|
|
name="date"
|
|
defaultValue={
|
|
defaultStartTime
|
|
? databaseTimestampToDate(defaultStartTime)
|
|
: undefined
|
|
}
|
|
min={MIN_DATE}
|
|
max={MAX_DATE}
|
|
required
|
|
/>
|
|
{i === datesCount - 1 && (
|
|
<>
|
|
<Button
|
|
tiny
|
|
disabled={
|
|
datesCount === CALENDAR_EVENT.MAX_AMOUNT_OF_DATES
|
|
}
|
|
onClick={() => setDatesCount((count) => count + 1)}
|
|
>
|
|
{t("common:actions.add")}
|
|
</Button>
|
|
{datesCount > 1 && (
|
|
<Button
|
|
tiny
|
|
onClick={() => setDatesCount((count) => count - 1)}
|
|
variant="destructive"
|
|
>
|
|
{t("common:actions.remove")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<FormMessage type="info" className={clsx({ invisible: !isMounted })}>
|
|
{t("calendar:inYourTimeZone")} {usersTimeZone}
|
|
</FormMessage>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BracketUrlInput() {
|
|
const { t } = useTranslation("calendar");
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="bracketUrl" required>
|
|
{t("forms.bracketUrl")}
|
|
</Label>
|
|
<input
|
|
name="bracketUrl"
|
|
type="url"
|
|
required
|
|
maxLength={CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH}
|
|
defaultValue={eventToEdit?.bracketUrl}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiscordLinkInput() {
|
|
const { t } = useTranslation("calendar");
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<div className="stack items-start">
|
|
<Label htmlFor="discordInviteCode">{t("forms.discordInvite")}</Label>
|
|
<Input
|
|
name="discordInviteCode"
|
|
leftAddon="https://discord.gg/"
|
|
maxLength={CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH}
|
|
defaultValue={eventToEdit?.discordInviteCode ?? undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TagsAdder() {
|
|
const { t } = useTranslation(["common", "calendar"]);
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
const [tags, setTags] = React.useState(
|
|
(eventToEdit?.tags ?? []) as Array<CalendarEventTag>
|
|
);
|
|
const id = React.useId();
|
|
|
|
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
|
|
(tag) => !tags.includes(tag) && tag !== "BADGE"
|
|
);
|
|
|
|
return (
|
|
<div className="stack sm">
|
|
<input
|
|
type="hidden"
|
|
name="tags"
|
|
value={JSON.stringify(tags.length > 0 ? tags : null)}
|
|
/>
|
|
<div>
|
|
<label htmlFor={id}>{t("calendar:forms.tags")}</label>
|
|
<select
|
|
id={id}
|
|
className="calendar-new__select"
|
|
onChange={(e) =>
|
|
setTags([...tags, e.target.value as CalendarEventTag])
|
|
}
|
|
>
|
|
<option value="">{t("calendar:forms.tags.placeholder")}</option>
|
|
{tagsForSelect.map((tag) => (
|
|
<option key={tag} value={tag}>
|
|
{t(`common:tag.name.${tag}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<FormMessage type="info">{t("calendar:forms.tags.info")}</FormMessage>
|
|
</div>
|
|
<Tags
|
|
tags={tags}
|
|
onDelete={(tagToDelete) =>
|
|
setTags(tags.filter((tag) => tag !== tagToDelete))
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BadgesAdder() {
|
|
const { t } = useTranslation("calendar");
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
const { managedBadges } = useLoaderData<typeof loader>();
|
|
const [badges, setBadges] = React.useState(eventToEdit?.badges ?? []);
|
|
const id = React.useId();
|
|
|
|
const input = (
|
|
<input
|
|
type="hidden"
|
|
name="badges"
|
|
value={JSON.stringify(badges.length > 0 ? badges.map((b) => b.id) : null)}
|
|
/>
|
|
);
|
|
|
|
if (managedBadges.length === 0) return input;
|
|
|
|
const handleBadgeDelete = (badgeId: BadgeType["id"]) => {
|
|
setBadges(badges.filter((badge) => badge.id !== badgeId));
|
|
};
|
|
|
|
const badgesForSelect = managedBadges.filter(
|
|
(badge) => !badges.some((b) => b.id === badge.id)
|
|
);
|
|
|
|
return (
|
|
<div className="stack md">
|
|
{input}
|
|
<div>
|
|
<label htmlFor={id}>{t("forms.badges")}</label>
|
|
<select
|
|
id={id}
|
|
className="calendar-new__select"
|
|
onChange={(e) => {
|
|
setBadges([
|
|
...badges,
|
|
managedBadges.find(
|
|
(badge) => badge.id === Number(e.target.value)
|
|
)!,
|
|
]);
|
|
}}
|
|
>
|
|
<option value="">{t("forms.badges.placeholder")}</option>
|
|
{badgesForSelect.map((badge) => (
|
|
<option key={badge.id} value={badge.id}>
|
|
{badge.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{badges.length > 0 && (
|
|
<div className="calendar-new__badges">
|
|
{badges.map((badge) => (
|
|
<div className="stack horizontal md items-center" key={badge.id}>
|
|
<Badge badge={badge} isAnimated size={32} />
|
|
<span>{badge.displayName}</span>
|
|
<Button
|
|
className="ml-auto"
|
|
onClick={() => handleBadgeDelete(badge.id)}
|
|
icon={<TrashIcon />}
|
|
variant="minimal-destructive"
|
|
aria-label="Remove badge"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapPoolSection() {
|
|
const { t } = useTranslation(["game-misc", "common"]);
|
|
|
|
const { eventToEdit, recentEventsWithMapPools } =
|
|
useLoaderData<typeof loader>();
|
|
const [mapPool, setMapPool] = React.useState<MapPool>(
|
|
eventToEdit?.mapPool ? new MapPool(eventToEdit.mapPool) : MapPool.EMPTY
|
|
);
|
|
const [includeMapPool, setIncludeMapPool] = React.useState(
|
|
Boolean(eventToEdit?.mapPool)
|
|
);
|
|
|
|
const id = React.useId();
|
|
|
|
return includeMapPool ? (
|
|
<>
|
|
<input type="hidden" name="pool" value={mapPool.serialized} />
|
|
|
|
<MapPoolSelector
|
|
className="w-full"
|
|
mapPool={mapPool}
|
|
handleRemoval={() => setIncludeMapPool(false)}
|
|
handleMapPoolChange={setMapPool}
|
|
recentEvents={recentEventsWithMapPools}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div>
|
|
<label htmlFor={id}>{t("common:maps.mapPool")}</label>
|
|
<Button
|
|
id={id}
|
|
variant="outlined"
|
|
tiny
|
|
onClick={() => setIncludeMapPool(true)}
|
|
>
|
|
{t("common:actions.add")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|