Skip to content

Commit b17ebbb

Browse files
Merge pull request #28 from CodeForPhilly/feat/public-screens
feat(public-screens): all read-only screens wired to read API
2 parents 65e964e + 37792b3 commit b17ebbb

42 files changed

Lines changed: 4308 additions & 96 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/web/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"dependencies": {
2525
"@fontsource-variable/geist": "^5.2.8",
2626
"@tailwindcss/vite": "^4.3.0",
27+
"@tanstack/react-query": "^5.100.10",
2728
"class-variance-authority": "^0.7.1",
2829
"clsx": "^2.1.1",
2930
"lucide-react": "^1.16.0",

‎apps/web/src/App.tsx‎

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
1-
import { createBrowserRouter, RouterProvider } from 'react-router';
1+
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router';
22
import { TooltipProvider } from '@/components/ui/tooltip';
33
import { AppShell } from '@/components/AppShell';
44
import { NetworkErrorProvider } from '@/components/NetworkErrorBanner';
55
import { AuthProvider } from '@/hooks/useAuth';
6-
import { HomeStub } from '@/pages/HomeStub';
6+
import { ApiQueryClientProvider } from '@/lib/queryClient';
7+
import { Home } from '@/screens/Home';
8+
import { ProjectsIndex } from '@/screens/ProjectsIndex';
9+
import { ProjectDetail } from '@/screens/ProjectDetail';
10+
import { PeopleIndex } from '@/screens/PeopleIndex';
11+
import { PersonDetail } from '@/screens/PersonDetail';
12+
import { HelpWantedIndex } from '@/screens/HelpWantedIndex';
13+
import { ProjectUpdatesFeed } from '@/screens/ProjectUpdatesFeed';
14+
import { ProjectBuzzFeed } from '@/screens/ProjectBuzzFeed';
15+
import { TagsOverview } from '@/screens/TagsOverview';
16+
import { TagsNamespace } from '@/screens/TagsNamespace';
17+
import { TagDetail } from '@/screens/TagDetail';
18+
import { Volunteer } from '@/screens/Volunteer';
19+
import { Sponsor } from '@/screens/Sponsor';
720
import { ComingSoon } from '@/pages/ComingSoon';
821
import { NotFound } from '@/pages/NotFound';
922
import { LoginPlaceholder } from '@/pages/LoginPlaceholder';
@@ -12,35 +25,51 @@ const router = createBrowserRouter([
1225
{
1326
element: <AppShell />,
1427
children: [
15-
{ path: '/', element: <HomeStub /> },
16-
{ path: '/projects', element: <ComingSoon /> },
28+
{ path: '/', element: <Home /> },
29+
{ path: '/projects', element: <ProjectsIndex /> },
1730
{ path: '/projects/create', element: <ComingSoon /> },
18-
{ path: '/projects/:slug', element: <ComingSoon /> },
31+
{ path: '/projects/:slug', element: <ProjectDetail /> },
1932
{ path: '/projects/:slug/edit', element: <ComingSoon /> },
20-
{ path: '/help-wanted', element: <ComingSoon /> },
21-
{ path: '/members', element: <ComingSoon /> },
22-
{ path: '/members/:slug', element: <ComingSoon /> },
23-
{ path: '/volunteer', element: <ComingSoon /> },
24-
{ path: '/sponsor', element: <ComingSoon /> },
33+
{ path: '/projects/:slug/updates/:number', element: <ProjectDetail anchor="update" /> },
34+
{ path: '/projects/:slug/buzz/:buzzSlug', element: <ProjectDetail anchor="buzz" /> },
35+
{ path: '/projects/:slug/buzz/new', element: <ComingSoon /> },
36+
{ path: '/help-wanted', element: <HelpWantedIndex /> },
37+
{ path: '/people', element: <Navigate to="/members" replace /> },
38+
{ path: '/members', element: <PeopleIndex /> },
39+
{ path: '/members/:slug', element: <PersonDetail /> },
40+
{ path: '/members/:slug/edit', element: <ComingSoon /> },
41+
{ path: '/project-updates', element: <ProjectUpdatesFeed /> },
42+
{ path: '/project-buzz', element: <ProjectBuzzFeed /> },
43+
{ path: '/tags', element: <TagsOverview /> },
44+
{ path: '/tags/:namespace', element: <TagsNamespace /> },
45+
{ path: '/tags/:namespace/:slug', element: <TagDetail /> },
46+
{ path: '/volunteer', element: <Volunteer /> },
47+
{ path: '/sponsor', element: <Sponsor /> },
2548
{ path: '/account', element: <ComingSoon /> },
26-
{ path: '/chat', element: <ComingSoon /> },
27-
{ path: '/search', element: <ComingSoon /> },
49+
{ path: '/search', element: <SearchRedirect /> },
2850
{ path: '/pages/:slug', element: <ComingSoon /> },
2951
{ path: '/contact', element: <ComingSoon /> },
30-
{ path: '/tags/:namespace/:slug', element: <ComingSoon /> },
3152
{ path: '/login', element: <LoginPlaceholder /> },
3253
{ path: '*', element: <NotFound /> },
3354
],
3455
},
3556
]);
3657

58+
// /search?q=… isn't a separate page in v1 — redirect to /projects with the query preserved.
59+
function SearchRedirect() {
60+
const q = new URLSearchParams(window.location.search).get('q');
61+
return <Navigate to={`/projects${q ? `?q=${encodeURIComponent(q)}` : ''}`} replace />;
62+
}
63+
3764
export function App() {
3865
return (
3966
<TooltipProvider>
4067
<NetworkErrorProvider>
41-
<AuthProvider>
42-
<RouterProvider router={router} />
43-
</AuthProvider>
68+
<ApiQueryClientProvider>
69+
<AuthProvider>
70+
<RouterProvider router={router} />
71+
</AuthProvider>
72+
</ApiQueryClientProvider>
4473
</NetworkErrorProvider>
4574
</TooltipProvider>
4675
);
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Link } from 'react-router';
2+
import { MarkdownView } from '@/components/MarkdownView';
3+
import { PersonAvatar } from '@/components/PersonAvatar';
4+
import { formatRelativeTime, formatAbsoluteDate } from '@/lib/time';
5+
import type { ProjectUpdateResponse, ProjectBuzzResponse } from '@/lib/api';
6+
7+
export type ActivityItem =
8+
| { kind: 'update'; data: ProjectUpdateResponse }
9+
| { kind: 'buzz'; data: ProjectBuzzResponse };
10+
11+
interface ActivityCardProps {
12+
item: ActivityItem;
13+
}
14+
15+
export function ActivityCard({ item }: ActivityCardProps) {
16+
if (item.kind === 'update') return <UpdateCard update={item.data} />;
17+
return <BuzzCard buzz={item.data} />;
18+
}
19+
20+
function UpdateCard({ update }: { update: ProjectUpdateResponse }) {
21+
return (
22+
<article className="rounded-lg border border-border bg-card p-4">
23+
<div className="flex items-center justify-between mb-2 text-sm">
24+
<div className="text-muted-foreground">
25+
<Link to={`/projects/${update.project.slug}`} className="hover:text-primary font-medium">
26+
{update.project.title}
27+
</Link>
28+
<span> · </span>
29+
<Link
30+
to={`/projects/${update.project.slug}/updates/${update.number}`}
31+
className="hover:text-primary"
32+
>
33+
Update #{update.number}
34+
</Link>
35+
</div>
36+
<span title={formatAbsoluteDate(update.createdAt)} className="text-xs text-muted-foreground">
37+
{formatRelativeTime(update.createdAt)}
38+
</span>
39+
</div>
40+
41+
{update.author && (
42+
<div className="flex items-center gap-2 mb-2 text-xs">
43+
<PersonAvatar person={update.author} size={20} />
44+
<Link to={`/members/${update.author.slug}`} className="text-muted-foreground hover:text-primary">
45+
{update.author.fullName}
46+
</Link>
47+
</div>
48+
)}
49+
50+
<div className="line-clamp-5">
51+
<MarkdownView html={update.bodyHtml} />
52+
</div>
53+
</article>
54+
);
55+
}
56+
57+
function BuzzCard({ buzz }: { buzz: ProjectBuzzResponse }) {
58+
let hostname: string;
59+
try {
60+
hostname = new URL(buzz.url).hostname;
61+
} catch {
62+
hostname = buzz.url;
63+
}
64+
return (
65+
<article className="rounded-lg border border-border bg-card p-4">
66+
<div className="flex items-center justify-between mb-2 text-sm text-muted-foreground">
67+
<span>
68+
<Link to={`/projects/${buzz.project.slug}`} className="hover:text-primary font-medium">
69+
{buzz.project.title}
70+
</Link>
71+
<span> · Buzz · </span>
72+
<span title={formatAbsoluteDate(buzz.publishedAt)}>
73+
{formatAbsoluteDate(buzz.publishedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
74+
</span>
75+
</span>
76+
</div>
77+
78+
<div className="flex gap-3">
79+
{buzz.imageUrl && (
80+
<img
81+
src={buzz.imageUrl}
82+
alt=""
83+
width={96}
84+
height={96}
85+
className="rounded object-cover shrink-0"
86+
/>
87+
)}
88+
<div className="flex-1 min-w-0">
89+
<h3 className="text-base font-semibold mb-0.5">
90+
<a href={buzz.url} target="_blank" rel="noopener noreferrer" className="hover:text-primary">
91+
{buzz.headline}
92+
</a>
93+
</h3>
94+
<p className="text-xs text-muted-foreground mb-2">{hostname}</p>
95+
{buzz.summaryHtml && (
96+
<div className="line-clamp-3 text-sm">
97+
<MarkdownView html={buzz.summaryHtml} />
98+
</div>
99+
)}
100+
</div>
101+
</div>
102+
103+
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border text-xs text-muted-foreground">
104+
{buzz.postedBy && (
105+
<span>
106+
Logged by{' '}
107+
<Link to={`/members/${buzz.postedBy.slug}`} className="hover:text-primary">
108+
{buzz.postedBy.fullName}
109+
</Link>
110+
</span>
111+
)}
112+
<Link to={`/projects/${buzz.project.slug}/buzz/${buzz.slug}`} className="hover:text-primary">
113+
View on site
114+
</Link>
115+
</div>
116+
</article>
117+
);
118+
}
119+
120+
/** Merge update + buzz arrays into a single reverse-chronological feed. */
121+
export function mergeActivity(
122+
updates: ProjectUpdateResponse[],
123+
buzz: ProjectBuzzResponse[],
124+
limit?: number,
125+
): ActivityItem[] {
126+
const items: Array<{ item: ActivityItem; sortKey: string }> = [
127+
...updates.map((u) => ({
128+
item: { kind: 'update' as const, data: u },
129+
sortKey: u.createdAt,
130+
})),
131+
...buzz.map((b) => ({
132+
item: { kind: 'buzz' as const, data: b },
133+
sortKey: b.publishedAt,
134+
})),
135+
];
136+
items.sort((a, b) => b.sortKey.localeCompare(a.sortKey));
137+
const merged = items.map((x) => x.item);
138+
return limit ? merged.slice(0, limit) : merged;
139+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { useState } from 'react';
2+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
3+
import { TagChip } from '@/components/TagChip';
4+
import { STAGES, type Stage } from '@/components/StageBadge';
5+
import { Link } from 'react-router';
6+
import { cn } from '@/lib/utils';
7+
import type { Facets, FacetEntry } from '@/lib/api';
8+
9+
interface FacetSidebarProps {
10+
facets: Facets | undefined;
11+
activeTags: string[];
12+
onToggleTag: (handle: string) => void;
13+
activeStages?: string[];
14+
onToggleStage?: (stage: string) => void;
15+
tabs?: Array<'topic' | 'tech' | 'event' | 'stage'>;
16+
limit?: number;
17+
className?: string;
18+
}
19+
20+
const NS_LABELS = {
21+
topic: 'Topics',
22+
tech: 'Tech',
23+
event: 'Events',
24+
stage: 'Stages',
25+
} as const;
26+
27+
const NS_SEE_ALL: Record<string, string> = {
28+
topic: '/tags/topic',
29+
tech: '/tags/tech',
30+
event: '/tags/event',
31+
};
32+
33+
function facetsForNamespace(facets: Facets | undefined, ns: 'topic' | 'tech' | 'event'): FacetEntry[] {
34+
if (!facets) return [];
35+
if (ns === 'topic') return facets.byTopic ?? [];
36+
if (ns === 'tech') return facets.byTech ?? [];
37+
return facets.byEvent ?? [];
38+
}
39+
40+
export function FacetSidebar({
41+
facets,
42+
activeTags,
43+
onToggleTag,
44+
activeStages,
45+
onToggleStage,
46+
tabs = ['topic', 'tech', 'event', 'stage'],
47+
limit = 10,
48+
className,
49+
}: FacetSidebarProps) {
50+
const [tab, setTab] = useState<string>(tabs[0] ?? 'topic');
51+
const activeTagSet = new Set(activeTags);
52+
const activeStageSet = new Set(activeStages ?? []);
53+
54+
return (
55+
<aside className={cn('w-full', className)} aria-label="Filters">
56+
<Tabs value={tab} onValueChange={setTab}>
57+
<TabsList className="w-full grid" style={{ gridTemplateColumns: `repeat(${tabs.length}, 1fr)` }}>
58+
{tabs.map((t) => (
59+
<TabsTrigger key={t} value={t} className="text-xs">
60+
{NS_LABELS[t]}
61+
</TabsTrigger>
62+
))}
63+
</TabsList>
64+
65+
{tabs
66+
.filter((t): t is 'topic' | 'tech' | 'event' => t !== 'stage')
67+
.map((ns) => {
68+
const entries = facetsForNamespace(facets, ns).slice(0, limit);
69+
return (
70+
<TabsContent key={ns} value={ns} className="space-y-2 pt-3">
71+
{entries.length === 0 ? (
72+
<p className="text-xs text-muted-foreground">No tags yet.</p>
73+
) : (
74+
<div className="flex flex-wrap gap-1.5">
75+
{entries.map((e) => {
76+
const handle = e.handle ?? `${ns}.${e.slug ?? ''}`;
77+
return (
78+
<TagChip
79+
key={handle}
80+
tag={{
81+
namespace: ns,
82+
slug: e.slug ?? handle.split('.').slice(1).join('.'),
83+
title: e.title ?? e.slug ?? handle,
84+
}}
85+
count={e.count}
86+
active={activeTagSet.has(handle)}
87+
onClick={() => onToggleTag(handle)}
88+
/>
89+
);
90+
})}
91+
</div>
92+
)}
93+
<div className="pt-1">
94+
<Link to={NS_SEE_ALL[ns] ?? '/tags'} className="text-xs text-primary hover:underline">
95+
See all {NS_LABELS[ns].toLowerCase()} →
96+
</Link>
97+
</div>
98+
</TabsContent>
99+
);
100+
})}
101+
102+
{tabs.includes('stage') && (
103+
<TabsContent value="stage" className="space-y-2 pt-3">
104+
<div className="flex flex-col gap-1">
105+
{(Object.keys(STAGES) as Stage[]).map((s) => {
106+
const stageFacet = facets?.byStage?.find((f) => f.stage === s);
107+
const count = stageFacet?.count ?? 0;
108+
const meta = STAGES[s];
109+
const isActive = activeStageSet.has(s);
110+
return (
111+
<button
112+
key={s}
113+
type="button"
114+
onClick={() => onToggleStage?.(s)}
115+
className={cn(
116+
'flex items-center justify-between gap-2 rounded border border-border px-2 py-1 text-sm hover:bg-accent transition-colors text-left',
117+
isActive && 'bg-accent ring-1 ring-primary',
118+
)}
119+
>
120+
<span className="flex items-center gap-2">
121+
<span className={cn('h-2 w-2 rounded-full', meta.barClassName)} />
122+
{meta.label}
123+
</span>
124+
<span className="text-xs text-muted-foreground">{count}</span>
125+
</button>
126+
);
127+
})}
128+
</div>
129+
</TabsContent>
130+
)}
131+
</Tabs>
132+
</aside>
133+
);
134+
}

0 commit comments

Comments
 (0)