diff --git a/cms/package.json b/cms/package.json index c579831..1b96c90 100644 --- a/cms/package.json +++ b/cms/package.json @@ -27,6 +27,7 @@ "graphql": "^16.8.1", "next": "15.4.11", "payload": "3.79.0", + "payload-plugin-import-export": "^3.0.20", "react": "19.2.1", "react-dom": "19.2.1", "sharp": "0.34.2", diff --git a/cms/src/app/(payload)/admin/importMap.js b/cms/src/app/(payload)/admin/importMap.js index f410558..b7f74eb 100644 --- a/cms/src/app/(payload)/admin/importMap.js +++ b/cms/src/app/(payload)/admin/importMap.js @@ -1,5 +1,7 @@ +import { ViewWrapper as ViewWrapper_023789d862ecf764c682ca49ec4d5e56 } from 'payload-plugin-import-export/client' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { - '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, + "payload-plugin-import-export/client#ViewWrapper": ViewWrapper_023789d862ecf764c682ca49ec4d5e56, + "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/cms/src/payload-types.ts b/cms/src/payload-types.ts index 576619e..d166c25 100644 --- a/cms/src/payload-types.ts +++ b/cms/src/payload-types.ts @@ -220,6 +220,7 @@ export interface Event { date: string description?: string | null coverImage?: (number | null) | Media + category?: ('games' | 'community' | 'food' | 'agm' | 'all') | null isUpcoming?: boolean | null images?: | { @@ -473,6 +474,7 @@ export interface EventsSelect { date?: T description?: T coverImage?: T + category?: T isUpcoming?: T images?: | T diff --git a/cms/src/payload.config.ts b/cms/src/payload.config.ts index 1294e8f..b5e7a04 100644 --- a/cms/src/payload.config.ts +++ b/cms/src/payload.config.ts @@ -1,5 +1,6 @@ import { postgresAdapter } from '@payloadcms/db-postgres' import { lexicalEditor } from '@payloadcms/richtext-lexical' +import importExportPlugin from 'payload-plugin-import-export' import path from 'path' import { buildConfig } from 'payload' import { fileURLToPath } from 'url' @@ -34,5 +35,15 @@ export default buildConfig({ }, }), sharp, - plugins: [], + plugins: [ + importExportPlugin({ + enabled: true, + excludeCollections: [Users.slug, Members.slug], + canImport: (user) => { + if (!user || typeof user !== 'object') return false + + return (user as { role?: string | null }).role === 'admin' + }, + }), + ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8e2b8e..8013660 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: payload: specifier: 3.79.0 version: 3.79.0(graphql@16.14.0)(typescript@5.7.3) + payload-plugin-import-export: + specifier: ^3.0.20 + version: 3.0.20(@payloadcms/ui@3.79.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3)) react: specifier: 19.2.1 version: 19.2.1 @@ -102,9 +105,6 @@ importers: web: dependencies: - '@tanstack/react-query': - specifier: ^5.100.10 - version: 5.100.10(react@19.2.3) '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@19.2.9)(react@19.2.3) @@ -117,6 +117,12 @@ importers: '@mui/material': specifier: ^9.0.1 version: 9.0.1(@emotion/react@11.14.0(@types/react@19.2.9)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.9)(react@19.2.3))(@types/react@19.2.9)(react@19.2.3))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@supabase/supabase-js': + specifier: ^2.107.0 + version: 2.107.0 + '@tanstack/react-query': + specifier: ^5.100.10 + version: 5.100.10(react@19.2.3) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.77.4) @@ -1740,6 +1746,33 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@supabase/auth-js@2.107.0': + resolution: {integrity: sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.107.0': + resolution: {integrity: sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.2': + resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} + + '@supabase/postgrest-js@2.107.0': + resolution: {integrity: sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.107.0': + resolution: {integrity: sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.107.0': + resolution: {integrity: sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.107.0': + resolution: {integrity: sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==} + engines: {node: '>=20.0.0'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3157,6 +3190,10 @@ packages: engines: {node: '>=18'} hasBin: true + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3846,6 +3883,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3885,6 +3925,13 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + payload-plugin-import-export@3.0.20: + resolution: {integrity: sha512-5R9PMhetwrA5posucr/7qHbg0fVy8rj3feNIO04ejNAfwe7I7WgsC+MK/LDKiYBdH9AYGl2zYh7kedBMAYug6g==} + engines: {node: ^18.20.2 || >=20.9.0, pnpm: ^9 || ^10} + peerDependencies: + '@payloadcms/ui': ^3.37.0 + payload: ^3.37.0 + payload@3.79.0: resolution: {integrity: sha512-Pey2gBhFL5QkAmN2KMkzXdiBS4QOi5IiQF4Ji9hCNu7kaDcNYtgk75EeWRGP4hOcb22+dqYnw2TZm5Zs/0KBKw==} engines: {node: ^18.20.2 || >=20.9.0} @@ -6373,6 +6420,38 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@supabase/auth-js@2.107.0': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.107.0': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.2': {} + + '@supabase/postgrest-js@2.107.0': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.107.0': + dependencies: + '@supabase/phoenix': 0.4.2 + tslib: 2.8.1 + + '@supabase/storage-js@2.107.0': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.107.0': + dependencies: + '@supabase/auth-js': 2.107.0 + '@supabase/functions-js': 2.107.0 + '@supabase/postgrest-js': 2.107.0 + '@supabase/realtime-js': 2.107.0 + '@supabase/storage-js': 2.107.0 + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -7940,6 +8019,8 @@ snapshots: husky@9.1.7: {} + iceberg-js@0.8.1: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -8759,6 +8840,8 @@ snapshots: dependencies: p-limit: 3.1.0 + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8798,6 +8881,12 @@ snapshots: pathe@2.0.3: {} + payload-plugin-import-export@3.0.20(@payloadcms/ui@3.79.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3)): + dependencies: + '@payloadcms/ui': 3.79.0(@types/react@19.2.9)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(sass@1.77.4))(payload@3.79.0(graphql@16.14.0)(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) + papaparse: 5.5.3 + payload: 3.79.0(graphql@16.14.0)(typescript@5.7.3) + payload@3.79.0(graphql@16.14.0)(typescript@5.7.3): dependencies: '@next/env': 15.5.16 diff --git a/web/next.config.ts b/web/next.config.ts index 4133a3f..2eabfcb 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,7 +1,33 @@ import type { NextConfig } from 'next' +function getCmsImageRemotePattern() { + const cmsUrl = process.env.NEXT_PUBLIC_CMS_URL + if (!cmsUrl) return undefined + + try { + const url = new URL(cmsUrl) + return { + protocol: url.protocol.replace(':', '') as 'http' | 'https', + hostname: url.hostname, + port: url.port, + pathname: '/api/media/file/**', + } + } catch { + return undefined + } +} + +const cmsImageRemotePattern = getCmsImageRemotePattern() +const allowLocalCmsImages = + cmsImageRemotePattern?.hostname === 'localhost' || + cmsImageRemotePattern?.hostname === '127.0.0.1' + const nextConfig: NextConfig = { output: 'standalone', + images: { + dangerouslyAllowLocalIP: allowLocalCmsImages, + remotePatterns: cmsImageRemotePattern ? [cmsImageRemotePattern] : [], + }, } export default nextConfig diff --git a/web/package.json b/web/package.json index 7eb204f..23dd45b 100644 --- a/web/package.json +++ b/web/package.json @@ -10,11 +10,12 @@ "format:check": "prettier --check ." }, "dependencies": { - "@tanstack/react-query": "^5.100.10", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", + "@supabase/supabase-js": "^2.107.0", + "@tanstack/react-query": "^5.100.10", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/web/src/app/about/_components/ExecGrid.tsx b/web/src/app/about/_components/ExecGrid.tsx index 7a697cd..0652dea 100644 --- a/web/src/app/about/_components/ExecGrid.tsx +++ b/web/src/app/about/_components/ExecGrid.tsx @@ -1,21 +1,53 @@ -import { execMembers } from './execData' +'use client' + +import { useExecs } from '@/hooks/useExecs' +import { getCmsMediaUrl } from '@/lib/media' + +const SKELETON_KEYS = [ + 'sk-1', + 'sk-2', + 'sk-3', + 'sk-4', + 'sk-5', + 'sk-6', + 'sk-7', + 'sk-8', +] import ExecCard from './ExecCard' export default function ExecGrid() { + const { data: execs, isLoading, isError } = useExecs() + return (

Meet the SSA Team

+ + {isError && ( +

+ Failed to load the team. Please try again later. +

+ )} + + {/* Show empty card placeholders while loading */}
- {execMembers.map((exec) => ( - - ))} + {isLoading + ? SKELETON_KEYS.map((key) => ( +
+ )) + : (execs ?? []).map((exec) => ( + + ))}
) diff --git a/web/src/app/events/_components/PastEventsSection.tsx b/web/src/app/events/_components/PastEventsSection.tsx index bf1ebc8..670c75c 100644 --- a/web/src/app/events/_components/PastEventsSection.tsx +++ b/web/src/app/events/_components/PastEventsSection.tsx @@ -2,13 +2,37 @@ import { useMemo, useState } from 'react' import { FaArrowRight, FaMagnifyingGlass } from 'react-icons/fa6' +import { usePastEvents } from '@/hooks/useEvents' +import { getCmsMediaUrl } from '@/lib/media' +import type { Event } from '@/types/events' import PastEventCard from './PastEventCard' -import { EVENT_FILTERS, pastEvents, type EventFilter } from './pastEventsData' +import { + CATEGORY_TO_TAG, + EVENT_FILTERS, + type EventFilter, + type PastEvent, +} from './pastEventsData' const INITIAL_VISIBLE = 6 const LOAD_MORE_STEP = 6 +// Maps a Supabase Event row to the shape PastEventCard expects +function toPastEvent(event: Event): PastEvent { + const tag = event.category ? CATEGORY_TO_TAG[event.category] : undefined + return { + slug: String(event.id), + name: event.title, + location: '', + date: event.date, + thumbnail: getCmsMediaUrl(event.cover_image?.url) ?? '/carousel_one.jpg', + thumbnailAlt: event.cover_image?.alt ?? event.title, + tags: tag ? [tag] : [], + } +} + export default function PastEventsSection() { + const { data: rawEvents, isLoading, isError } = usePastEvents() + const [query, setQuery] = useState('') const [activeFilter, setActiveFilter] = useState('All') const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE) @@ -23,6 +47,12 @@ export default function PastEventsSection() { setVisibleCount(INITIAL_VISIBLE) } + // Convert Supabase rows to the PastEvent shape once, then filter + const pastEvents = useMemo( + () => (rawEvents ?? []).map(toPastEvent), + [rawEvents], + ) + const filteredEvents = useMemo(() => { const q = query.trim().toLowerCase() return pastEvents.filter((event) => { @@ -30,12 +60,9 @@ export default function PastEventsSection() { activeFilter === 'All' || event.tags.includes(activeFilter) if (!matchesFilter) return false if (!q) return true - return ( - event.name.toLowerCase().includes(q) || - event.location.toLowerCase().includes(q) - ) + return event.name.toLowerCase().includes(q) }) - }, [query, activeFilter]) + }, [pastEvents, query, activeFilter]) const visibleEvents = filteredEvents.slice(0, visibleCount) const hasMore = visibleCount < filteredEvents.length @@ -88,14 +115,27 @@ export default function PastEventsSection() { })} - {/* Grid */} + {isError && ( +

