- Remove hard-coded limit=1000 default from listing_geojson and streaming endpoints, allowing all matching results to be returned - Add Redis caching service (db=2, 30min TTL) that caches query results as Redis Lists for fast re-queries with reduced DB load - Integrate cache into streaming endpoint: serve from cache on hit, populate cache on miss during DB streaming - Invalidate cache after scrape completes (both success and no-new-listings) - Replace ScrollArea with react-virtuoso in ListView for virtual scrolling, keeping only ~20-30 DOM nodes regardless of list size - Handle metadata streaming message to show "0 / N" progress from start - Throttle frontend state updates with requestAnimationFrame to prevent UI jank from rapid re-renders during cached response streaming
297 lines
9.8 KiB
TypeScript
297 lines
9.8 KiB
TypeScript
import type { User } from 'oidc-client-ts';
|
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
import './App.css';
|
|
import { getUser } from './auth/authService';
|
|
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 } 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<User | 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(() => {
|
|
// Load user data
|
|
getUser().then(setUser);
|
|
}, []);
|
|
|
|
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} />;
|
|
}
|
|
|
|
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 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}
|
|
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}
|
|
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;
|