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:
google-labs-jules[bot] 2025-12-01 11:39:24 +01:00 committed by GitHub
parent 557020a00c
commit 48c4c91d05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 333 additions and 66 deletions

Binary file not shown.

View File

@ -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>

View File

@ -179,6 +179,7 @@ export default function Explore() {
filters={filters}
onFilterChange={handleFilterChange}
tags={tags || []}
authors={[]}
/>
{/* Results */}

View File

@ -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 || []}
/>
)}

View File

@ -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
View 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
View 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;

View File

@ -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({