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 AuthCallback from './components/AuthCallback';
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 { Header } from './components/Header';
import { StatsBar } from './components/StatsBar';
import { ListView } from './components/ListView';
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 { Filter, Heart } from 'lucide-react';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
@ -32,6 +35,7 @@ import { MobileBottomSheet } from './components/MobileBottomSheet';
import { SwipeReviewMode } from './components/SwipeReviewMode';
import { FavoritesView } from './components/FavoritesView';
import { ListingDetailSheet } from './components/ListingDetailSheet';
import { FilterPanel } from './components/FilterPanel';
function AppContent() {
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
@ -54,6 +58,7 @@ function AppContent() {
} | null>(null);
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
const [listingType, setListingType] = useState<ListingType>(DEFAULT_FILTER_VALUES.listing_type);
const isMobile = useIsMobile();
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [showReviewMode, setShowReviewMode] = useState(false);
@ -373,6 +378,31 @@ function AppContent() {
// 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 = () => {
if (!processedListingData) {
return (
@ -383,7 +413,7 @@ function AppContent() {
<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.
Fetching listings with default filters. Adjust filters above to refine results.
</p>
</>
) : (
@ -391,7 +421,7 @@ function AppContent() {
<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.
Use the filters above to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
</p>
</>
)}
@ -520,40 +550,46 @@ function AppContent() {
>
<Heart className="h-6 w-6" />
</Button>
<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">
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel
onSubmit={onSubmit}
currentMetric={currentMetric}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
<Dialog open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
<Button size="lg" className="rounded-full shadow-lg h-14 w-14" onClick={() => setMobileFilterOpen(true)}>
<Filter className="h-6 w-6" />
</Button>
<DialogContent className="max-w-full h-full max-h-full rounded-none sm:max-w-full p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle>Filters</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 min-h-0 px-0">
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel
onSubmit={(action, params) => {
setMobileFilterOpen(false);
onSubmit(action, params);
}}
currentMetric={currentMetric}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
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 className="shrink-0 p-4">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
</SheetContent>
</Sheet>
</ScrollArea>
</DialogContent>
</Dialog>
</div>
{/* Bottom Sheet */}
@ -596,7 +632,7 @@ function AppContent() {
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Header */}
{/* Header with Listing Type Toggle */}
<Header
user={user}
tasks={tasks}
@ -611,55 +647,60 @@ function AppContent() {
return result;
}}
onTaskCompleted={handleTaskCompleted}
listingType={listingType}
onListingTypeChange={setListingType}
/>
{isMobile ? (
renderMobileLayout()
) : (
/* Desktop layout */
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Filter Panel - Desktop (fixed sidebar) */}
<div className="w-80 shrink-0 h-full overflow-hidden">
<div className="h-full flex flex-col">
<div className="flex-1 min-h-0">
<FilterPanel
onSubmit={onSubmit}
currentMetric={currentMetric}
isLoading={isLoading}
listingCount={processedListingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
onStartPoiPicking={handleStartPoiPicking}
pickedPoiLocation={pickedPoiLocation}
userPOIs={userPOIs}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
/>
</div>
<div className="shrink-0 p-4 border-r">
<VisualizationCard
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
</div>
/* Desktop layout: no sidebar, full-width main area */
<>
{/* Horizontal Filter Bar */}
<FilterBar
onSubmit={onSubmit}
isLoading={isLoading}
user={user}
userPOIs={userPOIs}
onPOIsChange={setUserPOIs}
poiTravelFilters={poiTravelFilters}
onPoiTravelFiltersChange={setPoiTravelFilters}
listingType={listingType}
onListingTypeChange={setListingType}
poiPickerActive={poiPickerActive}
onPoiPickerActiveChange={setPoiPickerActive}
pickedPoiLocation={pickedPoiLocation}
onPickedPoiLocationChange={setPickedPoiLocation}
currentMetric={currentMetric}
onTaskCreated={handlePOITaskCreated}
/>
{/* Active Filter Chips */}
{queryParameters && (
<FilterChips
values={queryParameters}
defaults={{ ...DEFAULT_FILTER_VALUES, available_from: new Date() }}
onRemove={handleRemoveChip}
/>
)}
{/* Streaming Progress Bar */}
<div className="relative shrink-0">
<StreamingProgressBar
progress={streamingProgress}
isLoading={isLoading}
onCancel={() => abortControllerRef.current?.abort()}
/>
</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} onCancel={() => abortControllerRef.current?.abort()} />
</div>
{/* Main content area (full width) */}
<main className="flex-1 flex flex-col min-h-0 min-w-0">
{/* Map/List Container */}
<div className="flex-1 flex overflow-hidden min-h-0">
{renderMainContent()}
</div>
{/* Stats Bar */}
{/* Stats Bar with Metric Selector */}
{processedListingData && processedListingData.features.length > 0 && (
<div className="shrink-0">
<StatsBar
@ -667,11 +708,15 @@ function AppContent() {
viewMode={viewMode}
onViewModeChange={setViewMode}
likedCount={likedCount}
metric={currentMetric}
onMetricChange={handleMetricChange}
userPOIs={userPOIs}
onPoiMetricChange={setPoiMetricSelection}
/>
</div>
)}
</div>
</div>
</main>
</>
)}
{/* Swipe Review Mode Overlay */}

View file

@ -2,6 +2,7 @@ import type { AuthUser } from '@/auth/types';
import type { TaskState } from '@/types';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { LogOut, Home } from 'lucide-react';
import { logout } from '@/auth/authService';
import { clearPasskeyUser } from '@/auth/passkeyService';
@ -9,6 +10,7 @@ import { HealthIndicator } from './HealthIndicator';
import { TaskIndicator } from './TaskIndicator';
import { MobileMenu } from './MobileMenu';
import { useIsMobile } from '@/hooks/use-mobile';
import { ListingType } from './FilterPanel';
interface HeaderProps {
user: AuthUser;
@ -23,6 +25,9 @@ interface HeaderProps {
onCancelTask: (taskId: string) => Promise<boolean>;
onClearAllTasks: () => Promise<boolean>;
onTaskCompleted?: () => void;
// Listing type toggle
listingType?: ListingType;
onListingTypeChange?: (type: ListingType) => void;
}
export function Header({
@ -33,6 +38,8 @@ export function Header({
onCancelTask,
onClearAllTasks,
onTaskCompleted,
listingType,
onListingTypeChange,
}: HeaderProps) {
const isMobile = useIsMobile();
@ -53,6 +60,26 @@ export function Header({
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
</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 */}
{!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 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 { Metric } from './FilterPanel';
export type ViewMode = 'map' | 'list' | 'split' | 'saved';
@ -10,6 +12,11 @@ interface StatsBarProps {
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
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 {
@ -54,7 +61,15 @@ function calculateStats(data: GeoJSONFeatureCollection | null): ListingStats {
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);
return (
@ -75,57 +90,83 @@ export function StatsBar({ listingData, viewMode, onViewModeChange, likedCount =
</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>
<span>Avg &pound;/m&sup2;: <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>
<span>Avg: <span className="font-medium text-foreground">{Math.round(stats.avgSize)} m&sup2;</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 className="flex items-center gap-2">
{/* Color-by Metric Selector */}
{metric && onMetricChange && (
<div className="hidden md:flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5 text-muted-foreground" />
<Select
value={metric}
onValueChange={(value) => onMetricChange(value as Metric)}
>
<SelectTrigger className="h-7 text-xs w-[110px] border-0 bg-transparent shadow-none">
<SelectValue placeholder="Color by" />
</SelectTrigger>
<SelectContent>
<SelectItem value={Metric.qmprice}>Price/m&sup2;</SelectItem>
<SelectItem value={Metric.rooms}>Bedrooms</SelectItem>
<SelectItem value={Metric.qm}>Size (m&sup2;)</SelectItem>
<SelectItem value={Metric.price}>Total Price</SelectItem>
{userPOIs && userPOIs.length > 0 && (
<SelectItem value={Metric.poi_travel}>Travel Time</SelectItem>
)}
</SelectContent>
</Select>
</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>
)}
{/* 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>
<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>
);