From 086577468656839e7a5c233872ca4132b2e7ea79 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 6 Mar 2026 21:15:27 +0100 Subject: [PATCH] feat: replace filter dropdown with modal, add click-to-filter on all panels - Filter button is now a solid pill that opens a centered modal with dimension grid and operator/value selection - Clicking any row in TopReferrers, TechSpecs, Locations, or ContentStats adds an "is" filter for that dimension and value - ContentStats preserves the external link icon separately via stopPropagation --- app/sites/[id]/page.tsx | 4 + components/dashboard/AddFilterDropdown.tsx | 144 ++++++++++----------- components/dashboard/ContentStats.tsx | 17 ++- components/dashboard/Locations.tsx | 45 ++++--- components/dashboard/TechSpecs.tsx | 34 +++-- components/dashboard/TopReferrers.tsx | 10 +- 6 files changed, 147 insertions(+), 107 deletions(-) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 36532c0..4646267 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -391,12 +391,14 @@ export default function SiteDashboardPage() { collectPagePaths={site.collect_page_paths ?? true} siteId={siteId} dateRange={dateRange} + onFilter={handleAddFilter} /> @@ -408,6 +410,7 @@ export default function SiteDashboardPage() { geoDataLevel={site.collect_geo_data || 'full'} siteId={siteId} dateRange={dateRange} + onFilter={handleAddFilter} /> diff --git a/components/dashboard/AddFilterDropdown.tsx b/components/dashboard/AddFilterDropdown.tsx index 5207bf0..ee795ab 100644 --- a/components/dashboard/AddFilterDropdown.tsx +++ b/components/dashboard/AddFilterDropdown.tsx @@ -1,40 +1,30 @@ 'use client' -import { useState, useRef, useEffect } from 'react' +import { useState } from 'react' +import { Modal } from '@ciphera-net/ui' import { DIMENSIONS, DIMENSION_LABELS, OPERATORS, OPERATOR_LABELS, type DimensionFilter } from '@/lib/filters' interface AddFilterDropdownProps { onAdd: (filter: DimensionFilter) => void } -type Step = 'dimension' | 'operator' | 'value' - export default function AddFilterDropdown({ onAdd }: AddFilterDropdownProps) { const [isOpen, setIsOpen] = useState(false) - const [step, setStep] = useState('dimension') const [dimension, setDimension] = useState('') const [operator, setOperator] = useState('is') const [value, setValue] = useState('') - const ref = useRef(null) - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setIsOpen(false) - resetState() - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) function resetState() { - setStep('dimension') setDimension('') setOperator('is') setValue('') } + function handleOpen() { + resetState() + setIsOpen(true) + } + function handleSubmit() { if (!dimension || !operator || !value.trim()) return onAdd({ dimension, operator, values: [value.trim()] }) @@ -42,78 +32,88 @@ export default function AddFilterDropdown({ onAdd }: AddFilterDropdownProps) { resetState() } + const hasDimension = dimension !== '' + return ( -
+ <> - {isOpen && ( -
- {step === 'dimension' && ( -
-
- Select dimension -
- {DIMENSIONS.map(dim => ( - - ))} + setIsOpen(false)} title="Add Filter"> + {!hasDimension ? ( +
+ {DIMENSIONS.map(dim => ( + + ))} +
+ ) : ( +
+ {/* Selected dimension header */} +
+ + + {DIMENSION_LABELS[dimension]} +
- )} - {step === 'operator' && ( -
-
- {DIMENSION_LABELS[dimension]} ... -
+ {/* Operator selection */} +
{OPERATORS.map(op => ( ))}
- )} - {step === 'value' && ( -
-
- {DIMENSION_LABELS[dimension]} {OPERATOR_LABELS[operator]} -
- setValue(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }} - placeholder="Enter value..." - className="w-full px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/30" - /> - -
- )} -
- )} -
+ {/* Value input */} + setValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }} + placeholder={`Enter ${DIMENSION_LABELS[dimension].toLowerCase()} value...`} + className="w-full px-4 py-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange/40 focus:border-brand-orange transition-colors" + /> + + {/* Apply */} + +
+ )} + + ) } diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index d2eeea7..ad159bf 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -7,6 +7,7 @@ import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats' import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' import { ListSkeleton } from '@/components/skeletons' +import { type DimensionFilter } from '@/lib/filters' interface ContentStatsProps { topPages: TopPage[] @@ -16,13 +17,14 @@ interface ContentStatsProps { collectPagePaths?: boolean siteId: string dateRange: { start: string, end: string } + onFilter?: (filter: DimensionFilter) => void } type Tab = 'top_pages' | 'entry_pages' | 'exit_pages' const LIMIT = 7 -export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) { +export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange, onFilter }: ContentStatsProps) { const [activeTab, setActiveTab] = useState('top_pages') const handleTabKeyDown = useTabListKeyboard() const [isModalOpen, setIsModalOpen] = useState(false) @@ -133,16 +135,21 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, ) : hasData ? ( <> {displayedData.map((page) => ( -
+
onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} + >
diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 5699188..f6c24df 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -12,6 +12,7 @@ import { ListSkeleton } from '@/components/skeletons' import { SiTorproject } from 'react-icons/si' import { FaUserSecret, FaSatellite } from 'react-icons/fa' import { getCountries, getCities, getRegions } from '@/lib/api/stats' +import { type DimensionFilter } from '@/lib/filters' interface LocationProps { countries: Array<{ country: string; pageviews: number }> @@ -20,13 +21,16 @@ interface LocationProps { geoDataLevel?: 'full' | 'country' | 'none' siteId: string dateRange: { start: string, end: string } + onFilter?: (filter: DimensionFilter) => void } type Tab = 'map' | 'countries' | 'regions' | 'cities' const LIMIT = 7 -export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) { +const TAB_TO_DIMENSION: Record = { countries: 'country', regions: 'region', cities: 'city' } + +export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange, onFilter }: LocationProps) { const [activeTab, setActiveTab] = useState('map') const handleTabKeyDown = useTabListKeyboard() const [isModalOpen, setIsModalOpen] = useState(false) @@ -247,23 +251,30 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' ) : ( hasData ? ( <> - {displayedData.map((item) => ( -
-
- {activeTab === 'countries' && {getFlagComponent(item.country ?? '')}} - {activeTab !== 'countries' && {getFlagComponent(item.country ?? '')}} - - - {activeTab === 'countries' ? getCountryName(item.country ?? '') : - activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : - getCityName(item.city ?? '')} - + {displayedData.map((item) => { + const dim = TAB_TO_DIMENSION[activeTab] + const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city + const canFilter = onFilter && dim && filterValue + return ( +
canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {getFlagComponent(item.country ?? '')} + + {activeTab === 'countries' ? getCountryName(item.country ?? '') : + activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') : + getCityName(item.city ?? '')} + +
+
+ {formatNumber(item.pageviews)} +
-
- {formatNumber(item.pageviews)} -
-
- ))} + ) + })} {Array.from({ length: emptySlots }).map((_, i) => ( ) : hasData ? ( <> - {displayedData.map((item) => ( -
-
- {item.icon && {item.icon}} - {item.name} + {displayedData.map((item) => { + const dim = TAB_TO_DIMENSION[activeTab] + const canFilter = onFilter && dim + return ( +
canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })} + className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} + > +
+ {item.icon && {item.icon}} + {item.name} +
+
+ {formatNumber(item.pageviews)} +
-
- {formatNumber(item.pageviews)} -
-
- ))} + ) + })} {Array.from({ length: emptySlots }).map((_, i) => (