diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz deleted file mode 100644 index 43c3b99..0000000 Binary files a/.yarn/install-state.gz and /dev/null differ diff --git a/client/src/components/explore/FilterSidebar.tsx b/client/src/components/explore/FilterSidebar.tsx index 5dd4a31..d9750d5 100644 --- a/client/src/components/explore/FilterSidebar.tsx +++ b/client/src/components/explore/FilterSidebar.tsx @@ -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) => 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"]} > + + Author + +
+ {authors.map((author) => ( +
+ { + const currentAuthors = filters.authorIds || []; + if (checked) { + onFilterChange({ + authorIds: [...currentAuthors, author.id], + }); + } else { + onFilterChange({ + authorIds: currentAuthors.filter( + (id) => id !== author.id, + ), + }); + } + }} + /> + +
+ ))} +
+
+
+ Language @@ -260,55 +298,31 @@ export function FilterSidebar({
- { + const val = e.target.value; + onFilterChange({ + yearStart: val ? parseInt(val, 10) : undefined, + }); }} - > - - - - - Any - {Array.from( - { length: 10 }, - (_, i) => 1500 + i * 50, - ).map((year) => ( - - {year} - - ))} - - + /> - - { + const val = e.target.value; + onFilterChange({ + yearEnd: val ? parseInt(val, 10) : undefined, + }); }} - > - - - - - Any - {Array.from( - { length: 11 }, - (_, i) => 1550 + i * 50, - ).map((year) => ( - - {year} - - ))} - - + />
@@ -419,14 +433,27 @@ export function FilterSidebar({ )} - +
+ + {onSavePreferences && ( + + )} +
)} diff --git a/client/src/pages/Explore.tsx b/client/src/pages/Explore.tsx index 5cb4023..f6c59bd 100644 --- a/client/src/pages/Explore.tsx +++ b/client/src/pages/Explore.tsx @@ -179,6 +179,7 @@ export default function Explore() { filters={filters} onFilterChange={handleFilterChange} tags={tags || []} + authors={[]} /> {/* Results */} diff --git a/client/src/pages/Search.tsx b/client/src/pages/Search.tsx index ee8657e..8b36459 100644 --- a/client/src/pages/Search.tsx +++ b/client/src/pages/Search.tsx @@ -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({ + 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() { )} diff --git a/server/index.ts b/server/index.ts index cd72f53..016bdb9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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); diff --git a/server/routes/filter.ts b/server/routes/filter.ts new file mode 100644 index 0000000..e8e8a94 --- /dev/null +++ b/server/routes/filter.ts @@ -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 { + 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; diff --git a/server/routes/search.ts b/server/routes/search.ts new file mode 100644 index 0000000..b3b009c --- /dev/null +++ b/server/routes/search.ts @@ -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( + WorksDocument, + { + search: q, + limit: 10 // Limit results for general search + } + ); + + // Fetch authors matching the query + const { authors } = await client.request( + AuthorsDocument, + { + search: q, + limit: 10 + } + ); + + res.json({ works, authors }); + } catch (error) { + respondWithError(res, error, "Failed to perform search"); + } +}); + +export default router; diff --git a/vite.config.ts b/vite.config.ts index a1260b8..3d79c6a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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({