103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Squares2X2Icon, Bars3Icon } from "@heroicons/react/24/solid";
|
|
import type { CursorObj } from "../../api";
|
|
|
|
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<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 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
|
|
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
|
|
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>
|
|
);
|
|
}
|