feat: /titles page implementation with cursor pagination
This commit is contained in:
parent
a515769823
commit
397d2bcf70
37 changed files with 797 additions and 1247 deletions
|
|
@ -1,52 +1,103 @@
|
|||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid";
|
||||
import type { CursorObj } from "../../api";
|
||||
|
||||
interface ListViewProps<TItem> {
|
||||
hook: ReturnType<typeof import("./useListView.tsx").useListView<TItem>>;
|
||||
renderHorizontal: (item: TItem) => React.ReactNode;
|
||||
renderSquare: (item: TItem) => React.ReactNode;
|
||||
}
|
||||
export type ListViewProps<T> = {
|
||||
fetchItems: (cursor: string, limit: number) => Promise<{ items: T[]; cursor: CursorObj}>;
|
||||
renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode;
|
||||
pageSize?: number;
|
||||
searchPlaceholder?: string;
|
||||
setSearch: any;
|
||||
};
|
||||
|
||||
export function ListView<TItem>({
|
||||
hook,
|
||||
renderHorizontal,
|
||||
renderSquare
|
||||
}: ListViewProps<TItem>) {
|
||||
const { items, search, setSearch, viewMode, setViewMode, loadMore, hasMore } = hook;
|
||||
export function ListView<T>({
|
||||
fetchItems,
|
||||
renderItem,
|
||||
pageSize = 20,
|
||||
searchPlaceholder = "Search...",
|
||||
}: ListViewProps<T>) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [cursorObj, setCursorObj] = useState<CursorObj | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [layout, setLayout] = useState<"square" | "horizontal">("horizontal");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadItems = async (reset: boolean = false) => {
|
||||
try {
|
||||
if (reset) {
|
||||
setLoading(true);
|
||||
setCursorObj(undefined);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
const cursorStr = cursorObj ? btoa(JSON.stringify(cursorObj)) : ""
|
||||
console.log("encoded cursor: " + cursorStr)
|
||||
|
||||
const result = await fetchItems(cursorStr, pageSize);
|
||||
|
||||
if (reset) setItems(result.items);
|
||||
else setItems(prev => [...prev, ...result.items]);
|
||||
|
||||
setCursorObj(result.cursor);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError("Failed to fetch items.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadItems(true);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Search + Layout Switcher */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
<div className="w-full min-h-screen bg-gray-50 p-6 text-black flex flex-col items-center">
|
||||
<div className="w-full sm:w-4/5 flex gap-4 mb-8">
|
||||
<input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
// value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-black"
|
||||
/>
|
||||
|
||||
<button onClick={() => setViewMode("horizontal")}>Horizontal</button>
|
||||
<button onClick={() => setViewMode("square")}>Square</button>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: viewMode === "square" ? "repeat(auto-fill, 160px)" : "1fr",
|
||||
gap: 12
|
||||
}}
|
||||
>
|
||||
{items.map(item =>
|
||||
viewMode === "horizontal"
|
||||
? renderHorizontal(item)
|
||||
: renderSquare(item)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button onClick={loadMore} style={{ marginTop: 16 }}>
|
||||
Load More
|
||||
<button
|
||||
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
onClick={() =>
|
||||
setLayout(prev => (prev === "square" ? "horizontal" : "square"))
|
||||
}>
|
||||
{layout === "square" ? <Squares2X2Icon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-600 mb-6 font-medium">{error}</div>}
|
||||
|
||||
<div
|
||||
className={`w-full sm:w-4/5 grid gap-6 ${
|
||||
layout === "square" ? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4" : "grid-cols-1"
|
||||
}`}
|
||||
>
|
||||
{items.map(item => renderItem(item, layout))}
|
||||
</div>
|
||||
|
||||
{cursorObj && (
|
||||
<div className="mt-8 flex justify-center w-full sm:w-4/5">
|
||||
<button
|
||||
className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => loadItems(false)}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="mt-20 font-medium">Loading...</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue