Skip to content

Commit

Permalink
Merge pull request #229 from UTDNebula/JUP-55
Browse files Browse the repository at this point in the history
JUP 55 - Create Event Page
  • Loading branch information
RubenOlano authored May 8, 2024
2 parents 73e183e + 0bd0cb9 commit 1aee81e
Show file tree
Hide file tree
Showing 13 changed files with 1,303 additions and 193 deletions.
352 changes: 161 additions & 191 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/app/manage/[clubId]/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ const Page = ({ params }: { params: { clubId: string } }) => {
<button className="rounded-md bg-blue-primary p-1 font-semibold text-white">
View members
</button>
<Link
href={`/manage/${params.clubId}/create`}
className="rounded-md bg-blue-primary p-1 font-semibold text-white"
>
Create Event
</Link>
</div>
</>
);
Expand Down
130 changes: 130 additions & 0 deletions src/app/manage/[clubId]/create/CreateEventForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
'use client'

import { useEffect, useState } from "react";
import { type SelectClub } from "@src/server/db/models";
import { createEventSchema } from "@src/utils/formSchemas";
import { useForm } from "react-hook-form";
import { api } from "@src/trpc/react";
import { type z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { UploadIcon } from "@src/icons/Icons";
import EventCardPreview from "./EventCardPreview";
import TimeSelect from "./TimeSelect";
import { type RouterOutputs } from "@src/trpc/shared";

const CreateEventForm = ({ clubId, officerClubs }: { clubId: string, officerClubs: SelectClub[]}) => {
const {
register,
handleSubmit,
watch,
setValue,
getValues,
} = useForm<z.infer<typeof createEventSchema>>({
resolver: zodResolver(createEventSchema),
defaultValues: {
clubId: clubId,
},
mode: "onSubmit",
});
const router = useRouter();
const [watchDescription, watchStartTime] = watch(['description', 'startTime']);
const [eventPreview, setEventPreview] = useState<RouterOutputs['event']['findByFilters']['events'][number]>({
name: "",
clubId,
description: "",
location: "",
liked: false,
id: "",
startTime: new Date(Date.now()),
endTime: new Date(Date.now()),
club: officerClubs.filter((v) => v.id == clubId)[0]!,
});
useEffect(() => {
const subscription = watch((data, info) => {
const { name, clubId, description, location, startTime, endTime } = data;
const club = officerClubs.find((val) => val.id == data.clubId);
if (club) {
setEventPreview({
name: name || "",
clubId: clubId || "",
description: description || "",
location: location || "",
liked: false,
id: "",
startTime: startTime?.toString() === "" || startTime == undefined ? new Date(Date.now()) : new Date(startTime),
endTime: endTime?.toString() === "" || endTime?.toString() == "Invalid Date" || !endTime ? new Date(Date.now()) : new Date(endTime),
club,
});
}
if (info.name == "clubId") {
router.replace(`/manage/${data.clubId}/create`);
}
});
return () => subscription.unsubscribe();
}, [router, watch, officerClubs]);

const createMutation = api.event.create.useMutation({
onSuccess: () => { location.reload(); }
})

const onSubmit = handleSubmit((data: z.infer<typeof createEventSchema>) => {
if (!createMutation.isPending) {
createMutation.mutate(data);
}
});

return (<form onSubmit={(e) => void onSubmit(e)} className="w-full flex flex-row flex-wrap justify-start gap-10 overflow-x-clip text-[#4D5E80] pb-4">
<div className="form-fields flex flex-col flex-1 gap-10 min-w-[320px] max-w-[830px]">
<div className="create-dropdown text-2xl font-bold py-2 flex flex-row justify-start whitespace-nowrap gap-1 max-w-full">
<span>Create Club Event <span className="text-[#3361FF]">for</span></span>
<div className="flex-1">
<select {...register("clubId")} className="bg-inherit text-[#3361FF] outline-none text-ellipsis overflow-hidden whitespace-nowrap w-full" defaultValue={clubId}>
{officerClubs.map((club) => {
return (<option key={club.id} value={club.id}>{club.name}</option>)
})}
</select>
</div>
</div>
<div className="event-pic w-full">
<h1 className="font-bold mb-4">Event Picture</h1>
<p className="upload-label text-xs font-bold mb-11">Drag or choose file to upload</p>
<div className="upload-box bg-[#E9EAEF] w-full h-48 rounded-md flex items-center flex-col justify-center gap-6">
<UploadIcon />
<p className="font-bold text-xs">JPEG, PNG, or SVG</p>
</div>
</div>
<div className="event-details w-full flex flex-col gap-4">
<h1 className="font-bold">Event Details</h1>
<div className="event-name">
<label className="text-xs font-bold mb-2 block" htmlFor="name">Event Name</label>
<input type="text"
className="rounded-md shadow-sm placeholder:text-[#7D8FB3] outline-none text-xs w-full p-2"
placeholder="Event name" {...register("name")} />
</div>
<div className="event-location">
<label className="text-xs font-bold mb-2 block" htmlFor="location">Location</label>
<input type="text"
className="rounded-md shadow-sm placeholder:text-[#7D8FB3] outline-none text-xs w-full p-2"
placeholder="123 Fun Street" {...register("location")} />
</div>
<div className="event-description">
<div className="desc-header flex w-full justify-between">
<label className="text-xs font-bold mb-2 block" htmlFor="description">Description</label>
<p className="text-xs">{watchDescription && watchDescription.length} of 1000 Characters used</p>
</div>
<textarea {...register("description")}
className="rounded-md shadow-sm placeholder:text-[#7D8FB3] outline-none text-xs w-full p-2"
placeholder="Event description" />
</div>
</div>
<TimeSelect register={register} setValue={setValue} getValues={getValues} watchStartTime={watchStartTime} />
<input className="bg-[#3361FF] text-white py-6 hover:cursor-pointer font-black text-xs rounded-md" type="submit" value="Create Event" />
</div>
<div className="form-preview w-64 flex flex-col gap-14">
<h1 className="text-lg font-bold">Preview</h1>
{eventPreview && <EventCardPreview event={eventPreview} />}
</div>
</form>)
}
export default CreateEventForm;
64 changes: 64 additions & 0 deletions src/app/manage/[clubId]/create/EventCardPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import EventTimeAlert from "@src/components/events/EventTimeAlert";
import { MoreIcon, PlusIcon } from "@src/icons/Icons";
import type { RouterOutputs } from "@src/trpc/shared"
import { format, isSameDay } from 'date-fns';
import Image from 'next/image';

interface Props {
event: RouterOutputs['event']['findByFilters']['events'][number],
}

const EventCardPreview = ({ event }: Props) => {
return (
<div className="container flex h-96 w-64 flex-col overflow-hidden rounded-lg bg-white shadow-sm transition-shadow hover:shadow-lg">
<div className="relative">
<div className="h-40 w-96">
<Image
src={'/event_default.jpg'}
alt="event image"
fill
objectFit="cover"
/>
<div className="absolute inset-0 p-2">
<EventTimeAlert event={event} />
</div>
</div>
</div>
<div className="flex h-full flex-col p-5">
<div className="space-y-2.5">
<h3 className="font-bold">{event.name}</h3>
<h4 className="text-xs font-bold">
<p
className="hover:text-blue-primary"
>
{event.club.name}
</p>
<div>
<span className="text-blue-primary">
{format(event.startTime, 'E, MMM d, p')}
{isSameDay(event.startTime, event.endTime) ? (
<> - {format(event.endTime, 'p')}</>
) : (
<>
{' '}
- <br />
{format(event.endTime, 'E, MMM d, p')}
</>
)}
</span>
</div>
</h4>
</div>
<div className="mt-auto flex flex-row space-x-4">
<p className=" h-10 w-10 rounded-full bg-blue-primary p-1.5 shadow-lg transition-colors hover:bg-blue-700 active:bg-blue-800 hover:cursor-pointer">
<MoreIcon fill="fill-white" />
</p>
<div className="h-10 w-10 rounded-full bg-white p-1.5 shadow-lg hover:cursor-pointer">
<PlusIcon fill="fill-slate-800" />
</div>
</div>
</div>
</div>
);
}
export default EventCardPreview
85 changes: 85 additions & 0 deletions src/app/manage/[clubId]/create/TimeSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client'
import { useEffect, useState } from "react";
import type {
UseFormRegister,
UseFormSetValue,
UseFormGetValues,
} from "react-hook-form";
import type { createEventSchema } from "@src/utils/formSchemas";
import type { z } from "zod";

interface Props {
register: UseFormRegister<z.infer<typeof createEventSchema>>,
setValue: UseFormSetValue<z.infer<typeof createEventSchema>>,
getValues: UseFormGetValues<z.infer<typeof createEventSchema>>,
watchStartTime: Date,
}

const TimeSelect = ({ register, setValue, getValues, watchStartTime }: Props) => {
const [multiDay, setMultiDay] = useState(false);
const [numHours, setNumHours] = useState(2);

useEffect(() => {
// If not multi-day, set end time to start time + numHours
if (!multiDay && watchStartTime !== undefined) {
const date = new Date(watchStartTime);
date.setHours(date.getHours() + numHours);
setValue("endTime", date);
}

// If start time is after end time, set end time to start time
if (new Date(watchStartTime) > new Date(getValues('endTime'))) {
setValue('endTime', watchStartTime);
}
}, [setValue, getValues, watchStartTime, multiDay, numHours])

return (<>
<div className="multi-day flex justify-between w-full">
<div className="left">
<h1 className="font-bold mb-2">Multi-Day Event</h1>
<p className="font-bold text-xs">Does the event last longer than one day?</p>
</div>
<div className="right">
<div className="h-fit flex items-center gap-6">
<div
className={`toggle-multi-day relative hover:cursor-pointer ${multiDay ? "bg-[#3361FF] after:bg-white after:translate-x-[20px]" : "bg-white after:bg-[#E1E5ED]"} w-[50px] h-[30px] rounded-2xl transition-colors duration-150 after:top-[2px] after:absolute after:left-[2px] after:transition-tranform after:duration-150 after:rounded-2xl after:content-[''] after:h-[26px] after:w-[26px]`}
onClick={() => {
// If switching to multiDay, clear endTime
if (!multiDay) {
setValue('endTime', new Date(NaN));
}
setMultiDay(!multiDay);
}} ></div>
<p className="font-bold inline-block w-[30px] text-right text-xs">{multiDay ? "Yes" : "No"}</p>
</div>
</div>
</div>
<div className="event-duration flex gap-32">
<div className="flex-1 justify-end flex flex-col">
<h1 className="font-bold mb-2 block">Duration</h1>
<label htmlFor="startTime" className="text-xs font-bold mb-2">Start Time</label>
<input {...register("startTime")} type="datetime-local" className="outline-none w-full block p-2 text-xs rounded-md text-[#7D8FB3]" />
</div>
{ multiDay ?
<div className="flex-1 justify-end flex flex-col">
<label htmlFor="endTime" className="text-xs font-bold mb-2">End Time</label>
<input {...register("endTime")} type="datetime-local"
onInput={(e) => { if (new Date(e.currentTarget.value) < new Date(watchStartTime)) setValue('endTime', watchStartTime); }}
min={watchStartTime?.toString()}
className="outline-none w-full block p-2 text-xs rounded-md text-[#7D8FB3]"
/>
</div>
:
<div className="flex-1 justify-end flex flex-col">
<label htmlFor="endTime" className="text-xs font-bold mb-2">Number of Hours</label>
<select className="outline-none p-[9px] text-xs rounded-md" id="endTime" value={numHours} onInput={(e) => {setNumHours(Number(e.currentTarget.value))}}>
{Array(24).fill(0).map((_, i) => (
<option className="" value={i+1} key={i}>{i+1} Hour{i+1 > 1 && "s"}</option>
))}
</select>
</div>
}
</div>
</>);
}
export default TimeSelect;
30 changes: 30 additions & 0 deletions src/app/manage/[clubId]/create/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Header from "@src/components/BaseHeader";
import { getServerAuthSession } from "@src/server/auth";
import { api } from "@src/trpc/server";
import { signInRoute } from "@src/utils/redirect";
import { redirect, notFound } from "next/navigation";
import CreateEventForm from "./CreateEventForm";

const Page = async ({ params }: { params: { clubId: string } }) => {
const session = await getServerAuthSession();
if (!session) {
redirect(signInRoute(`manage/${params.clubId}/create`));
}

const officerClubs = await api.club.getOfficerClubs();
const currentClub = officerClubs.filter(val => {
return val.id == params.clubId
})[0];
if (!currentClub) {
notFound();
}

return (<main className="md:pl-72 h-screen">
<Header />
<div className="flex flex-row justify-between gap-20 px-5">
<CreateEventForm clubId={currentClub.id} officerClubs={officerClubs} />
</div>

</main>)
}
export default Page;
1 change: 0 additions & 1 deletion src/components/events/EventTimeAlert.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
'use server';
import { type SelectEvent } from '@src/server/db/models';
import {
differenceInDays,
Expand Down
16 changes: 16 additions & 0 deletions src/icons/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,19 @@ export const CloseIcon = () => (
/>
</svg>
);

export const UploadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="30"
height="30"
fill="none"
viewBox="0 0 30 30"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22.35 13.04C21.67 9.59 18.64 7 15 7C12.11 7 9.6 8.64 8.35 11.04C5.34 11.36 3 13.91 3 17C3 20.31 5.69 23 9 23H22C24.76 23 27 20.76 27 18C27 15.36 24.95 13.22 22.35 13.04ZM17 16V20H13V16H10L14.65 11.35C14.85 11.15 15.16 11.15 15.36 11.35L20 16H17Z"
fill="#C3CAD9"/>
</svg>
)
24 changes: 23 additions & 1 deletion src/server/api/routers/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { z } from 'zod';
import { selectEvent } from '@src/server/db/models';
import { type DateRange } from 'react-day-picker';
import { add } from 'date-fns';
import { userMetadataToEvents } from '@src/server/db/schema/users';
import { userMetadataToClubs, userMetadataToEvents } from '@src/server/db/schema/users';
import { createEventSchema } from '@src/utils/formSchemas';
import { TRPCError } from '@trpc/server';
import { events } from '@src/server/db/schema/events';

function isDateRange(value: unknown): value is DateRange {
return Boolean(value && typeof value === 'object' && 'from' in value);
Expand Down Expand Up @@ -232,6 +235,25 @@ export const eventRouter = createTRPCRouter({
),
);
}),
create: protectedProcedure
.input(createEventSchema)
.mutation(async ({ input, ctx }) => {
const { clubId } = input
const userId = ctx.session.user.id;

const isOfficer = await ctx.db.query.userMetadataToClubs.findFirst({
where: and(
eq(userMetadataToClubs.userId, userId),
eq(userMetadataToClubs.clubId, clubId),
inArray(userMetadataToClubs.memberType, ["Officer", "President"])
)
});
if (!isOfficer) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

await ctx.db.insert(events).values({ ...input });
}),
byName: publicProcedure.input(byNameSchema).query(async ({ input, ctx }) => {
const { name, sortByDate } = input;
try {
Expand Down
Loading

0 comments on commit 1aee81e

Please sign in to comment.