mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-26 21:51:34 +00:00
Implement advanced search filters UI and backend support (#14)
- Add Author, Language, Work Type, Date Range filters - Implement Save Search Preferences - Add backend routes for search and filtering with client-side pagination support Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
parent
557020a00c
commit
48c4c91d05
Binary file not shown.
@ -17,13 +17,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import type { Author } from "@shared/schema";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
interface FilterState {
|
||||
language?: string;
|
||||
@ -31,6 +28,7 @@ interface FilterState {
|
||||
yearStart?: number;
|
||||
yearEnd?: number;
|
||||
tags?: string[];
|
||||
authorIds?: string[];
|
||||
query?: string;
|
||||
sort?: string;
|
||||
page: number;
|
||||
@ -39,13 +37,17 @@ interface FilterState {
|
||||
interface FilterSidebarProps {
|
||||
filters: FilterState;
|
||||
onFilterChange: (filters: Partial<FilterState>) => void;
|
||||
onSavePreferences?: () => void;
|
||||
tags: Tag[];
|
||||
authors: Author[];
|
||||
}
|
||||
|
||||
export function FilterSidebar({
|
||||
filters,
|
||||
onFilterChange,
|
||||
onSavePreferences,
|
||||
tags,
|
||||
authors,
|
||||
}: FilterSidebarProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
@ -144,6 +146,42 @@ export function FilterSidebar({
|
||||
type="multiple"
|
||||
defaultValue={["language", "type", "period", "sort", "tags"]}
|
||||
>
|
||||
<AccordionItem value="author">
|
||||
<AccordionTrigger>Author</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto pr-2">
|
||||
{authors.map((author) => (
|
||||
<div key={author.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`author-${author.id}`}
|
||||
checked={filters.authorIds?.includes(author.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentAuthors = filters.authorIds || [];
|
||||
if (checked) {
|
||||
onFilterChange({
|
||||
authorIds: [...currentAuthors, author.id],
|
||||
});
|
||||
} else {
|
||||
onFilterChange({
|
||||
authorIds: currentAuthors.filter(
|
||||
(id) => id !== author.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`author-${author.id}`}
|
||||
className="text-sm text-navy/80 dark:text-cream/80 cursor-pointer"
|
||||
>
|
||||
{author.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="language">
|
||||
<AccordionTrigger>Language</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
@ -260,55 +298,31 @@ export function FilterSidebar({
|
||||
<Label className="text-sm">Custom range:</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Select
|
||||
value={filters.yearStart?.toString() || ""}
|
||||
onValueChange={(value) => {
|
||||
const yearStart = value
|
||||
? parseInt(value, 10)
|
||||
: undefined;
|
||||
onFilterChange({ yearStart });
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="From"
|
||||
className="h-8 text-xs"
|
||||
value={filters.yearStart || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onFilterChange({
|
||||
yearStart: val ? parseInt(val, 10) : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8 text-xs">
|
||||
<SelectValue placeholder="From" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any</SelectItem>
|
||||
{Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => 1500 + i * 50,
|
||||
).map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
<span>-</span>
|
||||
<Select
|
||||
value={filters.yearEnd?.toString() || ""}
|
||||
onValueChange={(value) => {
|
||||
const yearEnd = value
|
||||
? parseInt(value, 10)
|
||||
: undefined;
|
||||
onFilterChange({ yearEnd });
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="To"
|
||||
className="h-8 text-xs"
|
||||
value={filters.yearEnd || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onFilterChange({
|
||||
yearEnd: val ? parseInt(val, 10) : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8 text-xs">
|
||||
<SelectValue placeholder="To" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any</SelectItem>
|
||||
{Array.from(
|
||||
{ length: 11 },
|
||||
(_, i) => 1550 + i * 50,
|
||||
).map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
@ -419,14 +433,27 @@ export function FilterSidebar({
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleClearFilters}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
{onSavePreferences && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onSavePreferences}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Preferences
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -179,6 +179,7 @@ export default function Explore() {
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
tags={tags || []}
|
||||
authors={[]}
|
||||
/>
|
||||
|
||||
{/* Results */}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Tag } from "@shared/schema";
|
||||
import type { Author } from "@shared/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronLeft,
|
||||
@ -19,10 +20,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { SearchResults, WorkWithAuthor } from "@/lib/types";
|
||||
|
||||
export default function Search() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
@ -33,14 +36,33 @@ export default function Search() {
|
||||
yearStart?: number;
|
||||
yearEnd?: number;
|
||||
tags?: string[];
|
||||
authorIds?: string[];
|
||||
page: number;
|
||||
}>({
|
||||
page: 1,
|
||||
});
|
||||
|
||||
// Parse URL parameters
|
||||
// Parse URL parameters or load from localStorage
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.split("?")[1]);
|
||||
const searchString = location.split("?")[1];
|
||||
if (!searchString) {
|
||||
// Try to load from localStorage if no URL params
|
||||
try {
|
||||
const savedPrefs = localStorage.getItem("searchPreferences");
|
||||
if (savedPrefs) {
|
||||
const parsed = JSON.parse(savedPrefs);
|
||||
setFilters((prev) => ({ ...prev, ...parsed, page: 1 }));
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
setActiveTab("advanced");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse search preferences", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(searchString);
|
||||
const q = searchParams.get("q");
|
||||
if (q) {
|
||||
setQuery(q);
|
||||
@ -52,6 +74,7 @@ export default function Search() {
|
||||
const yearStart = searchParams.get("yearStart");
|
||||
const yearEnd = searchParams.get("yearEnd");
|
||||
const tags = searchParams.get("tags");
|
||||
const authorIds = searchParams.get("authorIds");
|
||||
|
||||
setFilters({
|
||||
language: language || undefined,
|
||||
@ -59,10 +82,27 @@ export default function Search() {
|
||||
yearStart: yearStart ? parseInt(yearStart) : undefined,
|
||||
yearEnd: yearEnd ? parseInt(yearEnd) : undefined,
|
||||
tags: tags ? tags.split(",") : undefined,
|
||||
authorIds: authorIds ? authorIds.split(",") : undefined,
|
||||
page: parseInt(searchParams.get("page") || "1"),
|
||||
});
|
||||
}, [location]);
|
||||
|
||||
const handleSavePreferences = () => {
|
||||
const prefsToSave = {
|
||||
language: filters.language,
|
||||
type: filters.type,
|
||||
yearStart: filters.yearStart,
|
||||
yearEnd: filters.yearEnd,
|
||||
tags: filters.tags,
|
||||
authorIds: filters.authorIds,
|
||||
};
|
||||
localStorage.setItem("searchPreferences", JSON.stringify(prefsToSave));
|
||||
toast({
|
||||
title: "Preferences Saved",
|
||||
description: "Your search filters have been saved as default.",
|
||||
});
|
||||
};
|
||||
|
||||
// Search results query
|
||||
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||
queryKey: ["/api/search", query],
|
||||
@ -101,6 +141,8 @@ export default function Search() {
|
||||
if (filters.yearEnd) params.append("yearEnd", filters.yearEnd.toString());
|
||||
if (filters.tags && filters.tags.length > 0)
|
||||
params.append("tags", filters.tags.join(","));
|
||||
if (filters.authorIds && filters.authorIds.length > 0)
|
||||
params.append("authorIds", filters.authorIds.join(","));
|
||||
|
||||
const response = await fetch(`/api/filter?${params.toString()}`);
|
||||
return await response.json();
|
||||
@ -111,12 +153,15 @@ export default function Search() {
|
||||
!!filters.type ||
|
||||
!!filters.yearStart ||
|
||||
!!filters.yearEnd ||
|
||||
!!(filters.tags && filters.tags.length > 0)),
|
||||
!!(filters.tags && filters.tags.length > 0) ||
|
||||
!!(filters.authorIds && filters.authorIds.length > 0)),
|
||||
select: (data) =>
|
||||
data.map((work) => ({
|
||||
...work,
|
||||
tags: work.tags?.map((tag) =>
|
||||
typeof tag === "string" ? { name: tag, id: tag, type: "general", createdAt: "" } : tag,
|
||||
typeof tag === "string"
|
||||
? { name: tag, id: tag, type: "general", createdAt: "" }
|
||||
: tag,
|
||||
),
|
||||
})),
|
||||
});
|
||||
@ -126,6 +171,11 @@ export default function Search() {
|
||||
queryKey: ["/api/tags"],
|
||||
});
|
||||
|
||||
// Get authors for filter sidebar
|
||||
const { data: authors } = useQuery<Author[]>({
|
||||
queryKey: ["/api/authors"],
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
@ -142,6 +192,8 @@ export default function Search() {
|
||||
newParams.append("yearEnd", filters.yearEnd.toString());
|
||||
if (filters.tags && filters.tags.length > 0)
|
||||
newParams.append("tags", filters.tags.join(","));
|
||||
if (filters.authorIds && filters.authorIds.length > 0)
|
||||
newParams.append("authorIds", filters.authorIds.join(","));
|
||||
}
|
||||
|
||||
setLocation(`/search?${newParams.toString()}`);
|
||||
@ -193,9 +245,15 @@ export default function Search() {
|
||||
|
||||
// Handle display based on current active tab
|
||||
// Use any cast here because of the complex type transformation in select causing inference issues with WorkCard props
|
||||
const displayWorks =
|
||||
let displayWorks =
|
||||
activeTab === "advanced" ? filteredWorks || [] : searchResults?.works || [];
|
||||
|
||||
// Client-side pagination for advanced search
|
||||
if (activeTab === "advanced") {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
displayWorks = displayWorks.slice(startIndex, startIndex + itemsPerPage);
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
(searchLoading && activeTab !== "advanced") ||
|
||||
(filterLoading && activeTab === "advanced");
|
||||
@ -313,7 +371,9 @@ export default function Search() {
|
||||
<FilterSidebar
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
onSavePreferences={handleSavePreferences}
|
||||
tags={tags || []}
|
||||
authors={authors || []}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ import tagRouter from "./routes/tag";
|
||||
import collectionRouter from "./routes/collection";
|
||||
import blogRouter from "./routes/blog";
|
||||
import statsRouter from "./routes/stats";
|
||||
import searchRouter from "./routes/search";
|
||||
import filterRouter from "./routes/filter";
|
||||
import { log, serveStatic, setupVite } from "./vite";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
@ -62,6 +64,8 @@ app.use((req, res, next) => {
|
||||
app.use("/api/collections", collectionRouter);
|
||||
app.use("/api/blog", blogRouter);
|
||||
app.use("/api/stats", statsRouter);
|
||||
app.use("/api/search", searchRouter);
|
||||
app.use("/api/filter", filterRouter);
|
||||
// comments router already mounted earlier at /api/comments
|
||||
|
||||
const server = createServer(app);
|
||||
|
||||
118
server/routes/filter.ts
Normal file
118
server/routes/filter.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import { graphqlClient } from "../lib/graphqlClient";
|
||||
import { respondWithError } from "../lib/error";
|
||||
import { gql } from "graphql-request";
|
||||
import type { Work } from "../../shared/generated/graphql";
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface GqlRequest extends Request {
|
||||
gql?: typeof graphqlClient;
|
||||
}
|
||||
|
||||
// Define a type that includes fields we need for filtering
|
||||
interface WorkWithFilters extends Pick<Work, "id" | "name" | "language" | "createdAt"> {
|
||||
tags?: { id: string; name: string }[];
|
||||
authors?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const WORKS_WITH_FILTERS_QUERY = gql`
|
||||
query WorksWithFilters($search: String, $limit: Int) {
|
||||
works(search: $search, limit: $limit) {
|
||||
id
|
||||
name
|
||||
language
|
||||
createdAt
|
||||
tags {
|
||||
id
|
||||
name
|
||||
}
|
||||
authors {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// GET /api/filter
|
||||
router.get("/", async (req: GqlRequest, res) => {
|
||||
try {
|
||||
const q = req.query.q as string | undefined;
|
||||
const language = req.query.language as string | undefined;
|
||||
const type = req.query.type as string | undefined;
|
||||
const yearStart = req.query.yearStart ? parseInt(req.query.yearStart as string) : undefined;
|
||||
const yearEnd = req.query.yearEnd ? parseInt(req.query.yearEnd as string) : undefined;
|
||||
const tags = req.query.tags ? (req.query.tags as string).split(",") : undefined;
|
||||
const authorIds = req.query.authorIds ? (req.query.authorIds as string).split(",") : undefined;
|
||||
|
||||
// We ignore page param because we are doing client-side pagination for now,
|
||||
// returning all matching results (up to limit).
|
||||
// const page = req.query.page ? parseInt(req.query.page as string) : 1;
|
||||
|
||||
const client = req.gql || graphqlClient;
|
||||
|
||||
// Fetch a larger set to filter in memory.
|
||||
const variables = {
|
||||
search: q,
|
||||
limit: 1000,
|
||||
};
|
||||
|
||||
const response = await client.request<{ works: WorkWithFilters[] }>(
|
||||
WORKS_WITH_FILTERS_QUERY,
|
||||
variables
|
||||
);
|
||||
|
||||
let filteredWorks = response.works;
|
||||
|
||||
// Filter by language
|
||||
if (language) {
|
||||
filteredWorks = filteredWorks.filter(w => w.language === language);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (type) {
|
||||
filteredWorks = filteredWorks.filter(w => {
|
||||
const typeLower = type.toLowerCase();
|
||||
const hasTag = w.tags?.some(t => t.name.toLowerCase().includes(typeLower));
|
||||
return hasTag;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by year
|
||||
if (yearStart !== undefined || yearEnd !== undefined) {
|
||||
filteredWorks = filteredWorks.filter(w => {
|
||||
const year = new Date(w.createdAt).getFullYear();
|
||||
if (yearStart !== undefined && year < yearStart) return false;
|
||||
if (yearEnd !== undefined && year > yearEnd) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by tags
|
||||
if (tags && tags.length > 0) {
|
||||
filteredWorks = filteredWorks.filter(w => {
|
||||
if (!w.tags) return false;
|
||||
const workTagIds = w.tags.map(t => t.id);
|
||||
return tags.every(tagId => workTagIds.includes(tagId));
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by authors
|
||||
if (authorIds && authorIds.length > 0) {
|
||||
filteredWorks = filteredWorks.filter(w => {
|
||||
if (!w.authors) return false;
|
||||
const workAuthorIds = w.authors.map(a => a.id);
|
||||
return authorIds.some(authorId => workAuthorIds.includes(authorId));
|
||||
});
|
||||
}
|
||||
|
||||
// Return all results for client-side pagination
|
||||
res.json(filteredWorks);
|
||||
} catch (error) {
|
||||
respondWithError(res, error, "Failed to filter works");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
53
server/routes/search.ts
Normal file
53
server/routes/search.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import { graphqlClient } from "../lib/graphqlClient";
|
||||
import { respondWithError } from "../lib/error";
|
||||
import {
|
||||
WorksDocument,
|
||||
AuthorsDocument,
|
||||
type WorksQuery,
|
||||
type AuthorsQuery,
|
||||
} from "../../shared/generated/graphql";
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface GqlRequest extends Request {
|
||||
gql?: typeof graphqlClient;
|
||||
}
|
||||
|
||||
// GET /api/search
|
||||
router.get("/", async (req: GqlRequest, res) => {
|
||||
try {
|
||||
const q = req.query.q as string;
|
||||
|
||||
if (!q) {
|
||||
return res.json({ works: [], authors: [] });
|
||||
}
|
||||
|
||||
const client = req.gql || graphqlClient;
|
||||
|
||||
// Fetch works matching the query
|
||||
const { works } = await client.request<WorksQuery>(
|
||||
WorksDocument,
|
||||
{
|
||||
search: q,
|
||||
limit: 10 // Limit results for general search
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch authors matching the query
|
||||
const { authors } = await client.request<AuthorsQuery>(
|
||||
AuthorsDocument,
|
||||
{
|
||||
search: q,
|
||||
limit: 10
|
||||
}
|
||||
);
|
||||
|
||||
res.json({ works, authors });
|
||||
} catch (error) {
|
||||
respondWithError(res, error, "Failed to perform search");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -1,6 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user