feat: /titles page with search and sort functionality. Website header added

This commit is contained in:
nihonium 2025-11-22 05:45:54 +03:00
parent 31a95fabea
commit f1f7feffaa
Signed by: nihonium
GPG key ID: 0251623741027CFC
12 changed files with 625 additions and 155 deletions

View file

@ -1,103 +1,49 @@
import React, { useState, useEffect } from "react";
import React, { useState } 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}>;
items: T[];
layout: "square" | "horizontal";
renderItem: (item: T, layout: "square" | "horizontal") => React.ReactNode;
pageSize?: number;
searchPlaceholder?: string;
setSearch: any;
onLoadMore: () => void;
hasMore: boolean;
loadingMore: boolean;
};
export function ListView<T>({
fetchItems,
items,
layout,
renderItem,
pageSize = 20,
searchPlaceholder = "Search...",
onLoadMore,
hasMore,
loadingMore
}: 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 flex flex-col items-center">
{/* Items */}
<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"
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">
{/* Load More */}
{hasMore && (
<div className="mt-8">
<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)}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
disabled={loadingMore}
onClick={onLoadMore}
>
{loadingMore ? "Loading..." : "Load More"}
</button>
</div>
)}
{loading && <div className="mt-20 font-medium">Loading...</div>}
</div>
);
}