Skip to content

Commit b4bb75a

Browse files
committed
Add footer GitHub link and paginate task explorer
1 parent 299a63d commit b4bb75a

File tree

2 files changed

+169
-6
lines changed

2 files changed

+169
-6
lines changed

src/app/_components/gallery-client.tsx

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

3-
import { useDeferredValue, useMemo, useState } from "react";
3+
import clsx from "@/components/utils/clsx";
4+
import { IconChevronLeft, IconChevronRight } from "@/components/icons";
5+
import { useDeferredValue, useEffect, useMemo, useState } from "react";
46
import type { TaskFacet, TaskIndexItem } from "@/lib/task-index";
57
import { taskHasPreview } from "@/lib/html-companions";
68
import {
@@ -63,6 +65,93 @@ function FacetSection({
6365
);
6466
}
6567

68+
const TASKS_PER_PAGE = 15;
69+
70+
function parsePageParam(value: string | null) {
71+
const parsed = Number.parseInt(value ?? "1", 10);
72+
if (!Number.isFinite(parsed) || parsed < 1) return 1;
73+
return parsed;
74+
}
75+
76+
function PaginationControls({
77+
currentPage,
78+
totalPages,
79+
totalItems,
80+
startIndex,
81+
endIndex,
82+
onPageChange
83+
}: {
84+
currentPage: number;
85+
totalPages: number;
86+
totalItems: number;
87+
startIndex: number;
88+
endIndex: number;
89+
onPageChange: (page: number) => void;
90+
}) {
91+
if (totalPages <= 1) return null;
92+
93+
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
94+
95+
return (
96+
<div className="tb-frame flex flex-col gap-4 bg-[#fffdf9] px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
97+
<div className="text-sm text-slate-700">
98+
Showing <span className="font-bold text-[#25314d]">{startIndex + 1}</span>-
99+
<span className="font-bold text-[#25314d]">{endIndex}</span> of{" "}
100+
<span className="font-bold text-[#25314d]">{totalItems}</span> tasks
101+
</div>
102+
103+
<div className="flex flex-wrap items-center gap-2">
104+
<button
105+
type="button"
106+
className={clsx(
107+
"tb-focus-ring inline-flex min-w-[112px] items-center justify-center gap-2 rounded-[16px] border-2 border-[#25314d] px-4 py-2 text-sm font-bold text-[#25314d]",
108+
currentPage === 1 ? "cursor-not-allowed bg-slate-100 text-slate-400" : "bg-white hover:bg-[#eef8ff]"
109+
)}
110+
onClick={() => onPageChange(currentPage - 1)}
111+
disabled={currentPage === 1}
112+
>
113+
<IconChevronLeft className="size-4" />
114+
Previous
115+
</button>
116+
117+
<div className="flex flex-wrap items-center gap-2">
118+
{pages.map((page) => (
119+
<button
120+
key={page}
121+
type="button"
122+
className={clsx(
123+
"tb-focus-ring min-w-11 rounded-[14px] border-2 px-3 py-2 text-sm font-bold",
124+
page === currentPage
125+
? "border-[#25314d] bg-[#25314d] text-white"
126+
: "border-[#25314d] bg-white text-[#25314d] hover:bg-[#eef8ff]"
127+
)}
128+
onClick={() => onPageChange(page)}
129+
aria-current={page === currentPage ? "page" : undefined}
130+
>
131+
{page}
132+
</button>
133+
))}
134+
</div>
135+
136+
<button
137+
type="button"
138+
className={clsx(
139+
"tb-focus-ring inline-flex min-w-[112px] items-center justify-center gap-2 rounded-[16px] border-2 border-[#25314d] px-4 py-2 text-sm font-bold text-[#25314d]",
140+
currentPage === totalPages
141+
? "cursor-not-allowed bg-slate-100 text-slate-400"
142+
: "bg-white hover:bg-[#eef8ff]"
143+
)}
144+
onClick={() => onPageChange(currentPage + 1)}
145+
disabled={currentPage === totalPages}
146+
>
147+
Next
148+
<IconChevronRight className="size-4" />
149+
</button>
150+
</div>
151+
</div>
152+
);
153+
}
154+
66155
export function GalleryClient({
67156
tasks
68157
}: {
@@ -72,6 +161,7 @@ export function GalleryClient({
72161
const [query, setQuery] = useState<string>("");
73162
const [selected, setSelected] = useState<SelectedFacets>(() => emptySelectedFacets());
74163
const [activeRepo, setActiveRepo] = useState<string | null>(null);
164+
const [currentPage, setCurrentPage] = useState(1);
75165
const [openFacets, setOpenFacets] = useState<{
76166
maturity: boolean;
77167
preview: boolean;
@@ -91,6 +181,11 @@ export function GalleryClient({
91181
() => filterTasks(mergedTasks, deferredQuery, selected),
92182
[deferredQuery, mergedTasks, selected]
93183
);
184+
const totalPages = Math.max(1, Math.ceil(filtered.length / TASKS_PER_PAGE));
185+
const safeCurrentPage = Math.min(currentPage, totalPages);
186+
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
187+
const endIndex = Math.min(startIndex + TASKS_PER_PAGE, filtered.length);
188+
const visibleTasks = filtered.slice(startIndex, endIndex);
94189
const activeTask = useMemo(
95190
() => mergedTasks.find((task) => task.repo === activeRepo) ?? null,
96191
[activeRepo, mergedTasks]
@@ -105,7 +200,41 @@ export function GalleryClient({
105200
selected.modality.size > 0 ||
106201
selected.language.size > 0;
107202

203+
useEffect(() => {
204+
if (typeof window === "undefined") return undefined;
205+
206+
const syncPageFromLocation = () => {
207+
const params = new URLSearchParams(window.location.search);
208+
setCurrentPage(parsePageParam(params.get("page")));
209+
};
210+
211+
syncPageFromLocation();
212+
window.addEventListener("popstate", syncPageFromLocation);
213+
return () => window.removeEventListener("popstate", syncPageFromLocation);
214+
}, []);
215+
216+
useEffect(() => {
217+
if (typeof window === "undefined") return;
218+
219+
const params = new URLSearchParams(window.location.search);
220+
if (safeCurrentPage === 1) params.delete("page");
221+
else params.set("page", String(safeCurrentPage));
222+
223+
const queryString = params.toString();
224+
const nextUrl = queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname;
225+
window.history.replaceState(null, "", nextUrl);
226+
}, [safeCurrentPage]);
227+
228+
function updatePage(page: number) {
229+
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
230+
}
231+
232+
function resetToFirstPage() {
233+
if (safeCurrentPage !== 1) updatePage(1);
234+
}
235+
108236
function toggleFacet(facet: TaskFacet, value: string) {
237+
resetToFirstPage();
109238
setSelected((current) => {
110239
const next: SelectedFacets = {
111240
maturity: new Set(current.maturity),
@@ -122,6 +251,7 @@ export function GalleryClient({
122251
}
123252

124253
function clearAll() {
254+
resetToFirstPage();
125255
setQuery("");
126256
setSelected(emptySelectedFacets());
127257
}
@@ -165,7 +295,10 @@ export function GalleryClient({
165295
<input
166296
id="task-explorer-search"
167297
value={query}
168-
onChange={(event) => setQuery(event.target.value)}
298+
onChange={(event) => {
299+
resetToFirstPage();
300+
setQuery(event.target.value);
301+
}}
169302
placeholder="Search title, task ID, preview ID, repo..."
170303
className="tb-focus-ring mt-3 w-full rounded-[18px] border-2 border-[#25314d] bg-white px-4 py-3 text-sm text-[#25314d] placeholder:text-slate-400"
171304
/>
@@ -221,6 +354,15 @@ export function GalleryClient({
221354
</aside>
222355

223356
<section className="space-y-4 lg:col-span-8 xl:col-span-9">
357+
<PaginationControls
358+
currentPage={safeCurrentPage}
359+
totalPages={totalPages}
360+
totalItems={filtered.length}
361+
startIndex={startIndex}
362+
endIndex={endIndex}
363+
onPageChange={updatePage}
364+
/>
365+
224366
{filtered.length === 0 ? (
225367
<div className="tb-frame p-10 text-center">
226368
<div className="font-heading text-3xl font-bold text-[#25314d]">No matches</div>
@@ -234,14 +376,23 @@ export function GalleryClient({
234376
</div>
235377
</div>
236378
) : (
237-
filtered.map((task) => (
379+
visibleTasks.map((task) => (
238380
<TaskRow
239381
key={task.repo}
240382
task={task}
241383
onOpen={(nextTask) => setActiveRepo(nextTask.repo)}
242384
/>
243385
))
244386
)}
387+
388+
<PaginationControls
389+
currentPage={safeCurrentPage}
390+
totalPages={totalPages}
391+
totalItems={filtered.length}
392+
startIndex={startIndex}
393+
endIndex={endIndex}
394+
onPageChange={updatePage}
395+
/>
245396
</section>
246397
</div>
247398

src/components/site-footer.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { IconArrowRight, IconGithub } from "@/components/icons";
34
import { getIndex } from "@/lib/task-index";
45
import { taskHasPreview } from "@/lib/html-companions";
56
import { useTasksWithHtmlCompanions } from "@/lib/use-html-companions";
@@ -38,19 +39,30 @@ export function SiteFooter() {
3839
</div>
3940

4041
<div className="lg:justify-self-end">
41-
<div className="tb-frame-soft flex w-full items-start justify-center bg-[#eef8ff] px-4 py-5 sm:px-6 sm:py-8">
42+
<a
43+
className="tb-frame-soft tb-focus-ring group flex w-full items-start justify-center bg-[#eef8ff] px-4 py-5 transition-transform hover:-translate-y-0.5 sm:px-6 sm:py-8"
44+
href="https://github.com/TaskBeacon"
45+
target="_blank"
46+
rel="noreferrer"
47+
aria-label="Open the TaskBeacon GitHub organization"
48+
>
4249
<div className="flex w-full flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
4350
<TaskBeaconMark className="size-16 sm:size-20" />
44-
<div>
51+
<div className="w-full">
4552
<div className="font-heading text-[1.7rem] leading-none text-[#25314d] sm:text-[2.2rem]">
4653
TaskBeacon
4754
</div>
4855
<div className="mt-1 text-[0.7rem] font-bold uppercase tracking-[0.16em] text-slate-500 sm:mt-2 sm:text-sm">
4956
Canonical Task Hub
5057
</div>
58+
<div className="mt-4 inline-flex items-center gap-2 rounded-full border-2 border-[#25314d] bg-white px-4 py-2 text-xs font-bold uppercase tracking-[0.14em] text-[#25314d]">
59+
<IconGithub className="size-4" />
60+
Open GitHub Org
61+
<IconArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
62+
</div>
5163
</div>
5264
</div>
53-
</div>
65+
</a>
5466
</div>
5567
</div>
5668
</div>

0 commit comments

Comments
 (0)