feat: integrate FilterBar into layout, remove sidebar

Replace the fixed w-80 sidebar with a horizontal FilterBar below the
header, giving the map full viewport width. Key changes:

- App.tsx: Remove sidebar layout, add FilterBar + FilterChips + inline
  StreamingProgressBar between header and main content area
- Header.tsx: Add Rent/Buy listing type toggle (compact Tabs) after logo
- StatsBar.tsx: Add "Color by" metric selector (moved from
  VisualizationCard) as a compact Select alongside view mode toggles
- Mobile: Replace Sheet-based filter panel with full-screen Dialog
This commit is contained in:
Viktor Barzin 2026-02-28 16:16:03 +00:00
parent 4053c0c759
commit 8f112f30e3
No known key found for this signature in database
GPG key ID: 0EB088298288D958
3 changed files with 235 additions and 122 deletions

View file

@ -8,13 +8,16 @@ import AlertError from './components/AlertError';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import AuthCallback from './components/AuthCallback'; import AuthCallback from './components/AuthCallback';
import { Map } from './components/Map'; import { Map } from './components/Map';
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel'; import { type ParameterValues, DEFAULT_FILTER_VALUES, Metric, ListingType } from './components/FilterPanel';
import { FilterBar } from './components/FilterBar';
import { FilterChips } from './components/FilterChips';
import { VisualizationCard } from './components/VisualizationCard'; import { VisualizationCard } from './components/VisualizationCard';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { StatsBar } from './components/StatsBar'; import { StatsBar } from './components/StatsBar';
import { ListView } from './components/ListView'; import { ListView } from './components/ListView';
import { StreamingProgressBar } from './components/StreamingProgressBar'; import { StreamingProgressBar } from './components/StreamingProgressBar';
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './components/ui/dialog';
import { ScrollArea } from './components/ui/scroll-area';
import { Button } from './components/ui/button'; import { Button } from './components/ui/button';
import { Filter, Heart } from 'lucide-react'; import { Filter, Heart } from 'lucide-react';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
@ -32,6 +35,7 @@ import { MobileBottomSheet } from './components/MobileBottomSheet';
import { SwipeReviewMode } from './components/SwipeReviewMode'; import { SwipeReviewMode } from './components/SwipeReviewMode';
import { FavoritesView } from './components/FavoritesView'; import { FavoritesView } from './components/FavoritesView';
import { ListingDetailSheet } from './components/ListingDetailSheet'; import { ListingDetailSheet } from './components/ListingDetailSheet';
import { FilterPanel } from './components/FilterPanel';
function AppContent() { function AppContent() {
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null); const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
@ -54,6 +58,7 @@ function AppContent() {
} | null>(null); } | null>(null);
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({}); const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric); const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
const [listingType, setListingType] = useState<ListingType>(DEFAULT_FILTER_VALUES.listing_type);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null); const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [showReviewMode, setShowReviewMode] = useState(false); const [showReviewMode, setShowReviewMode] = useState(false);
@ -373,6 +378,31 @@ function AppContent() {
// Optionally: pan map to coordinates // Optionally: pan map to coordinates
}; };
/** Handle removing a filter chip: reset the field to its default value and re-submit */
const handleRemoveChip = (key: keyof ParameterValues) => {
if (!queryParameters) return;
const updated = { ...queryParameters };
// For paired keys (price, beds) reset both ends
switch (key) {
case 'min_price':
case 'max_price':
updated.min_price = DEFAULT_FILTER_VALUES.min_price;
updated.max_price = DEFAULT_FILTER_VALUES.max_price;
break;
case 'min_bedrooms':
case 'max_bedrooms':
updated.min_bedrooms = DEFAULT_FILTER_VALUES.min_bedrooms;
updated.max_bedrooms = DEFAULT_FILTER_VALUES.max_bedrooms;
break;
case 'furnish_types':
updated.furnish_types = [];
break;
default:
(updated as Record<string, unknown>)[key] = (DEFAULT_FILTER_VALUES as Record<string, unknown>)[key];
}
loadListings(updated);
};
const renderMainContent = () => { const renderMainContent = () => {
if (!processedListingData) { if (!processedListingData) {
return ( return (
@ -383,7 +413,7 @@ function AppContent() {
<div className="text-6xl mb-4 animate-pulse">🏠</div> <div className="text-6xl mb-4 animate-pulse">🏠</div>
<h2 className="text-xl font-semibold mb-2">Loading Properties...</h2> <h2 className="text-xl font-semibold mb-2">Loading Properties...</h2>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Fetching listings with default filters. You can adjust filters on the left. Fetching listings with default filters. Adjust filters above to refine results.
</p> </p>
</> </>
) : ( ) : (
@ -391,7 +421,7 @@ function AppContent() {
<div className="text-6xl mb-4">🏠</div> <div className="text-6xl mb-4">🏠</div>
<h2 className="text-xl font-semibold mb-2">Welcome to Property Explorer</h2> <h2 className="text-xl font-semibold mb-2">Welcome to Property Explorer</h2>
<p className="text-muted-foreground mb-4"> <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. Use the filters above to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
</p> </p>
</> </>
)} )}
@ -520,40 +550,46 @@ function AppContent() {
> >
<Heart className="h-6 w-6" /> <Heart className="h-6 w-6" />
</Button> </Button>
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}> <Dialog open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
<SheetTrigger asChild> <Button size="lg" className="rounded-full shadow-lg h-14 w-14" onClick={() => setMobileFilterOpen(true)}>
<Button size="lg" className="rounded-full shadow-lg h-14 w-14"> <Filter className="h-6 w-6" />
<Filter className="h-6 w-6" /> </Button>
</Button> <DialogContent className="max-w-full h-full max-h-full rounded-none sm:max-w-full p-0">
</SheetTrigger> <DialogHeader className="px-4 pt-4 pb-2">
<SheetContent side="left" className="w-80 p-0"> <DialogTitle>Filters</DialogTitle>
<div className="h-full flex flex-col"> </DialogHeader>
<div className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0 px-0">
<FilterPanel <div className="h-full flex flex-col">
onSubmit={onSubmit} <div className="flex-1 min-h-0">
currentMetric={currentMetric} <FilterPanel
isLoading={isLoading} onSubmit={(action, params) => {
listingCount={processedListingData?.features.length} setMobileFilterOpen(false);
user={user} onSubmit(action, params);
onTaskCreated={handlePOITaskCreated} }}
onStartPoiPicking={handleStartPoiPicking} currentMetric={currentMetric}
pickedPoiLocation={pickedPoiLocation} isLoading={isLoading}
userPOIs={userPOIs} listingCount={processedListingData?.features.length}
poiTravelFilters={poiTravelFilters} user={user}
onPoiTravelFiltersChange={setPoiTravelFilters} onTaskCreated={handlePOITaskCreated}
/> onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
</div>
<div className="shrink-0 p-4">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div> </div>
<div className="shrink-0 p-4"> </ScrollArea>
<VisualizationCard </DialogContent>
metric={currentMetric} </Dialog>
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
</SheetContent>
</Sheet>
</div> </div>
{/* Bottom Sheet */} {/* Bottom Sheet */}
@ -596,7 +632,7 @@ function AppContent() {
return ( return (
<div className="h-screen flex flex-col overflow-hidden"> <div className="h-screen flex flex-col overflow-hidden">
{/* Header */} {/* Header with Listing Type Toggle */}
<Header <Header
user={user} user={user}
tasks={tasks} tasks={tasks}
@ -611,55 +647,60 @@ function AppContent() {
return result; return result;
}} }}
onTaskCompleted={handleTaskCompleted} onTaskCompleted={handleTaskCompleted}
listingType={listingType}
onListingTypeChange={setListingType}
/> />
{isMobile ? ( {isMobile ? (
renderMobileLayout() renderMobileLayout()
) : ( ) : (
/* Desktop layout */ /* Desktop layout: no sidebar, full-width main area */
<div className="flex-1 flex overflow-hidden min-h-0"> <>
{/* Filter Panel - Desktop (fixed sidebar) */} {/* Horizontal Filter Bar */}
<div className="w-80 shrink-0 h-full overflow-hidden"> <FilterBar
<div className="h-full flex flex-col"> onSubmit={onSubmit}
<div className="flex-1 min-h-0"> isLoading={isLoading}
<FilterPanel user={user}
onSubmit={onSubmit} userPOIs={userPOIs}
currentMetric={currentMetric} onPOIsChange={setUserPOIs}
isLoading={isLoading} poiTravelFilters={poiTravelFilters}
listingCount={processedListingData?.features.length} onPoiTravelFiltersChange={setPoiTravelFilters}
user={user} listingType={listingType}
onTaskCreated={handlePOITaskCreated} onListingTypeChange={setListingType}
onStartPoiPicking={handleStartPoiPicking} poiPickerActive={poiPickerActive}
pickedPoiLocation={pickedPoiLocation} onPoiPickerActiveChange={setPoiPickerActive}
userPOIs={userPOIs} pickedPoiLocation={pickedPoiLocation}
poiTravelFilters={poiTravelFilters} onPickedPoiLocationChange={setPickedPoiLocation}
onPoiTravelFiltersChange={setPoiTravelFilters} currentMetric={currentMetric}
/> onTaskCreated={handlePOITaskCreated}
</div> />
<div className="shrink-0 p-4 border-r">
<VisualizationCard {/* Active Filter Chips */}
metric={currentMetric} {queryParameters && (
onMetricChange={handleMetricChange} <FilterChips
userPOIs={userPOIs} values={queryParameters}
onPoiMetricChange={setPoiMetricSelection} defaults={{ ...DEFAULT_FILTER_VALUES, available_from: new Date() }}
/> onRemove={handleRemoveChip}
</div> />
</div> )}
{/* Streaming Progress Bar */}
<div className="relative shrink-0">
<StreamingProgressBar
progress={streamingProgress}
isLoading={isLoading}
onCancel={() => abortControllerRef.current?.abort()}
/>
</div> </div>
{/* Main View Area */} {/* Main content area (full width) */}
<div className="flex-1 flex flex-col overflow-hidden min-h-0"> <main className="flex-1 flex flex-col min-h-0 min-w-0">
{/* Streaming Progress Bar */}
<div className="relative shrink-0">
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} onCancel={() => abortControllerRef.current?.abort()} />
</div>
{/* Map/List Container */} {/* Map/List Container */}
<div className="flex-1 flex overflow-hidden min-h-0"> <div className="flex-1 flex overflow-hidden min-h-0">
{renderMainContent()} {renderMainContent()}
</div> </div>
{/* Stats Bar */} {/* Stats Bar with Metric Selector */}
{processedListingData && processedListingData.features.length > 0 && ( {processedListingData && processedListingData.features.length > 0 && (
<div className="shrink-0"> <div className="shrink-0">
<StatsBar <StatsBar
@ -667,11 +708,15 @@ function AppContent() {
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
likedCount={likedCount} likedCount={likedCount}
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/> />
</div> </div>
)} )}
</div> </main>
</div> </>
)} )}
{/* Swipe Review Mode Overlay */} {/* Swipe Review Mode Overlay */}

View file

@ -2,6 +2,7 @@ import type { AuthUser } from '@/auth/types';
import type { TaskState } from '@/types'; import type { TaskState } from '@/types';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Separator } from './ui/separator'; import { Separator } from './ui/separator';
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { LogOut, Home } from 'lucide-react'; import { LogOut, Home } from 'lucide-react';
import { logout } from '@/auth/authService'; import { logout } from '@/auth/authService';
import { clearPasskeyUser } from '@/auth/passkeyService'; import { clearPasskeyUser } from '@/auth/passkeyService';
@ -9,6 +10,7 @@ import { HealthIndicator } from './HealthIndicator';
import { TaskIndicator } from './TaskIndicator'; import { TaskIndicator } from './TaskIndicator';
import { MobileMenu } from './MobileMenu'; import { MobileMenu } from './MobileMenu';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { ListingType } from './FilterPanel';
interface HeaderProps { interface HeaderProps {
user: AuthUser; user: AuthUser;
@ -23,6 +25,9 @@ interface HeaderProps {
onCancelTask: (taskId: string) => Promise<boolean>; onCancelTask: (taskId: string) => Promise<boolean>;
onClearAllTasks: () => Promise<boolean>; onClearAllTasks: () => Promise<boolean>;
onTaskCompleted?: () => void; onTaskCompleted?: () => void;
// Listing type toggle
listingType?: ListingType;
onListingTypeChange?: (type: ListingType) => void;
} }
export function Header({ export function Header({
@ -33,6 +38,8 @@ export function Header({
onCancelTask, onCancelTask,
onClearAllTasks, onClearAllTasks,
onTaskCompleted, onTaskCompleted,
listingType,
onListingTypeChange,
}: HeaderProps) { }: HeaderProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -53,6 +60,26 @@ export function Header({
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span> <span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
</div> </div>
{/* Listing Type Toggle (Rent / Buy) */}
{listingType && onListingTypeChange && (
<>
<Separator orientation="vertical" className="h-6" />
<Tabs
value={listingType}
onValueChange={(v) => onListingTypeChange(v as ListingType)}
>
<TabsList className="h-8 w-auto p-0.5">
<TabsTrigger value={ListingType.RENT} className="h-7 px-3 text-xs flex-initial">
Rent
</TabsTrigger>
<TabsTrigger value={ListingType.BUY} className="h-7 px-3 text-xs flex-initial">
Buy
</TabsTrigger>
</TabsList>
</Tabs>
</>
)}
{/* Desktop-only items */} {/* Desktop-only items */}
{!isMobile && ( {!isMobile && (
<> <>

View file

@ -1,7 +1,9 @@
import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart } from 'lucide-react'; import { BarChart3, MapPin, PoundSterling, Maximize2, List, Map as MapIcon, Heart, Palette } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { GeoJSONFeatureCollection, PropertyFeature } from '@/types'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import type { GeoJSONFeatureCollection, PropertyFeature, POI } from '@/types';
import { formatCurrency } from '@/utils/format'; import { formatCurrency } from '@/utils/format';
import { Metric } from './FilterPanel';
export type ViewMode = 'map' | 'list' | 'split' | 'saved'; export type ViewMode = 'map' | 'list' | 'split' | 'saved';
@ -10,6 +12,11 @@ interface StatsBarProps {
viewMode: ViewMode; viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void; onViewModeChange: (mode: ViewMode) => void;
likedCount?: number; likedCount?: number;
// Metric selector (moved from VisualizationCard)
metric?: Metric;
onMetricChange?: (metric: Metric) => void;
userPOIs?: POI[];
onPoiMetricChange?: (selection: { poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT' } | null) => void;
} }
interface ListingStats { interface ListingStats {
@ -54,7 +61,15 @@ function calculateStats(data: GeoJSONFeatureCollection | null): ListingStats {
return { count, avgPrice, avgPricePerSqm, avgSize }; return { count, avgPrice, avgPricePerSqm, avgSize };
} }
export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount = 0 }: StatsBarProps) { export function StatsBar({
listingData,
viewMode,
onViewModeChange,
likedCount = 0,
metric,
onMetricChange,
userPOIs,
}: StatsBarProps) {
const stats = calculateStats(listingData); const stats = calculateStats(listingData);
return ( return (
@ -75,57 +90,83 @@ export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount =
</div> </div>
<div className="hidden lg:flex items-center gap-1.5"> <div className="hidden lg:flex items-center gap-1.5">
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
<span>Avg £/m²: <span className="font-medium text-foreground">{formatCurrency(stats.avgPricePerSqm)}</span></span> <span>Avg &pound;/m&sup2;: <span className="font-medium text-foreground">{formatCurrency(stats.avgPricePerSqm)}</span></span>
</div> </div>
<div className="hidden lg:flex items-center gap-1.5"> <div className="hidden lg:flex items-center gap-1.5">
<Maximize2 className="h-4 w-4" /> <Maximize2 className="h-4 w-4" />
<span>Avg: <span className="font-medium text-foreground">{Math.round(stats.avgSize)} m²</span></span> <span>Avg: <span className="font-medium text-foreground">{Math.round(stats.avgSize)} m&sup2;</span></span>
</div> </div>
</> </>
)} )}
</div> </div>
{/* View Mode Toggle */} <div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-background rounded-md border p-0.5"> {/* Color-by Metric Selector */}
<Button {metric && onMetricChange && (
variant={viewMode === 'map' ? 'secondary' : 'ghost'} <div className="hidden md:flex items-center gap-1.5">
size="sm" <Palette className="h-3.5 w-3.5 text-muted-foreground" />
className="h-7 px-2" <Select
onClick={() => onViewModeChange('map')} value={metric}
> onValueChange={(value) => onMetricChange(value as Metric)}
<MapIcon className="h-4 w-4" /> >
<span className="hidden sm:inline ml-1">Map</span> <SelectTrigger className="h-7 text-xs w-[110px] border-0 bg-transparent shadow-none">
</Button> <SelectValue placeholder="Color by" />
<Button </SelectTrigger>
variant={viewMode === 'list' ? 'secondary' : 'ghost'} <SelectContent>
size="sm" <SelectItem value={Metric.qmprice}>Price/m&sup2;</SelectItem>
className="h-7 px-2" <SelectItem value={Metric.rooms}>Bedrooms</SelectItem>
onClick={() => onViewModeChange('list')} <SelectItem value={Metric.qm}>Size (m&sup2;)</SelectItem>
> <SelectItem value={Metric.price}>Total Price</SelectItem>
<List className="h-4 w-4" /> {userPOIs && userPOIs.length > 0 && (
<span className="hidden sm:inline ml-1">List</span> <SelectItem value={Metric.poi_travel}>Travel Time</SelectItem>
</Button> )}
<Button </SelectContent>
variant={viewMode === 'split' ? 'secondary' : 'ghost'} </Select>
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> </div>
<span className="hidden sm:inline ml-1">Split</span> )}
</Button>
<Button {/* View Mode Toggle */}
variant={viewMode === 'saved' ? 'secondary' : 'ghost'} <div className="flex items-center gap-1 bg-background rounded-md border p-0.5">
size="sm" <Button
className="h-7 px-2" variant={viewMode === 'map' ? 'secondary' : 'ghost'}
onClick={() => onViewModeChange('saved')} size="sm"
> className="h-7 px-2"
<Heart className="h-4 w-4" /> onClick={() => onViewModeChange('map')}
<span className="hidden sm:inline ml-1">Saved{likedCount > 0 ? ` (${likedCount})` : ''}</span> >
</Button> <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>
<Button
variant={viewMode === 'saved' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 px-2"
onClick={() => onViewModeChange('saved')}
>
<Heart className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Saved{likedCount > 0 ? ` (${likedCount})` : ''}</span>
</Button>
</div>
</div> </div>
</div> </div>
); );