Skip to content
Open
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
24 changes: 24 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
571 changes: 571 additions & 0 deletions frontend/bun.lock

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QMD</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
32 changes: 32 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"typescript": "~5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"clsx": "^2.1.1",
"lucide-react": "^0.564.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
}
}
21 changes: 21 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Routes, Route } from "react-router-dom";
import { Layout } from "./components/Layout";
import { SearchPage } from "./pages/SearchPage";
import { DocumentPage } from "./pages/DocumentPage";
import { BrowsePage } from "./pages/BrowsePage";
import { StatusPage } from "./pages/StatusPage";

export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<SearchPage />} />
<Route path="/doc/*" element={<DocumentPage />} />
<Route path="/browse" element={<BrowsePage />} />
<Route path="/browse/:collection" element={<BrowsePage />} />
<Route path="/browse/:collection/*" element={<BrowsePage />} />
<Route path="/status" element={<StatusPage />} />
</Route>
</Routes>
);
}
40 changes: 40 additions & 0 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NavLink, Outlet } from "react-router-dom";
import { Search, FolderOpen, Activity } from "lucide-react";
import { cn } from "@/lib/utils";

const navItems = [
{ to: "/", icon: Search, label: "Search" },
{ to: "/browse", icon: FolderOpen, label: "Browse" },
{ to: "/status", icon: Activity, label: "Status" },
];

export function Layout() {
return (
<div className="flex h-screen">
<nav className="w-48 border-r border-border bg-card flex flex-col p-3 gap-1">
<div className="text-lg font-bold px-3 py-2 mb-2 text-primary">QMD</div>
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}
57 changes: 57 additions & 0 deletions frontend/src/components/ResultCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Link } from "react-router-dom";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { SearchResult, HybridQueryResult } from "@/lib/api";

interface ResultCardProps {
result: SearchResult | HybridQueryResult;
}

function isSearchResult(r: SearchResult | HybridQueryResult): r is SearchResult {
return "source" in r;
}

export function ResultCard({ result }: ResultCardProps) {
const displayPath = result.displayPath;
const docid = result.docid;
const score = result.score;
const title = result.title;
const snippet = isSearchResult(result) ? result.body : result.bestChunk;

return (
<Link to={`/doc/${encodeURIComponent(displayPath)}`}>
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="font-medium text-sm truncate">{title}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground truncate">
{displayPath}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">
#{docid}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{isSearchResult(result) && (
<Badge variant="secondary" className="text-[10px] uppercase">
{result.source}
</Badge>
)}
<Badge className="font-mono">
{(score * 100).toFixed(0)}%
</Badge>
</div>
</div>
{snippet && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-3 whitespace-pre-wrap">
{snippet.slice(0, 300)}
</p>
)}
</CardContent>
</Card>
</Link>
);
}
61 changes: 61 additions & 0 deletions frontend/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState, type FormEvent } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

export type SearchTier = "keyword" | "semantic" | "deep";

interface SearchBarProps {
onSearch: (query: string, tier: SearchTier) => void;
loading?: boolean;
initialQuery?: string;
initialTier?: SearchTier;
}

export function SearchBar({
onSearch,
loading,
initialQuery = "",
initialTier = "keyword",
}: SearchBarProps) {
const [query, setQuery] = useState(initialQuery);
const [tier, setTier] = useState<SearchTier>(initialTier);

const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim()) onSearch(query.trim(), tier);
};

return (
<div className="space-y-3">
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
size={16}
/>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documents..."
className="pl-10"
/>
</div>
<Button type="submit" disabled={loading || !query.trim()}>
{loading ? "Searching..." : "Search"}
</Button>
</form>
<Tabs
value={tier}
onValueChange={(v) => setTier(v as SearchTier)}
>
<TabsList>
<TabsTrigger value="keyword">Keyword</TabsTrigger>
<TabsTrigger value="semantic">Semantic</TabsTrigger>
<TabsTrigger value="deep">Deep</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
}
26 changes: 26 additions & 0 deletions frontend/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type HTMLAttributes } from "react";
import { cn } from "@/lib/utils";

export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
variant?: "default" | "secondary" | "destructive" | "outline";
}

const variants = {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
destructive: "bg-destructive text-white",
outline: "border border-border text-foreground",
};

export function Badge({ className, variant = "default", ...props }: BadgeProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
variants[variant],
className
)}
{...props}
/>
);
}
39 changes: 39 additions & 0 deletions frontend/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
size?: "default" | "sm" | "lg" | "icon";
}

const variants = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
};

const sizes = {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
};

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className
)}
{...props}
/>
)
);
Button.displayName = "Button";
48 changes: 48 additions & 0 deletions frontend/src/components/ui/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { type HTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";

export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border border-border bg-card text-card-foreground", className)}
{...props}
/>
)
);
Card.displayName = "Card";

export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";

export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";

export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";

export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";

export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
Loading