From 009da51f79a1321dd535ac3a48d552d0c61610eb Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:43:22 +0800 Subject: [PATCH 01/13] Add discoverable newsletter and wiki archives The newsletter navigation pointed to one mislabeled Wiki edition, leaving past publications difficult to find.\n\nAdd separate archive pages and responsive year menus so students can browse verified editions while unpublished years remain clearly marked. --- app/newsletter/page.tsx | 30 ++++++ app/wiki/page.tsx | 42 ++++++++ components/ArchivePage.tsx | 96 ++++++++++++++++++ components/Header.tsx | 138 +++++++++++++++++++++---- components/custom-dropdown.tsx | 179 +++++++++++++++++++++++++++++---- 5 files changed, 444 insertions(+), 41 deletions(-) create mode 100644 app/newsletter/page.tsx create mode 100644 app/wiki/page.tsx create mode 100644 components/ArchivePage.tsx diff --git a/app/newsletter/page.tsx b/app/newsletter/page.tsx new file mode 100644 index 0000000..2697dad --- /dev/null +++ b/app/newsletter/page.tsx @@ -0,0 +1,30 @@ +import ArchivePage, { type ArchiveYear } from '@/components/ArchivePage'; + +const newsletterYears: ArchiveYear[] = [ + { year: 'AY26/27' }, + { + year: 'AY25/26', + editions: [{ label: 'SEMESTER 2', href: 'https://t.me/NUSChannel/6691' }], + }, + { + year: 'AY24/25', + editions: [ + { label: 'SEMESTER 2', href: 'https://t.me/NUSChannel/6245' }, + { label: 'SEMESTER 1', href: 'https://t.me/NUSChannel/6005' }, + ], + }, + { + year: 'AY22/23', + editions: [{ label: 'VIEW EDITION', href: 'https://t.me/NUSChannel/5207' }], + }, +]; + +export default function NewsletterPage() { + return ( + + ); +} diff --git a/app/wiki/page.tsx b/app/wiki/page.tsx new file mode 100644 index 0000000..8029a72 --- /dev/null +++ b/app/wiki/page.tsx @@ -0,0 +1,42 @@ +import ArchivePage, { type ArchiveYear } from '@/components/ArchivePage'; + +const wikiYears: ArchiveYear[] = [ + { year: 'AY25/26' }, + { + year: 'AY24/25', + editions: [ + { + label: 'VIEW WIKI', + href: 'https://sites.google.com/view/nusc-wiki-2425/home', + }, + ], + }, + { + year: 'AY23/24', + editions: [ + { + label: 'VIEW WIKI', + href: 'https://nusc-wiki.gitbook.io/nusc-wiki-23-24/', + }, + ], + }, + { + year: 'AY22/23', + editions: [ + { + label: 'VIEW WIKI', + href: 'https://nusc-wiki.gitbook.io/nusc-wiki-22/', + }, + ], + }, +]; + +export default function WikiPage() { + return ( + + ); +} diff --git a/components/ArchivePage.tsx b/components/ArchivePage.tsx new file mode 100644 index 0000000..84d5eca --- /dev/null +++ b/components/ArchivePage.tsx @@ -0,0 +1,96 @@ +import { ExternalLinkIcon } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; + +interface ArchiveEdition { + label: string; + href: string; +} + +export interface ArchiveYear { + year: string; + editions?: ArchiveEdition[]; +} + +interface ArchivePageProps { + archiveName: string; + description: string; + years: ArchiveYear[]; +} + +export default function ArchivePage({ + archiveName, + description, + years, +}: ArchivePageProps) { + const anchorFor = (year: string) => + year.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-'); + + return ( + <> +
+ Students performing at a NUS College event +
+
+

+ {archiveName} +

+
+
+ +
+
+

+ {description} +

+ +
+ {years.map((year) => ( +
+

+ {year.year} +

+ {year.editions ? ( +
    + {year.editions.map((edition) => ( +
  • + + {edition.label} +
  • + ))} +
+ ) : ( +

+ FORTHCOMING +

+ )} +
+ ))} +
+
+
+ + ); +} diff --git a/components/Header.tsx b/components/Header.tsx index f0a284b..c4dde75 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -33,8 +33,24 @@ import { useAuth } from '@/lib/hooks/useAuth'; import CustomDropdown from './custom-dropdown'; +const newsletterArchiveItems = [ + { label: 'AY26/27', href: '/newsletter#ay26-27' }, + { label: 'AY25/26', href: '/newsletter#ay25-26' }, + { label: 'AY24/25', href: '/newsletter#ay24-25' }, + { label: 'AY22/23', href: '/newsletter#ay22-23' }, +]; + +const wikiArchiveItems = [ + { label: 'AY25/26', href: '/wiki#ay25-26' }, + { label: 'AY24/25', href: '/wiki#ay24-25' }, + { label: 'AY23/24', href: '/wiki#ay23-24' }, + { label: 'AY22/23', href: '/wiki#ay22-23' }, +]; + export default function Header() { const [mobileSubmenuOpen, setMobileSubmenuOpen] = useState(true); + const [mobileNewsletterOpen, setMobileNewsletterOpen] = useState(false); + const [mobileWikiOpen, setMobileWikiOpen] = useState(false); const [adminSubmenuOpen, setAdminSubmenuOpen] = useState(true); const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -60,11 +76,15 @@ export default function Header() {
- - + @@ -137,15 +157,94 @@ export default function Header() { - - + +
setIsSidebarOpen(false)} > NEWSLETTER - + +
+ {mobileNewsletterOpen && ( +
+ {newsletterArchiveItems.map((item) => ( + setIsSidebarOpen(false)} + > + {item.label} + + ))} +
+ )} +
+ + +
+ setIsSidebarOpen(false)} + > + WIKI + + +
+ {mobileWikiOpen && ( +
+ {wikiArchiveItems.map((item) => ( + setIsSidebarOpen(false)} + > + {item.label} + + ))} +
+ )}
{/* Admin menu for logged in users */} @@ -232,17 +331,18 @@ export default function Header() {
- - - NEWSLETTER - - + + + + {/* Spacer pushes following items to the right */} diff --git a/components/custom-dropdown.tsx b/components/custom-dropdown.tsx index e094767..e825ecc 100644 --- a/components/custom-dropdown.tsx +++ b/components/custom-dropdown.tsx @@ -2,7 +2,7 @@ import { ChevronDownIcon } from 'lucide-react'; import Link from 'next/link'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; interface DropdownItem { label: string; @@ -12,20 +12,37 @@ interface DropdownItem { interface CustomDropdownProps { label: string; items: DropdownItem[]; + href?: string; } -const CustomDropdown: React.FC = ({ label, items }) => { +const CustomDropdown: React.FC = ({ + label, + items, + href, +}) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const menuRef = useRef(null); + const toggleRef = useRef(null); const timeoutRef = useRef(null); + const isClickOpenRef = useRef(false); + const menuId = useId(); + + const handlePointerLeave = (event: React.PointerEvent) => { + if (event.pointerType !== 'mouse') return; - const handleMouseLeave = () => { timeoutRef.current = setTimeout(() => { - setIsOpen(false); + if ( + !isClickOpenRef.current && + !dropdownRef.current?.contains(document.activeElement) + ) + setIsOpen(false); }, 100); }; - const handleMouseEnter = () => { + const handlePointerEnter = (event: React.PointerEvent) => { + if (event.pointerType !== 'mouse') return; + if (timeoutRef.current) { clearTimeout(timeoutRef.current); } @@ -33,11 +50,67 @@ const CustomDropdown: React.FC = ({ label, items }) => { }; const handleClick = () => { - setIsOpen(!isOpen); + // why: click pins an otherwise transient hover/focus menu so touch users + // and pointer users moving toward the menu get the same stable control. + isClickOpenRef.current = !isClickOpenRef.current; + setIsOpen(isClickOpenRef.current); + }; + + const handleFocus = () => { + setIsOpen(true); + }; + + const handleBlur = () => { + window.setTimeout(() => { + if (!dropdownRef.current?.contains(document.activeElement)) { + isClickOpenRef.current = false; + setIsOpen(false); + } + }, 0); + }; + + const handleToggleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowDown') return; + + event.preventDefault(); + setIsOpen(true); + window.requestAnimationFrame(() => { + menuRef.current?.querySelector('a')?.focus(); + }); + }; + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + const items = Array.from( + menuRef.current?.querySelectorAll('a') ?? [], + ); + const currentIndex = items.indexOf( + document.activeElement as HTMLAnchorElement, + ); + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const direction = event.key === 'ArrowDown' ? 1 : -1; + items + .at((currentIndex + direction + items.length) % items.length) + ?.focus(); + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + items.at(event.key === 'Home' ? 0 : -1)?.focus(); + } }; useEffect(() => { + const closeOnOutsidePointerDown = (event: PointerEvent) => { + if (!dropdownRef.current?.contains(event.target as Node)) { + isClickOpenRef.current = false; + setIsOpen(false); + } + }; + + document.addEventListener('pointerdown', closeOnOutsidePointerDown); + return () => { + document.removeEventListener('pointerdown', closeOnOutsidePointerDown); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } @@ -48,40 +121,102 @@ const CustomDropdown: React.FC = ({ label, items }) => {
{ + if (event.key === 'Escape') { + isClickOpenRef.current = false; + toggleRef.current?.focus(); + setIsOpen(false); + } + }} > - + {href ? ( +
+ { + isClickOpenRef.current = false; + setIsOpen(false); + }} + > + {label} + + +
+ ) : ( + + )} {isOpen &&
}
e.stopPropagation()} > -
    - {items.map((item, index) => ( +