Skip to content

Commit 33c667c

Browse files
committed
feat: enhance UpcomingConferences component with improved loading and error handling, and refactor layout
1 parent 07a194d commit 33c667c

2 files changed

Lines changed: 258 additions & 48 deletions

File tree

src/features/conferences/DisplayConferences.tsx

Lines changed: 138 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useMemo } from "react";
22
import type { HTConference } from "@/types/db";
3-
import { ConferenceCard } from "./ConferenceCell";
3+
import { ConferenceCard } from "./ConferenceCard";
44

5+
/** Firestore / date helpers */
56
type FirestoreTimestampLike = { toDate: () => Date };
67
type DateLike =
78
| Date
@@ -14,7 +15,6 @@ type DateLike =
1415
function isFirestoreTimestamp(v: unknown): v is FirestoreTimestampLike {
1516
return typeof (v as { toDate?: unknown })?.toDate === "function";
1617
}
17-
1818
function toMillis(value: DateLike): number {
1919
if (!value) return 0;
2020
if (value instanceof Date) return value.getTime();
@@ -62,62 +62,169 @@ export function DisplayConferences({
6262
const history = conferences.filter(
6363
(c) => startMs(c as ConferenceWithDates<HTConference>) < now
6464
);
65+
66+
const THIRTY_DAYS = 1000 * 60 * 60 * 24 * 30;
67+
68+
const recent = [...conferences]
69+
.filter((c) => {
70+
const updated = updatedMs(c as ConferenceWithDates<HTConference>);
71+
return updated && Date.now() - updated <= THIRTY_DAYS;
72+
})
73+
.sort(byUpdatedDesc)
74+
.reduce<HTConference[]>((acc, c) => {
75+
if (!acc.find((x) => x.id === c.id)) acc.push(c);
76+
return acc;
77+
}, []);
78+
6579
return {
6680
upcoming: future.sort(byStartAsc),
67-
updated: [...conferences].sort(byUpdatedDesc).slice(0, 8),
81+
updated: recent,
6882
past: history.sort(byStartDesc),
6983
};
7084
}, [conferences]);
7185

7286
return (
73-
<div className="space-y-8 my-10">
87+
<div className="my-10 space-y-10">
7488
{/* Upcoming */}
75-
<section id="upcoming" className="space-y-4">
76-
<h2 className="text-lg font-semibold text-neutral-200">
77-
Upcoming Conferences
78-
</h2>
89+
<Section
90+
id="upcoming"
91+
title="Upcoming Conferences"
92+
count={upcoming.length}
93+
ariaLabel="Upcoming conferences"
94+
>
7995
{upcoming.length ? (
80-
<div className="grid gap-4 sm:gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 [grid-auto-rows:1fr]">
96+
<CardsGrid>
8197
{upcoming.map((c) => (
82-
<ConferenceCard key={c.id} conference={c} />
98+
<CardWrap key={c.id}>
99+
<ConferenceCard conference={c} />
100+
</CardWrap>
83101
))}
84-
</div>
102+
</CardsGrid>
85103
) : (
86-
<p className="text-neutral-400">No upcoming conferences found.</p>
104+
<EmptyState message="No upcoming conferences found." />
87105
)}
88-
</section>
106+
</Section>
89107

90108
{/* Recently Updated */}
91-
<section id="updated" className="space-y-4">
92-
<h2 className="text-lg font-semibold text-neutral-200">
93-
Recently Updated
94-
</h2>
109+
<Section
110+
id="updated"
111+
title="Recently Updated"
112+
count={updated.length}
113+
ariaLabel="Recently updated conferences"
114+
>
95115
{updated.length ? (
96-
<div className="grid gap-4 sm:gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 [grid-auto-rows:1fr]">
116+
<CardsGrid>
97117
{updated.map((c) => (
98-
<ConferenceCard key={c.id} conference={c} />
118+
<CardWrap key={c.id}>
119+
<ConferenceCard conference={c} />
120+
</CardWrap>
99121
))}
100-
</div>
122+
</CardsGrid>
101123
) : (
102-
<p className="text-neutral-400">No recent updates.</p>
124+
<EmptyState message="No recent updates." />
103125
)}
104-
</section>
126+
</Section>
105127

106128
{/* Past */}
107-
<section id="past" className="space-y-4">
108-
<h2 className="text-lg font-semibold text-neutral-200">
109-
Past Conferences
110-
</h2>
129+
<Section
130+
id="past"
131+
title="Past Conferences"
132+
count={past.length}
133+
ariaLabel="Past conferences"
134+
>
111135
{past.length ? (
112-
<div className="grid gap-4 sm:gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 [grid-auto-rows:1fr]">
136+
<CardsGrid>
113137
{past.map((c) => (
114-
<ConferenceCard key={c.id} conference={c} />
138+
<CardWrap key={c.id}>
139+
<ConferenceCard conference={c} />
140+
</CardWrap>
115141
))}
116-
</div>
142+
</CardsGrid>
117143
) : (
118-
<p className="text-neutral-400">No past conferences found.</p>
144+
<EmptyState message="No past conferences found." />
119145
)}
120-
</section>
146+
</Section>
147+
</div>
148+
);
149+
}
150+
151+
function Section({
152+
id,
153+
title,
154+
count,
155+
children,
156+
}: {
157+
id: string;
158+
title: string;
159+
count?: number;
160+
ariaLabel: string;
161+
children: React.ReactNode;
162+
}) {
163+
return (
164+
<section
165+
id={id}
166+
role="region"
167+
aria-labelledby={`${id}-title`}
168+
className="space-y-4"
169+
>
170+
<header className="flex items-center justify-between">
171+
<h2
172+
id={`${id}-title`}
173+
className="group inline-flex items-center gap-2 text-base sm:text-lg font-semibold text-neutral-100"
174+
>
175+
{title}
176+
{typeof count === "number" && (
177+
<span className="rounded-full bg-neutral-800 text-neutral-300 text-xs px-2 py-0.5">
178+
{count}
179+
</span>
180+
)}
181+
<a
182+
href={`#${id}`}
183+
className="opacity-0 group-hover:opacity-100 transition-opacity text-neutral-500 hover:text-neutral-300"
184+
aria-label={`Link to section ${title}`}
185+
>
186+
#
187+
</a>
188+
</h2>
189+
</header>
190+
191+
<div className="h-px bg-gradient-to-r from-transparent via-neutral-800 to-transparent" />
192+
193+
{children}
194+
</section>
195+
);
196+
}
197+
198+
function CardsGrid({ children }: { children: React.ReactNode }) {
199+
return (
200+
<div className="grid items-stretch gap-4 sm:gap-5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
201+
{children}
202+
</div>
203+
);
204+
}
205+
206+
function CardWrap({ children }: { children: React.ReactNode }) {
207+
return <div className="h-full">{children}</div>;
208+
}
209+
210+
function EmptyState({ message }: { message: string }) {
211+
return (
212+
<div className="flex items-center gap-3 rounded-lg border border-neutral-800 bg-neutral-900/50 px-4 py-6 text-neutral-400">
213+
<svg
214+
className="h-5 w-5 shrink-0 text-neutral-500"
215+
viewBox="0 0 24 24"
216+
fill="none"
217+
stroke="currentColor"
218+
strokeWidth="1.5"
219+
aria-hidden="true"
220+
>
221+
<path
222+
strokeLinecap="round"
223+
strokeLinejoin="round"
224+
d="M3 3l18 18M12 21c-4.5-5.5-6.75-9-6.75-11.25a6.75 6.75 0 1113.5 0c0 2.25-2.25 5.75-6.75 11.25z"
225+
/>
226+
</svg>
227+
<p className="text-sm">{message}</p>
121228
</div>
122229
);
123230
}
Lines changed: 120 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,133 @@
1-
import { getUpcomingConferences } from "@/lib/db";
21
import { useEffect, useState } from "react";
3-
import { type HTConference } from "@/types/db";
4-
import { ConferenceCard } from "./ConferenceCell";
2+
import { getUpcomingConferences } from "@/lib/db";
3+
import type { HTConference } from "@/types/db";
4+
import { ConferenceCard } from "./ConferenceCard";
55

