mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 00:11:35 +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";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import {
|
import { Input } from "@/components/ui/input";
|
||||||
Select,
|
|
||||||
SelectContent,
|
import type { Author } from "@shared/schema";
|
||||||
SelectItem,
|
import { Save } from "lucide-react";
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
language?: string;
|
language?: string;
|
||||||
@ -31,6 +28,7 @@ interface FilterState {
|
|||||||
yearStart?: number;
|
yearStart?: number;
|
||||||
yearEnd?: number;
|
yearEnd?: number;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
authorIds?: string[];
|
||||||
query?: string;
|
query?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
page: number;
|
page: number;
|
||||||
@ -39,13 +37,17 @@ interface FilterState {
|
|||||||
interface FilterSidebarProps {
|
interface FilterSidebarProps {
|
||||||
filters: FilterState;
|
filters: FilterState;
|
||||||
onFilterChange: (filters: Partial<FilterState>) => void;
|
onFilterChange: (filters: Partial<FilterState>) => void;
|
||||||
|
onSavePreferences?: () => void;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
authors: Author[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterSidebar({
|
export function FilterSidebar({
|
||||||
filters,
|
filters,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
onSavePreferences,
|
||||||
tags,
|
tags,
|
||||||
|
authors,
|
||||||
}: FilterSidebarProps) {
|
}: FilterSidebarProps) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
@ -144,6 +146,42 @@ export function FilterSidebar({
|
|||||||
type="multiple"
|
type="multiple"
|
||||||
defaultValue={["language", "type", "period", "sort", "tags"]}
|
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">
|
<AccordionItem value="language">
|
||||||
<AccordionTrigger>Language</AccordionTrigger>
|
<AccordionTrigger>Language</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
@ -260,55 +298,31 @@ export function FilterSidebar({
|
|||||||
<Label className="text-sm">Custom range:</Label>
|
<Label className="text-sm">Custom range:</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Select
|
<Input
|
||||||
value={filters.yearStart?.toString() || ""}
|
type="number"
|
||||||
onValueChange={(value) => {
|
placeholder="From"
|
||||||
const yearStart = value
|
className="h-8 text-xs"
|
||||||
? parseInt(value, 10)
|
value={filters.yearStart || ""}
|
||||||
: undefined;
|
onChange={(e) => {
|
||||||
onFilterChange({ yearStart });
|
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>
|
<span>-</span>
|
||||||
<Select
|
<Input
|
||||||
value={filters.yearEnd?.toString() || ""}
|
type="number"
|
||||||
onValueChange={(value) => {
|
placeholder="To"
|
||||||
const yearEnd = value
|
className="h-8 text-xs"
|
||||||
? parseInt(value, 10)
|
value={filters.yearEnd || ""}
|
||||||
: undefined;
|
onChange={(e) => {
|
||||||
onFilterChange({ yearEnd });
|
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>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
@ -419,14 +433,27 @@ export function FilterSidebar({
|
|||||||
)}
|
)}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Button
|
<div className="flex flex-col gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="w-full"
|
size="sm"
|
||||||
onClick={handleClearFilters}
|
className="w-full"
|
||||||
>
|
onClick={handleClearFilters}
|
||||||
Clear All Filters
|
>
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|||||||
@ -179,6 +179,7 @@ export default function Explore() {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
tags={tags || []}
|
tags={tags || []}
|
||||||
|
authors={[]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Tag } from "@shared/schema";
|
import type { Tag } from "@shared/schema";
|
||||||
|
import type { Author } from "@shared/schema";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@ -19,10 +20,12 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { SearchResults, WorkWithAuthor } from "@/lib/types";
|
import type { SearchResults, WorkWithAuthor } from "@/lib/types";
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
|
const { toast } = useToast();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState("all");
|
const [activeTab, setActiveTab] = useState("all");
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||||
@ -33,14 +36,33 @@ export default function Search() {
|
|||||||
yearStart?: number;
|
yearStart?: number;
|
||||||
yearEnd?: number;
|
yearEnd?: number;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
authorIds?: string[];
|
||||||
page: number;
|
page: number;
|
||||||
}>({
|
}>({
|
||||||
page: 1,
|
page: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse URL parameters
|
// Parse URL parameters or load from localStorage
|
||||||
useEffect(() => {
|
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");
|
const q = searchParams.get("q");
|
||||||
if (q) {
|
if (q) {
|
||||||
setQuery(q);
|
setQuery(q);
|
||||||
@ -52,6 +74,7 @@ export default function Search() {
|
|||||||
const yearStart = searchParams.get("yearStart");
|
const yearStart = searchParams.get("yearStart");
|
||||||
const yearEnd = searchParams.get("yearEnd");
|
const yearEnd = searchParams.get("yearEnd");
|
||||||
const tags = searchParams.get("tags");
|
const tags = searchParams.get("tags");
|
||||||
|
const authorIds = searchParams.get("authorIds");
|
||||||
|
|
||||||
setFilters({
|
setFilters({
|
||||||
language: language || undefined,
|
language: language || undefined,
|
||||||
@ -59,10 +82,27 @@ export default function Search() {
|
|||||||
yearStart: yearStart ? parseInt(yearStart) : undefined,
|
yearStart: yearStart ? parseInt(yearStart) : undefined,
|
||||||
yearEnd: yearEnd ? parseInt(yearEnd) : undefined,
|
yearEnd: yearEnd ? parseInt(yearEnd) : undefined,
|
||||||
tags: tags ? tags.split(",") : undefined,
|
tags: tags ? tags.split(",") : undefined,
|
||||||
|
authorIds: authorIds ? authorIds.split(",") : undefined,
|
||||||
page: parseInt(searchParams.get("page") || "1"),
|
page: parseInt(searchParams.get("page") || "1"),
|
||||||
});
|
});
|
||||||
}, [location]);
|
}, [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
|
// Search results query
|
||||||
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||||
queryKey: ["/api/search", query],
|
queryKey: ["/api/search", query],
|
||||||
@ -101,6 +141,8 @@ export default function Search() {
|
|||||||
if (filters.yearEnd) params.append("yearEnd", filters.yearEnd.toString());
|
if (filters.yearEnd) params.append("yearEnd", filters.yearEnd.toString());
|
||||||
if (filters.tags && filters.tags.length > 0)
|
if (filters.tags && filters.tags.length > 0)
|
||||||
params.append("tags", filters.tags.join(","));
|
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()}`);
|
const response = await fetch(`/api/filter?${params.toString()}`);
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@ -111,12 +153,15 @@ export default function Search() {
|
|||||||
!!filters.type ||
|
!!filters.type ||
|
||||||
!!filters.yearStart ||
|
!!filters.yearStart ||
|
||||||
!!filters.yearEnd ||
|
!!filters.yearEnd ||
|
||||||
!!(filters.tags && filters.tags.length > 0)),
|
!!(filters.tags && filters.tags.length > 0) ||
|
||||||
|
!!(filters.authorIds && filters.authorIds.length > 0)),
|
||||||
select: (data) =>
|
select: (data) =>
|
||||||
data.map((work) => ({
|
data.map((work) => ({
|
||||||
...work,
|
...work,
|
||||||
tags: work.tags?.map((tag) =>
|
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"],
|
queryKey: ["/api/tags"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get authors for filter sidebar
|
||||||
|
const { data: authors } = useQuery<Author[]>({
|
||||||
|
queryKey: ["/api/authors"],
|
||||||
|
});
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
@ -142,6 +192,8 @@ export default function Search() {
|
|||||||
newParams.append("yearEnd", filters.yearEnd.toString());
|
newParams.append("yearEnd", filters.yearEnd.toString());
|
||||||
if (filters.tags && filters.tags.length > 0)
|
if (filters.tags && filters.tags.length > 0)
|
||||||
newParams.append("tags", filters.tags.join(","));
|
newParams.append("tags", filters.tags.join(","));
|
||||||
|
if (filters.authorIds && filters.authorIds.length > 0)
|
||||||
|
newParams.append("authorIds", filters.authorIds.join(","));
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocation(`/search?${newParams.toString()}`);
|
setLocation(`/search?${newParams.toString()}`);
|
||||||
@ -193,9 +245,15 @@ export default function Search() {
|
|||||||
|
|
||||||
// Handle display based on current active tab
|
// 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
|
// 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 || [];
|
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 =
|
const isLoading =
|
||||||
(searchLoading && activeTab !== "advanced") ||
|
(searchLoading && activeTab !== "advanced") ||
|
||||||
(filterLoading && activeTab === "advanced");
|
(filterLoading && activeTab === "advanced");
|
||||||
@ -313,7 +371,9 @@ export default function Search() {
|
|||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
|
onSavePreferences={handleSavePreferences}
|
||||||
tags={tags || []}
|
tags={tags || []}
|
||||||
|
authors={authors || []}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import tagRouter from "./routes/tag";
|
|||||||
import collectionRouter from "./routes/collection";
|
import collectionRouter from "./routes/collection";
|
||||||
import blogRouter from "./routes/blog";
|
import blogRouter from "./routes/blog";
|
||||||
import statsRouter from "./routes/stats";
|
import statsRouter from "./routes/stats";
|
||||||
|
import searchRouter from "./routes/search";
|
||||||
|
import filterRouter from "./routes/filter";
|
||||||
import { log, serveStatic, setupVite } from "./vite";
|
import { log, serveStatic, setupVite } from "./vite";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
@ -62,6 +64,8 @@ app.use((req, res, next) => {
|
|||||||
app.use("/api/collections", collectionRouter);
|
app.use("/api/collections", collectionRouter);
|
||||||
app.use("/api/blog", blogRouter);
|
app.use("/api/blog", blogRouter);
|
||||||
app.use("/api/stats", statsRouter);
|
app.use("/api/stats", statsRouter);
|
||||||
|
app.use("/api/search", searchRouter);
|
||||||
|
app.use("/api/filter", filterRouter);
|
||||||
// comments router already mounted earlier at /api/comments
|
// comments router already mounted earlier at /api/comments
|
||||||
|
|
||||||
const server = createServer(app);
|
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 { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user