From d8d35324f87ceb4ced1cf74457ec7bc50c51c25f Mon Sep 17 00:00:00 2001 From: Raphico Date: Sun, 16 Jun 2024 23:13:48 +0100 Subject: [PATCH] feat: add workshop page --- src/app/(app)/dashboard/loading.tsx | 5 +- src/app/(app)/explore/loading.tsx | 5 +- .../organizer-section-skeleton.tsx | 15 ++ .../_components/organizer-section.tsx | 30 +++ .../_components/workshop-settings.tsx | 74 +++++++ .../(app)/workshop/[workshopId]/loading.tsx | 50 +++++ .../(app)/workshop/[workshopId]/not-found.tsx | 15 ++ src/app/(app)/workshop/[workshopId]/page.tsx | 113 +++++++++++ src/app/global-error.tsx | 35 ++-- src/components/error-shell.tsx | 48 +++++ .../workshops/create-edit-workshop-modal.tsx | 4 +- .../workshops/workshop-details-modal.tsx | 105 ---------- src/components/workshops/workshop-details.tsx | 184 ------------------ .../workshops/workshop-skeletons.tsx | 11 ++ src/server/data/workshop.ts | 42 ++-- 15 files changed, 390 insertions(+), 346 deletions(-) create mode 100644 src/app/(app)/workshop/[workshopId]/_components/organizer-section-skeleton.tsx create mode 100644 src/app/(app)/workshop/[workshopId]/_components/organizer-section.tsx create mode 100644 src/app/(app)/workshop/[workshopId]/_components/workshop-settings.tsx create mode 100644 src/app/(app)/workshop/[workshopId]/loading.tsx create mode 100644 src/app/(app)/workshop/[workshopId]/not-found.tsx create mode 100644 src/app/(app)/workshop/[workshopId]/page.tsx create mode 100644 src/components/error-shell.tsx delete mode 100644 src/components/workshops/workshop-details-modal.tsx delete mode 100644 src/components/workshops/workshop-details.tsx create mode 100644 src/components/workshops/workshop-skeletons.tsx diff --git a/src/app/(app)/dashboard/loading.tsx b/src/app/(app)/dashboard/loading.tsx index 1533116..76c74ab 100644 --- a/src/app/(app)/dashboard/loading.tsx +++ b/src/app/(app)/dashboard/loading.tsx @@ -1,6 +1,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { PageHeader, PageHeaderHeading } from "@/components/page-header" import { Shell } from "@/components/shell" +import { WorkshopSkeletons } from "@/components/workshops/workshop-skeletons" export default function DashboardLoading() { return ( @@ -14,9 +15,7 @@ export default function DashboardLoading() {
- - - +
) diff --git a/src/app/(app)/explore/loading.tsx b/src/app/(app)/explore/loading.tsx index 4d04dbb..80f1dc2 100644 --- a/src/app/(app)/explore/loading.tsx +++ b/src/app/(app)/explore/loading.tsx @@ -1,6 +1,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { PageHeader, PageHeaderHeading } from "@/components/page-header" import { Shell } from "@/components/shell" +import { WorkshopSkeletons } from "@/components/workshops/workshop-skeletons" export default function ExploreLoading() { return ( @@ -14,9 +15,7 @@ export default function ExploreLoading() {
- - - +
) diff --git a/src/app/(app)/workshop/[workshopId]/_components/organizer-section-skeleton.tsx b/src/app/(app)/workshop/[workshopId]/_components/organizer-section-skeleton.tsx new file mode 100644 index 0000000..ee64f2c --- /dev/null +++ b/src/app/(app)/workshop/[workshopId]/_components/organizer-section-skeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export function OrganizerSectionSkeleton() { + return ( +
+

Organizer

+ +
+ + + +
+
+ ) +} diff --git a/src/app/(app)/workshop/[workshopId]/_components/organizer-section.tsx b/src/app/(app)/workshop/[workshopId]/_components/organizer-section.tsx new file mode 100644 index 0000000..c296b02 --- /dev/null +++ b/src/app/(app)/workshop/[workshopId]/_components/organizer-section.tsx @@ -0,0 +1,30 @@ +import { getWorkshopOrganizer } from "@/server/data/workshop" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" + +interface OrganizerSectionProps { + organizerId: string +} + +export async function OrganizerSection({ organizerId }: OrganizerSectionProps) { + const organizer = await getWorkshopOrganizer(organizerId) + + const organizerUsernameInitial = organizer?.username.charAt(0) + + return ( +
+

Organizer

+ +
+ + {organizer?.image ? ( + + ) : ( + {organizerUsernameInitial} + )} + + +

{organizer?.username}

+
+
+ ) +} diff --git a/src/app/(app)/workshop/[workshopId]/_components/workshop-settings.tsx b/src/app/(app)/workshop/[workshopId]/_components/workshop-settings.tsx new file mode 100644 index 0000000..fb3897c --- /dev/null +++ b/src/app/(app)/workshop/[workshopId]/_components/workshop-settings.tsx @@ -0,0 +1,74 @@ +"use client" + +import * as React from "react" + +import { type getWorkshop } from "@/server/data/workshop" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Icons } from "@/components/icons" +import { useCreateEditWorkshopModal } from "@/components/workshops/create-edit-workshop-modal" +import { useDeleteWorkshopAlert } from "@/components/workshops/delete-workshop-alert" + +interface WorkshopSettingsProps { + workshop: NonNullable>> +} + +export function WorkshopSettings({ workshop }: WorkshopSettingsProps) { + const [open, setOpen] = React.useState(false) + + const { setShowCreateEditWorkshopModal, CreateEditWorkshopModal } = + useCreateEditWorkshopModal({ + text: "Update", + workshop, + }) + + const { setShowDeleteWorkshopAlert, DeleteWorkshopAlert } = + useDeleteWorkshopAlert({ + id: workshop.id, + }) + + return ( + <> + + + + + + + + { + setOpen(false) + setShowCreateEditWorkshopModal(true) + }} + aria-label="Edit Workshop" + > + + + { + setOpen(false) + setShowDeleteWorkshopAlert(true) + }} + className="text-red-400" + > + + + + + ) +} diff --git a/src/app/(app)/workshop/[workshopId]/loading.tsx b/src/app/(app)/workshop/[workshopId]/loading.tsx new file mode 100644 index 0000000..c32b905 --- /dev/null +++ b/src/app/(app)/workshop/[workshopId]/loading.tsx @@ -0,0 +1,50 @@ +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" +import { Icons } from "@/components/icons" +import { Shell } from "@/components/shell" + +import { OrganizerSectionSkeleton } from "./_components/organizer-section-skeleton" + +export default function WorkshopLoading() { + return ( + +
+
+ + +
+ + +
+
+ +
+
+
+
+
+ + + +
+
+

