diff --git a/src/components/events/EventCard/style.module.scss b/src/components/events/EventCard/style.module.scss index 39881251..8dfd50cc 100644 --- a/src/components/events/EventCard/style.module.scss +++ b/src/components/events/EventCard/style.module.scss @@ -16,11 +16,11 @@ } .image { + aspect-ratio: 1.778; flex-shrink: 0; - height: 11.25rem; overflow: none; position: relative; - width: 20rem; + width: 100%; } .info { diff --git a/src/components/events/EventDisplay/index.tsx b/src/components/events/EventDisplay/index.tsx index 25e1f2e2..57a002df 100644 --- a/src/components/events/EventDisplay/index.tsx +++ b/src/components/events/EventDisplay/index.tsx @@ -1,3 +1,4 @@ +import { Typography } from '@/components/common'; import EventCard from '@/components/events/EventCard'; import { PublicAttendance, PublicEvent } from '@/lib/types/apiResponses'; import styles from './style.module.scss'; @@ -8,10 +9,12 @@ interface EventDisplayProps { } const EventDisplay = ({ events, attendances }: EventDisplayProps) => { - const displayedEvents = events.slice(0, 20); + if (events.length === 0) { + return No events found :(; + } return (
- {displayedEvents.map(event => ( + {events.map(event => ( { variant="title/medium" suppressHydrationWarning > - {formatEventDate(start, end)} + {formatEventDate(start, end, true)} {location} diff --git a/src/components/events/index.tsx b/src/components/events/index.tsx new file mode 100644 index 00000000..18664aff --- /dev/null +++ b/src/components/events/index.tsx @@ -0,0 +1,2 @@ +export { default as EventCarousel } from './EventCarousel'; +export { default as EventDisplay } from './EventDisplay'; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a4d61152..c2e5c438 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -61,6 +61,14 @@ export const capitalize = (text: string): string => { return text.slice(0, 1).toUpperCase() + text.slice(1).toLowerCase(); }; +/** + * Given text, removes all non alphanumeric characters. + * This makes search terms more lenient. + */ +export const formatSearch = (text: string): string => { + return text.toLowerCase().replace(/[^0-9a-zA-Z]/g, ''); +}; + /** * Helper function to map each user to a numeric value deterministically * TODO: Use the user's UUID to hash to a number since it will never change @@ -133,6 +141,71 @@ export const formatURLEventTitle = (title: string): string => { return encodeURIComponent(title.toLowerCase().trim().replace(/ /g, '-')); }; +/** Year ACM was founded. */ +const START_YEAR = 2019; +/** Number of seconds in a day. */ +const DAY_SECONDS = 86400; + +/** + * @returns The end of the current academic year. + */ +export const getEndYear = (): number => { + const today = new Date(); + // Arbitrarily start the next academic year in August + return today.getMonth() < 7 ? today.getFullYear() : today.getFullYear() + 1; +}; + +/** + * @returns A list of all years that ACM has existed. + */ +export const getYears = () => { + const endYear = getEndYear(); + const years = []; + for (let year = START_YEAR; year < endYear; year += 1) { + years.unshift({ value: String(year), label: `${year}–${year + 1}` }); + } + return years; +}; + +/** + * Given a sort option, returns the range of times to filter events/attendances by. + * @param sort Either a year (2023, 2019, etc.) or a string option (past-week, all-time, etc.) + * @returns A range of times to filter results by. + */ +export const getDateRange = (sort: string | number) => { + const now = Date.now() / 1000; + let from; + let to; + switch (sort) { + case 'upcoming': { + from = now; + break; + } + case 'past-week': { + from = now - DAY_SECONDS * 7; + break; + } + case 'past-month': { + from = now - DAY_SECONDS * 28; + break; + } + case 'past-year': { + from = now - DAY_SECONDS * 365; + break; + } + case 'all-time': { + break; + } + default: { + const year = +sort; + // Arbitrarily academic years on August 1, which should be during the summer + from = new Date(year, 7, 1).getTime() / 1000; + to = new Date(year + 1, 7, 1).getTime() / 1000; + } + } + return { from, to }; +}; + /** * Returns the default (first) photo for a merchandise item. * If there are no photos for this item, returns the default 404 image. diff --git a/src/pages/events.tsx b/src/pages/events.tsx index 8be8d91d..3c49f923 100644 --- a/src/pages/events.tsx +++ b/src/pages/events.tsx @@ -1,16 +1,172 @@ +import { Dropdown, PaginationControls, Typography } from '@/components/common'; +import { EventDisplay } from '@/components/events'; +import { EventAPI } from '@/lib/api'; import withAccessType from '@/lib/hoc/withAccessType'; -import { PermissionService } from '@/lib/services'; -import type { GetServerSideProps, NextPage } from 'next'; +import { CookieService, PermissionService } from '@/lib/services'; +import type { PublicAttendance, PublicEvent } from '@/lib/types/apiResponses'; +import { CookieType } from '@/lib/types/enums'; +import { formatSearch, getDateRange, getYears } from '@/lib/utils'; +import styles from '@/styles/pages/events.module.scss'; +import type { GetServerSideProps } from 'next'; +import { useMemo, useState } from 'react'; -const EventsPage: NextPage = () => { - return

Events Page

; +interface EventsPageProps { + events: PublicEvent[]; + attendances: PublicAttendance[]; +} + +interface FilterOptions { + query: string; + communityFilter: string; + dateFilter: string | number; + attendedFilter: string; +} + +const filterEvent = ( + event: PublicEvent, + attendances: PublicAttendance[], + { query, communityFilter, dateFilter, attendedFilter }: FilterOptions +): boolean => { + // Filter search query + if (query !== '' && !formatSearch(event.title).includes(formatSearch(query))) { + return false; + } + // Filter by community + if (communityFilter !== 'all' && event.committee.toLowerCase() !== communityFilter) { + return false; + } + // Filter by date + const { from, to } = getDateRange(dateFilter); + if (from !== undefined && new Date(event.start) < new Date(from * 1000)) { + return false; + } + if (to !== undefined && new Date(event.end) > new Date(to * 1000)) { + return false; + } + // Filter by attendance + if (attendedFilter === 'all') { + return true; + } + const attended = attendances.some(a => a.event.uuid === event.uuid); + if (attendedFilter === 'attended' && !attended) { + return false; + } + if (attendedFilter === 'not-attended' && attended) { + return false; + } + return true; +}; + +const ROWS_PER_PAGE = 25; +const EventsPage = ({ events, attendances }: EventsPageProps) => { + const [page, setPage] = useState(0); + const [communityFilter, setCommunityFilter] = useState('all'); + const [dateFilter, setDateFilter] = useState('upcoming'); + const [attendedFilter, setAttendedFilter] = useState('all'); + const [query, setQuery] = useState(''); + + const years = useMemo(getYears, []); + + const filteredEvents = events.filter(e => + filterEvent(e, attendances, { query, communityFilter, dateFilter, attendedFilter }) + ); + const displayedEvents = filteredEvents.slice(page * ROWS_PER_PAGE, (page + 1) * ROWS_PER_PAGE); + + return ( +
+ Events +
+ { + setQuery(e.currentTarget.value); + setPage(0); + }} + /> +
+ { + setCommunityFilter(v); + setPage(0); + }} + /> +
+ +
+ { + setDateFilter(v); + setPage(0); + }} + /> +
+ +
+ { + setAttendedFilter(v); + setPage(0); + }} + /> +
+
+ + + {filteredEvents.length > 0 && ( + setPage(page)} + pages={Math.ceil(filteredEvents.length / ROWS_PER_PAGE)} + /> + )} +
+ ); }; export default EventsPage; -const getServerSidePropsFunc: GetServerSideProps = async () => ({ - props: {}, -}); +const getServerSidePropsFunc: GetServerSideProps = async ({ req, res }) => { + const authToken = CookieService.getServerCookie(CookieType.ACCESS_TOKEN, { req, res }); + const events = await EventAPI.getAllEvents(); + const attendances = await EventAPI.getAttendancesForUser(authToken); + + return { props: { events, attendances } }; +}; export const getServerSideProps = withAccessType( getServerSidePropsFunc, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 5ecd8fe2..3e45a8d8 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,4 +1,4 @@ -import EventCarousel from '@/components/events/EventCarousel'; +import { EventCarousel } from '@/components/events'; import { EventAPI } from '@/lib/api'; import withAccessType from '@/lib/hoc/withAccessType'; import { CookieService, PermissionService } from '@/lib/services'; diff --git a/src/pages/leaderboard.tsx b/src/pages/leaderboard.tsx index c2b953a0..592b6bb1 100644 --- a/src/pages/leaderboard.tsx +++ b/src/pages/leaderboard.tsx @@ -7,18 +7,13 @@ import { CookieService, PermissionService } from '@/lib/services'; import { SlidingLeaderboardQueryParams } from '@/lib/types/apiRequests'; import { PrivateProfile, PublicProfile } from '@/lib/types/apiResponses'; import { CookieType } from '@/lib/types/enums'; -import { getProfilePicture, getUserRank } from '@/lib/utils'; +import { getDateRange, getEndYear, getProfilePicture, getUserRank, getYears } from '@/lib/utils'; import MyPositionIcon from '@/public/assets/icons/my-position-icon.svg'; import styles from '@/styles/pages/leaderboard.module.scss'; import { GetServerSideProps } from 'next'; import { useRouter } from 'next/router'; import { useMemo, useState } from 'react'; -/** Year ACM was founded. */ -const START_YEAR = 2019; -/** Number of seconds in a day. */ -const DAY_SECONDS = 86400; - interface Match { index: number; length: number; @@ -31,37 +26,14 @@ function filter(users: T[], query: string): (T & { matc }); } -function getEndYear(): number { - const today = new Date(); - // Arbitrarily start the next academic year in August - return today.getMonth() < 7 ? today.getFullYear() : today.getFullYear() + 1; -} - function getLeaderboardRange(sort: string | number, limit = 0): SlidingLeaderboardQueryParams { const params: SlidingLeaderboardQueryParams = { limit }; - const now = Date.now() / 1000; - switch (sort) { - case 'past-week': { - params.from = now - DAY_SECONDS * 7; - break; - } - case 'past-month': { - params.from = now - DAY_SECONDS * 28; - break; - } - case 'past-year': { - params.from = now - DAY_SECONDS * 365; - break; - } - case 'all-time': { - break; - } - default: { - const year = +sort; - // Arbitrarily academic years on August 1, which should be during the summer - params.from = new Date(year, 7, 1).getTime() / 1000; - params.to = new Date(year + 1, 7, 1).getTime() / 1000; - } + const { from, to } = getDateRange(sort); + if (from !== undefined) { + params.from = from; + } + if (to !== undefined) { + params.to = to; } return params; } @@ -83,14 +55,7 @@ const LeaderboardPage = ({ sort, leaderboard, user: { uuid } }: LeaderboardProps // user presses the button multiple times const [scrollIntoView, setScrollIntoView] = useState(0); - const years = useMemo(() => { - const endYear = getEndYear(); - const years = []; - for (let year = START_YEAR; year < endYear; year += 1) { - years.unshift({ value: String(year), label: `${year}–${year + 1}` }); - } - return years; - }, []); + const years = useMemo(getYears, []); const { allRows, myIndex } = useMemo(() => { const results = leaderboard.map((user, index) => ({ ...user, position: index + 1 })); diff --git a/src/styles/pages/Home.module.scss b/src/styles/pages/Home.module.scss index 54642013..1859b666 100644 --- a/src/styles/pages/Home.module.scss +++ b/src/styles/pages/Home.module.scss @@ -2,16 +2,4 @@ display: flex; flex-direction: column; gap: 3rem; - - .eventDisplay { - display: flex; - flex-direction: column; - gap: 1rem; - - .eventDisplayHeader { - display: flex; - flex-direction: column; - gap: 0.5rem; - } - } } diff --git a/src/styles/pages/Home.module.scss.d.ts b/src/styles/pages/Home.module.scss.d.ts index 0302f70e..190f135c 100644 --- a/src/styles/pages/Home.module.scss.d.ts +++ b/src/styles/pages/Home.module.scss.d.ts @@ -1,6 +1,4 @@ export type Styles = { - eventDisplay: string; - eventDisplayHeader: string; page: string; }; diff --git a/src/styles/pages/events.module.scss b/src/styles/pages/events.module.scss new file mode 100644 index 00000000..c2b0e3cb --- /dev/null +++ b/src/styles/pages/events.module.scss @@ -0,0 +1,41 @@ +.page { + display: flex; + flex-direction: column; + gap: 1.5rem; + + .controls { + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + + .search { + background: none; + border: 1px solid var(--theme-text-on-background-1); + border-radius: 1rem; + color: inherit; + font-size: 1rem; + line-height: 1.25rem; + max-width: 18rem; + padding: 0.5rem 1rem; + width: 100%; + + &::placeholder { + color: var(--theme-text-on-background-3); + } + } + + .filterOption { + border: 1px solid var(--theme-text-on-background-1); + border-radius: 1rem; + padding: 0.5rem 1rem; + + div > select { + font-size: 1rem; + font-weight: normal; + line-height: 1.25rem; + } + } + } +} diff --git a/src/styles/pages/events.module.scss.d.ts b/src/styles/pages/events.module.scss.d.ts new file mode 100644 index 00000000..e67e6d63 --- /dev/null +++ b/src/styles/pages/events.module.scss.d.ts @@ -0,0 +1,12 @@ +export type Styles = { + controls: string; + filterOption: string; + page: string; + search: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles;