Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@ set -euo pipefail

PIDS=()

pushd src/backend
uv run uvicorn api.v1.app:app --host 127.0.0.1 --port 8000 "$@" &
cd src
uv run --project backend uvicorn backend.api.v1.app:app --host 127.0.0.1 --port 8000 "$@" &
PIDS+=($!)
echo "Uvicorn server started with PID ${PIDS[0]}"
popd

pushd src/frontend
cd frontend
npm run dev &
PIDS+=($!)
echo "Vite server started with PID ${PIDS[1]}"
popd

cleanup() {
echo "Stopping all services..."
Expand Down
60 changes: 60 additions & 0 deletions src/backend/api/v1/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from typing import Annotated
from fastapi import FastAPI, Query, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from backend.search_engine.models.index import SearchResult
from backend.search_engine.query.query_engine import QueryEngine

app = FastAPI()

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# TODO change once not in json anymore
from backend.search_engine.query.query_engine import inverted_index
from backend.search_engine.index.inverted_index import InvertedIndex

inverted_index_loaded = InvertedIndex.from_json(
"PATH"
)
inverted_index.index = inverted_index_loaded.index
inverted_index.doc_store = inverted_index_loaded.doc_store
inverted_index.all_doc_ids = inverted_index_loaded.all_doc_ids
# ---------------------


@app.get("/search", response_model=list[SearchResult])
async def search(
q: Annotated[
str, Query(min_length=1, max_length=50, description="Search query")
] = ...,
limit: Annotated[
int, Query(ge=1, le=100, description="Maximum number of results")
] = 10,
) -> list[SearchResult]:
if inverted_index is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Search index not loaded",
)

try:
qe = QueryEngine(q)

return qe.search_results(limit)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid query syntax: {str(e)}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Search operation failed",
)
1 change: 1 addition & 0 deletions src/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies = [
"pydantic>=2.12.3",
"requests>=2.32.5",
"tqdm>=4.67.1",
"typer>=0.20.0",
]

[dependency-groups]
Expand Down
60 changes: 60 additions & 0 deletions src/backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 81 additions & 17 deletions src/frontend/src/pages/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SearchResults } from "@/components/SearchResults";
import { LoadingState } from "@/components/LoadingState";
import { ErrorState } from "@/components/ErrorState";
import { useToast } from "@/hooks/use-toast";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, Settings } from "lucide-react";
import seekrLogo from "@/assets/seekr-logo.png";
import { mockResults } from "@/mock/searchResults";

Expand All @@ -23,6 +23,8 @@ const Index = () => {
const [currentQuery, setCurrentQuery] = useState("");
const { toast } = useToast();
const [currentText, setCurrentText] = useState("");
const [limit, setLimit] = useState(10);
const [showSettings, setShowSettings] = useState(false);

const RESULTS_PER_PAGE = 10;

Expand All @@ -40,29 +42,34 @@ const Index = () => {
const params = new URLSearchParams(window.location.search);
const page = parseInt(params.get("page") || "1");
const query = params.get("q") || "";
const urlLimit = parseInt(params.get("limit") || "10");

if (page > 1) setCurrentPage(page);
if (urlLimit) setLimit(urlLimit);
if (query) {
setCurrentQuery(query);
setHasSearched(true);
handleSearch(query);
handleSearch(query, urlLimit);
}
}, []);

// Update URL when page changes
// Update URL when page or limit changes
useEffect(() => {
if (hasSearched && currentQuery) {
const params = new URLSearchParams();
params.set("q", currentQuery);
if (currentPage > 1) {
params.set("page", currentPage.toString());
}
if (limit !== 10) {
params.set("limit", limit.toString());
}
window.history.pushState({}, "", `?${params.toString()}`);

// Scroll to top when page changes
window.scrollTo({ top: 0, behavior: "smooth" });
}
}, [currentPage, hasSearched, currentQuery]);
}, [currentPage, limit, hasSearched, currentQuery]);

// Typing animation effect
useEffect(() => {
Expand Down Expand Up @@ -104,31 +111,30 @@ const Index = () => {
};
}, [hasSearched]);

const handleSearch = async (query: string) => {
const handleSearch = async (query: string, customLimit = limit) => {
setIsLoading(true);
setError(null);
setHasSearched(true);
setCurrentQuery(query);
setCurrentPage(1); // Reset to page 1 on new search
setCurrentPage(1);
setAllResults([]);

// Scroll to top when searching
setLimit(customLimit);

window.scrollTo({ top: 0, behavior: "smooth" });

try {
const response = await fetch(
`https://127.0.0.1:8000/search?q=${encodeURIComponent(query)}`
`http://127.0.0.1:8000/search?q=${encodeURIComponent(query)}&limit=${customLimit}`
);

if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}

const data = await response.json();

const searchResults = Array.isArray(data) ? data : data.results || [];
setAllResults(searchResults);

if (searchResults.length === 0) {
toast({
title: "No results found",
Expand All @@ -138,12 +144,11 @@ const Index = () => {
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "An unknown error occurred while searching";

console.error("Search error:", errorMessage);

setError(errorMessage);
setAllResults(mockResults);

toast({
title: "Search failed — showing mock results",
description: "Backend request failed. Displaying demo data instead.",
Expand All @@ -154,6 +159,14 @@ const Index = () => {
}
};

const handleLimitChange = (newLimit: number) => {
setLimit(newLimit);
if (hasSearched && currentQuery) {
handleSearch(currentQuery, newLimit);
}
setShowSettings(false);
};

const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
setCurrentPage(newPage);
Expand Down Expand Up @@ -197,6 +210,57 @@ const Index = () => {

return (
<div className="min-h-screen bg-background">
{/* Settings Button - Fixed Position */}
{hasSearched && (
<div className="fixed top-4 right-4 z-50">
<button
onClick={() => setShowSettings(!showSettings)}
className="p-2.5 rounded-lg bg-background border border-input hover:bg-accent transition-colors shadow-sm"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</button>

{/* Settings Dropdown */}
{showSettings && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setShowSettings(false)}
/>

{/* Settings Panel */}
<div className="absolute right-0 mt-2 w-64 bg-background border border-input rounded-lg shadow-lg p-4 z-50">
<h3 className="font-semibold text-sm mb-3">Search Settings</h3>

<div className="space-y-2">
<label className="text-sm text-muted-foreground">
Max. total results
</label>
<div className="grid grid-cols-2 gap-2">
{[10, 25, 50, 100].map((l) => (
<button
key={l}
onClick={() => handleLimitChange(l)}
disabled={isLoading}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
limit === l
? "bg-primary text-primary-foreground border-primary"
: "border-input hover:bg-accent"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{l}
</button>
))}
</div>
</div>
</div>
</>
)}
</div>
)}

{/* Centered Landing View */}
{!hasSearched && (
<div className="flex items-center justify-center min-h-screen px-4">
Expand Down
Loading
Loading