+ Failed to load events. Please try again later. +

+ )} + + {/* Show skeleton cards while loading */}
- {visibleEvents.map((event) => ( - - ))} + {isLoading + ? Array.from({ length: INITIAL_VISIBLE }, (_, i) => ( +
+ )) + : visibleEvents.map((event) => ( + + ))}
- {filteredEvents.length === 0 && ( + {!isLoading && filteredEvents.length === 0 && (

No events match your search.

diff --git a/web/src/app/events/_components/pastEventsData.ts b/web/src/app/events/_components/pastEventsData.ts index e5243a8..396830b 100644 --- a/web/src/app/events/_components/pastEventsData.ts +++ b/web/src/app/events/_components/pastEventsData.ts @@ -1,3 +1,6 @@ +// These types and constants are still used by PastEventCard and PastEventsSection. +// The static pastEvents array has been replaced by the usePastEvents hook. + export type PastEventTag = 'Games' | 'Community' | 'Food' | 'AGM' export type EventFilter = 'All' | PastEventTag @@ -6,7 +9,7 @@ export type PastEvent = { slug: string name: string location: string - /** ISO date string (YYYY-MM-DD) for the event date. */ + /** ISO date string (YYYY-MM-DD) */ date: string thumbnail: string thumbnailAlt: string @@ -21,105 +24,11 @@ export const EVENT_FILTERS: EventFilter[] = [ 'AGM', ] -// TODO: replace with CMS data once the events endpoint is available. -export const pastEvents: PastEvent[] = [ - { - slug: 'satay-by-the-quad-march-2026', - name: 'Satay by the Quad', - location: 'Outhwaite Park', - date: '2026-03-11', - thumbnail: '/carousel_one.jpg', - thumbnailAlt: 'Students gathered at Outhwaite Park for Satay by the Quad', - tags: ['Food', 'Community'], - }, - { - slug: 'junior-comms-april-2025', - name: 'Junior Comms', - location: 'UOA OGGB', - date: '2025-04-10', - thumbnail: '/carousel_two.jpg', - thumbnailAlt: 'Members posing together at Junior Comms event', - tags: ['Food', 'Games'], - }, - { - slug: 'satay-by-the-quad-march-2026-2', - name: 'Satay by the Quad', - location: 'Outhwaite Park', - date: '2026-03-11', - thumbnail: '/carousel_one.jpg', - thumbnailAlt: 'Students enjoying satay together on the quad lawn', - tags: ['Food', 'Community'], - }, - { - slug: 'satay-by-the-quad-march-2026-3', - name: 'Satay by the Quad', - location: 'Outhwaite Park', - date: '2026-03-11', - thumbnail: '/carousel_one.jpg', - thumbnailAlt: 'Group photo of students at Satay by the Quad', - tags: ['Food', 'Community'], - }, - { - slug: 'satay-by-the-quad-march-2026-4', - name: 'Satay by the Quad', - location: 'Outhwaite Park', - date: '2026-03-11', - thumbnail: '/carousel_one.jpg', - thumbnailAlt: 'Attendees mingling at Satay by the Quad', - tags: ['Food', 'Community'], - }, - { - slug: 'satay-by-the-quad-march-2026-5', - name: 'Satay by the Quad', - location: 'Outhwaite Park', - date: '2026-03-11', - thumbnail: '/carousel_one.jpg', - thumbnailAlt: 'Friends sharing food at Satay by the Quad', - tags: ['Food', 'Community'], - }, - { - slug: 'ice-kachang-night-2025', - name: 'Ice Kachang Night', - location: 'UOA Engineering', - date: '2025-07-13', - thumbnail: '/carousel_two.jpg', - thumbnailAlt: 'Students enjoying ice kachang at UOA Engineering', - tags: ['Food', 'Community'], - }, - { - slug: 'ssa-x-vausa-mid-autumn-2025', - name: 'SSA X VAUSA Mid-Autumn Festival', - location: 'UOA Engineering', - date: '2025-08-13', - thumbnail: '/carousel_one.jpg', - thumbnailAlt: 'Group photo at SSA X VAUSA Mid-Autumn Festival', - tags: ['Food', 'Community'], - }, - { - slug: 'ssa-camp-2025', - name: 'SSA Camp', - location: 'Camp Somewhere', - date: '2025-11-21', - thumbnail: '/carousel_two.jpg', - thumbnailAlt: 'SSA Camp group photo with banner', - tags: ['Community', 'Games'], - }, - { - slug: 'agm-2025', - name: 'Annual General Meeting', - location: 'UOA OGGB', - date: '2025-09-15', - thumbnail: '/carousel_one.jpg', - thumbnailAlt: 'SSA members at the Annual General Meeting', - tags: ['AGM', 'Community'], - }, - { - slug: 'games-night-2025', - name: 'Games Night', - location: 'UOA Recreation Centre', - date: '2025-06-05', - thumbnail: '/carousel_two.jpg', - thumbnailAlt: 'Students playing board games at SSA Games Night', - tags: ['Games', 'Community'], - }, -] +// Maps a single CMS category value to the display tag used on cards. +// 'all' has no equivalent tag so it returns undefined. +export const CATEGORY_TO_TAG: Record = { + games: 'Games', + community: 'Community', + food: 'Food', + agm: 'AGM', +} diff --git a/web/src/hooks/useEvents.ts b/web/src/hooks/useEvents.ts index 0e6d9cc..c1e9d40 100644 --- a/web/src/hooks/useEvents.ts +++ b/web/src/hooks/useEvents.ts @@ -1,36 +1,54 @@ import { useQuery } from '@tanstack/react-query' -import { fetchFromCMS } from '@/lib/api' -import type { Event, EventsResponse } from '@/types/events' - -export const eventKeys = { - all: ['events'] as const, - lists: () => [...eventKeys.all, 'list'] as const, - detail: (id: number) => [...eventKeys.all, 'detail', id] as const, -} +import { getSupabase } from '@/lib/supabase' +import type { Event } from '@/types/events' +// Get all events, newest date first. +// The cover_image field is joined from the media table via cover_image_id. export function useEvents() { return useQuery({ - queryKey: eventKeys.lists(), - queryFn: () => - fetchFromCMS('/events?depth=1&limit=100&sort=-date'), - select: (data) => data.docs, + queryKey: ['events'], + queryFn: async () => { + const { data, error } = await getSupabase() + .from('events') + .select('*, cover_image:media!cover_image_id(url, alt)') + .order('date', { ascending: false }) + + if (error) throw error + return (data ?? []) as Event[] + }, }) } +// Only events marked as upcoming, soonest first export function useUpcomingEvents() { - const query = useEvents() + return useQuery({ + queryKey: ['events', 'upcoming'], + queryFn: async () => { + const { data, error } = await getSupabase() + .from('events') + .select('*, cover_image:media!cover_image_id(url, alt)') + .eq('is_upcoming', true) + .order('date', { ascending: true }) - return { - ...query, - data: query.data?.filter((event: Event) => event.isUpcoming === true) ?? [], - } + if (error) throw error + return (data ?? []) as Event[] + }, + }) } +// Only past events, most recent first export function usePastEvents() { - const query = useEvents() + return useQuery({ + queryKey: ['events', 'past'], + queryFn: async () => { + const { data, error } = await getSupabase() + .from('events') + .select('*, cover_image:media!cover_image_id(url, alt)') + .eq('is_upcoming', false) + .order('date', { ascending: false }) - return { - ...query, - data: query.data?.filter((event: Event) => event.isUpcoming !== true) ?? [], - } + if (error) throw error + return (data ?? []) as Event[] + }, + }) } diff --git a/web/src/hooks/useExecs.ts b/web/src/hooks/useExecs.ts new file mode 100644 index 0000000..b9e2375 --- /dev/null +++ b/web/src/hooks/useExecs.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' +import { getSupabase } from '@/lib/supabase' +import type { Exec } from '@/types/execs' + +// Get all exec members, most recent year first. +// The photo field is joined from the media table using the photo_id foreign key. +export function useExecs() { + return useQuery({ + queryKey: ['execs'], + queryFn: async () => { + const { data, error } = await getSupabase() + .from('execs') + .select('*, photo:media!photo_id(url, alt)') + .order('year', { ascending: false }) + + if (error) throw error + return (data ?? []) as Exec[] + }, + }) +} diff --git a/web/src/hooks/useSponsors.ts b/web/src/hooks/useSponsors.ts new file mode 100644 index 0000000..5284892 --- /dev/null +++ b/web/src/hooks/useSponsors.ts @@ -0,0 +1,57 @@ +import { useQuery } from '@tanstack/react-query' +import { getSupabase } from '@/lib/supabase' +import type { Sponsor } from '@/types/sponsors' + +// Get all sponsors, sorted alphabetically by name +export function useSponsors() { + return useQuery({ + queryKey: ['sponsors'], + queryFn: async () => { + const { data, error } = await getSupabase() + .from('sponsors') + .select('*') + .order('name', { ascending: true }) + + if (error) throw error + return (data ?? []) as Sponsor[] + }, + }) +} + +// Get a single sponsor by ID +export function useSponsor(id: number | string | null | undefined) { + return useQuery({ + queryKey: ['sponsors', id], + enabled: id !== null && id !== undefined && id !== '', + queryFn: async () => { + const { data, error } = await getSupabase() + .from('sponsors') + .select('*, logo:media!logo_id(url, alt)') + .eq('id', id) + .single() + + if (error) throw error + return data as Sponsor + }, + }) +} + +// Get the single sponsor currently marked as sponsor of the week +export function useSponsorOfTheWeek() { + return useQuery({ + queryKey: ['sponsors', 'of-the-week'], + queryFn: async () => { + const { data, error } = await getSupabase() + .from('sponsors') + .select('*') + .eq('is_sponsor_of_the_week', true) + .limit(1) + .single() + + // .single() returns an error if no row is found — treat that as null + if (error?.code === 'PGRST116') return null + if (error) throw error + return data as Sponsor + }, + }) +} diff --git a/web/src/lib/media.ts b/web/src/lib/media.ts new file mode 100644 index 0000000..3906d4d --- /dev/null +++ b/web/src/lib/media.ts @@ -0,0 +1,10 @@ +const cmsUrl = process.env.NEXT_PUBLIC_CMS_URL?.replace(/\/$/, '') ?? '' + +export function getCmsMediaUrl(url: string | null | undefined): string | null { + if (!url) return null + if (/^https?:\/\//i.test(url)) return url + if (!url.startsWith('/api/media/')) return url + if (!cmsUrl) return url + + return `${cmsUrl}${url}` +} diff --git a/web/src/lib/supabase.ts b/web/src/lib/supabase.ts new file mode 100644 index 0000000..5960126 --- /dev/null +++ b/web/src/lib/supabase.ts @@ -0,0 +1,16 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js' + +let _client: SupabaseClient | null = null + +// Returns the shared Supabase client, creating it on first call. +// Defined as a function so the client is only created in the browser +// (inside a queryFn), never during SSR when env vars aren't available. +export function getSupabase(): SupabaseClient { + if (!_client) { + _client = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + ) + } + return _client +} diff --git a/web/src/types/events.ts b/web/src/types/events.ts index a8ae610..3368850 100644 --- a/web/src/types/events.ts +++ b/web/src/types/events.ts @@ -1,45 +1,20 @@ -export interface EventMedia { - id: number - url: string | null - alt: string - width: number | null - height: number | null -} - -export type EventCategory = - | 'social' - | 'cultural' - | 'academic' - | 'sports' - | 'other' +// Matches the columns Payload CMS creates in the 'events' PostgreSQL table. +// Column names are snake_case because that's what Payload stores in the database. -export interface EventImage { - id: string | null - image: EventMedia +export type EventCoverImage = { + url: string | null + alt: string | null } -export interface Event { +export type Event = { id: number title: string date: string description: string | null - coverImage: EventMedia | null - category: EventCategory | null - isUpcoming: boolean | null - images: EventImage[] | null - updatedAt: string - createdAt: string -} - -export interface EventsResponse { - docs: Event[] - totalDocs: number - limit: number - totalPages: number - page: number - pagingCounter: number - hasPrevPage: boolean - hasNextPage: boolean - prevPage: number | null - nextPage: number | null + // Joined from the 'media' table via cover_image_id foreign key + cover_image: EventCoverImage | null + category: 'games' | 'community' | 'food' | 'agm' | 'all' | null + is_upcoming: boolean | null + updated_at: string + created_at: string } diff --git a/web/src/types/execs.ts b/web/src/types/execs.ts new file mode 100644 index 0000000..d08b097 --- /dev/null +++ b/web/src/types/execs.ts @@ -0,0 +1,20 @@ +// Matches the columns Payload CMS creates in the 'execs' PostgreSQL table. +// Column names are snake_case because that's what Payload stores in the database. + +export type ExecPhoto = { + url: string | null + alt: string | null +} + +export type Exec = { + id: number + name: string + role: string + // Joined from the 'media' table via photo_id foreign key + photo: ExecPhoto | null + bio: string | null + // Academic year this exec served (e.g. 2024) + year: number | null + updated_at: string + created_at: string +} diff --git a/web/src/types/sponsors.ts b/web/src/types/sponsors.ts new file mode 100644 index 0000000..d7bc3be --- /dev/null +++ b/web/src/types/sponsors.ts @@ -0,0 +1,22 @@ +// Matches the columns Payload CMS creates in the 'sponsors' PostgreSQL table. +// Column names are snake_case because that's what Payload stores in the database. +export type SponsorLogo = { + url: string | null + alt: string | null +} + +export type Sponsor = { + id: number + name: string + // ID of the related media row (the actual logo is in the 'media' table) + logo_id: number | null + // Joined from the 'media' table via logo_id foreign key + logo?: SponsorLogo | null + website_url: string | null + is_sponsor_of_the_week: boolean | null + description: string | null + location: string | null + member_perks: string | null + updated_at: string + created_at: string +}