- {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) => (
))}
diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx
index f4bd4cd..4664aa1 100644
--- a/components/dashboard/TechSpecs.tsx
+++ b/components/dashboard/TechSpecs.tsx
@@ -9,6 +9,7 @@ import { MdMonitor } from 'react-icons/md'
import { Modal, GridIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
+import { type DimensionFilter } from '@/lib/filters'
interface TechSpecsProps {
browsers: Array<{ browser: string; pageviews: number }>
@@ -19,13 +20,16 @@ interface TechSpecsProps {
collectScreenResolution?: boolean
siteId: string
dateRange: { start: string, end: string }
+ onFilter?: (filter: DimensionFilter) => void
}
type Tab = 'browsers' | 'os' | 'devices' | 'screens'
const LIMIT = 7
-export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) {
+const TAB_TO_DIMENSION: Record
= { browsers: 'browser', os: 'os', devices: 'device' }
+
+export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange, onFilter }: TechSpecsProps) {
const [activeTab, setActiveTab] = useState('browsers')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -156,17 +160,25 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
) : 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) => (
))}
diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx
index 05cb903..e6a38be 100644
--- a/components/dashboard/TopReferrers.tsx
+++ b/components/dashboard/TopReferrers.tsx
@@ -8,17 +8,19 @@ import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeRefer
import { Modal, GlobeIcon } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
+import { type DimensionFilter } from '@/lib/filters'
interface TopReferrersProps {
referrers: Array<{ referrer: string; pageviews: number }>
collectReferrers?: boolean
siteId: string
dateRange: { start: string, end: string }
+ onFilter?: (filter: DimensionFilter) => void
}
const LIMIT = 7
-export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange }: TopReferrersProps) {
+export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange, onFilter }: TopReferrersProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState
([])
const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -103,7 +105,11 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
) : hasData ? (
<>
{displayedReferrers.map((ref) => (
-
+
onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
+ 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' : ''}`}
+ >
{renderReferrerIcon(ref.referrer)}
{getReferrerDisplayName(ref.referrer)}