Skip to content

Commit 2c09098

Browse files
committed
checkpoint: dashboard ui
1 parent edd2b57 commit 2c09098

22 files changed

Lines changed: 3246 additions & 1333 deletions

apps/dashboard/src/App.tsx

Lines changed: 245 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
1-
import { Routes, Route, Navigate, useNavigate } from "react-router-dom";
1+
import {
2+
Routes,
3+
Route,
4+
Navigate,
5+
useNavigate,
6+
useParams,
7+
} from "react-router-dom";
28
import { Authenticated, Unauthenticated, AuthLoading } from "convex/react";
3-
import type { ReactNode } from "react";
9+
import { useEffect, useRef, useState, type ReactNode } from "react";
410
import "./App.css";
511
import Landing from "./pages/Landing";
612
import { Home } from "./pages/Home";
713
import { Bookmarks } from "./pages/Bookmarks";
814
import { Subscriptions } from "./pages/Subscriptions";
915
import { Org } from "./pages/Org";
1016
import { Profile } from "./pages/profile";
17+
import { Search } from "./pages/Search";
1118
import type { SideBarItemId } from "@app/ui";
1219
import DesignSystem from "./pages/DesignSystem";
1320
import {
1421
SAMPLE_POSTS,
1522
SAMPLE_RSVP_GROUPS,
1623
SAMPLE_CLUBS,
1724
} from "./data/sampleHome";
25+
import { SAMPLE_BOOKMARKED_POSTS } from "./data/sampleBookmarks";
26+
import { SAMPLE_ORGS } from "./data/sampleOrgs";
27+
import { SAMPLE_SUBSCRIPTIONS } from "./data/sampleSubscriptions";
28+
import type { Club, Organization } from "@app/ui";
1829

1930
/**
2031
* Maps a SideBar nav id to its route path.
@@ -52,47 +63,269 @@ function ProtectedRoute({ children }: { children: ReactNode }) {
5263
// These small wrappers wire the router-aware values in once so the page files
5364
// stay framework-agnostic.
5465

66+
/**
67+
* Shared handler for clicking a club in the right-rail SearchPanel.
68+
* Navigates to /orgs/{slug} so each Club tile is a real link to its org page.
69+
* Falls back to club id when no slug is supplied (current sample data uses id).
70+
*/
71+
function useClubClick() {
72+
const navigate = useNavigate();
73+
return (club: Club) => {
74+
navigate(`/orgs/${club.id}`);
75+
};
76+
}
77+
78+
/**
79+
* Shared handler for clicking an organisation name in a post header.
80+
* Navigates to /orgs/{id} when the org has a slug. Without an id the click is a
81+
* no-op (the row stays focusable for the hover preview).
82+
*/
83+
function useOrgClick() {
84+
const navigate = useNavigate();
85+
return (org: Organization) => {
86+
if (org.id) navigate(`/orgs/${org.id}`);
87+
};
88+
}
89+
5590
function RoutedHome() {
5691
const navigate = useNavigate();
92+
const onClubClick = useClubClick();
93+
const onOrgClick = useOrgClick();
5794
return (
5895
<Home
5996
activeNavItem="home"
6097
onNavigate={(id) => navigate(pathForNavItem(id))}
6198
posts={SAMPLE_POSTS}
6299
rsvpGroups={SAMPLE_RSVP_GROUPS}
63100
clubs={SAMPLE_CLUBS}
101+
onClubClick={onClubClick}
102+
onOrgClick={onOrgClick}
103+
// Pressing Enter from /home pushes to /search?q=… so the search has a
104+
// shareable URL. Home still renders results inline either way.
105+
onSearchSubmit={(q) => navigate(`/search?q=${encodeURIComponent(q)}`)}
64106
/>
65107
);
66108
}
67109

110+
function RoutedSearch() {
111+
return <Search />;
112+
}
113+
68114
function RoutedBookmarks() {
69115
const navigate = useNavigate();
116+
const onClubClick = useClubClick();
117+
const onOrgClick = useOrgClick();
70118
return (
71119
<Bookmarks
72120
activeNavItem="bookmarks"
73121
onNavigate={(id) => navigate(pathForNavItem(id))}
122+
posts={SAMPLE_BOOKMARKED_POSTS}
123+
rsvpGroups={SAMPLE_RSVP_GROUPS}
124+
clubs={SAMPLE_CLUBS}
125+
onClubClick={onClubClick}
126+
onOrgClick={onOrgClick}
74127
/>
75128
);
76129
}
77130

131+
const SORT_OPTIONS = ["Alphabetical", "Most emails", "Recently added"] as const;
132+
78133
function RoutedSubscriptions() {
79134
const navigate = useNavigate();
135+
const onClubClick = useClubClick();
136+
const [sortIndex, setSortIndex] = useState(0);
80137
return (
81138
<Subscriptions
82139
activeNavItem="subscriptions"
83140
onNavigate={(id) => navigate(pathForNavItem(id))}
141+
subscriptionCount={SAMPLE_SUBSCRIPTIONS.length}
142+
subscriptions={SAMPLE_SUBSCRIPTIONS}
143+
sortLabel={SORT_OPTIONS[sortIndex]}
144+
onSortChange={() => setSortIndex((i) => (i + 1) % SORT_OPTIONS.length)}
145+
rsvpGroups={SAMPLE_RSVP_GROUPS}
146+
clubs={SAMPLE_CLUBS}
147+
onClubClick={onClubClick}
84148
/>
85149
);
86150
}
87151

152+
/**
153+
* Renders the Org page for a given URL slug.
154+
*
155+
* Lookup logic:
156+
* • Read `slug` from useParams.
157+
* • If the slug is missing or absent from SAMPLE_ORGS, render an inline
158+
* "not found" state alongside the SideBar so navigation remains accessible.
159+
* (We chose inline-not-found over redirect so users see context for the
160+
* broken link instead of a silent bounce to /home.)
161+
* • Otherwise, hydrate the Org page with the matched profile.
162+
*
163+
* Local state:
164+
* • `isFollowing` is seeded from the profile and toggled by the Follow button.
165+
* • `feedSearchValue` powers the in-page posts feed search input.
166+
* • `sidePanelSearchValue` powers the right-rail search input.
167+
* • `tagFilter` / `timeFilter` cycle through a small set of options when the
168+
* filter chips are clicked, so the buttons feel interactive even though
169+
* filtering is not yet wired to data.
170+
*/
171+
const TAG_FILTER_OPTIONS = ["All tags", "Tech", "Outdoors", "For you"] as const;
172+
const TIME_FILTER_OPTIONS = [
173+
"All time",
174+
"This week",
175+
"This month",
176+
"Past events",
177+
] as const;
178+
88179
function RoutedOrg() {
89180
const navigate = useNavigate();
90-
return <Org onNavigate={(id) => navigate(pathForNavItem(id))} />;
181+
const onClubClick = useClubClick();
182+
const onOrgClick = useOrgClick();
183+
const { slug } = useParams<{ slug: string }>();
184+
const profile = slug ? SAMPLE_ORGS[slug] : undefined;
185+
186+
// Hooks must run unconditionally — declare them above any early return.
187+
const [isFollowing, setIsFollowing] = useState<boolean>(
188+
profile?.isFollowing ?? false,
189+
);
190+
const [feedSearchValue, setFeedSearchValue] = useState("");
191+
const [tagFilterIndex, setTagFilterIndex] = useState(0);
192+
const [timeFilterIndex, setTimeFilterIndex] = useState(0);
193+
194+
if (!profile) {
195+
return (
196+
<Org
197+
onNavigate={(id) => navigate(pathForNavItem(id))}
198+
orgName="Organization not found"
199+
orgDescription={
200+
slug
201+
? `We couldn't find an organization with the slug "${slug}". It may have been removed or renamed.`
202+
: "No organization slug was provided."
203+
}
204+
orgTags={[]}
205+
posts={[]}
206+
rsvpGroups={SAMPLE_RSVP_GROUPS}
207+
clubs={SAMPLE_CLUBS}
208+
onClubClick={onClubClick}
209+
onOrgClick={onOrgClick}
210+
/>
211+
);
212+
}
213+
214+
return (
215+
<Org
216+
onNavigate={(id) => navigate(pathForNavItem(id))}
217+
orgName={profile.orgName}
218+
orgDescription={profile.orgDescription}
219+
orgAvatarUrl={profile.orgAvatarUrl}
220+
coverImageUrl={profile.coverImageUrl}
221+
isVerified={profile.isVerified}
222+
orgTags={profile.orgTags}
223+
loopSummary={profile.loopSummary}
224+
isFollowing={isFollowing}
225+
onFollow={() => setIsFollowing((prev) => !prev)}
226+
onWebsite={() =>
227+
window.open(profile.websiteUrl, "_blank", "noopener,noreferrer")
228+
}
229+
onEmail={() => {
230+
window.location.href = `mailto:${profile.email}`;
231+
}}
232+
posts={profile.posts}
233+
feedSearchValue={feedSearchValue}
234+
onFeedSearchChange={setFeedSearchValue}
235+
onFeedSearchClear={() => setFeedSearchValue("")}
236+
tagFilter={TAG_FILTER_OPTIONS[tagFilterIndex]}
237+
onTagFilterChange={() =>
238+
setTagFilterIndex((i) => (i + 1) % TAG_FILTER_OPTIONS.length)
239+
}
240+
timeFilter={TIME_FILTER_OPTIONS[timeFilterIndex]}
241+
onTimeFilterChange={() =>
242+
setTimeFilterIndex((i) => (i + 1) % TIME_FILTER_OPTIONS.length)
243+
}
244+
rsvpGroups={SAMPLE_RSVP_GROUPS}
245+
clubs={SAMPLE_CLUBS}
246+
onClubClick={onClubClick}
247+
onOrgClick={onOrgClick}
248+
/>
249+
);
91250
}
92251

