Skip to content

Commit 31c6206

Browse files
dcl10claude
andcommitted
Add layout components (closes #35)
Ports layout.jsx from the ui-kit-reference prototype into typed TypeScript modules under components/shared/: - TopBar: sticky 60px header; desktop shows logo, divider, Matchboard wordmark, DRS label, search, Post button, bell and avatar; mobile collapses to hamburger, logo, compact wordmark, Post icon and avatar - LeftRail: 220px sticky desktop sidebar with nav links and disciplines subsection; on mobile becomes a 280px slide-out drawer (220ms ease-out) with scrim overlay, closes on route change - PageHeader: eyebrow, title scaling 24→28→36px across breakpoints, optional description, mono meta row, and action slot - EmptyState: centred with circular icon background, title, description, optional CTA Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6fce416 commit 31c6206

5 files changed

Lines changed: 480 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
2+
import type { ComponentType, ReactNode } from "react";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
interface EmptyStateProps {
7+
/** Icon component to display; defaults to `MagnifyingGlassIcon`. */
8+
icon?: ComponentType<{ className?: string }>;
9+
title: string;
10+
/** Supporting description below the title. */
11+
description?: string;
12+
/** Optional CTA element rendered below the description. */
13+
action?: ReactNode;
14+
className?: string;
15+
}
16+
17+
/**
18+
* Centred empty state with a circular icon background, title, description, and optional CTA.
19+
* @param icon - Heroicon component to display; defaults to `MagnifyingGlassIcon`
20+
* @param title - Primary heading
21+
* @param description - Supporting body text
22+
* @param action - Optional call-to-action element
23+
*/
24+
export function EmptyState({
25+
icon: Icon = MagnifyingGlassIcon,
26+
title,
27+
description,
28+
action,
29+
className,
30+
}: EmptyStateProps) {
31+
return (
32+
<div
33+
className={cn(
34+
"text-center py-16 px-6",
35+
"border border-dashed border-[var(--border-strong)] rounded-md",
36+
"text-ink-soft",
37+
className,
38+
)}
39+
>
40+
<div className="inline-flex p-3.5 rounded-full bg-portland-stone mb-4">
41+
<Icon className="w-5 h-5 text-ink-muted" />
42+
</div>
43+
<h3 className="text-[18px] font-medium text-ink m-0 tracking-[-0.01em]">
44+
{title}
45+
</h3>
46+
{description && (
47+
<p className="text-[14px] text-ink-soft mt-2 mb-4 mx-auto max-w-[380px] leading-[1.5]">
48+
{description}
49+
</p>
50+
)}
51+
{action}
52+
</div>
53+
);
54+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"use client";
2+
3+
import {
4+
BeakerIcon,
5+
BookmarkIcon,
6+
InboxIcon,
7+
SparklesIcon,
8+
UsersIcon,
9+
XMarkIcon,
10+
} from "@heroicons/react/24/outline";
11+
import { useEffect, useRef } from "react";
12+
13+
import { Eyebrow } from "@/components/core/Eyebrow";
14+
import { Hairline } from "@/components/core/Hairline";
15+
import { useViewport } from "@/lib/hooks/useViewport";
16+
import { cn } from "@/lib/utils";
17+
18+
interface NavItem {
19+
id: string;
20+
label: string;
21+
Icon: React.ComponentType<{ className?: string }>;
22+
badge?: number;
23+
}
24+
25+
const NAV_ITEMS: NavItem[] = [
26+
{ id: "discover", label: "Discover", Icon: SparklesIcon },
27+
{ id: "matches", label: "Matches", Icon: InboxIcon, badge: 2 },
28+
{ id: "my-projects", label: "My projects", Icon: BeakerIcon },
29+
{ id: "saved", label: "Saved", Icon: BookmarkIcon },
30+
{ id: "network", label: "Network", Icon: UsersIcon },
31+
];
32+
33+
const DISCIPLINES = [
34+
"Materials Science",
35+
"Bioengineering",
36+
"Computational Biology",
37+
"Climate Science",
38+
"Ethics",
39+
];
40+
41+
interface LeftRailProps {
42+
/** Currently active route identifier. */
43+
route?: string;
44+
onNavigate?: (route: string) => void;
45+
/** Controls drawer visibility on mobile. */
46+
open?: boolean;
47+
/** Called when the drawer should close (route change or scrim click). */
48+
onClose?: () => void;
49+
}
50+
51+
/**
52+
* Primary navigation sidebar.
53+
* Desktop: 220px sticky rail with nav links and a disciplines section.
54+
* Mobile: 280px slide-out drawer (180ms ease-out) with scrim overlay;
55+
* closes automatically when the active route changes.
56+
* @param route - Active route identifier
57+
* @param open - Drawer visibility on mobile
58+
* @param onClose - Callback to close the drawer
59+
*/
60+
export function LeftRail({
61+
route,
62+
onNavigate,
63+
open = false,
64+
onClose,
65+
}: LeftRailProps) {
66+
const { isMobile } = useViewport();
67+
const prevRouteRef = useRef(route);
68+
69+
useEffect(() => {
70+
if (route !== prevRouteRef.current) {
71+
prevRouteRef.current = route;
72+
if (isMobile && open) onClose?.();
73+
}
74+
}, [route, isMobile, open, onClose]);
75+
76+
const railContent = (
77+
<>
78+
<NavList route={route} onNavigate={onNavigate} />
79+
<div className="h-6" />
80+
<Hairline className="w-[calc(100%-12px)]" />
81+
<div className="h-4" />
82+
<Eyebrow className="px-3 mb-2">Disciplines</Eyebrow>
83+
<div className="flex flex-col gap-px">
84+
{DISCIPLINES.map((d) => (
85+
<div
86+
key={d}
87+
className="px-3 py-1.5 text-[13px] text-ink-soft cursor-pointer rounded-sm hover:bg-[rgba(16,38,59,0.04)] transition-colors duration-[120ms]"
88+
>
89+
{d}
90+
</div>
91+
))}
92+
</div>
93+
</>
94+
);
95+
96+
if (isMobile) {
97+
return (
98+
<>
99+
{open && (
100+
<div
101+
onClick={onClose}
102+
className="fixed inset-0 bg-[rgba(16,38,59,0.4)] z-20 animate-[mb-fade_180ms_cubic-bezier(0.2,0,0,1)]"
103+
/>
104+
)}
105+
<nav
106+
className={cn(
107+
"fixed top-0 left-0 bottom-0 z-30 overflow-y-auto",
108+
"bg-paper border-r border-[var(--border)]",
109+
"transition-[transform,box-shadow] duration-[220ms] ease-[cubic-bezier(0.2,0,0,1)]",
110+
open ? "shadow-modal" : "",
111+
)}
112+
style={{
113+
width: 280,
114+
maxWidth: "85vw",
115+
padding: "20px 16px",
116+
transform: open ? "translateX(0)" : "translateX(-100%)",
117+
}}
118+
>
119+
<div className="flex items-center justify-between mb-[18px]">
120+
{/* eslint-disable-next-line @next/next/no-img-element */}
121+
<img
122+
src="/uon-logo-blue.png"
123+
alt="University of Nottingham"
124+
className="h-7"
125+
/>
126+
<button
127+
aria-label="Close navigation"
128+
onClick={onClose}
129+
className="w-8 h-8 rounded-sm flex items-center justify-center text-ink-soft bg-transparent border-none cursor-pointer hover:bg-[rgba(16,38,59,0.06)]"
130+
>
131+
<XMarkIcon className="w-[18px] h-[18px]" />
132+
</button>
133+
</div>
134+
{railContent}
135+
</nav>
136+
<style>{`@keyframes mb-fade { from { opacity: 0; } to { opacity: 1; } }`}</style>
137+
</>
138+
);
139+
}
140+
141+
return (
142+
<nav
143+
className="shrink-0 border-r border-[var(--border)] min-h-[calc(100vh-60px)] sticky top-[60px] self-start overflow-y-auto"
144+
style={{ width: 220, padding: "24px 0 24px 16px" }}
145+
>
146+
{railContent}
147+
</nav>
148+
);
149+
}
150+
151+
function NavList({
152+
route,
153+
onNavigate,
154+
}: {
155+
route?: string;
156+
onNavigate?: (route: string) => void;
157+
}) {
158+
return (
159+
<div className="flex flex-col gap-0.5">
160+
{NAV_ITEMS.map((item) => {
161+
const active = route === item.id;
162+
return (
163+
<div
164+
key={item.id}
165+
onClick={() => onNavigate?.(item.id)}
166+
className={cn(
167+
"flex items-center gap-3 px-3 py-[10px] rounded-sm cursor-pointer text-[14px]",
168+
"transition-colors duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)]",
169+
active
170+
? "text-nottingham-blue bg-nottingham-blue-5 font-medium"
171+
: "text-ink-soft font-normal hover:bg-[rgba(16,38,59,0.04)]",
172+
)}
173+
>
174+
<item.Icon className="w-[18px] h-[18px] shrink-0" />
175+
<span className="flex-1">{item.label}</span>
176+
{item.badge !== undefined && (
177+
<span
178+
className={cn(
179+
"font-mono text-[11px] px-1.5 py-px rounded-pill min-w-4 text-center",
180+
active
181+
? "bg-nottingham-blue text-paper"
182+
: "bg-portland-stone text-ink-soft",
183+
)}
184+
>
185+
{item.badge}
186+
</span>
187+
)}
188+
</div>
189+
);
190+
})}
191+
</div>
192+
);
193+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { ReactNode } from "react";
2+
3+
import { Eyebrow } from "@/components/core/Eyebrow";
4+
import { cn } from "@/lib/utils";
5+
6+
interface PageHeaderProps {
7+
/** Small uppercase label rendered above the title. */
8+
eyebrow?: string;
9+
title: string;
10+
/** Supporting body text below the title. */
11+
description?: string;
12+
/** Monospaced metadata row (e.g. date, author, status). */
13+
meta?: ReactNode;
14+
/** Action slot displayed to the right of the title. */
15+
action?: ReactNode;
16+
className?: string;
17+
}
18+
19+
/**
20+
* Responsive page-level header with optional eyebrow, description, meta row, and action slot.
21+
* Title scales: 24px (mobile) → 28px (≥900px) → 36px (≥1200px).
22+
* @param eyebrow - Small uppercase label above the title
23+
* @param title - Primary page heading
24+
* @param description - Supporting body text
25+
* @param meta - Monospaced metadata row
26+
* @param action - Optional CTA or action element
27+
*/
28+
export function PageHeader({
29+
eyebrow,
30+
title,
31+
description,
32+
meta,
33+
action,
34+
className,
35+
}: PageHeaderProps) {
36+
return (
37+
<div className={cn("mb-8", className)}>
38+
{eyebrow && <Eyebrow className="mb-2.5">{eyebrow}</Eyebrow>}
39+
<div className="flex items-start gap-4">
40+
<div className="flex-1 min-w-0">
41+
<h1 className="font-display font-medium tracking-[-0.03em] leading-[1.05] text-ink m-0 text-[24px] md:text-[28px] lg:text-[36px]">
42+
{title}
43+
</h1>
44+
{description && (
45+
<p className="mt-3 text-[16px] text-ink-soft leading-[1.55] max-w-[640px]">
46+
{description}
47+
</p>
48+
)}
49+
{meta && (
50+
<div className="mt-3.5 font-mono text-[12px] text-ink-muted flex flex-wrap gap-4">
51+
{meta}
52+
</div>
53+
)}
54+
</div>
55+
{action && <div className="shrink-0">{action}</div>}
56+
</div>
57+
</div>
58+
);
59+
}

0 commit comments

Comments
 (0)