Skip to content
Merged
Show file tree
Hide file tree
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 Dec 13, 2025
530d5da
Merge branch 'issue-6-Individual_event_pages' into issue-30-Events_page
saltyypringle Dec 13, 2025
cd67f0a
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle Dec 13, 2025
7b36317
initial changes: hook, backend and events page 1st version
pvkudr Dec 13, 2025
b1871ad
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle Dec 13, 2025
8766697
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle Dec 13, 2025
8bc5d94
Merge branch 'issue-30-Events_page' of https://github.com/codersforca…
pvkudr Dec 13, 2025
9000431
Fix event fetch API to support filtering by type (past/upcoming)
Yosuke-95 Jan 7, 2026
44b8482
Creating a toggle that can be used to switch between viewing past and…
Yosuke-95 Jan 9, 2026
7b13add
Layout Adjustment
Yosuke-95 Jan 9, 2026
8edadea
Merge remote-tracking branch 'origin/main' into issue-30-Events_page
saltyypringle Jan 10, 2026
38d4927
Added test code for event list API
Yosuke-95 Jan 14, 2026
c1421e8
delete z-index
Yosuke-95 Jan 17, 2026
892642c
toggle can be displayed even if no events are available.
Yosuke-95 Jan 17, 2026
905e0c7
using monospace in year
Yosuke-95 Jan 17, 2026
521cebc
using upcoming as the default
Yosuke-95 Jan 17, 2026
c458d51
Leave out the all option. Upcoming can be the default
Yosuke-95 Jan 17, 2026
18e3c9f
Added pagination functionality
Yosuke-95 Jan 21, 2026
d98a864
solved prettier error
Yosuke-95 Jan 21, 2026
49ff985
changed test code for pagenation
Yosuke-95 Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions client/src/hooks/useEvents.ts
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,
}),
});
}
252 changes: 252 additions & 0 deletions client/src/pages/events/index.tsx
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>
);
}
Loading