Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion client/src/components/main/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function Navbar() {

return (
<>
<header className="sticky top-0 flex h-24 w-full flex-wrap items-center justify-center rounded-md border-b border-border/20 bg-background px-20 font-jersey10">
<header className="sticky top-0 z-50 flex h-24 w-full flex-wrap items-center justify-center rounded-md border-b border-border/20 bg-background px-20 font-jersey10">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please remove the z-50 here? James will fix it in a separate PR :)

<Link
href="/"
className="flex flex-none items-center gap-3 text-2xl md:mr-5"
Expand Down
41 changes: 41 additions & 0 deletions client/src/hooks/useEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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;
};

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";

export function useEvents(type?: EventTypeFilter) {
return useQuery<ApiEvent[], AxiosError, UiEvent[]>({
queryKey: ["events", type ?? "all"],
queryFn: async () => {
const response = await api.get<ApiEvent[]>("/events/", {
params: type ? { type } : {},
});
return response.data;
},
select: (data) => data.map(transformApiEventToUiEvent),
});
}
182 changes: 182 additions & 0 deletions client/src/pages/events/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";

import { EventTypeFilter, 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 typeParam = router.query.type;
const type =
typeof typeParam === "string" &&
(typeParam === "past" || typeParam === "upcoming")
? (typeParam as EventTypeFilter)
: undefined;

const {
data: events,
isPending,
isError,
} = useEvents(router.isReady ? type : undefined);

if (isPending) {
return (
<main className="mx-auto min-h-dvh max-w-6xl px-6 py-16 md:px-20">
<p>Loading events...</p>
</main>
);
}

if (isError) {
return (
<main className="md:20 mx-auto min-h-dvh max-w-6xl px-6 py-16">
<p className="text-red-500" role="alert">
Failed to load events.
</p>
</main>
);
}

if (!events || events.length === 0) {
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>
<p>No events available.</p>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be a good idea to still have the toggle displayed here even if no events are available. This way users can easily flip the toggle to see events instead of having to navigate back to access the toggle

</main>
);
}

const eventsByYear = 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("/events?type=past")}
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"
}`}
>
Past
</button>
<button
type="button"
onClick={() => router.push("/events?type=upcoming")}
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"
}`}
>
Upcoming
</button>
</div>

<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="text-2xl font-semibold text-gray-200 md:text-3xl">
{year}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this was supposed to be in monospace according to the figma

</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>
);
}
60 changes: 60 additions & 0 deletions server/game_dev/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import datetime
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from django.urls import reverse


class MemberModelTest(TestCase):
Expand Down Expand Up @@ -70,3 +71,62 @@ def test_event_date_is_datetime(self):
def test_event_datetime_matches(self):
event = Event.objects.get(pk=self.event.pk)
self.assertEqual(event.date, self.event_datetime)


class EventListAPITest(TestCase):
def setUp(self):
self.url = reverse("events-list")

now = timezone.now()

self.past_old = Event.objects.create(
name="Past Old Event",
date=now - datetime.timedelta(days=30),
description="past old",
publicationDate=now.date(),
location="Perth",
)
self.past_new = Event.objects.create(
name="Old Event2",
date=now - datetime.timedelta(days=1),
description="past new",
publicationDate=now.date(),
location="Perth",
)
self.up_soon = Event.objects.create(
name="Upcoming Soon",
date=now + datetime.timedelta(days=1),
description="up soon 1 day",
publicationDate=now.date(),
location="Perth",
)
self.up_later = Event.objects.create(
name="Upcoming Later",
date=now + datetime.timedelta(days=30),
description="up later",
publicationDate=now.date(),
location="Perth",
)

def _ids(self, resp_json):
return [item["id"] for item in resp_json]

def test_past(self):
res = self.client.get(self.url, {"type": "past"})
self.assertEqual(res.status_code, 200)

ids = self._ids(res.json())

self.assertEqual(ids, [self.past_new.id, self.past_old.id])

def test_upcoming(self):
res = self.client.get(self.url, {"type": "upcoming"})
self.assertEqual(res.status_code, 200)

ids = self._ids(res.json())

self.assertEqual(ids, [self.up_soon.id, self.up_later.id])

def test_invalid_type(self):
res = self.client.get(self.url, {"type": "invalid"})
self.assertEqual(res.status_code, 400)
3 changes: 2 additions & 1 deletion server/game_dev/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path
from .views import EventDetailAPIView
from .views import EventListAPIView, EventDetailAPIView

urlpatterns = [
path("events/", EventListAPIView.as_view(), name="events-list"),
path("events/<int:id>/", EventDetailAPIView.as_view()),
]
28 changes: 28 additions & 0 deletions server/game_dev/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@
from rest_framework import generics
from .models import Event
from .serializers import EventSerializer
from django.utils import timezone
from rest_framework.exceptions import ValidationError


class EventListAPIView(generics.ListAPIView):
"""
GET /api/events/
Returns a list of events (optionally filtered by time)
"""
serializer_class = EventSerializer

def get_queryset(self):
qs = Event.objects.all()
type_param = self.request.query_params.get("type")

now = timezone.now()

if type_param is None or type_param == "":
return qs.order_by("date")

if type_param == "past":
return qs.filter(date__lt=now).order_by("-date")

if type_param == "upcoming":
return qs.filter(date__gte=now).order_by("date")

raise ValidationError(
{"type": "Invalid value. Use 'past' or 'upcoming'."})


class EventDetailAPIView(generics.RetrieveAPIView):
Expand Down