About

+ + +
+ + +
+
+ ) +} diff --git a/src/app/(app)/workshop/[workshopId]/not-found.tsx b/src/app/(app)/workshop/[workshopId]/not-found.tsx new file mode 100644 index 0000000..8e4883c --- /dev/null +++ b/src/app/(app)/workshop/[workshopId]/not-found.tsx @@ -0,0 +1,15 @@ +import { ErrorShell } from "@/components/error-shell" +import { Shell } from "@/components/shell" + +export default function WorkshopNotFound() { + return ( + + + + ) +} diff --git a/src/app/(app)/workshop/[workshopId]/page.tsx b/src/app/(app)/workshop/[workshopId]/page.tsx new file mode 100644 index 0000000..7bed002 --- /dev/null +++ b/src/app/(app)/workshop/[workshopId]/page.tsx @@ -0,0 +1,113 @@ +import * as React from "react" +import { notFound, redirect } from "next/navigation" + +import { redirects } from "@/config/constants" +import { getUserSession } from "@/server/data/user" +import { getWorkshop } from "@/server/data/workshop" +import { getExactScheduled } from "@/utils/format-scheduled-date" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { CopyButton } from "@/components/copy-button" +import { Icons } from "@/components/icons" +import { PageHeader, PageHeaderHeading } from "@/components/page-header" +import { Shell } from "@/components/shell" + +import { OrganizerSection } from "./_components/organizer-section" +import { OrganizerSectionSkeleton } from "./_components/organizer-section-skeleton" +import { WorkshopSettings } from "./_components/workshop-settings" + +interface WorkshopPageProps { + params: { + workshopId: string + } +} + +export default async function WorkshopPage({ params }: WorkshopPageProps) { + const workshopId = decodeURIComponent(params.workshopId) + + const workshop = await getWorkshop(workshopId) + + if (!workshop) { + notFound() + } + + const { user } = await getUserSession() + + if (!user) { + redirect(redirects.toLogin) + } + + const isCurrentUserWorkshop = workshop.organizerId === user.id + + return ( + +
+
+ + {workshop.title} + + +
+ + {isCurrentUserWorkshop && } +
+
+ +
+
+
+
+
+ + + +
+
+

About

+ +

+ {workshop.description} +

+
+ + }> + + + +
+ {!isCurrentUserWorkshop ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 64a2fe7..c2b5823 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,8 +1,8 @@ "use client" -import { Button } from "@/components/ui/button" -import { DotBg } from "@/components/dot-bg" -import { Icons } from "@/components/icons" +import { unknownError } from "@/config/constants" +import { ErrorShell } from "@/components/error-shell" +import { Shell } from "@/components/shell" export default function GlobalError({ error, @@ -14,28 +14,15 @@ export default function GlobalError({ return ( -
- -
-
+
diff --git a/src/components/error-shell.tsx b/src/components/error-shell.tsx new file mode 100644 index 0000000..58499f9 --- /dev/null +++ b/src/components/error-shell.tsx @@ -0,0 +1,48 @@ +import Link from "next/link" + +import { Icons } from "./icons" +import { Button, buttonVariants } from "./ui/button" + +type ErrorShellProps = { + title: string + description: string + icon?: keyof typeof Icons + retryLink?: string + retryLinkText?: string + reset?: () => void +} + +export function ErrorShell({ + title, + description, + retryLink, + retryLinkText, + icon, + reset, +}: ErrorShellProps) { + const Icon = icon && Icons[icon] + + return ( +
+ {Icon &&
+ ) +} diff --git a/src/components/workshops/create-edit-workshop-modal.tsx b/src/components/workshops/create-edit-workshop-modal.tsx index 14862de..a916bdc 100644 --- a/src/components/workshops/create-edit-workshop-modal.tsx +++ b/src/components/workshops/create-edit-workshop-modal.tsx @@ -7,7 +7,7 @@ import { createWorkshopAction, updateWorkshopAction, } from "@/server/actions/workshop" -import { type getWorkshops } from "@/server/data/workshop" +import { type getWorkshop } from "@/server/data/workshop" import { generateId } from "@/lib/id" import { createEditWorkshopSchema, @@ -36,7 +36,7 @@ import { CreateEditWorkshopForm } from "./create-edit-workshop-form" interface CreateEditWorkshopModalProps { text: "Create" | "Update" - workshop?: Awaited>[number] + workshop?: NonNullable>> } export function CreateEditWorkshopModal({ diff --git a/src/components/workshops/workshop-details-modal.tsx b/src/components/workshops/workshop-details-modal.tsx deleted file mode 100644 index 836c497..0000000 --- a/src/components/workshops/workshop-details-modal.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react" - -import { type getWorkshops } from "@/server/data/workshop" -import { useMediaQuery } from "@/hooks/use-media-query" - -import { Icons } from "../icons" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from "../ui/drawer" -import { WorkshopDetails } from "./workshop-details" - -interface WorkshopDetailsModalProps { - userId: string - workshop: Awaited>[number] -} - -export function WorkshopDetailsModal({ - showWorkshopDetailsModal, - setShowWorkshopDetailsModal, - props, -}: { - showWorkshopDetailsModal: boolean - setShowWorkshopDetailsModal: React.Dispatch> - props: WorkshopDetailsModalProps -}) { - const { userId, workshop } = props - - const isDesktop = useMediaQuery("(min-width: 768px)") - - if (isDesktop) { - return ( - - - - {workshop.title} - - - - - - ) - } - - return ( - - - -
- {workshop.title} - - -
-
- - -
-
- ) -} - -export function useWorkshopDetailsModal(props: WorkshopDetailsModalProps) { - const [showWorkshopDetailsModal, setShowWorkshopDetailsModal] = - React.useState(false) - - const WorkshopDetailsModalCallback = React.useCallback( - () => ( - - ), - [props, showWorkshopDetailsModal] - ) - - return React.useMemo( - () => ({ - showWorkshopDetailsModal, - setShowWorkshopDetailsModal, - WorkshopDetailsModal: WorkshopDetailsModalCallback, - }), - [ - WorkshopDetailsModalCallback, - setShowWorkshopDetailsModal, - showWorkshopDetailsModal, - ] - ) -} diff --git a/src/components/workshops/workshop-details.tsx b/src/components/workshops/workshop-details.tsx deleted file mode 100644 index 5fd5a87..0000000 --- a/src/components/workshops/workshop-details.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import * as React from "react" -import { toast } from "sonner" - -import { addParticipantAction } from "@/server/actions/registration" -import { type getWorkshops } from "@/server/data/workshop" -import { cn } from "@/lib/utils" -import { getExactScheduled } from "@/utils/format-scheduled-date" -import { getErrorMessage, showErrorToast } from "@/utils/handle-error" - -import { CopyButton } from "../copy-button" -import { Icons } from "../icons" -import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar" -import { Button } from "../ui/button" -import { Separator } from "../ui/separator" -import { useCreateEditWorkshopModal } from "./create-edit-workshop-modal" -import { useDeleteWorkshopAlert } from "./delete-workshop-alert" - -interface WorkshopDetailsProps extends React.HTMLAttributes { - userId: string - workshop: Awaited>[number] -} - -export function WorkshopDetails({ - userId, - workshop, - className, - ...props -}: WorkshopDetailsProps) { - const [isPending, startTransition] = React.useTransition() - - const { CreateEditWorkshopModal, setShowCreateEditWorkshopModal } = - useCreateEditWorkshopModal({ - text: "Update", - workshop, - }) - - const { DeleteWorkshopAlert, setShowDeleteWorkshopAlert } = - useDeleteWorkshopAlert({ - id: workshop.id, - }) - - const addParticipant = () => { - startTransition(async () => { - const { error } = await addParticipantAction({ - workshopId: workshop.id, - participantId: userId, - }) - - if (error) { - showErrorToast(error) - } - - toast.success("Registration successful") - - try { - await fetch("/api/email/new-participant", { - method: "POST", - body: JSON.stringify({ - email: workshop.organizer.email, - organizerUsername: workshop.organizer.username, - workshopTitle: workshop.title, - }), - }) - } catch (error) { - console.error(getErrorMessage(error)) - } - }) - } - - const isCurrentUserWorkshop = workshop.organizer.id === userId - - const organizerUsernameInitial = workshop.organizer.username.charAt(0) - - return ( - <> - - -
-
-
-
-
-
-
- - - -
-
-

About

-

- {workshop.description} -

-
- -
-

Organizer

- -
- - {workshop.organizer.image ? ( - - ) : ( - {organizerUsernameInitial} - )} - - -

- {workshop.organizer.username} -

-
-
-
- -
-
- - {isCurrentUserWorkshop && ( - <> - - - - )} -
- {!isCurrentUserWorkshop ? ( - - ) : ( - - )} -
-
- - ) -} diff --git a/src/components/workshops/workshop-skeletons.tsx b/src/components/workshops/workshop-skeletons.tsx new file mode 100644 index 0000000..c7299ab --- /dev/null +++ b/src/components/workshops/workshop-skeletons.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "../ui/skeleton" + +export function WorkshopSkeletons() { + return ( + <> + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) +} diff --git a/src/server/data/workshop.ts b/src/server/data/workshop.ts index 1a650e9..07b5780 100644 --- a/src/server/data/workshop.ts +++ b/src/server/data/workshop.ts @@ -4,7 +4,7 @@ import { unstable_cache as cache, unstable_noStore as noStore, } from "next/cache" -import { and, asc, eq, ne } from "drizzle-orm" +import { asc, eq } from "drizzle-orm" import { db } from "../db" import { registrations, users, workshops } from "../db/schema" @@ -14,6 +14,7 @@ export async function getWorkshop(workshopId: string) { return db.query.workshops.findFirst({ columns: { id: true, + organizerId: true, title: true, description: true, duration: true, @@ -22,9 +23,6 @@ export async function getWorkshop(workshopId: string) { isPublic: true, createdAt: true, }, - with: { - users: true, - }, where: eq(workshops.id, workshopId), }) } catch (err) { @@ -32,25 +30,6 @@ export async function getWorkshop(workshopId: string) { } } -export async function getOtherWorkshops(workshopId: string) { - noStore() - try { - return db - .select({ - id: workshops.id, - title: workshops.title, - duration: workshops.duration, - scheduled: workshops.scheduled, - }) - .from(workshops) - .where(and(ne(workshops.id, workshopId), eq(workshops.isPublic, true))) - .innerJoin(users, eq(users.id, workshops.organizerId)) - .orderBy(asc(workshops.scheduled)) - } catch (err) { - return [] - } -} - export async function getUserWorkshops(userId: string) { return await cache( async () => { @@ -91,7 +70,7 @@ export async function getWorkshops() { } } -export async function getWorkshopRegistrants(workshopId: string) { +export async function getWorkshopRegistrants(currentWorkshopId: string) { noStore() try { return await db @@ -101,8 +80,21 @@ export async function getWorkshopRegistrants(workshopId: string) { }) .from(registrations) .innerJoin(users, eq(registrations.participantId, users.id)) - .where(eq(registrations.workshopId, workshopId)) + .where(eq(registrations.workshopId, currentWorkshopId)) } catch (err) { return [] } } + +export async function getWorkshopOrganizer(organizerId: string) { + return await cache(async () => { + return await db.query.users.findFirst({ + columns: { + id: true, + username: true, + image: true, + }, + where: eq(users.id, organizerId), + }) + })() +}