Add: Web UI (React 19 + Vite + shadcn/ui) + FastAPI REST layer

Adds a self-contained web interface that wraps the MCP tools so end users
don't need to issue MCP commands. Single Docker container serves:

  /              → React SPA (RTL Hebrew, Heebo font, shadcn/ui components)
  /api/*         → FastAPI REST endpoints used by the SPA
  /api/docs      → OpenAPI / Swagger
  /mcp           → existing FastMCP streamable-http endpoint
  /health        → liveness probe

REST endpoints (in nadlan_mcp/web_app.py):
  GET  /api/search/address?q=...  — autocomplete + best-effort gush/חלקה
                                    via Govmap PARCEL_ALL layer
  POST /api/search/deals          — Govmap deals around an address
  POST /api/search/appraisals     — Decisive-appraiser search (gov.il)
  GET  /api/appraisals/pdf?url=.. — Stream PDFs through this server,
                                    carrying the upstream x-client-id
                                    header (a normal browser fetch fails)

UI flow:
  - Address search → resolves gush/חלקה → shows BOTH deals (Govmap) AND
    appraiser decisions (gov.il) for the same parcel side-by-side in tabs.
  - Block+plot search → goes straight to appraisals.
  - Appraiser-name search → all decisions by that appraiser.

Stack chosen for RTL-first usability and longevity: React 19, Vite 6,
TypeScript, Tailwind, shadcn/ui (Radix primitives), TanStack Query,
Lucide icons. ~295KB gzipped JS, no SSR overhead.

Multi-stage Dockerfile: Node 22 builds web/ in stage 1, Python 3.13
runtime serves the dist + the FastAPI app in stage 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:35:08 +00:00
parent 4bc054f315
commit 5e9963f51b
29 changed files with 5152 additions and 59 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
dist-node
.vite
.tsbuildinfo
.tsbuildinfo-node
*.log
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>נדל"ן ושמאי מכריע — Marcus-Law</title>
<meta name="description" content="חיפוש עסקאות נדל&quot;ן והחלטות שמאי מכריע באזור ספציפי" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Heebo:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3514
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "nadlan-mcp-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --max-warnings=0"
},
"dependencies": {
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+242
View File
@@ -0,0 +1,242 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Building2, Gavel, MapPin } from "lucide-react";
import { api } from "@/api/client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { SearchBar, SearchQuery } from "@/components/SearchBar";
import { DealsTable } from "@/components/DealsTable";
import { AppraisalsTable } from "@/components/AppraisalsTable";
function useAddressInfo(address: string | undefined) {
return useQuery({
queryKey: ["address", address],
queryFn: () => api.searchAddress(address!),
enabled: !!address,
});
}
function useDeals(query: SearchQuery | null) {
return useQuery({
queryKey: ["deals", query],
queryFn: () =>
api.searchDeals({
address: query!.address!,
years_back: 3,
radius_meters: 100,
max_deals: 50,
}),
enabled: !!query && query.mode === "address" && !!query.address,
});
}
function useAppraisals(query: SearchQuery | null, blockOverride?: string, plotOverride?: string) {
// For "address" mode, we feed in block/plot resolved by the address lookup.
const block = query?.block || blockOverride;
const plot = query?.plot || plotOverride;
const appraiser = query?.appraiser;
return useQuery({
queryKey: ["appraisals", { block, plot, appraiser }],
queryFn: () =>
api.searchAppraisals({
block,
plot,
decisive_appraiser: appraiser,
max_results: 30,
}),
enabled: Boolean(block || appraiser),
});
}
export default function App() {
const [query, setQuery] = useState<SearchQuery | null>(null);
const addressInfo = useAddressInfo(
query?.mode === "address" ? query.address : undefined
);
const resolvedBlock = addressInfo.data?.gush_helka?.block ?? undefined;
const resolvedPlot = addressInfo.data?.gush_helka?.plot ?? undefined;
const deals = useDeals(query);
const appraisals = useAppraisals(query, resolvedBlock, resolvedPlot);
const isLoadingTopBar =
addressInfo.isFetching || deals.isFetching || appraisals.isFetching;
// Default tab: appraiser-only search → appraisals; otherwise → deals.
const defaultTab = useMemo(
() => (query?.mode === "appraiser" ? "appraisals" : "deals"),
[query?.mode]
);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white pb-20">
<header className="border-b bg-white">
<div className="container py-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Building2 className="text-primary" />
נדל"ן ושמאי מכריע
</h1>
<p className="text-sm text-muted-foreground mt-1">
חיפוש עסקאות נדל"ן והחלטות שמאי מכריע Marcus-Law
</p>
</div>
<div className="text-xs text-muted-foreground hidden sm:block">
מקורות:{" "}
<span className="font-medium">Govmap</span> ·{" "}
<span className="font-medium">משרד המשפטים</span>
</div>
</div>
</header>
<main className="container mt-8 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">חיפוש</CardTitle>
<CardDescription>
חפש לפי כתובת מלאה (להצגת עסקאות נדל"ן + החלטות שמאי באותו גוש),
לפי גוש וחלקה ספציפיים, או לפי שם של שמאי מכריע.
</CardDescription>
</CardHeader>
<CardContent>
<SearchBar onSearch={setQuery} isLoading={isLoadingTopBar} />
</CardContent>
</Card>
{!query && (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-muted-foreground">
הזן כתובת כדי להתחיל. עסקאות מתקבלות מ-Govmap, החלטות שמאי מ-gov.il.
</CardContent>
</Card>
)}
{query && (
<>
{/* Address resolution summary */}
{query.mode === "address" && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MapPin className="size-4 text-primary" />
{query.address}
</CardTitle>
<CardDescription>
{addressInfo.isFetching && "מאתר את הכתובת..."}
{addressInfo.data?.gush_helka && (
<>
זוהה: גוש{" "}
<span className="font-medium">
{addressInfo.data.gush_helka.block || "?"}
</span>
{addressInfo.data.gush_helka.plot && (
<>
{" "}חלקה{" "}
<span className="font-medium">
{addressInfo.data.gush_helka.plot}
</span>
</>
)}
</>
)}
{addressInfo.data && !addressInfo.data.gush_helka && (
<span className="text-amber-700">
לא נמצאו פרטי גוש/חלקה לכתובת זו — חיפוש שמאי לפי כתובת לא יבוצע.
</span>
)}
</CardDescription>
</CardHeader>
</Card>
)}
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList>
<TabsTrigger value="deals" disabled={query.mode === "appraiser"}>
<Building2 className="ms-1 size-4" />
עסקאות נדל"ן
{deals.data && (
<span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5">
{deals.data.total}
</span>
)}
</TabsTrigger>
<TabsTrigger value="appraisals">
<Gavel className="ms-1 size-4" />
שמאי מכריע
{appraisals.data && (
<span className="ms-2 text-xs bg-muted-foreground/15 rounded-full px-2 py-0.5">
{appraisals.data.returned}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="deals" className="mt-6">
{deals.isFetching && <TableSkeleton />}
{deals.error && (
<ErrorBox error={String(deals.error)} />
)}
{deals.data && !deals.isFetching && (
<DealsTable deals={deals.data.deals} />
)}
{!deals.isFetching && !deals.error && !deals.data && (
<div className="text-center py-12 text-muted-foreground">
חיפוש עסקאות זמין רק לפי כתובת.
</div>
)}
</TabsContent>
<TabsContent value="appraisals" className="mt-6">
{appraisals.isFetching && <TableSkeleton />}
{appraisals.error && (
<ErrorBox error={String(appraisals.error)} />
)}
{appraisals.data && !appraisals.isFetching && (
<AppraisalsTable
decisions={appraisals.data.decisions}
totalInDb={appraisals.data.total_in_db}
/>
)}
{!appraisals.isFetching && !appraisals.error && !appraisals.data && (
<div className="text-center py-12 text-muted-foreground">
{query.mode === "address"
? "ממתין לזיהוי גוש/חלקה..."
: "אין נתונים."}
</div>
)}
</TabsContent>
</Tabs>
</>
)}
</main>
</div>
);
}
function TableSkeleton() {
return (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
function ErrorBox({ error }: { error: string }) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
שגיאה: {error}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import {
AddressSearchResponse,
AppraisalsSearchRequest,
AppraisalsSearchResponse,
DealsSearchRequest,
DealsSearchResponse,
} from "./types";
const API_BASE = "/api";
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(init?.headers || {}),
},
});
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
detail = body?.detail || detail;
} catch {
// ignore
}
throw new ApiError(res.status, detail);
}
return res.json();
}
export const api = {
searchAddress(q: string): Promise<AddressSearchResponse> {
return request(`/search/address?q=${encodeURIComponent(q)}`);
},
searchDeals(req: DealsSearchRequest): Promise<DealsSearchResponse> {
return request(`/search/deals`, {
method: "POST",
body: JSON.stringify(req),
});
},
searchAppraisals(req: AppraisalsSearchRequest): Promise<AppraisalsSearchResponse> {
return request(`/search/appraisals`, {
method: "POST",
body: JSON.stringify(req),
});
},
pdfProxyUrl(url: string): string {
return `${API_BASE}/appraisals/pdf?url=${encodeURIComponent(url)}`;
},
};
export { ApiError };
+100
View File
@@ -0,0 +1,100 @@
// Mirrors the Pydantic schemas in nadlan_mcp/web_app.py.
// Keep them in sync if you edit the backend.
export interface Coordinates {
longitude: number;
latitude: number;
}
export interface AddressResult {
text: string;
type: string;
score: number;
id: string;
coordinates?: Coordinates;
}
export interface AddressSearchResponse {
query: string;
results: AddressResult[];
gush_helka: { block: string | null; plot: string | null } | null;
}
export interface DealsSearchRequest {
address: string;
years_back?: number;
radius_meters?: number;
max_deals?: number;
deal_type?: 1 | 2;
}
export interface Deal {
id: number;
deal_amount?: number;
asset_area?: number;
rooms?: number;
floor?: number;
deal_date?: string;
property_type_description?: string;
settlementNameHeb?: string;
streetNameHeb?: string;
house_num?: string | number;
price_per_sqm?: number;
deal_source?: string | null;
[key: string]: unknown;
}
export interface DealsSearchResponse {
search: {
address: string;
coordinates: Coordinates | null;
years_back: number;
radius_meters: number;
deal_type: number;
};
total: number;
deals: Deal[];
}
export interface AppraisalsSearchRequest {
block?: string;
plot?: string;
decisive_appraiser?: string;
committee?: string;
decision_date_from?: string;
decision_date_to?: string;
publicity_date_from?: string;
publicity_date_to?: string;
search_text?: string;
appraisal_header?: string;
max_results?: number;
}
export interface AppraisalDocument {
file_url: string;
display_name?: string;
extension?: string;
}
export interface AppraisalDecision {
id: number;
appraisal_header?: string;
appraisal_type?: string;
appraisal_version?: string;
decisive_appraiser?: string;
appraiser_type?: string;
block?: string;
plot?: string;
committee?: string;
decision_date?: string;
publicity_date?: string;
pdf_url: string | null;
all_documents: AppraisalDocument[];
}
export interface AppraisalsSearchResponse {
total_in_db: number;
returned: number;
filters: Record<string, unknown>;
decisions: AppraisalDecision[];
}
+96
View File
@@ -0,0 +1,96 @@
import { Download, FileText } from "lucide-react";
import { AppraisalDecision } from "@/api/types";
import { Button } from "@/components/ui/button";
import { api } from "@/api/client";
import { formatDate } from "@/lib/utils";
interface Props {
decisions: AppraisalDecision[];
totalInDb: number;
}
export function AppraisalsTable({ decisions, totalInDb }: Props) {
if (!decisions.length) {
return (
<div className="text-center py-12 text-muted-foreground">
לא נמצאו החלטות שמאי מכריע באזור הזה
</div>
);
}
return (
<div className="space-y-3">
<div className="text-sm text-muted-foreground">
נמצאו {decisions.length} מתוך {totalInDb.toLocaleString("he-IL")} החלטות במאגר
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 text-start font-medium">תאריך הכרעה</th>
<th className="p-3 text-start font-medium">שמאי מכריע</th>
<th className="p-3 text-start font-medium">ועדה</th>
<th className="p-3 text-start font-medium">גוש/חלקה</th>
<th className="p-3 text-start font-medium">סוג שומה</th>
<th className="p-3 text-start font-medium">כותרת ההחלטה</th>
<th className="p-3 text-end font-medium">PDF</th>
</tr>
</thead>
<tbody>
{decisions.map((d) => (
<tr key={d.id} className="border-t hover:bg-accent/40 align-top">
<td className="p-3 whitespace-nowrap">{formatDate(d.decision_date)}</td>
<td className="p-3 whitespace-nowrap">{d.decisive_appraiser || "—"}</td>
<td className="p-3 whitespace-nowrap">{d.committee || "—"}</td>
<td className="p-3 whitespace-nowrap tabular-nums">
{d.block ?? "—"}
{d.plot ? ` / ${d.plot}` : ""}
</td>
<td className="p-3">{d.appraisal_type || "—"}</td>
<td className="p-3 max-w-md">
<span className="line-clamp-2">{d.appraisal_header || "—"}</span>
</td>
<td className="p-3 text-end whitespace-nowrap">
{d.pdf_url ? (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="outline"
asChild
title="פתח PDF בלשונית חדשה"
>
<a
href={api.pdfProxyUrl(d.pdf_url)}
target="_blank"
rel="noopener noreferrer"
>
<FileText />
פתח
</a>
</Button>
<Button
size="sm"
variant="ghost"
asChild
title="הורד PDF"
>
<a
href={api.pdfProxyUrl(d.pdf_url) + "&download=1"}
download={`${d.block || "appraisal"}-${d.plot || "0"}-${d.id}.pdf`}
>
<Download />
</a>
</Button>
</div>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { Deal } from "@/api/types";
import { formatCurrencyILS, formatDate, formatNumber } from "@/lib/utils";
interface Props {
deals: Deal[];
}
const SOURCE_LABELS: Record<string, string> = {
same_building: "אותו בניין",
street: "באותו רחוב",
neighborhood: "בשכונה",
};
export function DealsTable({ deals }: Props) {
if (!deals.length) {
return (
<div className="text-center py-12 text-muted-foreground">
לא נמצאו עסקאות באזור הזה
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="p-3 text-start font-medium">תאריך</th>
<th className="p-3 text-start font-medium">כתובת</th>
<th className="p-3 text-start font-medium">סוג</th>
<th className="p-3 text-end font-medium">חדרים</th>
<th className="p-3 text-end font-medium">קומה</th>
<th className="p-3 text-end font-medium">שטח (מ"ר)</th>
<th className="p-3 text-end font-medium">מחיר</th>
<th className="p-3 text-end font-medium">מחיר/מ"ר</th>
<th className="p-3 text-start font-medium">קרבה</th>
</tr>
</thead>
<tbody>
{deals.map((d) => {
const street = (d.streetNameHeb as string) || "";
const houseNum = (d.house_num as string | number | undefined) ?? "";
const settlement = (d.settlementNameHeb as string) || "";
const address = [street, houseNum, settlement].filter(Boolean).join(" ");
return (
<tr key={d.id} className="border-t hover:bg-accent/40">
<td className="p-3 whitespace-nowrap">{formatDate(d.deal_date)}</td>
<td className="p-3">{address || "—"}</td>
<td className="p-3">{d.property_type_description || "—"}</td>
<td className="p-3 text-end tabular-nums">{d.rooms ?? "—"}</td>
<td className="p-3 text-end tabular-nums">{d.floor ?? "—"}</td>
<td className="p-3 text-end tabular-nums">{formatNumber(d.asset_area)}</td>
<td className="p-3 text-end tabular-nums">
{formatCurrencyILS(d.deal_amount)}
</td>
<td className="p-3 text-end tabular-nums">
{formatCurrencyILS(d.price_per_sqm)}
</td>
<td className="p-3">
{d.deal_source ? (
<span
className={
"inline-block rounded-full px-2 py-0.5 text-xs " +
(d.deal_source === "same_building"
? "bg-emerald-100 text-emerald-800"
: d.deal_source === "street"
? "bg-blue-100 text-blue-800"
: "bg-slate-100 text-slate-700")
}
>
{SOURCE_LABELS[d.deal_source] || d.deal_source}
</span>
) : (
"—"
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type SearchMode = "address" | "block_plot" | "appraiser";
export interface SearchQuery {
mode: SearchMode;
address?: string;
block?: string;
plot?: string;
appraiser?: string;
}
interface Props {
onSearch: (q: SearchQuery) => void;
isLoading?: boolean;
}
export function SearchBar({ onSearch, isLoading }: Props) {
const [mode, setMode] = useState<SearchMode>("address");
const [address, setAddress] = useState("");
const [block, setBlock] = useState("");
const [plot, setPlot] = useState("");
const [appraiser, setAppraiser] = useState("");
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (mode === "address" && address.trim()) {
onSearch({ mode, address: address.trim() });
} else if (mode === "block_plot" && block.trim()) {
onSearch({ mode, block: block.trim(), plot: plot.trim() || undefined });
} else if (mode === "appraiser" && appraiser.trim()) {
onSearch({ mode, appraiser: appraiser.trim() });
}
};
return (
<form onSubmit={submit} className="space-y-4">
<div className="flex flex-wrap gap-2 text-sm">
{(
[
["address", "כתובת"],
["block_plot", "גוש וחלקה"],
["appraiser", "שמאי מכריע"],
] as const
).map(([m, label]) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={
"rounded-full border px-4 py-1.5 transition-colors " +
(mode === m
? "bg-primary text-primary-foreground border-primary"
: "bg-background text-muted-foreground hover:bg-accent")
}
>
{label}
</button>
))}
</div>
{mode === "address" && (
<div className="space-y-2">
<Label htmlFor="address">כתובת מלאה</Label>
<Input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="לדוגמה: סוקולוב 38 חולון"
autoFocus
/>
</div>
)}
{mode === "block_plot" && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="block">גוש (חובה)</Label>
<Input
id="block"
value={block}
onChange={(e) => setBlock(e.target.value)}
placeholder="6212"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="plot">חלקה (אופציונלי)</Label>
<Input
id="plot"
value={plot}
onChange={(e) => setPlot(e.target.value)}
placeholder="894"
/>
</div>
</div>
)}
{mode === "appraiser" && (
<div className="space-y-2">
<Label htmlFor="appraiser">שם השמאי המכריע</Label>
<Input
id="appraiser"
value={appraiser}
onChange={(e) => setAppraiser(e.target.value)}
placeholder="לדוגמה: כהן אליהו"
autoFocus
/>
</div>
)}
<Button type="submit" disabled={isLoading} className="w-full">
<Search className="ms-1" />
{isLoading ? "מחפש..." : "חפש"}
</Button>
</form>
);
}
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground 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",
},
size: {
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",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 215 70% 35%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 215 70% 35%;
--radius: 0.5rem;
}
* {
@apply border-border;
}
html,
body {
@apply bg-background text-foreground antialiased;
font-family: "Heebo", system-ui, -apple-system, sans-serif;
}
/* Tailwind's default scrollbar gutters cause RTL drift; force stable. */
html {
scrollbar-gutter: stable;
}
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+54
View File
@@ -0,0 +1,54 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
container: {
center: true,
padding: "1rem",
screens: { "2xl": "1400px" },
},
extend: {
fontFamily: {
sans: ["Heebo", "system-ui", "-apple-system", "sans-serif"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
};
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "./.tsbuildinfo"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"emitDeclarationOnly": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"outDir": "./dist-node",
"tsBuildInfoFile": "./.tsbuildinfo-node"
},
"include": ["vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// During dev (`pnpm dev`), Vite serves on :5173 and proxies /api + /mcp + /health
// to the FastAPI server on :8765 (or PORT env var).
const BACKEND_PORT = process.env.BACKEND_PORT || "8765";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": `http://127.0.0.1:${BACKEND_PORT}`,
"/mcp": `http://127.0.0.1:${BACKEND_PORT}`,
"/health": `http://127.0.0.1:${BACKEND_PORT}`,
},
},
build: {
outDir: "dist",
sourcemap: false,
},
});