Add configurable scheduling, UI health/task indicators, and auto-load map with default filters
This commit is contained in:
parent
1c8c3e4657
commit
c7ac448f15
18 changed files with 2287 additions and 656 deletions
|
|
@ -1,94 +1,38 @@
|
|||
import type { User } from 'oidc-client-ts';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import './App.css';
|
||||
import { AppSidebar } from './AppSidebar';
|
||||
import { getUser, handleCallback, logout } from './auth/authService';
|
||||
import ActiveQuery from './components/ActiveQuery';
|
||||
import { getUser, handleCallback } from './auth/authService';
|
||||
import AlertError from './components/AlertError';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import { Map } from './components/Map';
|
||||
import { Parameters, type ParameterValues } from './components/Parameters';
|
||||
import { Spinner } from './components/Spinner';
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from './components/ui/breadcrumb';
|
||||
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES } 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 { Separator } from './components/ui/separator';
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from './components/ui/sidebar';
|
||||
|
||||
const fetchData = async (user: User, baseQueyrUri: string, parameters: ParameterValues, method: string = 'GET') => {
|
||||
const accessToken = user.access_token;
|
||||
const queryString = new URLSearchParams();
|
||||
queryString.append('listing_type', parameters.listing_type)
|
||||
if (parameters.min_bedrooms) {
|
||||
queryString.append('min_bedrooms', parameters.min_bedrooms.toString());
|
||||
}
|
||||
if (parameters.max_bedrooms) {
|
||||
queryString.append('max_bedrooms', parameters.max_bedrooms.toString())
|
||||
}
|
||||
if (parameters.max_price) {
|
||||
queryString.append("max_price", parameters.max_price.toString());
|
||||
}
|
||||
if (parameters.min_price) {
|
||||
queryString.append("min_price", parameters.min_price.toString());
|
||||
}
|
||||
if (parameters.min_sqm) {
|
||||
queryString.append("min_sqm", parameters.min_sqm.toString());
|
||||
}
|
||||
if (parameters.last_seen_days) {
|
||||
queryString.append("last_seen_days", parameters.last_seen_days.toString());
|
||||
}
|
||||
if (parameters.available_from) {
|
||||
queryString.append("let_date_available_from", parameters.available_from.toISOString());
|
||||
}
|
||||
if (parameters.district) {
|
||||
queryString.append("district_names", parameters.district);
|
||||
}
|
||||
|
||||
const response = await fetch(baseQueyrUri + '?' + queryString,
|
||||
{
|
||||
method: method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`, // Pass the token
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Error: ' + response.status);
|
||||
}
|
||||
const data: Response = await response.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
const fetchActiveTasksForUser = async (user: User) => {
|
||||
const accessToken = user?.access_token;
|
||||
const response = await fetch(`/api/tasks_for_user`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`, // Pass the token
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch active tasks for user: ${response.status}`);
|
||||
}
|
||||
|
||||
const data =
|
||||
await response.json();
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
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({});
|
||||
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
||||
const [taskID, setTaskID] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isParametersModalOpen, setIsParametersModalOpen] = useState(true);
|
||||
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
||||
const [spinnerText, setSpinnerText] = useState<string | null>(null);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if this is a callback from Authentik (after login)
|
||||
|
|
@ -107,101 +51,233 @@ function App() {
|
|||
if (!user) {
|
||||
return;
|
||||
}
|
||||
fetchActiveTasksForUser(user).then((tasks) => {
|
||||
if (tasks) {
|
||||
setTaskID(tasks[0])
|
||||
fetchTasksForUser(user).then((tasks) => {
|
||||
if (tasks && tasks.length > 0) {
|
||||
setTaskID(tasks[0]);
|
||||
}
|
||||
})
|
||||
}, [user, taskID])
|
||||
});
|
||||
}, [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);
|
||||
|
||||
try {
|
||||
for await (const batch of streamListingGeoJSON(user, parameters, (progress) => {
|
||||
setStreamingProgress(progress);
|
||||
})) {
|
||||
accumulatedFeaturesRef.current.push(...batch);
|
||||
setListingData({
|
||||
type: 'FeatureCollection',
|
||||
features: [...accumulatedFeaturesRef.current]
|
||||
});
|
||||
}
|
||||
} 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} />
|
||||
return <LoginModal isOpen={user === null} />;
|
||||
}
|
||||
|
||||
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
|
||||
// Fetch listing data
|
||||
setQueryParameters(parameters)
|
||||
setIsParametersModalOpen(false)
|
||||
let data = null;
|
||||
if (action === 'visualize') {
|
||||
setSpinnerText("Loading data for visualization...")
|
||||
try {
|
||||
data = await fetchData(user, "/api/listing_geojson", parameters);
|
||||
} catch (error) {
|
||||
// @ts-expect-error
|
||||
setSubmitError(error.message)
|
||||
setAlertDialogIsOpen(true)
|
||||
} finally {
|
||||
setSpinnerText(null)
|
||||
}
|
||||
if (data) {
|
||||
setListingData(data);
|
||||
}
|
||||
loadListings(parameters);
|
||||
} else if (action === 'fetch-data') {
|
||||
setSpinnerText("Submitting query to refresh listings...")
|
||||
setQueryParameters(parameters);
|
||||
setMobileFilterOpen(false);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
data = await fetchData(user, "/api/refresh_listings", parameters, 'POST');
|
||||
// @ts-expect-error
|
||||
setTaskID(data.task_id)
|
||||
const data = await refreshListings(user!, parameters);
|
||||
setTaskID(data.task_id);
|
||||
} catch (error) {
|
||||
// @ts-expect-error
|
||||
setSubmitError(error.message)
|
||||
setAlertDialogIsOpen(true)
|
||||
if (error instanceof Error) {
|
||||
setSubmitError(error.message);
|
||||
} else {
|
||||
setSubmitError(String(error));
|
||||
}
|
||||
setAlertDialogIsOpen(true);
|
||||
} finally {
|
||||
setSpinnerText(null)
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
console.log(data)
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink href="#">
|
||||
Building Your Application
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</header>
|
||||
<div className="flex flex-col h-screen w-full">
|
||||
<div className="flex gap-2 p-2 bg-gray-100">
|
||||
<h1>Welcome, {user.profile.email}!</h1>
|
||||
<Button onClick={logout}>Logout</Button>
|
||||
<Parameters onSubmit={onSubmit} isOpen={isParametersModalOpen} setIsOpen={setIsParametersModalOpen} />
|
||||
<ActiveQuery taskID={taskID} />
|
||||
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header
|
||||
user={user}
|
||||
taskID={taskID}
|
||||
onTaskCancelled={handleTaskCancelled}
|
||||
/>
|
||||
|
||||
<Spinner show={spinnerText !== null} >
|
||||
<span >{spinnerText}</span>
|
||||
</Spinner>
|
||||
{/* 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}
|
||||
isLoading={isLoading}
|
||||
listingCount={listingData?.features.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{Object.keys(listingData).length > 0 &&
|
||||
<div className="flex-1 w-full relative" style={{ minHeight: 0, marginBottom: '8rem' }}>
|
||||
<Map listingData={listingData} queryParameters={queryParameters} />
|
||||
</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}
|
||||
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>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
{/* 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
|
||||
export default App;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue