diff --git a/client/src/components/main/Navbar.tsx b/client/src/components/main/Navbar.tsx index b25a62b..faf8bfe 100644 --- a/client/src/components/main/Navbar.tsx +++ b/client/src/components/main/Navbar.tsx @@ -18,7 +18,7 @@ export default function Navbar() { return ( <> -
+
& { + 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({ + queryKey: ["events", type ?? "all"], + queryFn: async () => { + const response = await api.get("/events/", { + params: type ? { type } : {}, + }); + return response.data; + }, + select: (data) => data.map(transformApiEventToUiEvent), + }); +} diff --git a/client/src/pages/events/index.tsx b/client/src/pages/events/index.tsx new file mode 100644 index 0000000..3adc401 --- /dev/null +++ b/client/src/pages/events/index.tsx @@ -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 = Record; + +function groupEventsByYear( + events: T[], +): EventsByYear { + 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); +} + +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 ( +
+

Loading events...

+
+ ); + } + + if (isError) { + return ( +
+

+ Failed to load events. +

+
+ ); + } + + if (!events || events.length === 0) { + return ( +
+

Events

+

No events available.

+
+ ); + } + + const eventsByYear = groupEventsByYear(events); + const sortedYears = Object.keys(eventsByYear) + .map(Number) + .sort((a, b) => b - a); + + return ( +
+

Events

+ +
+ + +
+ +
+ {sortedYears.map((year) => ( +
+
+
+
+ {year} +
+ + +
+ {eventsByYear[year].map((event) => ( + +
+
+

+ {event.name} +

+ +
+
+ {formatDateTimeLine(event.date)} +
+
{event.location}
+
+ +

+ {event.description} +

+
+ +
+ {`Cover { + e.currentTarget.src = "/game_dev_club_logo.svg"; + }} + /> +
+
+ + ))} +
+
+
+ ))} +
+
+ ); +} diff --git a/server/game_dev/tests.py b/server/game_dev/tests.py index 411a639..7bcdefe 100644 --- a/server/game_dev/tests.py +++ b/server/game_dev/tests.py @@ -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): @@ -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) diff --git a/server/game_dev/urls.py b/server/game_dev/urls.py index e387e58..d42d6a8 100644 --- a/server/game_dev/urls.py +++ b/server/game_dev/urls.py @@ -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//", EventDetailAPIView.as_view()), ] diff --git a/server/game_dev/views.py b/server/game_dev/views.py index 71a747c..c79e61a 100644 --- a/server/game_dev/views.py +++ b/server/game_dev/views.py @@ -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):