66
export function UpcomingConferences() {
77
const [conferences, setConferences] = useState<HTConference[]>([]);
8+
const [loading, setLoading] = useState(true);
9+
const [err, setErr] = useState<string | null>(null);
810

911
useEffect(() => {
10-
const fetchConferences = async () => {
11-
const conferences = await getUpcomingConferences();
12-
setConferences(conferences);
13-
};
14-
15-
fetchConferences();
12+
const ac = new AbortController();
13+
(async () => {
14+
try {
15+
setLoading(true);
16+
setErr(null);
17+
const list = await getUpcomingConferences();
18+
if (!ac.signal.aborted) setConferences(list ?? []);
19+
} catch {
20+
if (!ac.signal.aborted) setErr("Failed to load conferences.");
21+
} finally {
22+
if (!ac.signal.aborted) setLoading(false);
23+
}
24+
})();
25+
return () => ac.abort();
1626
}, []);
1727

18-
return conferences.length > 0 ? (
19-
<div>
20-
<h2 className="mb-4 text-left text-lg font-semibold text-neutral-200">
21-
Upcoming Conferences
22-
</h2>
23-
<div className="grid gap-4 sm:gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 [grid-auto-rows:1fr]">
28+
if (loading) {
29+
return (
30+
<section aria-label="Upcoming conferences" className="space-y-4">
31+
<Header
32+
title="Upcoming Conferences"
33+
count={undefined}
34+
id="upcoming-mini"
35+
/>
36+
<div className="grid items-stretch gap-4 sm:gap-5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
37+
{Array.from({ length: 4 }).map((_, i) => (
38+
<div
39+
key={i}
40+
className="h-full rounded-lg border border-neutral-800 bg-neutral-900/50 p-4 animate-pulse"
41+
/>
42+
))}
43+
</div>
44+
</section>
45+
);
46+
}
47+
48+
if (err) {
49+
return (
50+
<section aria-label="Upcoming conferences" className="space-y-4">
51+
<Header title="Upcoming Conferences" count={0} id="upcoming-mini" />
52+
<EmptyState message={err} />
53+
</section>
54+
);
55+
}
56+
57+
if (conferences.length === 0) return null;
58+
59+
return (
60+
<section aria-label="Upcoming conferences" className="space-y-4">
61+
<Header
62+
title="Upcoming Conferences"
63+
count={conferences.length}
64+
id="upcoming-mini"
65+
/>
66+
<div className="grid items-stretch gap-4 sm:gap-5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
2467
{conferences.map((c) => (
25-
<ConferenceCard key={c.id} conference={c} />
68+
<div key={c.id} className="h-full">
69+
<ConferenceCard conference={c} />
70+
</div>
2671
))}
2772
</div>
73+
</section>
74+
);
75+
}
76+
77+
function Header({
78+
title,
79+
count,
80+
id,
81+
}: {
82+
title: string;
83+
count?: number;
84+
id: string;
85+
}) {
86+
return (
87+
<>
88+
<div className="flex items-center justify-between">
89+
<h2
90+
id={`${id}-title`}
91+
className="group inline-flex items-center gap-2 text-base sm:text-lg font-semibold text-neutral-100"
92+
>
93+
{title}
94+
{typeof count === "number" && (
95+
<span className="rounded-full bg-neutral-800 text-neutral-300 text-xs px-2 py-0.5">
96+
{count}
97+
</span>
98+
)}
99+
<a
100+
href={`#${id}`}
101+
className="opacity-0 group-hover:opacity-100 transition-opacity text-neutral-500 hover:text-neutral-300"
102+
aria-label={`Link to section ${title}`}
103+
>
104+
#
105+
</a>
106+
</h2>
107+
</div>
108+
<div className="h-px bg-gradient-to-r from-transparent via-neutral-800 to-transparent" />
109+
</>
110+
);
111+
}
112+
113+
function EmptyState({ message }: { message: string }) {
114+
return (
115+
<div className="flex items-center gap-3 rounded-lg border border-neutral-800 bg-neutral-900/50 px-4 py-6 text-neutral-400">
116+
<svg
117+
className="h-5 w-5 shrink-0 text-neutral-500"
118+
viewBox="0 0 24 24"
119+
fill="none"
120+
stroke="currentColor"
121+
strokeWidth="1.5"
122+
aria-hidden="true"
123+
>
124+
<path
125+
strokeLinecap="round"
126+
strokeLinejoin="round"
127+
d="M3 3l18 18M12 21c-4.5-5.5-6.75-9-6.75-11.25a6.75 6.75 0 1113.5 0c0 2.25-2.25 5.75-6.75 11.25z"
128+
/>
129+
</svg>
130+
<p className="text-sm">{message}</p>
28131
</div>
29-
) : null;
132+
);
30133
}

0 commit comments

Comments
 (0)