tercul-frontend/client/src/pages/Explore.tsx
google-labs-jules[bot] ea15477b86 feat: Fix TypeScript errors and improve type safety
This commit addresses 275 TypeScript compilation errors and improves type safety, code quality, and maintainability across the frontend codebase.

The following issues have been resolved:
- Standardized `translationId` to `number`
- Fixed missing properties on annotation types
- Resolved `tags` type mismatch
- Corrected `country` type mismatch
- Addressed date vs. string mismatches
- Fixed variable hoisting issues
- Improved server-side type safety
- Added missing null/undefined checks
- Fixed arithmetic operations on non-numbers
- Resolved `RefObject` type issues

Note: I was unable to verify the frontend changes due to local setup issues with the development server. The server would not start, and I was unable to run the Playwright tests.
2025-11-27 17:48:31 +00:00

337 lines
8.9 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Grid, List } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "wouter";
import { SearchBar } from "@/components/common/SearchBar";
import { WorkCard } from "@/components/common/WorkCard";
import { FilterSidebar } from "@/components/explore/FilterSidebar";
import { PageLayout } from "@/components/layout/PageLayout";
import { Button } from "@/components/ui/button";
import type { WorkWithAuthor } from "@/lib/types";
interface FilterState {
language?: string;
type?: string;
yearStart?: number;
yearEnd?: number;
tags?: number[];
query?: string;
sort?: string;
page: number;
}
export default function Explore() {
const [location] = useLocation();
const [filters, setFilters] = useState<FilterState>({
page: 1,
});
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// Parse URL search params on initial load
useEffect(() => {
const searchParams = new URLSearchParams(location.split("?")[1]);
const newFilters: FilterState = { page: 1 };
if (searchParams.has("q")) {
newFilters.query = searchParams.get("q") || undefined;
}
if (searchParams.has("language")) {
newFilters.language = searchParams.get("language") || undefined;
}
if (searchParams.has("type")) {
newFilters.type = searchParams.get("type") || undefined;
}
if (searchParams.has("yearStart")) {
newFilters.yearStart =
parseInt(searchParams.get("yearStart") || "0", 10) || undefined;
}
if (searchParams.has("yearEnd")) {
newFilters.yearEnd =
parseInt(searchParams.get("yearEnd") || "0", 10) || undefined;
}
if (searchParams.has("tags")) {
newFilters.tags =
searchParams.get("tags")?.split(",").map(Number) || undefined;
}
if (searchParams.has("sort")) {
newFilters.sort = searchParams.get("sort") || undefined;
}
if (searchParams.has("page")) {
newFilters.page = parseInt(searchParams.get("page") || "1", 10);
}
setFilters(newFilters);
}, [location]);
// Build API query string based on filters
const getQueryString = () => {
const params = new URLSearchParams();
if (filters.query) {
params.append("q", filters.query);
}
if (filters.language) {
params.append("language", filters.language);
}
if (filters.type) {
params.append("type", filters.type);
}
if (filters.yearStart) {
params.append("yearStart", filters.yearStart.toString());
}
if (filters.yearEnd) {
params.append("yearEnd", filters.yearEnd.toString());
}
if (filters.tags && filters.tags.length > 0) {
params.append("tags", filters.tags.join(","));
}
if (filters.sort) {
params.append("sort", filters.sort);
}
// Pagination (we'll use limit/offset)
const limit = 10;
const offset = (filters.page - 1) * limit;
params.append("limit", limit.toString());
params.append("offset", offset.toString());
return params.toString();
};
const queryString = getQueryString();
const { data: works, isLoading } = useQuery<WorkWithAuthor[]>({
queryKey: [`/api/filter?${queryString}`],
select: (data) =>
data.map((work) => ({
...work,
tags: work.tags?.map((tag) =>
typeof tag === "string" ? { name: tag } : tag,
),
})),
});
const { data: tags } = useQuery({
queryKey: ["/api/tags"],
});
const handleFilterChange = (newFilters: Partial<FilterState>) => {
setFilters((prev) => ({ ...prev, ...newFilters, page: 1 }));
};
const handlePageChange = (newPage: number) => {
setFilters((prev) => ({ ...prev, page: newPage }));
window.scrollTo({ top: 0, behavior: "smooth" });
};
// Generate page numbers for pagination
const totalWorks = works?.length || 0;
const workPerPage = 10;
const totalPages = Math.ceil(totalWorks / workPerPage);
const pageNumbers = [];
const maxPageButtons = 5;
let startPage = Math.max(1, filters.page - Math.floor(maxPageButtons / 2));
const endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
if (endPage - startPage + 1 < maxPageButtons) {
startPage = Math.max(1, endPage - maxPageButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return (
<PageLayout>
<div className="max-w-[var(--content-width)] mx-auto px-4 md:px-6 py-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl md:text-3xl font-bold font-serif text-navy dark:text-cream">
Explore Literature
</h1>
<p className="text-navy/70 dark:text-cream/70 mt-1">
Discover works across languages, genres, and time periods
</p>
</div>
<div className="flex items-center gap-3">
<SearchBar />
</div>
</div>
<div className="flex flex-col lg:flex-row gap-8">
{/* Filters sidebar */}
<FilterSidebar
filters={filters}
onFilterChange={handleFilterChange}
tags={tags || []}
/>
{/* Results */}
<div className="flex-1">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-navy/70 dark:text-cream/70">
Showing{" "}
<span className="font-medium text-navy dark:text-cream">
{works?.length || 0}
</span>{" "}
results
{filters.query && (
<span>
{" "}
for{" "}
<span className="font-medium text-navy dark:text-cream">
"{filters.query}"
</span>
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-navy/70 dark:text-cream/70">
Display:
</span>
<Button
size="icon"
variant={viewMode === "grid" ? "default" : "ghost"}
className="h-8 w-8 p-0"
onClick={() => setViewMode("grid")}
>
<Grid className="h-4 w-4" />
<span className="sr-only">Grid view</span>
</Button>
<Button
size="icon"
variant={viewMode === "list" ? "default" : "ghost"}
className="h-8 w-8 p-0"
onClick={() => setViewMode("list")}
>
<List className="h-4 w-4" />
<span className="sr-only">List view</span>
</Button>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-40 bg-cream dark:bg-dark-surface rounded-lg animate-pulse"
></div>
))}
</div>
) : viewMode === "list" ? (
<div className="space-y-3">
{works?.map((work) => (
<WorkCard key={work.id} work={work} />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{works?.map((work) => (
<WorkCard key={work.id} work={work} grid />
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-8 flex justify-center">
<nav
className="flex items-center gap-1"
aria-label="Pagination"
>
<Button
variant="ghost"
size="icon"
onClick={() =>
handlePageChange(Math.max(1, filters.page - 1))
}
disabled={filters.page === 1}
>
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">Previous</span>
</Button>
{startPage > 1 && (
<>
<Button
variant={filters.page === 1 ? "default" : "ghost"}
size="sm"
onClick={() => handlePageChange(1)}
>
1
</Button>
{startPage > 2 && (
<span className="px-2 text-navy/60 dark:text-cream/60">
...
</span>
)}
</>
)}
{pageNumbers.map((number) => (
<Button
key={number}
variant={filters.page === number ? "default" : "ghost"}
size="sm"
onClick={() => handlePageChange(number)}
>
{number}
</Button>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && (
<span className="px-2 text-navy/60 dark:text-cream/60">
...
</span>
)}
<Button
variant={
filters.page === totalPages ? "default" : "ghost"
}
size="sm"
onClick={() => handlePageChange(totalPages)}
>
{totalPages}
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
onClick={() =>
handlePageChange(Math.min(totalPages, filters.page + 1))
}
disabled={filters.page === totalPages}
>
<ChevronRight className="h-4 w-4" />
<span className="sr-only">Next</span>
</Button>
</nav>
</div>
)}
</div>
</div>
</div>
</PageLayout>
);
}