setIsSidebarOpen(false)}
>
@@ -364,14 +347,14 @@ export default function Header() {
diff --git a/lib/archive.ts b/lib/archive.ts
new file mode 100644
index 0000000..8417e71
--- /dev/null
+++ b/lib/archive.ts
@@ -0,0 +1,124 @@
+export interface ArchiveEdition {
+ label: string;
+ href: string;
+}
+
+export type ArchiveYear =
+ | {
+ year: string;
+ editions?: undefined;
+ }
+ | {
+ year: string;
+ editions: readonly [ArchiveEdition, ...ArchiveEdition[]];
+ };
+
+export interface ArchiveDefinition {
+ name: string;
+ href: string;
+ description: string;
+ years: readonly ArchiveYear[];
+}
+
+export interface ArchiveDropdownItem {
+ label: string;
+ href: string;
+}
+
+export const newsletterArchive = {
+ name: 'Newsletter Archive',
+ href: '/newsletter',
+ description:
+ 'Browse past NUS College Club newsletters and keep up with the stories, updates, and moments that shaped Student Life.',
+ years: [
+ // why: omitting editions is the single forthcoming state used by every archive surface.
+ { year: 'AY26/27' },
+ {
+ year: 'AY25/26',
+ editions: [
+ {
+ label: 'Issue 1 - Semester 1',
+ href: 'https://ogmmddvrmieazwmy.public.blob.vercel-storage.com/newsletters/ay25-26/semester-1.pdf',
+ },
+ {
+ label: 'Issue 2 - Semester 2',
+ href: 'https://ogmmddvrmieazwmy.public.blob.vercel-storage.com/newsletters/ay25-26/semester-2.pdf',
+ },
+ ],
+ },
+ {
+ year: 'AY24/25',
+ editions: [
+ {
+ label: 'Issue 1 - Semester 1',
+ href: 'https://ogmmddvrmieazwmy.public.blob.vercel-storage.com/newsletters/ay24-25/semester-1.pdf',
+ },
+ ],
+ },
+ {
+ year: 'AY22/23',
+ editions: [
+ {
+ label: 'The NUSC Minute',
+ href: 'https://ogmmddvrmieazwmy.public.blob.vercel-storage.com/newsletters/ay22-23/the-nusc-minute.pdf',
+ },
+ ],
+ },
+ ],
+} satisfies ArchiveDefinition;
+
+export const wikiArchive = {
+ name: 'Wiki Archive',
+ href: '/wiki',
+ description:
+ 'Explore the NUS College Club wiki archives for practical guides, community knowledge, and resources collected by students.',
+ years: [
+ { 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/',
+ },
+ ],
+ },
+ ],
+} satisfies ArchiveDefinition;
+
+export const anchorForArchiveYear = (year: string) =>
+ year.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-');
+
+export function getArchiveDropdownItems(
+ archive: ArchiveDefinition,
+): ArchiveDropdownItem[] {
+ return archive.years.map((entry) => {
+ if (entry.editions?.length === 1) {
+ return { label: entry.year, href: entry.editions[0].href };
+ }
+
+ // why: forthcoming and multi-edition years need the index because no single destination is canonical.
+ return {
+ label: entry.year,
+ href: `${archive.href}#${anchorForArchiveYear(entry.year)}`,
+ };
+ });
+}
From a7e7f871a778e423a2a187c4a420bf285d611fb8 Mon Sep 17 00:00:00 2001
From: Andrew <159703254+andrewsoonqn@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:26:53 +0800
Subject: [PATCH 09/13] Clarify dropdown interaction safeguards
---
components/custom-dropdown.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/components/custom-dropdown.tsx b/components/custom-dropdown.tsx
index 12246c4..3964db2 100644
--- a/components/custom-dropdown.tsx
+++ b/components/custom-dropdown.tsx
@@ -25,10 +25,12 @@ const CustomDropdown: React.FC = ({
const menuRef = useRef(null);
const toggleRef = useRef(null);
const timeoutRef = useRef(null);
+ // why: hover/focus visibility is transient, while a click pins the menu open.
const isClickOpenRef = useRef(false);
const menuId = useId();
const handlePointerLeave = (event: React.PointerEvent) => {
+ // why: touch and pen pointers should use the stable click interaction.
if (event.pointerType !== 'mouse') return;
timeoutRef.current = setTimeout(() => {
@@ -50,8 +52,6 @@ const CustomDropdown: React.FC = ({
};
const handleClick = () => {
- // 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);
};
@@ -61,6 +61,7 @@ const CustomDropdown: React.FC = ({
};
const handleBlur = () => {
+ // why: wait for the browser to move focus before deciding it left the menu.
window.setTimeout(() => {
if (!dropdownRef.current?.contains(document.activeElement)) {
isClickOpenRef.current = false;
@@ -74,6 +75,7 @@ const CustomDropdown: React.FC = ({
event.preventDefault();
setIsOpen(true);
+ // why: the menu must render before its first link can receive focus.
window.requestAnimationFrame(() => {
menuRef.current?.querySelector('a')?.focus();
});
@@ -182,8 +184,10 @@ const CustomDropdown: React.FC = ({
)}
+ {/* why: bridge the visual gap so moving a mouse into the menu does not close it. */}
{isOpen && }
+ {/* why: keep the menu mounted for its close animation, but make closed links inert. */}
Date: Sat, 15 Aug 2026 01:12:35 +0800
Subject: [PATCH 10/13] Keep archive keyboard paths consistent
---
components/custom-dropdown.tsx | 27 ++++++++++++++++++++++-----
1 file changed, 22 insertions(+), 5 deletions(-)
diff --git a/components/custom-dropdown.tsx b/components/custom-dropdown.tsx
index 3964db2..2e10eca 100644
--- a/components/custom-dropdown.tsx
+++ b/components/custom-dropdown.tsx
@@ -33,6 +33,8 @@ const CustomDropdown: React.FC = ({
// why: touch and pen pointers should use the stable click interaction.
if (event.pointerType !== 'mouse') return;
+ // why: a short grace period lets the pointer cross the visual gap to the
+ // menu without closing it mid-movement and causing a flicker.
timeoutRef.current = setTimeout(() => {
if (
!isClickOpenRef.current &&
@@ -57,11 +59,14 @@ const CustomDropdown: React.FC = ({
};
const handleFocus = () => {
+ // why: keyboard users who Tab into the control need the same menu reveal
+ // that mouse users get from hovering.
setIsOpen(true);
};
const handleBlur = () => {
- // why: wait for the browser to move focus before deciding it left the menu.
+ // why: blur fires before focus settles on the next element, so checking
+ // immediately would close the menu while the user Tabs into one of its links.
window.setTimeout(() => {
if (!dropdownRef.current?.contains(document.activeElement)) {
isClickOpenRef.current = false;
@@ -70,18 +75,22 @@ const CustomDropdown: React.FC = ({
}, 0);
};
- const handleToggleKeyDown = (event: React.KeyboardEvent) => {
+ const handleTriggerKeyDown = (event: React.KeyboardEvent) => {
+ // why: ArrowDown is the standard shortcut for entering an attached menu;
+ // handling keydown lets us prevent page scrolling before it occurs.
if (event.key !== 'ArrowDown') return;
event.preventDefault();
setIsOpen(true);
- // why: the menu must render before its first link can receive focus.
+ // why: let React commit the open state before moving keyboard focus into the menu.
window.requestAnimationFrame(() => {
menuRef.current?.querySelector('a')?.focus();
});
};
const handleMenuKeyDown = (event: React.KeyboardEvent) => {
+ // why: menu-style arrow navigation moves focus between choices without
+ // making keyboard users Tab through the rest of the page.
const items = Array.from(
menuRef.current?.querySelectorAll('a') ?? [],
);
@@ -135,11 +144,16 @@ const CustomDropdown: React.FC = ({
}
}}
>
+ {/* why: both trigger variants must expose the controlled menu and its open state to screen readers. */}
{href ? (
{
isClickOpenRef.current = false;
setIsOpen(false);
@@ -147,12 +161,13 @@ const CustomDropdown: React.FC = ({
>
{label}
+ {/* why: screen readers need to distinguish this menu toggle from the adjacent page link. */}
= ({
type='button'
className='flex h-10 items-center gap-1 px-4 py-2 text-sm'
onClick={handleClick}
- onKeyDown={handleToggleKeyDown}
+ onKeyDown={handleTriggerKeyDown}
aria-controls={menuId}
aria-expanded={isOpen}
aria-haspopup='menu'
@@ -188,6 +203,7 @@ const CustomDropdown: React.FC = ({
{isOpen && }
{/* why: keep the menu mounted for its close animation, but make closed links inert. */}
+ {/* why: assistive technology also needs an explicit signal while those mounted contents are unavailable. */}
= ({
onPointerLeave={handlePointerLeave}
onClick={(e) => e.stopPropagation()}
>
+ {/* why: menu semantics announce that arrow keys move between these choices. */}