wrongmove/frontend/src/App.tsx

429 lines
15 KiB
TypeScript
Raw Normal View History

import { useEffect, useState, useRef, useCallback, useMemo } 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, POI, POITravelFilter } from '@/types';
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services';
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
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);
const [userPOIs, setUserPOIs] = useState<POI[]>([]);
const [poiPickerActive, setPoiPickerActive] = useState(false);
const [pickedPoiLocation, setPickedPoiLocation] = useState<{ lat: number; lng: number } | null>(null);
const [poiMetricSelection, setPoiMetricSelection] = useState<{
poiId: number;
poiName: string;
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
} | null>(null);
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
// 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 user's POIs
useEffect(() => {
if (!user) return;
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
}, [user]);
// 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);
}, { includePoiDistances: userPOIs.length > 0 })) {
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, userPOIs]);
// Compute processed listing data: inject synthetic POI metric property & apply max travel filter
const processedListingData = useMemo(() => {
if (!listingData) return null;
let features = listingData.features;
// Inject synthetic flat property for the selected POI metric
if (poiMetricSelection) {
features = injectPoiMetricProperty(
features,
poiMetricSelection.poiId,
poiMetricSelection.travelMode,
);
}
// Filter by per-POI max travel time (AND logic: listing must satisfy all active filters)
const activeFilters = Object.entries(poiTravelFilters)
.filter(([, f]) => f.maxMinutes !== undefined)
.map(([id, f]) => ({ poiId: Number(id), travelMode: f.travelMode, maxMinutes: f.maxMinutes! }));
if (activeFilters.length > 0) {
features = features.filter((f) => {
const distances = f.properties.poi_distances;
if (!distances) return false;
return activeFilters.every((filter) => {
const dist = distances.find(
(d) => d.poi_id === filter.poiId && d.travel_mode === filter.travelMode,
);
return dist !== undefined && dist.duration_seconds <= filter.maxMinutes * 60;
});
});
}
return { ...listingData, features };
}, [listingData, poiMetricSelection, poiTravelFilters]);
// Compute the effective metric string for the heatmap
const effectiveMetric = useMemo(() => {
if (queryParameters?.metric === Metric.poi_travel && poiMetricSelection) {
return poiMetricPropertyName(poiMetricSelection.poiId, poiMetricSelection.travelMode);
}
return queryParameters?.metric;
}, [queryParameters?.metric, poiMetricSelection]);
// 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]);
const handleTaskCompleted = useCallback(() => {
if (queryParameters) {
loadListings(queryParameters);
}
}, [queryParameters, 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 (!processedListingData) {
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 (processedListingData.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={processedListingData}
queryParameters={queryParameters}
effectiveMetric={effectiveMetric}
onPropertyClick={handlePropertyClick}
pois={userPOIs}
isPickingPOI={poiPickerActive}
onPoiLocationPick={handlePoiLocationPick}
onCancelPoiPicking={handleCancelPoiPicking}
/>
</div>
)}
{/* List View */}
{(viewMode === 'list' || viewMode === 'split') && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-l' : 'flex-1'}`}>
<ListView
listingData={processedListingData}
onPropertyClick={handlePropertyClick}
highlightedPropertyUrl={highlightedProperty}
poiMetricSelection={poiMetricSelection}
/>
</div>
)}
</>
);
};
const handleTaskCancelled = () => {
setTaskID(null);
};
const handlePOITaskCreated = (taskId: string) => {
setTaskID(taskId);
// Refresh POI list in case new ones were created
if (user) {
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
}
};
const handleStartPoiPicking = () => {
setPoiPickerActive(true);
setPickedPoiLocation(null);
};
const handlePoiLocationPick = (lat: number, lng: number) => {
setPickedPoiLocation({ lat, lng });
setPoiPickerActive(false);
};
const handleCancelPoiPicking = () => {
setPoiPickerActive(false);
};
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Header */}
<Header
user={user}
taskID={taskID}
onTaskCancelled={handleTaskCancelled}
onTaskCompleted={handleTaskCompleted}
/>
{/* Main content area */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Filter Panel - Desktop (fixed sidebar) */}
<div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
<FilterPanel
onSubmit={onSubmit}
onMetricChange={handleMetricChange}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
</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={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
</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 */}
{processedListingData && processedListingData.features.length > 0 && (
<div className="shrink-0">
<StatsBar
listingData={processedListingData}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</div>
)}
</div>
</div>
{/* Error Dialog */}
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</div>
);
}
export default App;