252+
/**
253+
* RoutedProfile — owns the profile-modal state.
254+
*
255+
* Wraps the Profile dialog with a backdrop and centred layout. Manages selected
256+
* Major / Grad Year / Minor / Interests, plus a `saved` flag that swaps the
257+
* dialog body to the "Recalibrating your feed…" confirmation. After ~1500 ms in
258+
* the saved state the modal auto-dismisses by navigating back to /home.
259+
*/
93260
function RoutedProfile() {
94261
const navigate = useNavigate();
95-
return <Profile onClose={() => navigate("/home")} />;
262+
const [major, setMajor] = useState<string>("Computer Science");
263+
const [gradYear, setGradYear] = useState<string>("2027");
264+
const [minor, setMinor] = useState<string>("Linguistics");
265+
const [interests, setInterests] = useState<string[]>([
266+
"Tech",
267+
"Health",
268+
"Finance",
269+
"Education",
270+
]);
271+
const [saved, setSaved] = useState(false);
272+
const dismissTimer = useRef<number | null>(null);
273+
274+
useEffect(() => {
275+
return () => {
276+
if (dismissTimer.current !== null) {
277+
window.clearTimeout(dismissTimer.current);
278+
}
279+
};
280+
}, []);
281+
282+
const handleSave = () => {
283+
setSaved(true);
284+
dismissTimer.current = window.setTimeout(() => {
285+
navigate("/home");
286+
}, 1500);
287+
};
288+
289+
const handleClose = () => {
290+
if (dismissTimer.current !== null) {
291+
window.clearTimeout(dismissTimer.current);
292+
dismissTimer.current = null;
293+
}
294+
navigate("/home");
295+
};
296+
297+
return (
298+
<>
299+
{/*
300+
* Render the Home dashboard underneath so the modal floats over it,
301+
* matching Figma node 633:4436. The modal's z-50 backdrop captures all
302+
* pointer events so the dashboard is visual-only while the modal is open.
303+
*/}
304+
<RoutedHome />
305+
<div
306+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
307+
onClick={(e) => {
308+
// Click outside the card closes the modal.
309+
if (e.target === e.currentTarget) handleClose();
310+
}}
311+
>
312+
<Profile
313+
userName="Megan"
314+
major={major}
315+
onMajorChange={setMajor}
316+
gradYear={gradYear}
317+
onGradYearChange={setGradYear}
318+
minor={minor}
319+
onMinorChange={setMinor}
320+
interests={interests}
321+
onInterestsChange={setInterests}
322+
saved={saved}
323+
onSave={handleSave}
324+
onClose={handleClose}
325+
/>
326+
</div>
327+
</>
328+
);
96329
}
97330

98331
function App() {
@@ -142,6 +375,14 @@ function App() {
142375
</ProtectedRoute>
143376
}
144377
/>
378+
<Route
379+
path="/search"
380+
element={
381+
<ProtectedRoute>
382+
<RoutedSearch />
383+
</ProtectedRoute>
384+
}
385+
/>
145386

146387
{/* Dev-only design system page */}
147388
{import.meta.env.DEV && (

0 commit comments

Comments
 (0)