mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 16:20:36 -05:00
Calendar new page can edit event
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -20,6 +20,8 @@ Calendar
|
||||
|
||||
## Other
|
||||
|
||||
- [ ] Tags selector can remove
|
||||
- [ ] Calendar new title
|
||||
- [ ] Sort tags so that they are always in same order
|
||||
- [x] Maybe make bracket URL mandatory?
|
||||
- [ ] components render twice on state change?
|
||||
|
||||
@@ -5,18 +5,25 @@ export function Input({
|
||||
className,
|
||||
minLength,
|
||||
maxLength,
|
||||
defaultValue,
|
||||
leftAddon,
|
||||
}: {
|
||||
name: string;
|
||||
className?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
defaultValue?: string;
|
||||
leftAddon?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("input-container", className)}>
|
||||
{leftAddon ? <div className="input-addon">{leftAddon}</div> : null}
|
||||
<input name={name} minLength={minLength} maxLength={maxLength} />
|
||||
<input
|
||||
name={name}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ const createStm = sql.prepare(`
|
||||
) returning *
|
||||
`);
|
||||
|
||||
const updateStm = sql.prepare(`
|
||||
update "CalendarEvent" set
|
||||
"name" = $name,
|
||||
"tags" = $tags,
|
||||
"description" = $description,
|
||||
"discordInviteCode" = $discordInviteCode,
|
||||
"bracketUrl" = $bracketUrl
|
||||
where "id" = $eventId
|
||||
`);
|
||||
|
||||
const createDateStm = sql.prepare(`
|
||||
insert into "CalendarEventDate" (
|
||||
"eventId",
|
||||
@@ -38,6 +48,11 @@ const createDateStm = sql.prepare(`
|
||||
)
|
||||
`);
|
||||
|
||||
const deleteDatesByEventIdStm = sql.prepare(`
|
||||
delete from "CalendarEventDate"
|
||||
where "eventId" = $eventId
|
||||
`);
|
||||
|
||||
const createBadgeStm = sql.prepare(`
|
||||
insert into "CalendarEventBadge" (
|
||||
"eventId",
|
||||
@@ -49,6 +64,11 @@ const createBadgeStm = sql.prepare(`
|
||||
)
|
||||
`);
|
||||
|
||||
const deleteBadgesByEventIdStm = sql.prepare(`
|
||||
delete from "CalendarEventBadge"
|
||||
where "eventId" = $eventId
|
||||
`);
|
||||
|
||||
export type CreateArgs = Pick<
|
||||
CalendarEvent,
|
||||
| "name"
|
||||
@@ -83,6 +103,33 @@ export const create = sql.transaction(
|
||||
}
|
||||
);
|
||||
|
||||
export const update = sql.transaction(
|
||||
({
|
||||
startTimes,
|
||||
badges,
|
||||
eventId,
|
||||
...calendarEventArgs
|
||||
}: Omit<CreateArgs, "authorId"> & { eventId: CalendarEvent["id"] }) => {
|
||||
updateStm.run({ ...calendarEventArgs, eventId });
|
||||
|
||||
deleteDatesByEventIdStm.run({ eventId });
|
||||
for (const startTime of startTimes) {
|
||||
createDateStm.run({
|
||||
eventId,
|
||||
startTime,
|
||||
});
|
||||
}
|
||||
|
||||
deleteBadgesByEventIdStm.run({ eventId });
|
||||
for (const badgeId of badges) {
|
||||
createBadgeStm.run({
|
||||
eventId,
|
||||
badgeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const findAllBetweenTwoTimestampsStm = sql.prepare(`
|
||||
select
|
||||
"CalendarEvent"."name",
|
||||
@@ -150,9 +197,11 @@ const findByIdStm = sql.prepare(`
|
||||
select
|
||||
"CalendarEvent"."name",
|
||||
"CalendarEvent"."description",
|
||||
"CalendarEvent"."discordInviteCode",
|
||||
"CalendarEvent"."discordUrl",
|
||||
"CalendarEvent"."bracketUrl",
|
||||
"CalendarEvent"."tags",
|
||||
"User"."id" as "authorId",
|
||||
exists (select 1
|
||||
from "CalendarEventBadge"
|
||||
where "CalendarEventBadge"."eventId" = "CalendarEventDate"."eventId"
|
||||
@@ -174,7 +223,13 @@ export function findById(id: CalendarEvent["id"]) {
|
||||
const [firstRow, ...rest] = findByIdStm.all({ id }) as Array<
|
||||
Pick<
|
||||
CalendarEvent,
|
||||
"name" | "description" | "discordUrl" | "bracketUrl" | "tags"
|
||||
| "name"
|
||||
| "description"
|
||||
| "discordUrl"
|
||||
| "discordInviteCode"
|
||||
| "bracketUrl"
|
||||
| "tags"
|
||||
| "authorId"
|
||||
> &
|
||||
Pick<CalendarEventDate, "startTime" | "eventId"> &
|
||||
Pick<
|
||||
|
||||
@@ -2,11 +2,7 @@ import { Form, useLoaderData } from "@remix-run/react";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import * as React from "react";
|
||||
import type {
|
||||
Badge as BadgeType,
|
||||
CalendarEvent,
|
||||
CalendarEventTag,
|
||||
} from "~/db/types";
|
||||
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
|
||||
import {
|
||||
CALENDAR_EVENT_BRACKET_URL_MAX_LENGTH,
|
||||
CALENDAR_EVENT_DESCRIPTION_MAX_LENGTH,
|
||||
@@ -37,6 +33,7 @@ import { requireUser } from "~/modules/auth";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
actualNumber,
|
||||
date,
|
||||
falsyToNull,
|
||||
id,
|
||||
@@ -46,6 +43,7 @@ import {
|
||||
} from "~/utils/zod";
|
||||
import { parseRequestFormData } from "~/utils/remix";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
dateToYearMonthDayHourMinuteString,
|
||||
} from "~/utils/dates";
|
||||
@@ -60,7 +58,23 @@ export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
// export const meta: MetaFunction = (args) => {
|
||||
// const data = args.data as Nullable<UseDataFunctionReturn<typeof loader>>;
|
||||
|
||||
// if (!data) return {};
|
||||
|
||||
// return {
|
||||
// title: data.title,
|
||||
// description: `${data.events.length} events happening during week ${
|
||||
// data.displayedWeek
|
||||
// } including ${joinListToNaturalString(
|
||||
// data.events.slice(0, 3).map((e) => e.name)
|
||||
// )}`,
|
||||
// };
|
||||
// };
|
||||
|
||||
const newCalendarEventActionSchema = z.object({
|
||||
eventToEditId: z.preprocess(actualNumber, id.nullish()),
|
||||
name: z
|
||||
.string()
|
||||
.min(CALENDAR_EVENT_NAME_MIN_LENGTH)
|
||||
@@ -100,8 +114,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
schema: newCalendarEventActionSchema,
|
||||
});
|
||||
|
||||
const createdEventId = db.calendarEvents.create({
|
||||
authorId: user.id,
|
||||
const commonArgs = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
startTimes: data.dates.map((date) => dateToDatabaseTimestamp(date)),
|
||||
@@ -109,9 +122,27 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
discordInviteCode: data.discordInviteCode,
|
||||
tags: data.tags ? data.tags.join(",") : data.tags,
|
||||
badges: data.badges ?? [],
|
||||
});
|
||||
};
|
||||
if (data.eventToEditId) {
|
||||
const eventToEdit = db.calendarEvents.findById(data.eventToEditId);
|
||||
if (eventToEdit?.authorId !== user.id) {
|
||||
throw new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
return redirect(calendarEventPage(createdEventId));
|
||||
db.calendarEvents.update({
|
||||
eventId: data.eventToEditId,
|
||||
...commonArgs,
|
||||
});
|
||||
|
||||
return redirect(calendarEventPage(data.eventToEditId));
|
||||
} else {
|
||||
const createdEventId = db.calendarEvents.create({
|
||||
authorId: user.id,
|
||||
...commonArgs,
|
||||
});
|
||||
|
||||
return redirect(calendarEventPage(createdEventId));
|
||||
}
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
@@ -120,16 +151,35 @@ export const handle = {
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
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);
|
||||
|
||||
return json({
|
||||
managedBadges: db.badges.managedByUserId(user.id),
|
||||
eventToEdit:
|
||||
eventToEdit?.authorId === user.id
|
||||
? { ...eventToEdit, badges: db.calendarEvents.findBadgesById(eventId) }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default function CalendarNewEventPage() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<Form className="stack md items-start" method="post">
|
||||
{eventToEdit && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="eventToEditId"
|
||||
value={eventToEdit.eventId}
|
||||
/>
|
||||
)}
|
||||
<NameInput />
|
||||
<DescriptionTextarea />
|
||||
<DatesInput />
|
||||
@@ -146,6 +196,8 @@ export default function CalendarNewEventPage() {
|
||||
}
|
||||
|
||||
function NameInput() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="name" required>
|
||||
@@ -156,17 +208,15 @@ function NameInput() {
|
||||
required
|
||||
minLength={CALENDAR_EVENT_NAME_MIN_LENGTH}
|
||||
maxLength={CALENDAR_EVENT_NAME_MAX_LENGTH}
|
||||
defaultValue={eventToEdit?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionTextarea({
|
||||
initialValue,
|
||||
}: {
|
||||
initialValue?: CalendarEvent["description"];
|
||||
}) {
|
||||
const [value, setValue] = React.useState(initialValue ?? "");
|
||||
function DescriptionTextarea() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
const [value, setValue] = React.useState(eventToEdit?.description ?? "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -191,9 +241,15 @@ function DescriptionTextarea({
|
||||
}
|
||||
|
||||
function DatesInput() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
const { i18n } = useTranslation();
|
||||
const [dateInputValue, setDateInputValue] = React.useState<string>();
|
||||
const [dates, setDates] = React.useState<{ date: Date; id: string }[]>([]);
|
||||
const [dates, setDates] = React.useState(
|
||||
(eventToEdit?.startTimes ?? []).map((startTime) => ({
|
||||
date: databaseTimestampToDate(startTime),
|
||||
id: String(Math.random()),
|
||||
}))
|
||||
);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const usersTimeZone = isMounted
|
||||
@@ -249,23 +305,27 @@ function DatesInput() {
|
||||
{dates.map(({ date, id }, i) => (
|
||||
<React.Fragment key={id}>
|
||||
<div
|
||||
className={clsx("text-lighter", { hidden: dates.length === 1 })}
|
||||
className={clsx("text-lighter", {
|
||||
hidden: dates.length === 1,
|
||||
})}
|
||||
>
|
||||
Day {i + 1}
|
||||
</div>
|
||||
<div>
|
||||
{date.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
})}
|
||||
<div className={clsx({ invisible: !isMounted })}>
|
||||
{isMounted
|
||||
? date.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
})
|
||||
: "T"}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setDates(dates.filter((date) => date.id !== id))}
|
||||
className="mr-auto"
|
||||
className={clsx("mr-auto", { invisible: !isMounted })}
|
||||
icon={<TrashIcon />}
|
||||
variant="minimal-destructive"
|
||||
aria-label="Remove date"
|
||||
@@ -278,20 +338,9 @@ function DatesInput() {
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordLinkInput() {
|
||||
return (
|
||||
<div className="stack items-start">
|
||||
<Label htmlFor="discordInviteCode">Discord server invite URL</Label>
|
||||
<Input
|
||||
name="discordInviteCode"
|
||||
leftAddon="https://discord.gg/"
|
||||
maxLength={CALENDAR_EVENT_DISCORD_INVITE_CODE_MAX_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BracketUrlInput() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="bracketUrl" required>
|
||||
@@ -302,19 +351,39 @@ function BracketUrlInput() {
|
||||
type="url"
|
||||
required
|
||||
maxLength={CALENDAR_EVENT_BRACKET_URL_MAX_LENGTH}
|
||||
defaultValue={eventToEdit?.bracketUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordLinkInput() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack items-start">
|
||||
<Label htmlFor="discordInviteCode">Discord server invite URL</Label>
|
||||
<Input
|
||||
name="discordInviteCode"
|
||||
leftAddon="https://discord.gg/"
|
||||
maxLength={CALENDAR_EVENT_DISCORD_INVITE_CODE_MAX_LENGTH}
|
||||
defaultValue={eventToEdit?.discordInviteCode ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsAdder() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation("calendar");
|
||||
const [tags, setTags] = React.useState<CalendarEventTag[]>([]);
|
||||
const [tags, setTags] = React.useState(
|
||||
(eventToEdit?.tags ?? []) as Array<CalendarEventTag>
|
||||
);
|
||||
const id = React.useId();
|
||||
|
||||
const tagsForSelect = (
|
||||
Object.keys(allTags) as Array<CalendarEventTag>
|
||||
).filter((tag) => !tags.includes(tag));
|
||||
).filter((tag) => !tags.includes(tag) && tag !== "BADGE");
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
@@ -349,8 +418,9 @@ function TagsAdder() {
|
||||
}
|
||||
|
||||
function BadgesAdder() {
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
const { managedBadges } = useLoaderData<typeof loader>();
|
||||
const [badges, setBadges] = React.useState<BadgeType[]>([]);
|
||||
const [badges, setBadges] = React.useState(eventToEdit?.badges ?? []);
|
||||
const id = React.useId();
|
||||
|
||||
if (managedBadges.length === 0) return null;
|
||||
|
||||
Reference in New Issue
Block a user