feat: add inline bar charts to all dashboard list components
Add proportional background bars (brand-orange) to Pages, Referrers, Locations, Technology, and Campaigns tables. Bars scale relative to the top item in each list.
This commit is contained in:
@@ -155,13 +155,19 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
|||||||
) : hasData ? (
|
) : hasData ? (
|
||||||
<>
|
<>
|
||||||
{displayedData.map((item) => {
|
{displayedData.map((item) => {
|
||||||
|
const maxVis = displayedData[0]?.visitors ?? 0
|
||||||
|
const barWidth = maxVis > 0 ? (item.visitors / maxVis) * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${item.source}|${item.medium}|${item.campaign}`}
|
key={`${item.source}|${item.medium}|${item.campaign}`}
|
||||||
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
|
onClick={() => onFilter?.({ dimension: 'utm_source', operator: 'is', values: [item.source] })}
|
||||||
className={`flex items-center justify-between py-1.5 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
className={`relative flex items-center justify-between py-1.5 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
|
<div
|
||||||
|
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-md transition-all"
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex-1 text-neutral-900 dark:text-white flex items-center gap-3 min-w-0">
|
||||||
{renderSourceIcon(item.source)}
|
{renderSourceIcon(item.source)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-medium text-sm" title={item.source}>
|
<div className="truncate font-medium text-sm" title={item.source}>
|
||||||
@@ -174,7 +180,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="relative flex items-center gap-2 ml-4">
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
|
{totalVisitors > 0 ? `${Math.round((item.visitors / totalVisitors) * 100)}%` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -148,34 +148,42 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
|
|||||||
</div>
|
</div>
|
||||||
) : hasData ? (
|
) : hasData ? (
|
||||||
<>
|
<>
|
||||||
{displayedData.map((page) => (
|
{displayedData.map((page, idx) => {
|
||||||
<div
|
const maxPv = displayedData[0]?.pageviews ?? 0
|
||||||
key={page.path}
|
const barWidth = maxPv > 0 ? (page.pageviews / maxPv) * 100 : 0
|
||||||
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
|
return (
|
||||||
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' : ''}`}
|
<div
|
||||||
>
|
key={page.path}
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
onClick={() => onFilter?.({ dimension: 'page', operator: 'is', values: [page.path] })}
|
||||||
<span className="truncate">{page.path}</span>
|
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||||
<a
|
>
|
||||||
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
<div
|
||||||
target="_blank"
|
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-md transition-all"
|
||||||
rel="noopener noreferrer"
|
style={{ width: `${barWidth}%` }}
|
||||||
onClick={e => e.stopPropagation()}
|
/>
|
||||||
className="ml-2 flex-shrink-0"
|
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center">
|
||||||
>
|
<span className="truncate">{page.path}</span>
|
||||||
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
|
<a
|
||||||
</a>
|
href={`https://${domain.replace(/^https?:\/\//, '')}${page.path}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="ml-2 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowUpRightIcon className="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity hover:text-brand-orange" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(page.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
)
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
})}
|
||||||
{totalPageviews > 0 ? `${Math.round((page.pageviews / totalPageviews) * 100)}%` : ''}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
|
||||||
{formatNumber(page.pageviews)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -289,13 +289,19 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
const dim = TAB_TO_DIMENSION[activeTab]
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
||||||
const canFilter = onFilter && dim && filterValue
|
const canFilter = onFilter && dim && filterValue
|
||||||
|
const maxPv = displayedData[0]?.pageviews ?? 0
|
||||||
|
const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
||||||
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
|
onClick={() => 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' : ''}`}
|
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
<div
|
||||||
|
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-md transition-all"
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
||||||
@@ -303,7 +309,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
|
|||||||
getCityName(item.city ?? '')}
|
getCityName(item.city ?? '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="relative flex items-center gap-2 ml-4">
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -181,17 +181,23 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
|
|||||||
{displayedData.map((item) => {
|
{displayedData.map((item) => {
|
||||||
const dim = TAB_TO_DIMENSION[activeTab]
|
const dim = TAB_TO_DIMENSION[activeTab]
|
||||||
const canFilter = onFilter && dim
|
const canFilter = onFilter && dim
|
||||||
|
const maxPv = displayedData[0]?.pageviews ?? 0
|
||||||
|
const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.name}
|
key={item.name}
|
||||||
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [item.name] })}
|
onClick={() => 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' : ''}`}
|
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
<div
|
||||||
|
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-md transition-all"
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
{item.icon && <span className="text-lg">{item.icon}</span>}
|
{item.icon && <span className="text-lg">{item.icon}</span>}
|
||||||
<span className="truncate">{capitalize(item.name)}</span>
|
<span className="truncate">{capitalize(item.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="relative flex items-center gap-2 ml-4">
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
{totalPageviews > 0 ? `${Math.round((item.pageviews / totalPageviews) * 100)}%` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -112,26 +112,34 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
|||||||
</div>
|
</div>
|
||||||
) : hasData ? (
|
) : hasData ? (
|
||||||
<>
|
<>
|
||||||
{displayedReferrers.map((ref) => (
|
{displayedReferrers.map((ref) => {
|
||||||
<div
|
const maxPv = displayedReferrers[0]?.pageviews ?? 0
|
||||||
key={ref.referrer}
|
const barWidth = maxPv > 0 ? (ref.pageviews / maxPv) * 100 : 0
|
||||||
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
|
return (
|
||||||
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' : ''}`}
|
<div
|
||||||
>
|
key={ref.referrer}
|
||||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
|
||||||
{renderReferrerIcon(ref.referrer)}
|
className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
||||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/5 dark:bg-brand-orange/10 rounded-md transition-all"
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||||
|
{renderReferrerIcon(ref.referrer)}
|
||||||
|
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-center gap-2 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(ref.pageviews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
)
|
||||||
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
})}
|
||||||
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
|
||||||
{formatNumber(ref.pageviews)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Array.from({ length: emptySlots }).map((_, i) => (
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user