Flatten repo structure: move crawler/ to root, remove vqa/ and immoweb/
The crawler subdirectory was the only active project. Moving it to the repo root simplifies paths and removes the unnecessary nesting. The vqa/ and immoweb/ directories were legacy/unused and have been removed. Updated .drone.yml, .gitignore, .claude/ docs, and skills to reflect the new flat structure.
This commit is contained in:
parent
e2247be700
commit
eafbc1ac52
221 changed files with 70 additions and 146140 deletions
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
.git
|
||||
.env.local
|
||||
*.md
|
||||
7
frontend/.env.sample
Normal file
7
frontend/.env.sample
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# IMPORTANT: This is the URL you will use to access your app via https.
|
||||
# Make sure it resolves to your dev server's IP address or hostname.
|
||||
# Also it must be an authorized URL in your OIDC provider (reach out to viktor to configure that).
|
||||
export DEV_HOST="<CHANGE ME>"
|
||||
|
||||
export FRONTEND_SERVICE="$DEV_HOST:5173" # react app server
|
||||
export BACKEND_SERVICE="$DEV_HOST:5001" # where the backend api is running
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
|
@ -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?
|
||||
11
frontend/Caddyfile.dev
Normal file
11
frontend/Caddyfile.dev
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{$DEV_HOST:localhost}:443 {
|
||||
tls internal
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy app:5001
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:5173
|
||||
}
|
||||
}
|
||||
29
frontend/Dockerfile
Normal file
29
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Stage 1: Build the React app
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies (prefers yarn if available)
|
||||
RUN npm ci
|
||||
|
||||
# Copy all files and build
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx static files
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only necessary files from the builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wrongmove</title>
|
||||
<script src="/HexgridHeatmap.js"> </script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Root directory for static files (must match Docker COPY path)
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Cache static assets
|
||||
# location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||
# expires 1y;
|
||||
# add_header Cache-Control "public, immutable";
|
||||
# }
|
||||
}
|
||||
6291
frontend/package-lock.json
generated
Normal file
6291
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
70
frontend/package.json
Normal file
70
frontend/package.json
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"name": "wrongmove",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@types/crossfilter": "^0.0.38",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/mapbox-gl": "^3.4.1",
|
||||
"@types/turf": "^3.5.32",
|
||||
"chrono-node": "^2.8.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"crossfilter2": "^1.5.4",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mapbox-gl": "^3.12.0",
|
||||
"oidc-client-ts": "^3.2.1",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
2491
frontend/public/HexgridHeatmap.js
Normal file
2491
frontend/public/HexgridHeatmap.js
Normal file
File diff suppressed because it is too large
Load diff
4
frontend/public/robots.txt
Normal file
4
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Block ALL bots from ALL pages
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
14
frontend/src/App.css
Normal file
14
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
317
frontend/src/App.tsx
Normal file
317
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import './App.css';
|
||||
import { getUser } from './auth/authService';
|
||||
import { getStoredPasskeyUser } from './auth/passkeyService';
|
||||
import { fromOidcUser, type AuthUser } from './auth/types';
|
||||
import AlertError from './components/AlertError';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import AuthCallback from './components/AuthCallback';
|
||||
import { Map } from './components/Map';
|
||||
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel';
|
||||
import { Header } from './components/Header';
|
||||
import { StatsBar, type ViewMode } from './components/StatsBar';
|
||||
import { ListView } from './components/ListView';
|
||||
import { StreamingProgressBar } from './components/StreamingProgressBar';
|
||||
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Filter } from 'lucide-react';
|
||||
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
|
||||
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type StreamingProgress } from '@/services';
|
||||
|
||||
function App() {
|
||||
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
||||
const [taskID, setTaskID] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
|
||||
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
|
||||
|
||||
// Ref to track accumulated features during streaming
|
||||
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
||||
// Ref to track if initial load has been triggered
|
||||
const initialLoadTriggeredRef = useRef(false);
|
||||
|
||||
// Check if this is the callback route - render dedicated component
|
||||
if (window.location.pathname === '/callback') {
|
||||
return <AuthCallback />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Check passkey user first, then fall back to OIDC
|
||||
const passkeyUser = getStoredPasskeyUser();
|
||||
if (passkeyUser) {
|
||||
setUser(passkeyUser);
|
||||
} else {
|
||||
getUser().then((oidcUser) => {
|
||||
if (oidcUser) {
|
||||
setUser(fromOidcUser(oidcUser));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePasskeyLogin = (passkeyUser: AuthUser) => {
|
||||
setUser(passkeyUser);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
fetchTasksForUser(user).then((tasks) => {
|
||||
if (tasks && tasks.length > 0) {
|
||||
setTaskID(tasks[0]);
|
||||
}
|
||||
});
|
||||
}, [user, taskID]);
|
||||
|
||||
// Load listings function - used by both auto-load and manual submit
|
||||
const loadListings = useCallback(async (parameters: ParameterValues) => {
|
||||
if (!user) return;
|
||||
|
||||
setQueryParameters(parameters);
|
||||
setMobileFilterOpen(false);
|
||||
setIsLoading(true);
|
||||
accumulatedFeaturesRef.current = [];
|
||||
setStreamingProgress({ count: 0 });
|
||||
setListingData(null);
|
||||
|
||||
let updateScheduled = false;
|
||||
|
||||
const flushUpdate = () => {
|
||||
updateScheduled = false;
|
||||
setListingData({
|
||||
type: 'FeatureCollection',
|
||||
features: [...accumulatedFeaturesRef.current]
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
if (!updateScheduled) {
|
||||
updateScheduled = true;
|
||||
requestAnimationFrame(flushUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const batch of streamListingGeoJSON(user, parameters, (progress) => {
|
||||
setStreamingProgress(progress);
|
||||
})) {
|
||||
accumulatedFeaturesRef.current.push(...batch);
|
||||
scheduleUpdate();
|
||||
}
|
||||
// Final flush to ensure all data is rendered
|
||||
flushUpdate();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setSubmitError(error.message);
|
||||
} else {
|
||||
setSubmitError(String(error));
|
||||
}
|
||||
setAlertDialogIsOpen(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setStreamingProgress(null);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Auto-load data with default filters when user is authenticated
|
||||
useEffect(() => {
|
||||
if (!user || initialLoadTriggeredRef.current) {
|
||||
return;
|
||||
}
|
||||
initialLoadTriggeredRef.current = true;
|
||||
|
||||
const defaultParams: ParameterValues = {
|
||||
...DEFAULT_FILTER_VALUES,
|
||||
available_from: new Date(),
|
||||
};
|
||||
|
||||
loadListings(defaultParams);
|
||||
}, [user, loadListings]);
|
||||
|
||||
if (!user) {
|
||||
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
||||
}
|
||||
|
||||
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
|
||||
if (action === 'visualize') {
|
||||
loadListings(parameters);
|
||||
} else if (action === 'fetch-data') {
|
||||
setQueryParameters(parameters);
|
||||
setMobileFilterOpen(false);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await refreshListings(user!, parameters);
|
||||
setTaskID(data.task_id);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setSubmitError(error.message);
|
||||
} else {
|
||||
setSubmitError(String(error));
|
||||
}
|
||||
setAlertDialogIsOpen(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetricChange = (metric: Metric) => {
|
||||
setQueryParameters(prev => prev ? { ...prev, metric } : null);
|
||||
};
|
||||
|
||||
const handlePropertyClick = (property: PropertyProperties, _coordinates: [number, number]) => {
|
||||
setHighlightedProperty(property.url);
|
||||
// Optionally: pan map to coordinates
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (!listingData) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center p-8 max-w-md">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="text-6xl mb-4 animate-pulse">🏠</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Loading Properties...</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Fetching listings with default filters. You can adjust filters on the left.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-6xl mb-4">🏠</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Welcome to Property Explorer</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Use the filters on the left to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (listingData.features.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No listings found</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Try adjusting the filters or run a data refresh to fetch new listings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Map View */}
|
||||
{(viewMode === 'map' || viewMode === 'split') && (
|
||||
<div className={`relative ${viewMode === 'split' ? 'w-1/2' : 'flex-1'}`} style={{ minHeight: 0 }}>
|
||||
<Map
|
||||
listingData={listingData}
|
||||
queryParameters={queryParameters}
|
||||
onPropertyClick={handlePropertyClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List View */}
|
||||
{(viewMode === 'list' || viewMode === 'split') && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2 border-l' : 'flex-1'}`}>
|
||||
<ListView
|
||||
listingData={listingData}
|
||||
onPropertyClick={handlePropertyClick}
|
||||
highlightedPropertyUrl={highlightedProperty}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTaskCancelled = () => {
|
||||
setTaskID(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header
|
||||
user={user}
|
||||
taskID={taskID}
|
||||
onTaskCancelled={handleTaskCancelled}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
{/* Filter Panel - Desktop (fixed sidebar) */}
|
||||
<div className="hidden md:block w-64 shrink-0 h-full overflow-hidden">
|
||||
<FilterPanel
|
||||
onSubmit={onSubmit}
|
||||
onMetricChange={handleMetricChange}
|
||||
isLoading={isLoading}
|
||||
listingCount={listingData?.features.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel - Mobile (sheet) */}
|
||||
<div className="md:hidden fixed bottom-4 right-4 z-50">
|
||||
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="lg" className="rounded-full shadow-lg h-14 w-14">
|
||||
<Filter className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-80 p-0">
|
||||
<FilterPanel
|
||||
onSubmit={onSubmit}
|
||||
onMetricChange={handleMetricChange}
|
||||
isLoading={isLoading}
|
||||
listingCount={listingData?.features.length}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Main View Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{/* Streaming Progress Bar */}
|
||||
<div className="relative shrink-0">
|
||||
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* Map/List Container */}
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{listingData && listingData.features.length > 0 && (
|
||||
<div className="shrink-0">
|
||||
<StatsBar
|
||||
listingData={listingData}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Dialog */}
|
||||
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
89
frontend/src/AppSidebar.tsx
Normal file
89
frontend/src/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import * as React from "react"
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Property Explorer",
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Map View",
|
||||
url: "#",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: "List View",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Data Management",
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Refresh Listings",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Active Tasks",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Preferences",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{data.navMain.map((item) => (
|
||||
<SidebarGroup key={item.title}>
|
||||
<SidebarGroupLabel>{item.title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuItem key={subItem.title}>
|
||||
<SidebarMenuButton asChild isActive={subItem.isActive}>
|
||||
<a href={subItem.url}>{subItem.title}</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
80
frontend/src/assets/Map.css
Normal file
80
frontend/src/assets/Map.css
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#map-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#legend {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
line-height: 18px;
|
||||
height: auto;
|
||||
min-height: 300px;
|
||||
width: 90px;
|
||||
padding: 12px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#legend .axis path,
|
||||
#legend .axis line {
|
||||
stroke: #e5e7eb;
|
||||
}
|
||||
|
||||
#legend .axis text {
|
||||
fill: #6b7280;
|
||||
}
|
||||
|
||||
.propertyListingPopupItem {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
padding: 6px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
/* Mapbox popup styling improvements */
|
||||
.mapboxgl-popup-content {
|
||||
padding: 0 !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.mapboxgl-popup-close-button {
|
||||
font-size: 20px;
|
||||
padding: 4px 8px;
|
||||
color: #6b7280;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mapboxgl-popup-close-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Improve marker visibility */
|
||||
.mapboxgl-marker {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
#legend {
|
||||
width: 75px;
|
||||
padding: 8px;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.mapboxgl-popup-content {
|
||||
max-width: 90vw !important;
|
||||
}
|
||||
}
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
45
frontend/src/auth/authService.ts
Normal file
45
frontend/src/auth/authService.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { User, UserManager } from 'oidc-client-ts';
|
||||
import { oidcConfig } from './config';
|
||||
import { parseOidcError, type AuthError } from './errors';
|
||||
|
||||
const userManager = new UserManager(oidcConfig);
|
||||
|
||||
export const login = async (): Promise<void> => {
|
||||
try {
|
||||
await userManager.signinRedirect();
|
||||
} catch (error) {
|
||||
console.error('Login redirect failed:', error);
|
||||
throw parseOidcError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await userManager.signoutRedirect();
|
||||
} catch (error) {
|
||||
console.error('Logout redirect failed:', error);
|
||||
throw parseOidcError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCallback = async (): Promise<User> => {
|
||||
try {
|
||||
const user = await userManager.signinRedirectCallback();
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Callback handling failed:', error);
|
||||
throw parseOidcError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getUser = async (): Promise<User | null> => {
|
||||
try {
|
||||
const user = await userManager.getUser();
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export type { AuthError };
|
||||
14
frontend/src/auth/config.ts
Normal file
14
frontend/src/auth/config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { WebStorageStateStore } from "oidc-client-ts";
|
||||
|
||||
export const oidcConfig = {
|
||||
authority: "https://authentik.viktorbarzin.me/application/o/wrongmove/",
|
||||
client_id: "5AJKRgcdgVm1OyApBzFkadDFfStW9a555zwv2MOe",
|
||||
redirect_uri: import.meta.env.MODE === 'development' ? "https://localhost/callback" : "https://wrongmove.viktorbarzin.me/callback",
|
||||
post_logout_redirect_uri: import.meta.env.MODE === 'development' ? "https://localhost/" : "https://wrongmove.viktorbarzin.me/",
|
||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||
response_type: 'code', // PKCE flow (recommended for SPAs)
|
||||
scope: 'openid profile email', // Requested scopes
|
||||
automaticSilentRenew: true, // Renew tokens silently
|
||||
filterProtocolClaims: true,
|
||||
loadUserInfo: true,
|
||||
};
|
||||
60
frontend/src/auth/errors.ts
Normal file
60
frontend/src/auth/errors.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
export enum AuthErrorType {
|
||||
REDIRECT_FAILED = 'REDIRECT_FAILED',
|
||||
CALLBACK_FAILED = 'CALLBACK_FAILED',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
USER_CANCELLED = 'USER_CANCELLED',
|
||||
}
|
||||
|
||||
export interface AuthError {
|
||||
type: AuthErrorType;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export function parseOidcError(error: unknown): AuthError {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorString = errorMessage.toLowerCase();
|
||||
|
||||
// Check for popup/redirect blocked errors
|
||||
if (errorString.includes('popup') || errorString.includes('blocked') || errorString.includes('window')) {
|
||||
return {
|
||||
type: AuthErrorType.REDIRECT_FAILED,
|
||||
message: 'Unable to redirect. Please check if popups are blocked.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for user cancellation
|
||||
if (errorString.includes('cancel') || errorString.includes('closed') || errorString.includes('denied')) {
|
||||
return {
|
||||
type: AuthErrorType.USER_CANCELLED,
|
||||
message: 'Sign in was cancelled.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for network errors
|
||||
if (errorString.includes('network') || errorString.includes('fetch') || errorString.includes('timeout') || errorString.includes('failed to fetch')) {
|
||||
return {
|
||||
type: AuthErrorType.NETWORK_ERROR,
|
||||
message: 'Unable to reach authentication server. Please check your connection.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for callback/state errors
|
||||
if (errorString.includes('state') || errorString.includes('invalid') || errorString.includes('mismatch') || errorString.includes('no matching state')) {
|
||||
return {
|
||||
type: AuthErrorType.CALLBACK_FAILED,
|
||||
message: 'Login verification failed. Please try again.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Default error
|
||||
return {
|
||||
type: AuthErrorType.CALLBACK_FAILED,
|
||||
message: errorMessage || 'An unexpected error occurred during sign in.',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
155
frontend/src/auth/passkeyService.ts
Normal file
155
frontend/src/auth/passkeyService.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||
import type { AuthUser } from './types';
|
||||
|
||||
const PASSKEY_USER_KEY = 'passkey_user';
|
||||
|
||||
interface RegisterBeginResponse {
|
||||
options: PublicKeyCredentialCreationOptionsJSON;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
interface LoginBeginResponse {
|
||||
options: PublicKeyCredentialRequestOptionsJSON;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
interface AuthTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// WebAuthn JSON types from the spec
|
||||
type PublicKeyCredentialCreationOptionsJSON = Parameters<typeof startRegistration>[0];
|
||||
type PublicKeyCredentialRequestOptionsJSON = Parameters<typeof startAuthentication>[0];
|
||||
|
||||
function parseJwt(token: string): Record<string, unknown> {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
export async function registerPasskey(email: string): Promise<AuthUser> {
|
||||
// Step 1: Begin registration
|
||||
const beginRes = await fetch('/api/passkey/register/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!beginRes.ok) {
|
||||
const err = await beginRes.json();
|
||||
throw new Error(err.detail || 'Failed to start registration');
|
||||
}
|
||||
|
||||
const beginData: RegisterBeginResponse = await beginRes.json();
|
||||
|
||||
// Step 2: Browser WebAuthn ceremony
|
||||
const attResp = await startRegistration(beginData.options);
|
||||
|
||||
// Step 3: Complete registration
|
||||
const completeRes = await fetch('/api/passkey/register/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: beginData.session_id,
|
||||
credential: attResp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const err = await completeRes.json();
|
||||
throw new Error(err.detail || 'Failed to complete registration');
|
||||
}
|
||||
|
||||
const { token }: AuthTokenResponse = await completeRes.json();
|
||||
const claims = parseJwt(token);
|
||||
|
||||
const user: AuthUser = {
|
||||
sub: claims.sub as string,
|
||||
email: claims.email as string,
|
||||
name: (claims.name as string) || (claims.email as string),
|
||||
accessToken: token,
|
||||
provider: 'passkey',
|
||||
};
|
||||
|
||||
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(): Promise<AuthUser> {
|
||||
// Step 1: Begin authentication
|
||||
const beginRes = await fetch('/api/passkey/login/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!beginRes.ok) {
|
||||
const err = await beginRes.json();
|
||||
throw new Error(err.detail || 'Failed to start login');
|
||||
}
|
||||
|
||||
const beginData: LoginBeginResponse = await beginRes.json();
|
||||
|
||||
// Step 2: Browser WebAuthn ceremony
|
||||
const assertionResp = await startAuthentication(beginData.options);
|
||||
|
||||
// Step 3: Complete authentication
|
||||
const completeRes = await fetch('/api/passkey/login/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: beginData.session_id,
|
||||
credential: assertionResp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const err = await completeRes.json();
|
||||
throw new Error(err.detail || 'Failed to complete login');
|
||||
}
|
||||
|
||||
const { token }: AuthTokenResponse = await completeRes.json();
|
||||
const claims = parseJwt(token);
|
||||
|
||||
const user: AuthUser = {
|
||||
sub: claims.sub as string,
|
||||
email: claims.email as string,
|
||||
name: (claims.name as string) || (claims.email as string),
|
||||
accessToken: token,
|
||||
provider: 'passkey',
|
||||
};
|
||||
|
||||
localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify(user));
|
||||
return user;
|
||||
}
|
||||
|
||||
export function getStoredPasskeyUser(): AuthUser | null {
|
||||
const stored = localStorage.getItem(PASSKEY_USER_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
const user: AuthUser = JSON.parse(stored);
|
||||
|
||||
// Check JWT expiration
|
||||
const claims = parseJwt(user.accessToken);
|
||||
const exp = claims.exp as number | undefined;
|
||||
if (exp && exp * 1000 < Date.now()) {
|
||||
localStorage.removeItem(PASSKEY_USER_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch {
|
||||
localStorage.removeItem(PASSKEY_USER_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPasskeyUser(): void {
|
||||
localStorage.removeItem(PASSKEY_USER_KEY);
|
||||
}
|
||||
19
frontend/src/auth/types.ts
Normal file
19
frontend/src/auth/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { User } from 'oidc-client-ts';
|
||||
|
||||
export interface AuthUser {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
accessToken: string;
|
||||
provider: 'oidc' | 'passkey';
|
||||
}
|
||||
|
||||
export function fromOidcUser(user: User): AuthUser {
|
||||
return {
|
||||
sub: user.profile.sub,
|
||||
email: user.profile.email ?? '',
|
||||
name: user.profile.name ?? user.profile.email ?? '',
|
||||
accessToken: user.access_token,
|
||||
provider: 'oidc',
|
||||
};
|
||||
}
|
||||
159
frontend/src/components/ActiveQuery.tsx
Normal file
159
frontend/src/components/ActiveQuery.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { getUser } from '@/auth/authService';
|
||||
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
||||
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
||||
import { POLLING_INTERVALS } from '@/constants';
|
||||
import { fetchTaskStatus, cancelTask } from '@/services';
|
||||
import { TaskStatus, type TaskResult } from '@/types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AlertError from './AlertError';
|
||||
import { Spinner } from './Spinner';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Button } from './ui/button';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ActiveQueryProps {
|
||||
taskID: string | null;
|
||||
onTaskCancelled?: () => void;
|
||||
}
|
||||
|
||||
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
useEffect(() => {
|
||||
const passkeyUser = getStoredPasskeyUser();
|
||||
if (passkeyUser) {
|
||||
setUser(passkeyUser);
|
||||
} else {
|
||||
getUser().then((oidcUser) => {
|
||||
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
||||
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(TaskStatus.PENDING);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
|
||||
const [fetchStatusError, setFetchStatusError] = useState<string | null>(null);
|
||||
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const handleCancelTask = async () => {
|
||||
if (!user || !taskID || isCancelling) return;
|
||||
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
const result = await cancelTask(user, taskID);
|
||||
if (result.success) {
|
||||
setTaskStatus(TaskStatus.REVOKED);
|
||||
onTaskCancelled?.();
|
||||
} else {
|
||||
setFetchStatusError(result.message);
|
||||
setAlertDialogIsOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setFetchStatusError(error instanceof Error ? error.message : 'Failed to cancel task');
|
||||
setAlertDialogIsOpen(true);
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollTaskStatus = async (interval: NodeJS.Timeout) => {
|
||||
if (!user || !taskID) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchTaskStatus(user, taskID);
|
||||
setLastUpdateTime(new Date());
|
||||
const status = data.status as TaskStatus;
|
||||
setTaskStatus(status);
|
||||
|
||||
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
||||
clearInterval(interval);
|
||||
setFetchStatusError('Task failed with status: ' + status);
|
||||
setAlertDialogIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === TaskStatus.SUCCESS) {
|
||||
clearInterval(interval);
|
||||
setProgressPercentage(100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only parse result for in-progress tasks
|
||||
if (data.result) {
|
||||
try {
|
||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
||||
setProgressPercentage(parsedResult.progress * 100);
|
||||
} catch {
|
||||
// Result parsing failed, but task is still running - ignore
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(interval);
|
||||
setTaskStatus(TaskStatus.FAILURE);
|
||||
setAlertDialogIsOpen(true);
|
||||
if (error instanceof Error) {
|
||||
setFetchStatusError(error.message);
|
||||
} else {
|
||||
setFetchStatusError('Failed to update task status: ' + String(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(
|
||||
() => pollTaskStatus(interval),
|
||||
POLLING_INTERVALS.TASK_STATUS_MS
|
||||
);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskID, user]);
|
||||
|
||||
if (!taskID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInProgress = taskStatus &&
|
||||
taskStatus !== TaskStatus.SUCCESS &&
|
||||
taskStatus !== TaskStatus.FAILURE &&
|
||||
taskStatus !== TaskStatus.REVOKED;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-2 border-t bg-muted/50">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{taskStatus && <span className="text-sm">Task: {taskStatus}</span>}
|
||||
{isInProgress && <Spinner />}
|
||||
</div>
|
||||
<Progress value={progressPercentage} className="mt-1" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
Task ID: {taskID}
|
||||
<br />
|
||||
Last updated: {lastUpdateTime.toLocaleString()}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{isInProgress && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancelTask}
|
||||
disabled={isCancelling}
|
||||
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="ml-1 hidden sm:inline">Cancel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<AlertError message={fetchStatusError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveQuery;
|
||||
26
frontend/src/components/AlertError.tsx
Normal file
26
frontend/src/components/AlertError.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog";
|
||||
|
||||
export default function AlertError(
|
||||
props: {
|
||||
message: string | null;
|
||||
open: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
) {
|
||||
return (
|
||||
<AlertDialog open={props.open} onOpenChange={props.setIsOpen}>
|
||||
{/* <AlertDialogTrigger>Open</AlertDialogTrigger> */}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Error</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{props.message}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => props.setIsOpen(false)}>Close</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
111
frontend/src/components/AuthCallback.tsx
Normal file
111
frontend/src/components/AuthCallback.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { handleCallback, login, type AuthError } from '@/auth/authService';
|
||||
import { Loader2, CheckCircle, AlertCircle, Home } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type CallbackState = 'processing' | 'success' | 'error';
|
||||
|
||||
const AuthCallback: React.FC = () => {
|
||||
const [state, setState] = useState<CallbackState>('processing');
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const processCallback = async () => {
|
||||
try {
|
||||
await handleCallback();
|
||||
setState('success');
|
||||
// Auto-redirect after success
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err as AuthError);
|
||||
setState('error');
|
||||
}
|
||||
};
|
||||
|
||||
processCallback();
|
||||
}, []);
|
||||
|
||||
const handleRetry = async () => {
|
||||
setState('processing');
|
||||
setError(null);
|
||||
try {
|
||||
await login();
|
||||
} catch (err) {
|
||||
setError(err as AuthError);
|
||||
setState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-card border rounded-xl shadow-lg p-8">
|
||||
{state === 'processing' && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-4 bg-primary/10 rounded-full">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">Completing Sign In</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Please wait while we verify your credentials...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'success' && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-4 bg-green-500/10 rounded-full">
|
||||
<CheckCircle className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">Welcome Back!</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Redirecting you to the dashboard...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-4 bg-destructive/10 rounded-full">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">Sign In Failed</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{error?.message || 'An unexpected error occurred.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button onClick={handleRetry} className="gap-2">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGoHome} className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
Go Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthCallback;
|
||||
569
frontend/src/components/FilterPanel.tsx
Normal file
569
frontend/src/components/FilterPanel.tsx
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "./ui/button";
|
||||
import { Calendar29 } from "./ui/DatePicker";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
|
||||
import { Loader2, Filter, RefreshCw } from "lucide-react";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
export enum Metric {
|
||||
qmprice = 'qmprice',
|
||||
rooms = 'rooms',
|
||||
qm = 'qm',
|
||||
price = 'total_price',
|
||||
}
|
||||
|
||||
export enum ListingType {
|
||||
RENT = 'RENT',
|
||||
BUY = 'BUY'
|
||||
}
|
||||
|
||||
export enum FurnishType {
|
||||
FURNISHED = 'furnished',
|
||||
PART_FURNISHED = 'partFurnished',
|
||||
UNFURNISHED = 'unfurnished',
|
||||
}
|
||||
|
||||
// Default filter values - exported so App.tsx can use them for initial load
|
||||
export const DEFAULT_FILTER_VALUES = {
|
||||
metric: Metric.qmprice,
|
||||
listing_type: ListingType.RENT,
|
||||
min_bedrooms: 1,
|
||||
max_bedrooms: 3,
|
||||
max_price: 3000,
|
||||
min_price: 2000,
|
||||
min_sqm: 50,
|
||||
max_sqm: undefined,
|
||||
min_price_per_sqm: undefined,
|
||||
max_price_per_sqm: undefined,
|
||||
last_seen_days: 28,
|
||||
available_from: new Date(),
|
||||
district: '',
|
||||
furnish_types: [] as FurnishType[],
|
||||
} as const;
|
||||
|
||||
export interface ParameterValues {
|
||||
metric: Metric
|
||||
listing_type: ListingType
|
||||
min_bedrooms?: number
|
||||
max_bedrooms?: number
|
||||
min_price?: number
|
||||
max_price?: number
|
||||
min_sqm?: number
|
||||
max_sqm?: number
|
||||
min_price_per_sqm?: number
|
||||
max_price_per_sqm?: number
|
||||
last_seen_days?: number
|
||||
available_from?: Date
|
||||
district: string
|
||||
furnish_types?: FurnishType[]
|
||||
}
|
||||
|
||||
interface FilterPanelProps {
|
||||
onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void;
|
||||
onMetricChange?: (metric: Metric) => void;
|
||||
isLoading?: boolean;
|
||||
listingCount?: number;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
metric: z.nativeEnum(Metric, { required_error: "Metric is required" }),
|
||||
listing_type: z.nativeEnum(ListingType, { required_error: "Listing Type is required" }),
|
||||
min_bedrooms: z.number().min(0).max(10).optional(),
|
||||
max_bedrooms: z.number().min(0).max(10).optional(),
|
||||
max_price: z.number().optional(),
|
||||
min_price: z.number().min(0).optional(),
|
||||
min_sqm: z.number().optional(),
|
||||
max_sqm: z.number().optional(),
|
||||
min_price_per_sqm: z.number().optional(),
|
||||
max_price_per_sqm: z.number().optional(),
|
||||
last_seen_days: z.number().min(0).optional(),
|
||||
available_from: z.date(),
|
||||
district: z.string(),
|
||||
furnish_types: z.array(z.nativeEnum(FurnishType)).optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount }: FilterPanelProps) {
|
||||
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
|
||||
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...DEFAULT_FILTER_VALUES,
|
||||
available_from: new Date(), // Fresh date on each render
|
||||
},
|
||||
});
|
||||
|
||||
// Watch listing_type to make filters type-aware
|
||||
const watchedListingType = form.watch('listing_type');
|
||||
|
||||
// Update price defaults when listing type changes
|
||||
useEffect(() => {
|
||||
if (watchedListingType === ListingType.BUY) {
|
||||
form.setValue('min_price', 300000);
|
||||
form.setValue('max_price', 600000);
|
||||
} else {
|
||||
form.setValue('min_price', 2000);
|
||||
form.setValue('max_price', 3000);
|
||||
}
|
||||
// Clear furnish types when switching to BUY
|
||||
if (watchedListingType === ListingType.BUY) {
|
||||
setSelectedFurnishTypes([]);
|
||||
}
|
||||
}, [watchedListingType, form]);
|
||||
|
||||
const handleFormSubmit = (action: 'fetch-data' | 'visualize') => {
|
||||
return form.handleSubmit((values) => {
|
||||
const params: ParameterValues = {
|
||||
...values,
|
||||
furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined,
|
||||
};
|
||||
onSubmit(action, params);
|
||||
})();
|
||||
};
|
||||
|
||||
const toggleFurnishType = (type: FurnishType) => {
|
||||
setSelectedFurnishTypes(prev =>
|
||||
prev.includes(type)
|
||||
? prev.filter(t => t !== type)
|
||||
: [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
// Count active filters
|
||||
const countActiveFilters = (): number => {
|
||||
const values = form.getValues();
|
||||
let count = 0;
|
||||
if (values.min_bedrooms && values.min_bedrooms > 0) count++;
|
||||
if (values.max_bedrooms && values.max_bedrooms < 10) count++;
|
||||
if (values.min_price && values.min_price > 0) count++;
|
||||
if (values.max_price) count++;
|
||||
if (values.min_sqm && values.min_sqm > 0) count++;
|
||||
if (values.max_sqm) count++;
|
||||
if (values.min_price_per_sqm) count++;
|
||||
if (values.max_price_per_sqm) count++;
|
||||
if (values.district && values.district.length > 0) count++;
|
||||
if (selectedFurnishTypes.length > 0) count++;
|
||||
if (values.last_seen_days && values.last_seen_days < 365) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(() => {
|
||||
setActiveFilterCount(countActiveFilters());
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, selectedFurnishTypes]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background border-r overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
<h2 className="font-semibold text-lg">Filters</h2>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="ml-auto bg-primary text-primary-foreground text-xs px-2 py-0.5 rounded-full">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{listingCount !== undefined && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{listingCount.toLocaleString()} listings
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<Form {...form}>
|
||||
<form className="p-4 space-y-4">
|
||||
<Accordion type="multiple" defaultValue={["visualization", "price-size", "features"]} className="w-full">
|
||||
{/* Visualization Options */}
|
||||
<AccordionItem value="visualization">
|
||||
<AccordionTrigger className="py-2 text-sm font-medium">
|
||||
Visualization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metric"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Color by</FormLabel>
|
||||
<Select onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
onMetricChange?.(value as Metric);
|
||||
}} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Metric" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={Metric.qmprice}>Price per m²</SelectItem>
|
||||
<SelectItem value={Metric.rooms}>Bedrooms</SelectItem>
|
||||
<SelectItem value={Metric.qm}>Size (m²)</SelectItem>
|
||||
<SelectItem value={Metric.price}>Total Price</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listing_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={ListingType.RENT}>For Rent</SelectItem>
|
||||
<SelectItem value={ListingType.BUY}>For Sale</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Price & Size */}
|
||||
<AccordionItem value="price-size">
|
||||
<AccordionTrigger className="py-2 text-sm font-medium">
|
||||
Price & Size
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min Price (£)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max Price (£)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Any"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min Size (m²)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max Size (m²)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Any"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_price_per_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min £/m²</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_price_per_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max £/m²</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Any"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Features */}
|
||||
<AccordionItem value="features">
|
||||
<AccordionTrigger className="py-2 text-sm font-medium">
|
||||
Features
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_bedrooms"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Min Beds</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
min={0}
|
||||
max={10}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_bedrooms"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Max Beds</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Any"
|
||||
className="h-8 text-sm"
|
||||
min={0}
|
||||
max={10}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{watchedListingType === ListingType.RENT && (
|
||||
<div>
|
||||
<FormLabel className="text-xs">Furnishing</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{[
|
||||
{ value: FurnishType.FURNISHED, label: 'Furnished' },
|
||||
{ value: FurnishType.PART_FURNISHED, label: 'Part' },
|
||||
{ value: FurnishType.UNFURNISHED, label: 'Unfurn.' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => toggleFurnishType(option.value)}
|
||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||
selectedFurnishTypes.includes(option.value)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted border-input'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Location */}
|
||||
<AccordionItem value="location">
|
||||
<AccordionTrigger className="py-2 text-sm font-medium">
|
||||
Location
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="district"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">District</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g., Westminster, Camden"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Comma-separated list of districts
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Availability / Recency */}
|
||||
<AccordionItem value="availability">
|
||||
<AccordionTrigger className="py-2 text-sm font-medium">
|
||||
{watchedListingType === ListingType.RENT ? 'Availability' : 'Recency'}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
{watchedListingType === ListingType.RENT && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="available_from"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Available From</FormLabel>
|
||||
<FormControl>
|
||||
<Calendar29
|
||||
onSelect={field.onChange}
|
||||
selected={field.value}
|
||||
rawInputValue={availableFromRawInput}
|
||||
onChangeRawInputValue={setAvailableFromRawInput}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="last_seen_days"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Last Seen (days)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="28"
|
||||
className="h-8 text-sm"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Show listings seen in last N days
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="p-4 border-t space-y-2 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleFormSubmit('visualize')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Apply Filters
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => handleFormSubmit('fetch-data')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/Header.tsx
Normal file
90
frontend/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { AuthUser } from '@/auth/types';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { LogOut, Home, Filter } from 'lucide-react';
|
||||
import { logout } from '@/auth/authService';
|
||||
import { clearPasskeyUser } from '@/auth/passkeyService';
|
||||
import { HealthIndicator } from './HealthIndicator';
|
||||
import { TaskIndicator } from './TaskIndicator';
|
||||
|
||||
interface HeaderProps {
|
||||
user: AuthUser;
|
||||
activeFilterCount?: number;
|
||||
taskID?: string | null;
|
||||
isLoading?: boolean;
|
||||
onToggleFilters?: () => void;
|
||||
showFilterToggle?: boolean;
|
||||
onTaskCancelled?: () => void;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
user,
|
||||
activeFilterCount = 0,
|
||||
taskID,
|
||||
onToggleFilters,
|
||||
showFilterToggle = false,
|
||||
onTaskCancelled,
|
||||
}: HeaderProps) {
|
||||
const handleLogout = async () => {
|
||||
if (user.provider === 'passkey') {
|
||||
clearPasskeyUser();
|
||||
window.location.reload();
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4">
|
||||
{/* Logo / Brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Home className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
{/* Health Indicator */}
|
||||
<HealthIndicator />
|
||||
|
||||
{/* Task Indicator */}
|
||||
<TaskIndicator taskID={taskID ?? null} onTaskCancelled={onTaskCancelled} />
|
||||
|
||||
{/* Filter Toggle (mobile) */}
|
||||
{showFilterToggle && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:hidden"
|
||||
onClick={onToggleFilters}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="ml-1 bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline">
|
||||
{user.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/HealthIndicator.tsx
Normal file
83
frontend/src/components/HealthIndicator.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { checkBackendHealth, type HealthStatus, type HealthCheckResult } from '@/services';
|
||||
import { Circle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface HealthIndicatorProps {
|
||||
/** How often to check health in milliseconds (default: 30000 = 30s) */
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export function HealthIndicator({ interval = 30000 }: HealthIndicatorProps) {
|
||||
const [health, setHealth] = useState<HealthCheckResult>({ status: 'checking' });
|
||||
|
||||
useEffect(() => {
|
||||
// Initial check
|
||||
checkBackendHealth().then(setHealth);
|
||||
|
||||
// Periodic checks
|
||||
const intervalId = setInterval(() => {
|
||||
checkBackendHealth().then(setHealth);
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [interval]);
|
||||
|
||||
const getStatusColor = (status: HealthStatus) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'text-green-500';
|
||||
case 'unhealthy':
|
||||
return 'text-red-500';
|
||||
case 'checking':
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: HealthStatus) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'Connected';
|
||||
case 'unhealthy':
|
||||
return 'Disconnected';
|
||||
case 'checking':
|
||||
return 'Checking...';
|
||||
}
|
||||
};
|
||||
|
||||
const getTooltipContent = () => {
|
||||
if (health.status === 'checking') {
|
||||
return 'Checking backend connection...';
|
||||
}
|
||||
|
||||
if (health.status === 'healthy') {
|
||||
return `Backend connected (${health.latencyMs}ms)`;
|
||||
}
|
||||
|
||||
return `Backend unavailable: ${health.error || 'Unknown error'}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
{health.status === 'checking' ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Circle
|
||||
className={`h-2.5 w-2.5 fill-current ${getStatusColor(health.status)}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`text-xs ${getStatusColor(health.status)} hidden sm:inline`}>
|
||||
{getStatusLabel(health.status)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{getTooltipContent()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
151
frontend/src/components/ListView.tsx
Normal file
151
frontend/src/components/ListView.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Button } from './ui/button';
|
||||
import { PropertyCard } from './PropertyCard';
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties } from '@/types';
|
||||
|
||||
type SortField = 'total_price' | 'qmprice' | 'qm' | 'rooms' | 'last_seen';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface ListViewProps {
|
||||
listingData: GeoJSONFeatureCollection;
|
||||
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
||||
highlightedPropertyUrl?: string | null;
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
field: SortField;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: { field: SortField; label: string }[] = [
|
||||
{ field: 'total_price', label: 'Price' },
|
||||
{ field: 'qmprice', label: '£/m²' },
|
||||
{ field: 'qm', label: 'Size' },
|
||||
{ field: 'rooms', label: 'Beds' },
|
||||
{ field: 'last_seen', label: 'Last Seen' },
|
||||
];
|
||||
|
||||
export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl }: ListViewProps) {
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({ field: 'qmprice', order: 'asc' });
|
||||
|
||||
// Calculate average price per sqm for "good deal" indicator
|
||||
const avgPricePerSqm = useMemo(() => {
|
||||
const validPrices = listingData.features
|
||||
.map((f) => f.properties.qmprice)
|
||||
.filter((p): p is number => typeof p === 'number' && p > 0);
|
||||
return validPrices.length > 0
|
||||
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
||||
: 0;
|
||||
}, [listingData]);
|
||||
|
||||
// Sort features
|
||||
const sortedFeatures = useMemo(() => {
|
||||
const features = [...listingData.features];
|
||||
|
||||
features.sort((a, b) => {
|
||||
let aValue: number | string;
|
||||
let bValue: number | string;
|
||||
|
||||
switch (sortConfig.field) {
|
||||
case 'total_price':
|
||||
aValue = a.properties.total_price || 0;
|
||||
bValue = b.properties.total_price || 0;
|
||||
break;
|
||||
case 'qmprice':
|
||||
aValue = a.properties.qmprice || 0;
|
||||
bValue = b.properties.qmprice || 0;
|
||||
break;
|
||||
case 'qm':
|
||||
aValue = a.properties.qm || 0;
|
||||
bValue = b.properties.qm || 0;
|
||||
break;
|
||||
case 'rooms':
|
||||
aValue = a.properties.rooms || 0;
|
||||
bValue = b.properties.rooms || 0;
|
||||
break;
|
||||
case 'last_seen':
|
||||
aValue = new Date(a.properties.last_seen).getTime();
|
||||
bValue = new Date(b.properties.last_seen).getTime();
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortConfig.order === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return features;
|
||||
}, [listingData.features, sortConfig]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
setSortConfig((prev) => ({
|
||||
field,
|
||||
order: prev.field === field && prev.order === 'asc' ? 'desc' : 'asc',
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePropertyClick = useCallback((feature: PropertyFeature) => {
|
||||
if (onPropertyClick) {
|
||||
onPropertyClick(feature.properties, feature.geometry.coordinates);
|
||||
}
|
||||
}, [onPropertyClick]);
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortConfig.field !== field) {
|
||||
return <ArrowUpDown className="h-3.5 w-3.5" />;
|
||||
}
|
||||
return sortConfig.order === 'asc'
|
||||
? <ArrowUp className="h-3.5 w-3.5" />
|
||||
: <ArrowDown className="h-3.5 w-3.5" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Sort controls */}
|
||||
<div className="flex items-center gap-1 p-2 border-b overflow-x-auto">
|
||||
<span className="text-xs text-muted-foreground mr-1 shrink-0">Sort:</span>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.field}
|
||||
variant={sortConfig.field === option.field ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs shrink-0"
|
||||
onClick={() => handleSort(option.field)}
|
||||
>
|
||||
{option.label}
|
||||
<SortIcon field={option.field} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Listing count */}
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground border-b">
|
||||
Showing {sortedFeatures.length.toLocaleString()} properties
|
||||
</div>
|
||||
|
||||
{/* Property list */}
|
||||
<Virtuoso
|
||||
className="flex-1"
|
||||
data={sortedFeatures}
|
||||
overscan={200}
|
||||
itemContent={(_index, feature) => (
|
||||
<div className="px-3 pb-2 first:pt-3">
|
||||
<PropertyCard
|
||||
key={feature.properties.url}
|
||||
property={feature.properties}
|
||||
variant="compact"
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||
onClick={() => handlePropertyClick(feature)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/LoginModal.tsx
Normal file
189
frontend/src/components/LoginModal.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { login, type AuthError } from '@/auth/authService';
|
||||
import { registerPasskey, loginWithPasskey } from '@/auth/passkeyService';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogDescription } from '@radix-ui/react-dialog';
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Home, LogIn, AlertCircle, Loader2, Fingerprint, Mail } from 'lucide-react';
|
||||
|
||||
interface LoginModalProps {
|
||||
isOpen: boolean;
|
||||
onPasskeyLogin: (user: AuthUser) => void;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onPasskeyLogin }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSSOLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login();
|
||||
} catch (err) {
|
||||
setError((err as AuthError).message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await loginWithPasskey();
|
||||
onPasskeyLogin(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyRegister = async () => {
|
||||
if (!email.trim()) {
|
||||
setError('Please enter your email address');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const user = await registerPasskey(email.trim());
|
||||
onPasskeyLogin(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => setError(null);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]" showCloseButton={false}>
|
||||
<DialogHeader className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Home className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">Wrongmove</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
Your smart property search companion
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-start gap-3 mb-4">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={clearError}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="signin">
|
||||
<TabsList>
|
||||
<TabsTrigger value="signin">Sign In</TabsTrigger>
|
||||
<TabsTrigger value="signup">Sign Up</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="signin" className="space-y-4 pt-2">
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
)}
|
||||
Sign in with Passkey
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="signup" className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handlePasskeyRegister();
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pl-10 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePasskeyRegister}
|
||||
disabled={isLoading || !email.trim()}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
)}
|
||||
Create account with Passkey
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
A passkey uses your device's biometrics or security key for secure, passwordless authentication.
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
366
frontend/src/components/Map.tsx
Normal file
366
frontend/src/components/Map.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import crossfilter from "crossfilter2";
|
||||
import * as d3 from "d3";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import { useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import "../assets/Map.css";
|
||||
import { Metric, type ParameterValues } from "./Parameters";
|
||||
import { PropertyCard } from "./PropertyCard";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties } from "@/types";
|
||||
import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
|
||||
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
||||
import { clone, percentile, calculateColorStops } from "@/utils/mapUtils";
|
||||
|
||||
// Type declaration for the external HexgridHeatmap library
|
||||
declare class HexgridHeatmap {
|
||||
_tree: {
|
||||
search: (bounds: { minX: number; maxX: number; minY: number; maxY: number }) => PropertyWithCoords[];
|
||||
};
|
||||
constructor(map: mapboxgl.Map, id: string, beforeLayer: string);
|
||||
setIntensity(value: number): void;
|
||||
setSpread(value: number): void;
|
||||
setCellDensity(value: number): void;
|
||||
setPropertyName(name: string): void;
|
||||
setData(data: GeoJSONFeatureCollection): void;
|
||||
setColorStops(stops: [number, string][]): void;
|
||||
update(): void;
|
||||
}
|
||||
|
||||
interface PropertyWithCoords {
|
||||
properties: PropertyProperties;
|
||||
}
|
||||
|
||||
interface CrossfilterRecord extends PropertyProperties {
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface MapProps {
|
||||
listingData: GeoJSONFeatureCollection;
|
||||
queryParameters: ParameterValues | null;
|
||||
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
city: string;
|
||||
country: string | null;
|
||||
mode: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function Map(props: MapProps) {
|
||||
const data = props.listingData;
|
||||
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const heatmapRef = useRef<HexgridHeatmap | null>(null);
|
||||
const updateTimeoutRef = useRef<number | null>(null);
|
||||
const isMapLoadedRef = useRef<boolean>(false);
|
||||
const lastDataLengthRef = useRef<number>(0);
|
||||
|
||||
const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
|
||||
if (props.queryParameters) {
|
||||
filter.mode = props.queryParameters.metric;
|
||||
}
|
||||
|
||||
// Get appropriate color scheme based on metric
|
||||
const colorScheme = useMemo(() => {
|
||||
return getColorSchemeForMetric(filter.mode);
|
||||
}, [filter.mode]);
|
||||
|
||||
const metricInfo = useMemo(() => {
|
||||
return getMetricInterpretation(filter.mode);
|
||||
}, [filter.mode]);
|
||||
|
||||
// Calculate average price per sqm for property cards
|
||||
const avgPricePerSqm = useMemo(() => {
|
||||
const validPrices = data.features
|
||||
.map((f) => f.properties.qmprice)
|
||||
.filter((p): p is number => typeof p === 'number' && p > 0);
|
||||
return validPrices.length > 0
|
||||
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
||||
: 0;
|
||||
}, [data]);
|
||||
|
||||
// Build crossfilter data
|
||||
const buildCrossfilterData = useCallback(() => {
|
||||
return data.features.map(function (d: PropertyFeature, i: number) {
|
||||
const propsCopy = clone(d.properties) as CrossfilterRecord;
|
||||
propsCopy.index = i;
|
||||
return propsCopy;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const updateHeatmap = useCallback(() => {
|
||||
if (!mapRef.current || !isMapLoadedRef.current) return;
|
||||
|
||||
const crossData = buildCrossfilterData();
|
||||
const cf = crossfilter(crossData);
|
||||
const qmDim = cf.dimension(function (d: CrossfilterRecord) { return d.qm; });
|
||||
const cityDim = cf.dimension(function (d: CrossfilterRecord) { return d.city; });
|
||||
const countryDim = cf.dimension(function (d: CrossfilterRecord) { return d.country; });
|
||||
const indexDim = cf.dimension(function (d: CrossfilterRecord) { return d.index; });
|
||||
|
||||
// Create heatmap if it doesn't exist
|
||||
if (!heatmapRef.current) {
|
||||
heatmapRef.current = new HexgridHeatmap(mapRef.current, "hexgrid-heatmap", "waterway-label");
|
||||
heatmapRef.current.setIntensity(HEATMAP_CONFIG.INTENSITY);
|
||||
heatmapRef.current.setSpread(HEATMAP_CONFIG.SPREAD);
|
||||
heatmapRef.current.setCellDensity(HEATMAP_CONFIG.CELL_DENSITY);
|
||||
}
|
||||
|
||||
const heatmap = heatmapRef.current;
|
||||
heatmap.setPropertyName(filter.mode);
|
||||
|
||||
if (filter.mode === Metric.qmprice) {
|
||||
qmDim.filter((d) => (d as number) > 0);
|
||||
}
|
||||
|
||||
if (filter.city) {
|
||||
cityDim.filterExact(filter.city);
|
||||
} else if (filter.country) {
|
||||
countryDim.filterExact(filter.country);
|
||||
}
|
||||
|
||||
const subset: GeoJSONFeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
indexDim.top(Infinity).forEach(function (i: CrossfilterRecord) {
|
||||
subset.features.push(data.features[i.index]);
|
||||
});
|
||||
|
||||
// Update heatmap data
|
||||
heatmap.setData(subset);
|
||||
let values = subset.features.map(function (d: PropertyFeature) {
|
||||
return d.properties[filter.mode as keyof PropertyProperties] as number;
|
||||
});
|
||||
values = values.sort(function (a: number, b: number) { return a - b; });
|
||||
|
||||
const minIndex = Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND);
|
||||
const maxIndex = Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND);
|
||||
const min = values[minIndex];
|
||||
const max = values[maxIndex];
|
||||
|
||||
makeLegend(colorScheme, min, max);
|
||||
const colorStopsValue = calculateColorStops(colorScheme, min, max);
|
||||
heatmap.setColorStops(colorStopsValue);
|
||||
heatmap.update();
|
||||
|
||||
// Fit bounds only on first load or significant data change
|
||||
if (lastDataLengthRef.current === 0 && subset.features.length > 0) {
|
||||
const longitudes = subset.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; });
|
||||
const latitudes = subset.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[1]; }).sort(function (a: number, b: number) { return a - b; });
|
||||
const minlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
|
||||
const maxlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
|
||||
const minlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
|
||||
const maxlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
|
||||
|
||||
mapRef.current?.fitBounds([
|
||||
[minlng, minlat],
|
||||
[maxlng, maxlat]
|
||||
], { duration: 0 });
|
||||
}
|
||||
|
||||
lastDataLengthRef.current = subset.features.length;
|
||||
}, [data, filter.mode, filter.city, filter.country, colorScheme, buildCrossfilterData]);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
|
||||
mapboxgl.accessToken = MAP_CONFIG.MAPBOX_TOKEN;
|
||||
mapRef.current = new mapboxgl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: MAP_CONFIG.STYLE,
|
||||
center: MAP_CONFIG.DEFAULT_CENTER,
|
||||
zoom: MAP_CONFIG.DEFAULT_ZOOM
|
||||
});
|
||||
mapRef.current.on('load', function () {
|
||||
isMapLoadedRef.current = true;
|
||||
lastDataLengthRef.current = 0;
|
||||
updateHeatmap();
|
||||
});
|
||||
mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) {
|
||||
if (!mapRef.current) return;
|
||||
const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back']
|
||||
.filter(l => mapRef.current!.getLayer(l));
|
||||
if (layers.length === 0) return;
|
||||
const features = mapRef.current.queryRenderedFeatures(e.point, { layers });
|
||||
if (features.length > 0) {
|
||||
const props = features[0].properties;
|
||||
if (props && props.searchMinX !== undefined) {
|
||||
openListingsDialog(e.lngLat.lng, e.lngLat.lat, {
|
||||
minX: props.searchMinX,
|
||||
minY: props.searchMinY,
|
||||
maxX: props.searchMaxX,
|
||||
maxY: props.searchMaxY
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
mapRef.current.on('mousemove', function (e: mapboxgl.MapMouseEvent) {
|
||||
if (!mapRef.current) return;
|
||||
const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back']
|
||||
.filter(l => mapRef.current!.getLayer(l));
|
||||
if (layers.length === 0) return;
|
||||
const features = mapRef.current.queryRenderedFeatures(e.point, { layers });
|
||||
mapRef.current.getCanvas().style.cursor = features.length > 0 ? 'pointer' : '';
|
||||
});
|
||||
return () => {
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
heatmapRef.current = null;
|
||||
isMapLoadedRef.current = false;
|
||||
mapRef.current?.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Debounced update effect - only update after 200ms of no changes
|
||||
useEffect(() => {
|
||||
if (!isMapLoadedRef.current) return;
|
||||
|
||||
// Clear any pending update
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Schedule new update after 200ms of no changes
|
||||
updateTimeoutRef.current = window.setTimeout(() => {
|
||||
updateHeatmap();
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, updateHeatmap]);
|
||||
|
||||
function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) {
|
||||
const svg_height = 280, svg_width = 80;
|
||||
d3.select('#svg').selectAll('*').remove();
|
||||
const svg = d3.select('#svg');
|
||||
svg
|
||||
.attr('height', svg_height)
|
||||
.attr('width', svg_width);
|
||||
|
||||
// Add metric name at top
|
||||
svg.append("text")
|
||||
.attr("x", svg_width / 2)
|
||||
.attr("y", 12)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", "11px")
|
||||
.attr("font-weight", "600")
|
||||
.attr("fill", "#374151")
|
||||
.text(metricInfo.name);
|
||||
|
||||
const gradientTop = 30;
|
||||
const gradientHeight = svg_height - 70;
|
||||
|
||||
const linearGradient = svg.append("defs")
|
||||
.append("linearGradient")
|
||||
.attr("id", "linear-gradient");
|
||||
|
||||
linearGradient
|
||||
.attr("x1", "0%")
|
||||
.attr("y1", "100%")
|
||||
.attr("x2", "0%")
|
||||
.attr("y2", "0%");
|
||||
|
||||
svg.append("rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", gradientTop)
|
||||
.attr("width", svg_width * 0.35)
|
||||
.attr("height", gradientHeight)
|
||||
.attr('rx', 4)
|
||||
.style("fill", "url(#linear-gradient)");
|
||||
|
||||
colorstops.forEach(function (d: [number, string]) {
|
||||
linearGradient.append("stop")
|
||||
.attr("offset", d[0] + "%")
|
||||
.attr("stop-color", d[1]);
|
||||
});
|
||||
|
||||
const xScale = d3.scaleLinear().range([gradientHeight - 10, 0]).domain([minValue, maxValue]);
|
||||
const xAxis = d3.axisRight(xScale).ticks(5).tickFormat((d) => {
|
||||
const num = d as number;
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return String(Math.round(num));
|
||||
});
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "axis")
|
||||
.attr("transform", "translate(" + (svg_width * 0.38) + "," + (gradientTop + 5) + ")")
|
||||
.call(xAxis)
|
||||
.selectAll("text")
|
||||
.attr("font-size", "10px");
|
||||
|
||||
// Add interpretation labels at bottom
|
||||
svg.append("text")
|
||||
.attr("x", svg_width / 2)
|
||||
.attr("y", svg_height - 25)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", "9px")
|
||||
.attr("fill", "#22c55e")
|
||||
.text(metricInfo.low);
|
||||
|
||||
svg.append("text")
|
||||
.attr("x", svg_width / 2)
|
||||
.attr("y", svg_height - 10)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", "9px")
|
||||
.attr("fill", "#ef4444")
|
||||
.text(metricInfo.high);
|
||||
}
|
||||
|
||||
function openListingsDialog(longitude: number, latitude: number, searchBounds: { minX: number; minY: number; maxX: number; maxY: number }) {
|
||||
if (!heatmapRef.current || !mapRef.current) return;
|
||||
|
||||
const properties = heatmapRef.current._tree.search(searchBounds);
|
||||
if (properties.length > 0) {
|
||||
const listingDialogPopup = getListingDialog(properties);
|
||||
new mapboxgl.Popup()
|
||||
.setLngLat([longitude, latitude])
|
||||
.setHTML(renderToString(listingDialogPopup))
|
||||
.setMaxWidth("450px")
|
||||
.addTo(mapRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
function getListingDialog(properties: PropertyWithCoords[]) {
|
||||
return (
|
||||
<ScrollArea className="rounded-md">
|
||||
<div className="overflow-y-auto max-h-[500px] w-[420px]">
|
||||
<div className="px-3 py-2 text-sm font-medium border-b bg-muted/50">
|
||||
<strong>{properties.length}</strong> properties in this area
|
||||
</div>
|
||||
<div className="p-2 space-y-2">
|
||||
{properties.map((property) => (
|
||||
<PropertyCard
|
||||
key={property.properties.url}
|
||||
property={property.properties}
|
||||
variant="full"
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div id='map-container' ref={mapContainerRef}></div>
|
||||
<div id="legend">
|
||||
<svg id="svg"></svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export { Metric, type ParameterValues } from "./Parameters";
|
||||
263
frontend/src/components/Parameters.tsx
Normal file
263
frontend/src/components/Parameters.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "./ui/button";
|
||||
import { Calendar29 } from "./ui/DatePicker";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
|
||||
|
||||
export enum Metric {
|
||||
qmprice = 'qmprice',
|
||||
rooms = 'rooms',
|
||||
qm = 'qm',
|
||||
price = 'total_price',
|
||||
}
|
||||
export enum ListingType {
|
||||
RENT = 'RENT',
|
||||
BUY = 'BUY'
|
||||
}
|
||||
|
||||
export enum FurnishType {
|
||||
FURNISHED = 'furnished',
|
||||
PART_FURNISHED = 'partFurnished',
|
||||
UNFURNISHED = 'unfurnished',
|
||||
}
|
||||
|
||||
|
||||
export interface ParameterValues {
|
||||
metric: Metric
|
||||
listing_type: ListingType
|
||||
min_bedrooms?: number
|
||||
max_bedrooms?: number
|
||||
min_price?: number
|
||||
max_price?: number
|
||||
min_sqm?: number
|
||||
max_sqm?: number
|
||||
min_price_per_sqm?: number
|
||||
max_price_per_sqm?: number
|
||||
last_seen_days?: number
|
||||
available_from?: Date
|
||||
district: string
|
||||
furnish_types?: FurnishType[]
|
||||
}
|
||||
|
||||
export function Parameters(
|
||||
props: {
|
||||
isOpen: boolean,
|
||||
onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void,
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
}
|
||||
) {
|
||||
const {
|
||||
register,
|
||||
} = useForm<ParameterValues>()
|
||||
const [action, setAction] = useState<'fetch-data' | 'visualize' | null>(null)
|
||||
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
|
||||
|
||||
const formSchema = z.object({
|
||||
metric: z.nativeEnum(Metric, { required_error: "Metric is required" }),
|
||||
listing_type: z.nativeEnum(ListingType, { required_error: "Listing Type is required" }),
|
||||
min_bedrooms: z.number().min(1).max(10).optional(),
|
||||
max_bedrooms: z.number().min(1).max(10).optional(),
|
||||
max_price: z.number().optional(),
|
||||
min_price: z.number().min(0).optional(),
|
||||
min_sqm: z.number().optional(),
|
||||
last_seen_days: z.number().min(0).optional(),
|
||||
available_from: z.date(),
|
||||
district: z.string()
|
||||
})
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
metric: Metric.qmprice,
|
||||
listing_type: ListingType.RENT,
|
||||
min_bedrooms: 1,
|
||||
max_bedrooms: 3,
|
||||
max_price: 3000,
|
||||
min_price: 2000,
|
||||
min_sqm: 50,
|
||||
last_seen_days: 28,
|
||||
available_from: new Date(),
|
||||
district: ''
|
||||
},
|
||||
})
|
||||
// 2. Define a submit handler.
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
// Do something with the form values.
|
||||
// ✅ This will be type-safe and validated.
|
||||
console.log(values)
|
||||
if (action) {
|
||||
props.onSubmit(action, values)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return <>
|
||||
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen} >
|
||||
{/* <Dialog > */}
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" onClick={() => props.setIsOpen(true)}>Open Parameters</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogTitle>
|
||||
Visualization Parameters
|
||||
</DialogTitle>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metric"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Metric to visualize</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Metric to Visualize" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent {...register('metric')} >
|
||||
<SelectItem value={Metric.qmprice}>Price per squaremeter</SelectItem>
|
||||
<SelectItem value={Metric.rooms}>Number of rooms</SelectItem>
|
||||
<SelectItem value={Metric.qm}>Area</SelectItem>
|
||||
<SelectItem value={Metric.price}>Price</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="listing_type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Listing Type</FormLabel>
|
||||
<FormControl >
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Metric to Visualize" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent >
|
||||
<SelectItem value={ListingType.BUY}>To buy</SelectItem>
|
||||
<SelectItem value={ListingType.RENT}>To rent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_sqm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Min square meters</FormLabel>
|
||||
<FormControl >
|
||||
<Input type="number" placeholder={"m²"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_bedrooms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Minimum number of bedrooms</FormLabel>
|
||||
<FormControl >
|
||||
<Input type="number" placeholder={"# bedrooms"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_bedrooms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Maximum number of bedrooms</FormLabel>
|
||||
<FormControl >
|
||||
<Input type="number" placeholder={"# bedrooms"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_price"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Min price</FormLabel>
|
||||
<FormControl >
|
||||
<Input type="number" placeholder={"£"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_price"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Max price</FormLabel>
|
||||
<FormControl >
|
||||
<Input type="number" placeholder={"£"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="available_from"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Available from</FormLabel>
|
||||
<FormControl>
|
||||
<Calendar29 onSelect={field.onChange} selected={field.value} rawInputValue={availableFromRawInput} onChangeRawInputValue={setAvailableFromRawInput} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Applicable for renting listings only
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="last_seen_days"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-4">
|
||||
<FormLabel>Last seen days</FormLabel>
|
||||
<FormControl >
|
||||
<Input type="number" placeholder={"days"} {...field} onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" value={"visualize"} onClick={() => setAction("visualize")}>Visualize existing data</Button>
|
||||
<Button type="submit" value={"fetch-data"} onClick={() => setAction("fetch-data")}>Submit data refresh job</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
|
||||
200
frontend/src/components/PropertyCard.tsx
Normal file
200
frontend/src/components/PropertyCard.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import type { PropertyProperties } from '@/types';
|
||||
|
||||
interface PropertyCardProps {
|
||||
property: PropertyProperties;
|
||||
variant?: 'compact' | 'full';
|
||||
isHighlighted?: boolean;
|
||||
avgPricePerSqm?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function PropertyCard({
|
||||
property,
|
||||
variant = 'compact',
|
||||
isHighlighted = false,
|
||||
avgPricePerSqm,
|
||||
onClick,
|
||||
}: PropertyCardProps) {
|
||||
const lastSeenDate = property.last_seen.split('T')[0];
|
||||
const lastSeenDays = Math.round((Date.now() - new Date(lastSeenDate).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Determine if this is a good deal
|
||||
const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9;
|
||||
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
|
||||
|
||||
const priceIndicator = isGoodDeal
|
||||
? { color: 'text-green-600 bg-green-50', label: 'Good deal' }
|
||||
: isExpensive
|
||||
? { color: 'text-red-600 bg-red-50', label: 'Above avg' }
|
||||
: null;
|
||||
|
||||
const handleClick = () => {
|
||||
window.open(property.url, '_blank', 'noopener,noreferrer');
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-3 p-3 rounded-lg border transition-colors cursor-pointer hover:bg-muted/50 ${
|
||||
isHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-20 rounded-md overflow-hidden flex-shrink-0 bg-muted">
|
||||
{property.photo_thumbnail && (
|
||||
<img
|
||||
src={property.photo_thumbnail}
|
||||
alt="Property"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-semibold text-base truncate">
|
||||
£{property.total_price.toLocaleString()}
|
||||
{property.listing_type !== 'BUY' && (
|
||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||
)}
|
||||
</div>
|
||||
{priceIndicator && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${priceIndicator.color}`}>
|
||||
{priceIndicator.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Bed className="h-3.5 w-3.5" />
|
||||
{property.rooms}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
{property.qm} m²
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
£{property.qmprice}/m²
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{lastSeenDays}d ago
|
||||
</span>
|
||||
<span className="truncate">{property.agency}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full variant (for popup/detail view)
|
||||
return (
|
||||
<div className={`p-4 border rounded-lg ${isHighlighted ? 'ring-2 ring-primary' : ''}`}>
|
||||
{/* Header with image and price */}
|
||||
<div className="flex gap-4">
|
||||
<a
|
||||
href={property.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-32 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{property.photo_thumbnail && (
|
||||
<img
|
||||
src={property.photo_thumbnail}
|
||||
alt="Property"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-xl">
|
||||
£{property.total_price.toLocaleString()}
|
||||
{property.listing_type !== 'BUY' && (
|
||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||
)}
|
||||
</div>
|
||||
{priceIndicator && (
|
||||
<span className={`inline-block mt-1 text-xs px-2 py-0.5 rounded ${priceIndicator.color}`}>
|
||||
{priceIndicator.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Bed className="h-4 w-4 text-muted-foreground" />
|
||||
<span><strong>{property.rooms}</strong> bedrooms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span><strong>{property.qm}</strong> m²</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<PoundSterling className="h-4 w-4 text-muted-foreground" />
|
||||
<span><strong>£{property.qmprice}</strong>/m²</span>
|
||||
</div>
|
||||
{property.listing_type !== 'BUY' && property.available_from && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Available <strong>{property.available_from}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
{property.listing_type === 'BUY' && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Seen <strong>{lastSeenDays}d</strong> ago</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agency and last seen */}
|
||||
<div className="flex items-center gap-2 mt-3 text-sm text-muted-foreground">
|
||||
<Building className="h-4 w-4" />
|
||||
<span>{property.agency}</span>
|
||||
<span className="mx-1">•</span>
|
||||
<span>Seen {lastSeenDays} days ago</span>
|
||||
</div>
|
||||
|
||||
{/* Price history */}
|
||||
{property.price_history.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Price history</div>
|
||||
<div className="space-y-0.5">
|
||||
{property.price_history.slice(0, 5).map((entry) => (
|
||||
<div key={entry.id} className="text-sm flex justify-between">
|
||||
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
|
||||
<span>£{entry.price.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4">
|
||||
<Button asChild className="w-full">
|
||||
<a href={property.url} target="_blank" rel="noopener noreferrer">
|
||||
View Listing
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/Spinner.tsx
Normal file
46
frontend/src/components/Spinner.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const spinnerVariants = cva('flex-col items-center justify-center', {
|
||||
variants: {
|
||||
show: {
|
||||
true: 'flex',
|
||||
false: 'hidden',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
show: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loaderVariants = cva('animate-spin text-primary', {
|
||||
variants: {
|
||||
size: {
|
||||
small: 'size-6',
|
||||
medium: 'size-8',
|
||||
large: 'size-12',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
});
|
||||
|
||||
interface SpinnerContentProps
|
||||
extends VariantProps<typeof spinnerVariants>,
|
||||
VariantProps<typeof loaderVariants> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Spinner({ size, show, children, className }: SpinnerContentProps) {
|
||||
return (
|
||||
<span className={spinnerVariants({ show })}>
|
||||
<Loader2 className={cn(loaderVariants({ size }), className)} />
|
||||
{/* <span className="text-red-400">Loading with custom style</span> */}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
128
frontend/src/components/StatsBar.tsx
Normal file
128
frontend/src/components/StatsBar.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types';
|
||||
|
||||
export type ViewMode = 'map' | 'list' | 'split';
|
||||
|
||||
interface StatsBarProps {
|
||||
listingData: GeoJSONFeatureCollection | null;
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
}
|
||||
|
||||
interface ListingStats {
|
||||
count: number;
|
||||
avgPrice: number;
|
||||
avgPricePerSqm: number;
|
||||
avgSize: number;
|
||||
}
|
||||
|
||||
function calculateStats(data: GeoJSONFeatureCollection | null): ListingStats {
|
||||
if (!data || data.features.length === 0) {
|
||||
return { count: 0, avgPrice: 0, avgPricePerSqm: 0, avgSize: 0 };
|
||||
}
|
||||
|
||||
const features = data.features;
|
||||
const count = features.length;
|
||||
|
||||
const validPrices = features
|
||||
.map((f: PropertyFeature) => f.properties.total_price)
|
||||
.filter((p): p is number => typeof p === 'number' && p > 0);
|
||||
|
||||
const validPricesPerSqm = features
|
||||
.map((f: PropertyFeature) => f.properties.qmprice)
|
||||
.filter((p): p is number => typeof p === 'number' && p > 0);
|
||||
|
||||
const validSizes = features
|
||||
.map((f: PropertyFeature) => f.properties.qm)
|
||||
.filter((s): s is number => typeof s === 'number' && s > 0);
|
||||
|
||||
const avgPrice = validPrices.length > 0
|
||||
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
||||
: 0;
|
||||
|
||||
const avgPricePerSqm = validPricesPerSqm.length > 0
|
||||
? validPricesPerSqm.reduce((a, b) => a + b, 0) / validPricesPerSqm.length
|
||||
: 0;
|
||||
|
||||
const avgSize = validSizes.length > 0
|
||||
? validSizes.reduce((a, b) => a + b, 0) / validSizes.length
|
||||
: 0;
|
||||
|
||||
return { count, avgPrice, avgPricePerSqm, avgSize };
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
if (value >= 1000) {
|
||||
return `£${(value / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return `£${Math.round(value)}`;
|
||||
}
|
||||
|
||||
export function StatsBar({ listingData, viewMode, onViewModeChange }: StatsBarProps) {
|
||||
const stats = calculateStats(listingData);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-t text-sm">
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">{stats.count.toLocaleString()}</span>
|
||||
<span className="hidden sm:inline">listings</span>
|
||||
</div>
|
||||
|
||||
{stats.avgPrice > 0 && (
|
||||
<>
|
||||
<div className="hidden md:flex items-center gap-1.5">
|
||||
<PoundSterling className="h-4 w-4" />
|
||||
<span>Avg: <span className="font-medium text-foreground">{formatCurrency(stats.avgPrice)}</span></span>
|
||||
</div>
|
||||
<div className="hidden lg:flex items-center gap-1.5">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span>Avg £/m²: <span className="font-medium text-foreground">{formatCurrency(stats.avgPricePerSqm)}</span></span>
|
||||
</div>
|
||||
<div className="hidden lg:flex items-center gap-1.5">
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
<span>Avg: <span className="font-medium text-foreground">{Math.round(stats.avgSize)} m²</span></span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center gap-1 bg-background rounded-md border p-0.5">
|
||||
<Button
|
||||
variant={viewMode === 'map' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => onViewModeChange('map')}
|
||||
>
|
||||
<MapIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-1">Map</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => onViewModeChange('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-1">List</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'split' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2 hidden md:flex"
|
||||
onClick={() => onViewModeChange('split')}
|
||||
>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="w-2 h-4 bg-current rounded-sm opacity-60" />
|
||||
<div className="w-2 h-4 border border-current rounded-sm" />
|
||||
</div>
|
||||
<span className="hidden sm:inline ml-1">Split</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/StreamingProgressBar.tsx
Normal file
47
frontend/src/components/StreamingProgressBar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Loader2 } from 'lucide-react';
|
||||
import type { StreamingProgress } from '@/services';
|
||||
|
||||
interface StreamingProgressBarProps {
|
||||
progress: StreamingProgress | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function StreamingProgressBar({ progress, isLoading }: StreamingProgressBarProps) {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0 right-0 z-10 bg-background/95 backdrop-blur-sm border-b px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{progress
|
||||
? `Loading listings...`
|
||||
: 'Loading...'}
|
||||
</span>
|
||||
{progress && (
|
||||
<span className="text-muted-foreground">
|
||||
{progress.count.toLocaleString()}
|
||||
{progress.total ? ` / ${progress.total.toLocaleString()}` : ''} loaded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{progress && (
|
||||
<div className="mt-1 h-1.5 w-full bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out rounded-full"
|
||||
style={{
|
||||
width: progress.total
|
||||
? `${Math.min((progress.count / progress.total) * 100, 100)}%`
|
||||
: '100%',
|
||||
animation: progress.total ? undefined : 'pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
frontend/src/components/TaskIndicator.tsx
Normal file
294
frontend/src/components/TaskIndicator.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { getUser } from '@/auth/authService';
|
||||
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
||||
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
||||
import { POLLING_INTERVALS } from '@/constants';
|
||||
import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services';
|
||||
import { TaskStatus, type TaskResult } from '@/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { Button } from './ui/button';
|
||||
import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
|
||||
import { TaskProgressDrawer } from './TaskProgressDrawer';
|
||||
|
||||
interface TaskIndicatorProps {
|
||||
taskID: string | null;
|
||||
onTaskCancelled?: () => void;
|
||||
}
|
||||
|
||||
export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
||||
const [processed, setProcessed] = useState<number | null>(null);
|
||||
const [total, setTotal] = useState<number | null>(null);
|
||||
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(null);
|
||||
const [taskResult, setTaskResult] = useState<TaskResult | null>(null);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const passkeyUser = getStoredPasskeyUser();
|
||||
if (passkeyUser) {
|
||||
setUser(passkeyUser);
|
||||
} else {
|
||||
getUser().then((oidcUser) => {
|
||||
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !taskID) {
|
||||
setTaskStatus(null);
|
||||
setTaskResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state for new task
|
||||
setTaskStatus(TaskStatus.PENDING);
|
||||
setProgressPercentage(0);
|
||||
setProcessed(null);
|
||||
setTotal(null);
|
||||
setTaskResult(null);
|
||||
|
||||
const pollTaskStatus = async () => {
|
||||
try {
|
||||
const data = await fetchTaskStatus(user, taskID);
|
||||
const status = data.status as TaskStatus;
|
||||
setTaskStatus(status);
|
||||
|
||||
if (status === TaskStatus.SUCCESS) {
|
||||
setProgressPercentage(100);
|
||||
// Parse final result for the drawer to show completed state.
|
||||
// Only update taskResult if the new result has phase info;
|
||||
// otherwise keep the last in-progress result which has richer data
|
||||
// than the bare SUCCESS return value.
|
||||
if (data.result) {
|
||||
try {
|
||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
||||
if (parsedResult.phase) {
|
||||
setTaskResult(parsedResult);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
return true; // Stop polling
|
||||
}
|
||||
|
||||
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
||||
return true; // Stop polling
|
||||
}
|
||||
|
||||
// Parse progress for in-progress tasks
|
||||
if (data.result) {
|
||||
try {
|
||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
||||
// Only update taskResult if the parsed data has a phase field.
|
||||
// This prevents blanking the drawer when the backend sends a
|
||||
// state update without phase info (e.g. during brief transitions).
|
||||
if (parsedResult.phase) {
|
||||
setTaskResult(parsedResult);
|
||||
}
|
||||
// Only update progress/processed/total when the fields are
|
||||
// actually present — otherwise keep the previous values so
|
||||
// the UI doesn't flash back to 0 during phase transitions.
|
||||
if (parsedResult.progress !== undefined) {
|
||||
setProgressPercentage(parsedResult.progress * 100);
|
||||
}
|
||||
if (parsedResult.processed !== undefined) {
|
||||
setProcessed(parsedResult.processed);
|
||||
}
|
||||
if (parsedResult.total !== undefined) {
|
||||
setTotal(parsedResult.total);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
return false; // Continue polling
|
||||
} catch {
|
||||
setTaskStatus(TaskStatus.FAILURE);
|
||||
return true; // Stop polling on error
|
||||
}
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
pollTaskStatus();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const shouldStop = await pollTaskStatus();
|
||||
if (shouldStop) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, POLLING_INTERVALS.TASK_STATUS_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [taskID, user]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!user || !taskID || isCancelling) return;
|
||||
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
const result = await cancelTask(user, taskID);
|
||||
if (result.success) {
|
||||
setTaskStatus(TaskStatus.REVOKED);
|
||||
onTaskCancelled?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!user || isClearing) return;
|
||||
|
||||
setIsClearing(true);
|
||||
try {
|
||||
const result = await clearAllTasks(user);
|
||||
if (result.success) {
|
||||
setTaskStatus(null);
|
||||
setTaskResult(null);
|
||||
onTaskCancelled?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore clear errors
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!taskID || !taskStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInProgress = taskStatus !== TaskStatus.SUCCESS &&
|
||||
taskStatus !== TaskStatus.FAILURE &&
|
||||
taskStatus !== TaskStatus.REVOKED;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isInProgress) {
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
|
||||
}
|
||||
if (taskStatus === TaskStatus.SUCCESS) {
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />;
|
||||
}
|
||||
return <XCircle className="h-3.5 w-3.5 text-red-500" />;
|
||||
};
|
||||
|
||||
const getProgressText = () => {
|
||||
if (processed !== null && total !== null && total > 0) {
|
||||
return `${processed} / ${total}`;
|
||||
}
|
||||
if (taskResult?.phase && taskResult.phase !== 'processing') {
|
||||
const phaseLabels: Record<string, string> = {
|
||||
splitting: 'Splitting',
|
||||
splitting_complete: 'Split done',
|
||||
fetching: 'Fetching',
|
||||
};
|
||||
return phaseLabels[taskResult.phase] ?? `${Math.round(progressPercentage)}%`;
|
||||
}
|
||||
return `${Math.round(progressPercentage)}%`;
|
||||
};
|
||||
|
||||
const getTooltipContent = () => {
|
||||
if (isInProgress) {
|
||||
if (processed !== null && total !== null && total > 0) {
|
||||
return `Processing: ${processed} / ${total} listings (${Math.round(progressPercentage)}%) — click for details`;
|
||||
}
|
||||
return `Task running: ${getProgressText()} — click for details`;
|
||||
}
|
||||
if (taskStatus === TaskStatus.SUCCESS) {
|
||||
return 'Task completed successfully — click for details';
|
||||
}
|
||||
if (taskStatus === TaskStatus.REVOKED) {
|
||||
return 'Task was cancelled';
|
||||
}
|
||||
return 'Task failed';
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
{isInProgress && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-1.5 bg-primary/20 rounded-full overflow-hidden hidden sm:block">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out rounded-full"
|
||||
style={{ width: `${Math.min(progressPercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline min-w-[60px]">
|
||||
{getProgressText()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isInProgress && (
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{taskStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{getTooltipContent()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">ID: {taskID.slice(0, 8)}...</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isInProgress && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Cancel task</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClearAll}
|
||||
disabled={isClearing}
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Clear all tasks</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TaskProgressDrawer
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
taskResult={taskResult}
|
||||
taskStatus={taskStatus}
|
||||
taskID={taskID}
|
||||
onCancel={handleCancel}
|
||||
isCancelling={isCancelling}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
367
frontend/src/components/TaskProgressDrawer.tsx
Normal file
367
frontend/src/components/TaskProgressDrawer.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import { TaskStatus, type TaskPhase, type TaskResult } from '@/types';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from './ui/sheet';
|
||||
import { Button } from './ui/button';
|
||||
import { CheckCircle2, Circle, Loader2, XCircle } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface TaskProgressDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
taskResult: TaskResult | null;
|
||||
taskStatus: TaskStatus | null;
|
||||
taskID: string | null;
|
||||
onCancel: () => void;
|
||||
isCancelling: boolean;
|
||||
}
|
||||
|
||||
const PHASES: { key: TaskPhase; label: string }[] = [
|
||||
{ key: 'splitting', label: 'Splitting queries' },
|
||||
{ key: 'fetching', label: 'Fetching & processing' },
|
||||
{ key: 'processing', label: 'Processing remaining' },
|
||||
];
|
||||
|
||||
function getPhaseIndex(phase: TaskPhase | undefined): number {
|
||||
if (!phase) return -1;
|
||||
if (phase === 'splitting_complete') return 1; // splitting done, fetching is next
|
||||
if (phase === 'completed') return PHASES.length;
|
||||
return PHASES.findIndex((p) => p.key === phase);
|
||||
}
|
||||
|
||||
function formatEta(seconds: number | undefined): string {
|
||||
if (seconds === undefined || seconds <= 0) return '';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
if (mins > 0) {
|
||||
return `~${mins}m ${secs}s remaining`;
|
||||
}
|
||||
return `~${secs}s remaining`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: TaskStatus | null }) {
|
||||
if (!status) return null;
|
||||
|
||||
const isInProgress =
|
||||
status !== TaskStatus.SUCCESS &&
|
||||
status !== TaskStatus.FAILURE &&
|
||||
status !== TaskStatus.REVOKED;
|
||||
|
||||
if (isInProgress) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Running
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === TaskStatus.SUCCESS) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === TaskStatus.REVOKED) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Cancelled
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseTimeline({
|
||||
currentPhase,
|
||||
taskStatus,
|
||||
}: {
|
||||
currentPhase: TaskPhase | undefined;
|
||||
taskStatus: TaskStatus | null;
|
||||
}) {
|
||||
const isTerminal =
|
||||
taskStatus === TaskStatus.SUCCESS ||
|
||||
taskStatus === TaskStatus.FAILURE ||
|
||||
taskStatus === TaskStatus.REVOKED;
|
||||
const activeIdx = isTerminal ? PHASES.length : getPhaseIndex(currentPhase);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{PHASES.map((phase, idx) => {
|
||||
const isCompleted = idx < activeIdx;
|
||||
const isActive = idx === activeIdx && !isTerminal;
|
||||
const isFuture = idx > activeIdx;
|
||||
|
||||
return (
|
||||
<div key={phase.key} className="flex items-center gap-2">
|
||||
{isCompleted && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
)}
|
||||
{isActive && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500 shrink-0" />
|
||||
)}
|
||||
{isFuture && (
|
||||
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
isActive
|
||||
? 'text-sm font-medium text-foreground'
|
||||
: isCompleted
|
||||
? 'text-sm text-muted-foreground'
|
||||
: 'text-sm text-muted-foreground/40'
|
||||
}
|
||||
>
|
||||
{phase.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterRow({ label, value, total }: { label: string; value?: number; total?: number }) {
|
||||
if (value === undefined) return null;
|
||||
return (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{value}
|
||||
{total !== undefined && ` / ${total}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseDetails({ result }: { result: TaskResult }) {
|
||||
const phase = result.phase;
|
||||
|
||||
if (phase === 'splitting' || phase === 'splitting_complete') {
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Splitting
|
||||
</p>
|
||||
<CounterRow
|
||||
label="Subqueries probed"
|
||||
value={result.subqueries_probed}
|
||||
total={result.subqueries_initial}
|
||||
/>
|
||||
{result.subqueries_total !== undefined && (
|
||||
<CounterRow label="Final subqueries" value={result.subqueries_total} />
|
||||
)}
|
||||
{result.estimated_results !== undefined && (
|
||||
<CounterRow label="Estimated results" value={result.estimated_results} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'fetching') {
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{result.fetching_done ? 'Fetching complete' : 'Fetching & processing'}
|
||||
</p>
|
||||
<CounterRow
|
||||
label="Subqueries completed"
|
||||
value={result.subqueries_completed}
|
||||
total={result.subqueries_total}
|
||||
/>
|
||||
<CounterRow label="IDs collected" value={result.ids_collected} />
|
||||
<CounterRow label="Pages fetched" value={result.pages_fetched} />
|
||||
{(result.details_fetched !== undefined && result.details_fetched > 0) && (
|
||||
<>
|
||||
<div className="border-t my-2" />
|
||||
<CounterRow
|
||||
label="Details fetched"
|
||||
value={result.details_fetched}
|
||||
total={result.total}
|
||||
/>
|
||||
<CounterRow label="Images downloaded" value={result.images_downloaded} />
|
||||
<CounterRow label="OCR completed" value={result.ocr_completed} />
|
||||
{(result.failed ?? 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-red-500">Failed</span>
|
||||
<span className="font-mono tabular-nums text-red-500">{result.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'processing') {
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Processing
|
||||
</p>
|
||||
<CounterRow
|
||||
label="Details fetched"
|
||||
value={result.details_fetched}
|
||||
total={result.total}
|
||||
/>
|
||||
<CounterRow label="Images downloaded" value={result.images_downloaded} />
|
||||
<CounterRow label="OCR completed" value={result.ocr_completed} />
|
||||
{(result.failed ?? 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-red-500">Failed</span>
|
||||
<span className="font-mono tabular-nums text-red-500">{result.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function LogViewer({ logs }: { logs: string[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScrolling = useRef(true);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
||||
isAutoScrolling.current = atBottom;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoScrolling.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="rounded-md bg-zinc-950 p-3 overflow-y-auto font-mono text-[11px] leading-4 text-zinc-300 min-h-[100px] h-full"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<span className="text-zinc-500 italic">Waiting for logs...</span>
|
||||
) : (
|
||||
logs.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskProgressDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
taskResult,
|
||||
taskStatus,
|
||||
taskID,
|
||||
onCancel,
|
||||
isCancelling,
|
||||
}: TaskProgressDrawerProps) {
|
||||
const isInProgress =
|
||||
taskStatus !== null &&
|
||||
taskStatus !== TaskStatus.SUCCESS &&
|
||||
taskStatus !== TaskStatus.FAILURE &&
|
||||
taskStatus !== TaskStatus.REVOKED;
|
||||
|
||||
const progressPercent = taskResult
|
||||
? Math.min((taskResult.progress ?? 0) * 100, 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex flex-col w-full sm:!max-w-lg">
|
||||
<SheetHeader>
|
||||
<div className="flex items-center justify-between pr-6">
|
||||
<SheetTitle>Crawl Job Progress</SheetTitle>
|
||||
<StatusBadge status={taskStatus} />
|
||||
</div>
|
||||
{taskID && (
|
||||
<SheetDescription>
|
||||
Task ID: {taskID.slice(0, 8)}...
|
||||
</SheetDescription>
|
||||
)}
|
||||
</SheetHeader>
|
||||
|
||||
{/* Fixed top section: timeline + counters + progress */}
|
||||
<div className="space-y-3 px-4 shrink-0">
|
||||
<PhaseTimeline
|
||||
currentPhase={taskResult?.phase}
|
||||
taskStatus={taskStatus}
|
||||
/>
|
||||
|
||||
{taskResult && <PhaseDetails result={taskResult} />}
|
||||
|
||||
{taskResult && (taskResult.phase === 'processing' || taskResult.phase === 'fetching') && (taskResult.total ?? 0) > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="w-full h-2 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out rounded-full"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{taskResult.processed ?? 0} / {taskResult.total ?? '?'}
|
||||
</span>
|
||||
<span>{formatEta(taskResult.eta_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskResult?.message && (
|
||||
<p className="text-sm text-muted-foreground">{taskResult.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log viewer fills remaining space */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-1 px-4 pb-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide shrink-0">
|
||||
Worker Logs
|
||||
</p>
|
||||
<div className="flex-1 min-h-0">
|
||||
<LogViewer logs={taskResult?.logs ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInProgress && (
|
||||
<SheetFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onCancel}
|
||||
disabled={isCancelling}
|
||||
className="w-full"
|
||||
>
|
||||
{isCancelling ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Cancelling...
|
||||
</>
|
||||
) : (
|
||||
'Cancel Job'
|
||||
)}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/ui/DatePicker.tsx
Normal file
111
frontend/src/components/ui/DatePicker.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client"
|
||||
|
||||
import { parseDate } from "chrono-node"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
function formatDate(date: Date | undefined) {
|
||||
if (!date) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
export function Calendar29(
|
||||
props: {
|
||||
onSelect?: (date?: Date) => void,
|
||||
selected?: Date | undefined,
|
||||
rawInputValue?: string | undefined
|
||||
onChangeRawInputValue?: (rawInputValue: string) => void
|
||||
}
|
||||
) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [value, setValue] = React.useState(props.rawInputValue ?? "now")
|
||||
|
||||
const [date, setDate] = React.useState<Date | undefined>(
|
||||
parseDate(value) || undefined
|
||||
)
|
||||
const [month, setMonth] = React.useState<Date | undefined>(date)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative flex gap-2">
|
||||
<Input
|
||||
id="date"
|
||||
value={props.rawInputValue !== undefined ? props.rawInputValue : value}
|
||||
placeholder="Tomorrow or next week"
|
||||
className="bg-background pr-10"
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
if (props.onChangeRawInputValue) {
|
||||
props.onChangeRawInputValue(e.target.value)
|
||||
}
|
||||
const date = parseDate(e.target.value)
|
||||
if (date) {
|
||||
setDate(date)
|
||||
setMonth(date)
|
||||
if (props.onSelect) {
|
||||
props.onSelect(date)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="date-picker"
|
||||
variant="ghost"
|
||||
className="absolute top-1/2 right-2 size-6 -translate-y-1/2"
|
||||
>
|
||||
<CalendarIcon className="size-3.5" />
|
||||
<span className="sr-only">Select date</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onSelect={(date) => {
|
||||
setDate(date)
|
||||
setValue(formatDate(date))
|
||||
if (props.onChangeRawInputValue) {
|
||||
props.onChangeRawInputValue(formatDate(date))
|
||||
}
|
||||
setOpen(false)
|
||||
if (props.onSelect) {
|
||||
props.onSelect(date)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="text-muted-foreground px-1 text-sm">
|
||||
<span className="font-medium">{formatDate(date)}</span>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/ui/accordion.tsx
Normal file
56
frontend/src/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
155
frontend/src/components/ui/alert-dialog.tsx
Normal file
155
frontend/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
90
frontend/src/components/ui/breadcrumb.tsx
Normal file
90
frontend/src/components/ui/breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
}
|
||||
59
frontend/src/components/ui/button.tsx
Normal file
59
frontend/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
208
frontend/src/components/ui/calendar.tsx
Normal file
208
frontend/src/components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
29
frontend/src/components/ui/checkbox.tsx
Normal file
29
frontend/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
141
frontend/src/components/ui/dialog.tsx
Normal file
141
frontend/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
165
frontend/src/components/ui/form.tsx
Normal file
165
frontend/src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
42
frontend/src/components/ui/hover-card.tsx
Normal file
42
frontend/src/components/ui/hover-card.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
22
frontend/src/components/ui/label.tsx
Normal file
22
frontend/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
46
frontend/src/components/ui/popover.tsx
Normal file
46
frontend/src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
29
frontend/src/components/ui/progress.tsx
Normal file
29
frontend/src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
56
frontend/src/components/ui/scroll-area.tsx
Normal file
56
frontend/src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
183
frontend/src/components/ui/select.tsx
Normal file
183
frontend/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
frontend/src/components/ui/separator.tsx
Normal file
28
frontend/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
137
frontend/src/components/ui/sheet.tsx
Normal file
137
frontend/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
725
frontend/src/components/ui/sidebar.tsx
Normal file
725
frontend/src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,725 @@
|
|||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
}
|
||||
|
||||
13
frontend/src/components/ui/skeleton.tsx
Normal file
13
frontend/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
34
frontend/src/components/ui/slider.tsx
Normal file
34
frontend/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
{props.defaultValue?.map((_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={index}
|
||||
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
)) ?? (
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
)}
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
60
frontend/src/components/ui/tabs.tsx
Normal file
60
frontend/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return <TabsPrimitive.Root data-slot="tabs" {...props} />
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
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 flex-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
61
frontend/src/components/ui/tooltip.tsx
Normal file
61
frontend/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
83
frontend/src/constants/colorSchemes.ts
Normal file
83
frontend/src/constants/colorSchemes.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Color schemes for map visualization
|
||||
// Different color schemes for different metrics to improve clarity
|
||||
|
||||
import { Metric } from "@/components/Parameters";
|
||||
|
||||
// For metrics where LOW is GOOD (price, price per sqm): Green → Yellow → Red
|
||||
export const LOW_IS_GOOD_COLOR_STOPS: [number, string][] = [
|
||||
[0, 'rgba(34, 197, 94, 0.7)'], // Green - good deal
|
||||
[25, 'rgba(132, 204, 22, 0.7)'], // Lime
|
||||
[50, 'rgba(250, 204, 21, 0.7)'], // Yellow - neutral
|
||||
[75, 'rgba(249, 115, 22, 0.7)'], // Orange
|
||||
[100, 'rgba(239, 68, 68, 0.7)'], // Red - expensive
|
||||
];
|
||||
|
||||
// For metrics where HIGH is GOOD (size, rooms): Red → Yellow → Green
|
||||
export const HIGH_IS_GOOD_COLOR_STOPS: [number, string][] = [
|
||||
[0, 'rgba(239, 68, 68, 0.7)'], // Red - small
|
||||
[25, 'rgba(249, 115, 22, 0.7)'], // Orange
|
||||
[50, 'rgba(250, 204, 21, 0.7)'], // Yellow - medium
|
||||
[75, 'rgba(132, 204, 22, 0.7)'], // Lime
|
||||
[100, 'rgba(34, 197, 94, 0.7)'], // Green - large
|
||||
];
|
||||
|
||||
// Legacy color stops (for backwards compatibility)
|
||||
export const LEGACY_COLOR_STOPS: [number, string][] = [
|
||||
[0, 'rgba(0,185,243,0)'],
|
||||
[25, 'rgba(0,185,243,0.24)'],
|
||||
[60, 'rgba(255,223,0,0.3)'],
|
||||
[100, 'rgba(255,105,0,0.3)'],
|
||||
];
|
||||
|
||||
// Get the appropriate color scheme based on metric type
|
||||
export function getColorSchemeForMetric(metric: Metric | string): [number, string][] {
|
||||
switch (metric) {
|
||||
case Metric.qmprice:
|
||||
case Metric.price:
|
||||
case 'qmprice':
|
||||
case 'total_price':
|
||||
// Lower price is better → Green for low, Red for high
|
||||
return LOW_IS_GOOD_COLOR_STOPS;
|
||||
|
||||
case Metric.qm:
|
||||
case Metric.rooms:
|
||||
case 'qm':
|
||||
case 'rooms':
|
||||
// Higher value is better → Green for high, Red for low
|
||||
return HIGH_IS_GOOD_COLOR_STOPS;
|
||||
|
||||
default:
|
||||
return LOW_IS_GOOD_COLOR_STOPS;
|
||||
}
|
||||
}
|
||||
|
||||
// Get interpretation text for legend
|
||||
export function getMetricInterpretation(metric: Metric | string): { low: string; high: string; name: string } {
|
||||
switch (metric) {
|
||||
case Metric.qmprice:
|
||||
case 'qmprice':
|
||||
return { low: 'Good deal', high: 'Expensive', name: 'Price per m²' };
|
||||
|
||||
case Metric.price:
|
||||
case 'total_price':
|
||||
return { low: 'Good deal', high: 'Expensive', name: 'Total Price' };
|
||||
|
||||
case Metric.qm:
|
||||
case 'qm':
|
||||
return { low: 'Small', high: 'Large', name: 'Size (m²)' };
|
||||
|
||||
case Metric.rooms:
|
||||
case 'rooms':
|
||||
return { low: 'Few rooms', high: 'Many rooms', name: 'Bedrooms' };
|
||||
|
||||
default:
|
||||
return { low: 'Low', high: 'High', name: 'Value' };
|
||||
}
|
||||
}
|
||||
|
||||
// Color scheme names for display
|
||||
export const COLOR_SCHEME_NAMES = {
|
||||
LOW_IS_GOOD: 'Green → Red (low is good)',
|
||||
HIGH_IS_GOOD: 'Red → Green (high is good)',
|
||||
LEGACY: 'Classic (blue → orange)',
|
||||
} as const;
|
||||
62
frontend/src/constants/index.ts
Normal file
62
frontend/src/constants/index.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Application constants and configuration
|
||||
|
||||
// Re-export color schemes
|
||||
export * from './colorSchemes';
|
||||
|
||||
// API endpoints
|
||||
export const API_ENDPOINTS = {
|
||||
LISTING_GEOJSON: '/api/listing_geojson',
|
||||
LISTING_GEOJSON_STREAM: '/api/listing_geojson/stream',
|
||||
REFRESH_LISTINGS: '/api/refresh_listings',
|
||||
TASK_STATUS: '/api/task_status',
|
||||
TASKS_FOR_USER: '/api/tasks_for_user',
|
||||
CANCEL_TASK: '/api/cancel_task',
|
||||
CLEAR_ALL_TASKS: '/api/clear_all_tasks',
|
||||
GET_DISTRICTS: '/api/get_districts',
|
||||
} as const;
|
||||
|
||||
// Map configuration
|
||||
export const MAP_CONFIG = {
|
||||
MAPBOX_TOKEN: import.meta.env.VITE_MAPBOX_TOKEN || 'pk.eyJ1IjoiZGktdG8iLCJhIjoiY2o0bnBoYXcxMW1mNzJ3bDhmc2xiNWttaiJ9.ZccatVk_4shzoAsEUXXecA',
|
||||
DEFAULT_CENTER: [13.38032, 49.994210] as [number, number],
|
||||
DEFAULT_ZOOM: 5,
|
||||
STYLE: 'mapbox://styles/mapbox/light-v9',
|
||||
} as const;
|
||||
|
||||
// Heatmap configuration
|
||||
export const HEATMAP_CONFIG = {
|
||||
INTENSITY: 9,
|
||||
SPREAD: 0.05,
|
||||
CELL_DENSITY: 0.5, // Smaller value = bigger hexagons
|
||||
} as const;
|
||||
|
||||
// Percentile configuration for data visualization
|
||||
export const PERCENTILE_CONFIG = {
|
||||
MIN_BOUND: 0.05, // 5th percentile for color scale minimum
|
||||
MAX_BOUND: 0.95, // 95th percentile for color scale maximum
|
||||
BOUNDS_CLIP_MIN: 0.01, // 1st percentile for bounding box
|
||||
BOUNDS_CLIP_MAX: 0.99, // 99th percentile for bounding box
|
||||
} as const;
|
||||
|
||||
// Heatmap color gradient stops
|
||||
export const HEATMAP_COLOR_STOPS: [number, string][] = [
|
||||
[0, 'rgba(0,185,243,0)'],
|
||||
[25, 'rgba(0,185,243,0.24)'],
|
||||
[60, 'rgba(255,223,0,0.3)'],
|
||||
[100, 'rgba(255,105,0,0.3)'],
|
||||
];
|
||||
|
||||
// Default form values
|
||||
export const DEFAULT_FORM_VALUES = {
|
||||
min_bedrooms: 1,
|
||||
max_bedrooms: 3,
|
||||
max_price: 3000,
|
||||
min_price: 2000,
|
||||
min_sqm: 50,
|
||||
last_seen_days: 28,
|
||||
} as const;
|
||||
|
||||
// Polling intervals
|
||||
export const POLLING_INTERVALS = {
|
||||
TASK_STATUS_MS: 5000, // 5 seconds
|
||||
} as const;
|
||||
19
frontend/src/hooks/use-mobile.ts
Normal file
19
frontend/src/hooks/use-mobile.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
147
frontend/src/index.css
Normal file
147
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accordion animations */
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-accordion-down {
|
||||
animation: accordion-down 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-accordion-up {
|
||||
animation: accordion-up 0.2s ease-out;
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
import { AuthProvider } from "react-oidc-context";
|
||||
import { oidcConfig } from './auth/config.ts';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthProvider {...oidcConfig}>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
61
frontend/src/services/apiClient.ts
Normal file
61
frontend/src/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Generic API client with authentication
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import { ApiError } from '@/types';
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
params?: Record<string, string | number | boolean | Date | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters object
|
||||
*/
|
||||
function buildQueryString(params: Record<string, string | number | boolean | Date | undefined>): string {
|
||||
const queryString = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (value instanceof Date) {
|
||||
queryString.append(key, value.toISOString());
|
||||
} else {
|
||||
queryString.append(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queryString.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic authenticated API request
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
user: AuthUser,
|
||||
endpoint: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', params } = options;
|
||||
|
||||
let url = endpoint;
|
||||
if (params) {
|
||||
const queryString = buildQueryString(params);
|
||||
if (queryString) {
|
||||
url = `${endpoint}?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
74
frontend/src/services/healthService.ts
Normal file
74
frontend/src/services/healthService.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Health check service for backend connectivity
|
||||
|
||||
export type HealthStatus = 'checking' | 'healthy' | 'unhealthy';
|
||||
|
||||
export interface HealthCheckResult {
|
||||
status: HealthStatus;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check backend health by calling the /api/status endpoint
|
||||
*/
|
||||
export async function checkBackendHealth(): Promise<HealthCheckResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Short timeout for health checks
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
const latencyMs = Math.round(performance.now() - startTime);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: `HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'OK') {
|
||||
return {
|
||||
status: 'healthy',
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: 'Unexpected response',
|
||||
};
|
||||
} catch (error) {
|
||||
const latencyMs = Math.round(performance.now() - startTime);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: 'Request timeout',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
error: 'Connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
6
frontend/src/services/index.ts
Normal file
6
frontend/src/services/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Re-export all services
|
||||
export { apiRequest } from './apiClient';
|
||||
export { fetchListingGeoJSON, refreshListings } from './listingService';
|
||||
export { streamListingGeoJSON, type StreamingProgress } from './streamingService';
|
||||
export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService';
|
||||
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
||||
54
frontend/src/services/listingService.ts
Normal file
54
frontend/src/services/listingService.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Listing service for fetching and refreshing listings
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { GeoJSONFeatureCollection, RefreshListingsResponse } from '@/types';
|
||||
import type { ParameterValues } from '@/components/FilterPanel';
|
||||
import { apiRequest } from './apiClient';
|
||||
import { API_ENDPOINTS } from '@/constants';
|
||||
|
||||
/**
|
||||
* Build listing query parameters from form values
|
||||
*/
|
||||
function buildListingParams(parameters: ParameterValues): Record<string, string | number | boolean | Date | undefined> {
|
||||
return {
|
||||
listing_type: parameters.listing_type,
|
||||
min_bedrooms: parameters.min_bedrooms,
|
||||
max_bedrooms: parameters.max_bedrooms,
|
||||
max_price: parameters.max_price,
|
||||
min_price: parameters.min_price,
|
||||
min_sqm: parameters.min_sqm,
|
||||
max_sqm: parameters.max_sqm,
|
||||
min_price_per_sqm: parameters.min_price_per_sqm,
|
||||
max_price_per_sqm: parameters.max_price_per_sqm,
|
||||
last_seen_days: parameters.last_seen_days,
|
||||
let_date_available_from: parameters.available_from,
|
||||
district_names: parameters.district || undefined,
|
||||
furnish_types: parameters.furnish_types?.join(',') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch listing data as GeoJSON
|
||||
*/
|
||||
export async function fetchListingGeoJSON(
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues
|
||||
): Promise<GeoJSONFeatureCollection> {
|
||||
return apiRequest<GeoJSONFeatureCollection>(user, API_ENDPOINTS.LISTING_GEOJSON, {
|
||||
method: 'GET',
|
||||
params: buildListingParams(parameters),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a listing refresh task
|
||||
*/
|
||||
export async function refreshListings(
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues
|
||||
): Promise<RefreshListingsResponse> {
|
||||
return apiRequest<RefreshListingsResponse>(user, API_ENDPOINTS.REFRESH_LISTINGS, {
|
||||
method: 'POST',
|
||||
params: buildListingParams(parameters),
|
||||
});
|
||||
}
|
||||
137
frontend/src/services/streamingService.ts
Normal file
137
frontend/src/services/streamingService.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Streaming service for progressive listing data loading
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { PropertyFeature } from '@/types';
|
||||
import type { ParameterValues } from '@/components/FilterPanel';
|
||||
import { ApiError } from '@/types';
|
||||
import { API_ENDPOINTS } from '@/constants';
|
||||
|
||||
/**
|
||||
* Build query string from parameters object
|
||||
*/
|
||||
function buildQueryString(params: Record<string, string | number | boolean | Date | undefined>): string {
|
||||
const queryString = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (value instanceof Date) {
|
||||
queryString.append(key, value.toISOString());
|
||||
} else {
|
||||
queryString.append(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queryString.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build listing query parameters from form values
|
||||
*/
|
||||
function buildListingParams(parameters: ParameterValues): Record<string, string | number | boolean | Date | undefined> {
|
||||
return {
|
||||
listing_type: parameters.listing_type,
|
||||
min_bedrooms: parameters.min_bedrooms,
|
||||
max_bedrooms: parameters.max_bedrooms,
|
||||
max_price: parameters.max_price,
|
||||
min_price: parameters.min_price,
|
||||
min_sqm: parameters.min_sqm,
|
||||
max_sqm: parameters.max_sqm,
|
||||
min_price_per_sqm: parameters.min_price_per_sqm,
|
||||
max_price_per_sqm: parameters.max_price_per_sqm,
|
||||
last_seen_days: parameters.last_seen_days,
|
||||
let_date_available_from: parameters.available_from,
|
||||
district_names: parameters.district || undefined,
|
||||
furnish_types: parameters.furnish_types?.join(',') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamMessage {
|
||||
type: 'metadata' | 'batch' | 'complete';
|
||||
features?: PropertyFeature[];
|
||||
total?: number;
|
||||
total_expected?: number;
|
||||
batch_size?: number;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamingProgress {
|
||||
count: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream listing GeoJSON data as an async generator.
|
||||
* Yields batches of features as they arrive from the server.
|
||||
*/
|
||||
export async function* streamListingGeoJSON(
|
||||
user: AuthUser,
|
||||
parameters: ParameterValues,
|
||||
onProgress?: (progress: StreamingProgress) => void
|
||||
): AsyncGenerator<PropertyFeature[], void, unknown> {
|
||||
const params = buildListingParams(parameters);
|
||||
const queryString = buildQueryString(params);
|
||||
const url = queryString
|
||||
? `${API_ENDPOINTS.LISTING_GEOJSON_STREAM}?${queryString}`
|
||||
: API_ENDPOINTS.LISTING_GEOJSON_STREAM;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Error: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let totalCount = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(line);
|
||||
|
||||
if (message.type === 'metadata') {
|
||||
onProgress?.({ count: 0, total: message.total_expected });
|
||||
} else if (message.type === 'batch' && message.features) {
|
||||
totalCount += message.features.length;
|
||||
onProgress?.({ count: totalCount });
|
||||
yield message.features;
|
||||
} else if (message.type === 'complete') {
|
||||
onProgress?.({ count: message.total ?? totalCount, total: message.total });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse streaming message:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in the buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const message: StreamMessage = JSON.parse(buffer);
|
||||
if (message.type === 'batch' && message.features) {
|
||||
yield message.features;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse final streaming message:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
frontend/src/services/taskService.ts
Normal file
58
frontend/src/services/taskService.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Task service for fetching task status
|
||||
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { TaskStatusResponse } from '@/types';
|
||||
import { apiRequest } from './apiClient';
|
||||
import { API_ENDPOINTS } from '@/constants';
|
||||
|
||||
export interface CancelTaskResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ClearAllTasksResponse {
|
||||
success: boolean;
|
||||
count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all active tasks for the current user
|
||||
*/
|
||||
export async function fetchTasksForUser(user: AuthUser): Promise<string[]> {
|
||||
return apiRequest<string[]>(user, API_ENDPOINTS.TASKS_FOR_USER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the status of a specific task
|
||||
*/
|
||||
export async function fetchTaskStatus(
|
||||
user: AuthUser,
|
||||
taskId: string
|
||||
): Promise<TaskStatusResponse> {
|
||||
return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, {
|
||||
params: { task_id: taskId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running task
|
||||
*/
|
||||
export async function cancelTask(
|
||||
user: AuthUser,
|
||||
taskId: string
|
||||
): Promise<CancelTaskResponse> {
|
||||
return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, {
|
||||
method: 'POST',
|
||||
params: { task_id: taskId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tasks for the current user
|
||||
*/
|
||||
export async function clearAllTasks(user: AuthUser): Promise<ClearAllTasksResponse> {
|
||||
return apiRequest<ClearAllTasksResponse>(user, API_ENDPOINTS.CLEAR_ALL_TASKS, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
98
frontend/src/types/index.ts
Normal file
98
frontend/src/types/index.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// TypeScript types for the frontend application
|
||||
|
||||
// GeoJSON types
|
||||
export interface PropertyPriceHistory {
|
||||
id: number;
|
||||
price: number;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export interface PropertyProperties {
|
||||
url: string;
|
||||
city: string;
|
||||
country: string;
|
||||
qm: number;
|
||||
qmprice: number;
|
||||
total_price: number;
|
||||
rooms: number;
|
||||
agency: string;
|
||||
available_from: string;
|
||||
last_seen: string;
|
||||
photo_thumbnail: string;
|
||||
price_history: PropertyPriceHistory[];
|
||||
listing_type?: 'RENT' | 'BUY';
|
||||
}
|
||||
|
||||
export interface PropertyFeature {
|
||||
type: 'Feature';
|
||||
geometry: {
|
||||
type: 'Point';
|
||||
coordinates: [number, number]; // [longitude, latitude]
|
||||
};
|
||||
properties: PropertyProperties;
|
||||
}
|
||||
|
||||
export interface GeoJSONFeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: PropertyFeature[];
|
||||
}
|
||||
|
||||
// Task status types
|
||||
export enum TaskStatus {
|
||||
PENDING = 'PENDING',
|
||||
STARTED = 'STARTED',
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILURE = 'FAILURE',
|
||||
REVOKED = 'REVOKED',
|
||||
}
|
||||
|
||||
export interface TaskStatusResponse {
|
||||
status: TaskStatus;
|
||||
result: string; // JSON string containing TaskResult
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type TaskPhase = 'splitting' | 'splitting_complete' | 'fetching' | 'processing' | 'completed';
|
||||
|
||||
export interface TaskResult {
|
||||
progress: number;
|
||||
processed?: number;
|
||||
total?: number;
|
||||
phase?: TaskPhase;
|
||||
message?: string;
|
||||
// Splitting phase
|
||||
subqueries_probed?: number;
|
||||
subqueries_initial?: number;
|
||||
estimated_results?: number;
|
||||
subqueries_total?: number;
|
||||
// Fetching phase
|
||||
subqueries_completed?: number;
|
||||
ids_collected?: number;
|
||||
pages_fetched?: number;
|
||||
fetching_done?: boolean;
|
||||
// Processing phase
|
||||
details_fetched?: number;
|
||||
images_downloaded?: number;
|
||||
ocr_completed?: number;
|
||||
failed?: number;
|
||||
elapsed_seconds?: number;
|
||||
rate_per_second?: number;
|
||||
eta_seconds?: number;
|
||||
// Live logs
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
export interface RefreshListingsResponse {
|
||||
task_id: string;
|
||||
}
|
||||
|
||||
// API error type
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
45
frontend/src/utils/mapUtils.ts
Normal file
45
frontend/src/utils/mapUtils.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Map utility functions
|
||||
|
||||
/**
|
||||
* Deep clone an object using JSON serialization
|
||||
*/
|
||||
export function clone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the value at a given percentile in a sorted array
|
||||
* @param arr Sorted array of numbers
|
||||
* @param p Percentile (0-1)
|
||||
*/
|
||||
export function percentile(arr: number[], p: number): number {
|
||||
if (arr.length === 0) return 0;
|
||||
if (typeof p !== 'number') throw new TypeError('p must be a number');
|
||||
if (p <= 0) return arr[0];
|
||||
if (p >= 1) return arr[arr.length - 1];
|
||||
|
||||
const index = arr.length * p;
|
||||
const lower = Math.floor(index);
|
||||
const upper = lower + 1;
|
||||
const weight = index % 1;
|
||||
|
||||
if (upper >= arr.length) return arr[lower];
|
||||
return arr[lower] * (1 - weight) + arr[upper] * weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert percentage-based color stops to value-based color stops
|
||||
* @param colorStopsPerc Array of [percentage, color] tuples
|
||||
* @param min Minimum value
|
||||
* @param max Maximum value
|
||||
*/
|
||||
export function calculateColorStops(
|
||||
colorStopsPerc: [number, string][],
|
||||
min: number,
|
||||
max: number
|
||||
): [number, string][] {
|
||||
return colorStopsPerc.map(([perc, color]) => [
|
||||
min + (perc * (max - min)) / 100,
|
||||
color,
|
||||
]);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
77
frontend/start.sh
Executable file
77
frontend/start.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# This script is used to start the npm dev server along with a caddy proxy running a self signed TLS cert
|
||||
# This is needed as the app uses external auth that requires running on https
|
||||
|
||||
# Usage:
|
||||
# 1. Set $dev_hostname to the hostname of your dev server (e.g. devvm.viktorbarzin.lan) in /ets/hosts (or external dns to resolve to)
|
||||
# 2. Run the script
|
||||
# 3. Start npm server in a different tab: npm run dev -- --host
|
||||
# 4. Open a browser and navigate to https://$dev_hostname:443
|
||||
|
||||
# Now your app would be accessible via https
|
||||
# App requests to the backend API can be done via https://$dev_hostname/api
|
||||
|
||||
set -eux
|
||||
|
||||
# Kill any existing caddy servers
|
||||
sudo pkill -f 'caddy'
|
||||
|
||||
# Fail if caddy is not installed
|
||||
if ! command -v caddy &> /dev/null; then
|
||||
echo "Error: caddy is not installed. Please install caddy and try again"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pwd=$PWD
|
||||
|
||||
caddy_dir="$pwd/caddy_dev"
|
||||
dev_crt_path="$caddy_dir/certs/dev.crt"
|
||||
dev_key_path="$caddy_dir/certs/dev.key"
|
||||
|
||||
# throw if .env does not exist
|
||||
if [ ! -f "$pwd/.env" ]; then
|
||||
echo "Error: .env file not found. Please use the sample env file to populate the .env file"
|
||||
exit 1
|
||||
fi
|
||||
source "$pwd/.env" # get env for dev host
|
||||
dev_hostname=$DEV_HOST
|
||||
frontend_service="$FRONTEND_SERVICE"
|
||||
backend_service="$BACKEND_SERVICE"
|
||||
|
||||
# Create self signed certs if they don't exist already
|
||||
if [ ! -f "$caddy_dir/certs/dev.crt" ] || [ ! -f "$caddy_dir/certs/dev.key" ]; then
|
||||
echo
|
||||
echo "Creating self-signed certificates..."
|
||||
mkdir -p $caddy_dir/certs
|
||||
openssl req -x509 -newkey rsa:4096 -keyout $dev_key_path -out $dev_crt_path -days 365 -nodes -subj "/CN=$dev_hostname"
|
||||
echo "Certificates created."
|
||||
fi
|
||||
|
||||
# Create a caddyfile for the dev environment if it doesn't exist
|
||||
if [ ! -f "$caddy_dir/Caddyfile" ]; then
|
||||
echo "Creating a caddyfile for the dev environment..."
|
||||
cat <<-EOF > $caddy_dir/Caddyfile
|
||||
# We need https for the frontend to enable auth with external oidc
|
||||
https://$dev_hostname:443 {
|
||||
tls "$dev_crt_path" "$dev_key_path"
|
||||
reverse_proxy http://$frontend_service
|
||||
}
|
||||
|
||||
# We need https for the backend so that the frontend can send secure requests to the backend
|
||||
https://$dev_hostname:443/api/* {
|
||||
tls "$dev_crt_path" "$dev_key_path"
|
||||
reverse_proxy http://$backend_service
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Caddyfile created."
|
||||
else
|
||||
echo "Caddyfile already exists. Skipping creation."
|
||||
fi
|
||||
|
||||
# Start the caddy proxy with the self signed certs
|
||||
sudo caddy start --config "$caddy_dir/Caddyfile" # caddy run for interactive session
|
||||
# Start the npm dev server
|
||||
npm run dev -- --host
|
||||
|
||||
38
frontend/tsconfig.app.json
Normal file
38
frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
// "erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
// "noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"public/HexgridHeatmap.js",
|
||||
]
|
||||
}
|
||||
1
frontend/tsconfig.app.tsbuildinfo
Normal file
1
frontend/tsconfig.app.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/parameters.tsx","./src/components/propertycard.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts"],"version":"5.8.3"}
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
27
frontend/tsconfig.node.json
Normal file
27
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
// "erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
// "noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
1
frontend/tsconfig.node.tsbuildinfo
Normal file
1
frontend/tsconfig.node.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./vite.config.ts"],"version":"5.8.3"}
|
||||
24
frontend/vite.config.ts
Normal file
24
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from "path";
|
||||
import { env } from "process";
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist"
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: [
|
||||
env.DEV_HOST ?? 'localhost',
|
||||
'wrongmove.viktorbarzin.me',
|
||||
],
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue