generated from codersforcauses/django-nextjs-template
-
Notifications
You must be signed in to change notification settings - Fork 3
Issue 30 events page #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
93d830f
Merge remote-tracking branch 'origin/issue-6-Individual_event_pages' …
pvkudr 530d5da
Merge branch 'issue-6-Individual_event_pages' into issue-30-Events_page
saltyypringle cd67f0a
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle 7b36317
initial changes: hook, backend and events page 1st version
pvkudr b1871ad
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle 8766697
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle 8bc5d94
Merge branch 'issue-30-Events_page' of https://github.com/codersforca…
pvkudr 9000431
Fix event fetch API to support filtering by type (past/upcoming)
Yosuke-95 44b8482
Creating a toggle that can be used to switch between viewing past and…
Yosuke-95 7b13add
Layout Adjustment
Yosuke-95 8edadea
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle 38d4927
Added test code for event list API
Yosuke-95 c1421e8
delete z-index
Yosuke-95 892642c
toggle can be displayed even if no events are available.
Yosuke-95 905e0c7
using monospace in year
Yosuke-95 521cebc
using upcoming as the default
Yosuke-95 c458d51
Leave out the all option. Upcoming can be the default
Yosuke-95 18e3c9f
Added pagination functionality
Yosuke-95 d98a864
solved prettier error
Yosuke-95 49ff985
changed test code for pagenation
Yosuke-95 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { AxiosError } from "axios"; | ||
|
|
||
| import api from "@/lib/api"; | ||
|
|
||
| type ApiEvent = { | ||
| id: number; | ||
| name: string; | ||
| description: string; | ||
| publicationDate: string; | ||
| date: string; | ||
| startTime: string | null; | ||
| location: string; | ||
| cover_image: string | null; | ||
| }; | ||
|
|
||
| export type UiEvent = Omit<ApiEvent, "cover_image"> & { | ||
| coverImage: string; | ||
| }; | ||
|
|
||
| function transformApiEventToUiEvent(data: ApiEvent): UiEvent { | ||
| return { | ||
| ...data, | ||
| coverImage: data.cover_image ?? "/game_dev_club_logo.svg", | ||
| }; | ||
| } | ||
|
|
||
| export type EventTypeFilter = "past" | "upcoming"; | ||
|
|
||
| type PaginatedResponse<T> = { | ||
| count: number; | ||
| next: string | null; | ||
| previous: string | null; | ||
| results: T[]; | ||
| }; | ||
|
|
||
| export type EventsPageData = { | ||
| items: UiEvent[]; | ||
| count: number; | ||
| next: string | null; | ||
| previous: string | null; | ||
| }; | ||
|
|
||
| type UseEventsParams = { | ||
| type?: EventTypeFilter; | ||
| page?: number; | ||
| pageSize?: number; | ||
| }; | ||
|
|
||
| export function useEvents({ | ||
| type = "upcoming", | ||
| page = 1, | ||
| pageSize, | ||
| }: UseEventsParams = {}) { | ||
| return useQuery<PaginatedResponse<ApiEvent>, AxiosError, EventsPageData>({ | ||
| queryKey: ["events", type, page, pageSize ?? "default"], | ||
| queryFn: async () => { | ||
| const response = await api.get<PaginatedResponse<ApiEvent>>("/events/", { | ||
| params: { | ||
| type, | ||
| page, | ||
| ...(pageSize ? { page_size: pageSize } : {}), | ||
| }, | ||
| }); | ||
| return response.data; | ||
| }, | ||
| select: (data) => ({ | ||
| items: data.results.map(transformApiEventToUiEvent), | ||
| count: data.count, | ||
| next: data.next, | ||
| previous: data.previous, | ||
| }), | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,252 @@ | ||
| import Image from "next/image"; | ||
| import Link from "next/link"; | ||
| import { useRouter } from "next/router"; | ||
| import { useEffect, useMemo, useState } from "react"; | ||
|
|
||
| import { EventTypeFilter, UiEvent, useEvents } from "@/hooks/useEvents"; | ||
|
|
||
| function formatDateTimeLine(dateString: string): string { | ||
| try { | ||
| const date = new Date(dateString); | ||
|
|
||
| const d = new Intl.DateTimeFormat("en-US", { | ||
| month: "long", | ||
| day: "numeric", | ||
| year: "numeric", | ||
| }).format(date); | ||
|
|
||
| const t = new Intl.DateTimeFormat("en-US", { | ||
| hour: "2-digit", | ||
| minute: "2-digit", | ||
| hour12: true, | ||
| }) | ||
| .format(date) | ||
| .replace("AM", "am") | ||
| .replace("PM", "pm"); | ||
|
|
||
| return `${d} ・ ${t}`; | ||
| } catch { | ||
| return ""; | ||
| } | ||
| } | ||
|
|
||
| type EventsByYear<T> = Record<number, T[]>; | ||
|
|
||
| function groupEventsByYear<T extends { date: string }>( | ||
| events: T[], | ||
| ): EventsByYear<T> { | ||
| return events.reduce((acc, event) => { | ||
| const year = new Date(event.date).getFullYear(); | ||
| if (!acc[year]) acc[year] = []; | ||
| acc[year].push(event); | ||
| return acc; | ||
| }, {} as EventsByYear<T>); | ||
| } | ||
|
|
||
| export default function EventsPage() { | ||
| const router = useRouter(); | ||
| const [page, setPage] = useState(1); | ||
|
|
||
| const pageSize = 20; | ||
|
|
||
| const rawType = useMemo(() => { | ||
| const t = router.query.type; | ||
| return typeof t === "string" && (t === "past" || t === "upcoming") | ||
| ? t | ||
| : null; | ||
| }, [router.query.type]); | ||
|
|
||
| const type: EventTypeFilter = rawType ?? "upcoming"; | ||
|
|
||
| useEffect(() => { | ||
| if (!router.isReady) return; | ||
| if (rawType === null) { | ||
| router.replace( | ||
| { pathname: "/events", query: { type: "upcoming" } }, | ||
| undefined, | ||
| { shallow: true }, | ||
| ); | ||
| } | ||
| }, [router.isReady, rawType, router]); | ||
|
|
||
| useEffect(() => { | ||
| setPage(1); | ||
| }, [type]); | ||
|
|
||
| const { data, isPending, isError, isFetching } = useEvents({ | ||
| type, | ||
| page, | ||
| pageSize, | ||
| }); | ||
|
|
||
| const events: UiEvent[] | undefined = data?.items; | ||
| const count = data?.count ?? 0; | ||
|
|
||
| const hasNext = Boolean(data?.next); | ||
| const hasPrev = Boolean(data?.previous); | ||
|
|
||
| const shouldShowPagination = !isPending && !isError && count > pageSize; | ||
|
|
||
| const isEmpty = !isPending && !isError && (!events || events.length === 0); | ||
|
|
||
| const eventsByYear = | ||
| events && events.length > 0 ? groupEventsByYear(events) : {}; | ||
| const sortedYears = Object.keys(eventsByYear) | ||
| .map(Number) | ||
| .sort((a, b) => b - a); | ||
|
|
||
| return ( | ||
| <main className="mx-auto min-h-dvh max-w-6xl px-6 py-16 md:px-20"> | ||
| <h1 className="mb-8 font-jersey10 text-4xl text-primary">Events</h1> | ||
|
|
||
| <div className="mb-10 flex w-fit overflow-hidden rounded-md border border-gray-600"> | ||
| <button | ||
| type="button" | ||
| onClick={() => | ||
| router.push( | ||
| { pathname: "/events", query: { type: "past" } }, | ||
| undefined, | ||
| { shallow: true }, | ||
| ) | ||
| } | ||
| className={`px-6 py-2 text-sm font-medium transition-colors ${ | ||
| type === "past" | ||
| ? "bg-white text-black" | ||
| : "bg-transparent text-gray-300 hover:bg-gray-700" | ||
| }`} | ||
| aria-pressed={type === "past"} | ||
| > | ||
| Past | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => | ||
| router.push( | ||
| { pathname: "/events", query: { type: "upcoming" } }, | ||
| undefined, | ||
| { shallow: true }, | ||
| ) | ||
| } | ||
| className={`px-6 py-2 text-sm font-medium transition-colors ${ | ||
| type === "upcoming" | ||
| ? "bg-white text-black" | ||
| : "bg-transparent text-gray-300 hover:bg-gray-700" | ||
| }`} | ||
| aria-pressed={type === "upcoming"} | ||
| > | ||
| Upcoming | ||
| </button> | ||
| </div> | ||
|
|
||
| {shouldShowPagination && ( | ||
| <div className="mb-10 flex items-center justify-center gap-3"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setPage((p) => Math.max(1, p - 1))} | ||
| disabled={!hasPrev || isPending || isFetching} | ||
| className={`rounded-md border px-4 py-2 text-sm transition-colors ${ | ||
| !hasPrev || isPending || isFetching | ||
| ? "border-gray-700 text-gray-500" | ||
| : "border-gray-600 text-gray-200 hover:bg-gray-800" | ||
| }`} | ||
| > | ||
| Prev | ||
| </button> | ||
|
|
||
| <div className="text-sm text-gray-300"> | ||
| Page <span className="font-semibold text-gray-100">{page}</span> | ||
| <span className="text-gray-400"> ・ {count} total</span> | ||
| </div> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={() => setPage((p) => p + 1)} | ||
| disabled={!hasNext || isPending || isFetching} | ||
| className={`rounded-md border px-4 py-2 text-sm transition-colors ${ | ||
| !hasNext || isPending || isFetching | ||
| ? "border-gray-700 text-gray-500" | ||
| : "border-gray-600 text-gray-200 hover:bg-gray-800" | ||
| }`} | ||
| > | ||
| Next | ||
| </button> | ||
|
|
||
| {isFetching && !isPending && ( | ||
| <span className="text-sm text-gray-400">Loading...</span> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
| {isPending && <p>Loading events...</p>} | ||
|
|
||
| {isError && ( | ||
| <p className="text-red-500" role="alert"> | ||
| Failed to load events. | ||
| </p> | ||
| )} | ||
|
|
||
| {isEmpty && <p>No events available.</p>} | ||
|
|
||
| {!isPending && !isError && events && events.length > 0 && ( | ||
| <div className="flex flex-col gap-14"> | ||
| {sortedYears.map((year) => ( | ||
| <section key={year}> | ||
| <div className="flex gap-6 md:gap-10"> | ||
| <div className="relative w-14 flex-shrink-0 md:w-20"> | ||
| <div className="font-mono text-2xl font-semibold text-gray-200 md:text-3xl"> | ||
| {year} | ||
| </div> | ||
| <div | ||
| aria-hidden="true" | ||
| className="absolute bottom-0 left-2 top-12 w-px bg-gray-600/60 md:left-4" | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="flex min-w-0 flex-1 flex-col gap-6"> | ||
| {eventsByYear[year].map((event) => ( | ||
| <Link | ||
| key={event.id} | ||
| href={`/events/${event.id}`} | ||
| className="group block overflow-hidden rounded-xl border border-indigo-300/30 bg-gray-950/30 shadow-[0_0_0_1px_rgba(99,102,241,0.10)] transition-colors hover:bg-gray-950/45" | ||
| > | ||
| <div className="flex flex-col md:flex-row"> | ||
| <div className="flex min-w-0 flex-1 flex-col gap-4 px-8 py-7"> | ||
| <h3 className="min-w-0 font-jersey10 text-4xl text-white md:text-5xl"> | ||
| <span className="block truncate">{event.name}</span> | ||
| </h3> | ||
|
|
||
| <div className="space-y-1 text-sm md:text-base"> | ||
| <div className="text-primary"> | ||
| {formatDateTimeLine(event.date)} | ||
| </div> | ||
| <div className="text-primary">{event.location}</div> | ||
| </div> | ||
|
|
||
| <p className="max-w-3xl text-sm leading-relaxed text-gray-200/90 md:text-base"> | ||
| {event.description} | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="relative h-56 w-full flex-shrink-0 border-t border-indigo-300/20 md:h-auto md:w-80 md:border-l md:border-t-0"> | ||
| <Image | ||
| src={event.coverImage} | ||
| alt={`Cover image for ${event.name}`} | ||
| fill | ||
| className="object-cover transition-transform duration-300 group-hover:scale-105" | ||
| onError={(e) => { | ||
| e.currentTarget.src = "/game_dev_club_logo.svg"; | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| </Link> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </section> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </main> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.