init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
318
studio/src/pages/AnalyticsPage.tsx
Normal file
318
studio/src/pages/AnalyticsPage.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip } from 'chart.js'
|
||||
import { $analytics, $days } from '../stores/analytics'
|
||||
import { BreakdownList, AnalyticsPageSkeleton, EmptyState, PageHeader } from '../components/shared'
|
||||
import { Tabs } from '../components/ui'
|
||||
import { Icons, getReferrerIcon, getCountryFlagUrl, getBrowserIcon, getDeviceIcon, getOSIcon } from '../components/shared/Icons'
|
||||
|
||||
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip)
|
||||
|
||||
const periodTabs = [
|
||||
{ value: '7', label: '7d' },
|
||||
{ value: '30', label: '30d' },
|
||||
{ value: '90', label: '90d' },
|
||||
]
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatChange(change: number): { text: string; positive: boolean } {
|
||||
const sign = change >= 0 ? '+' : ''
|
||||
return {
|
||||
text: `${sign}${change.toFixed(1)}%`,
|
||||
positive: change >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const { data, error } = useStore($analytics)
|
||||
const days = useStore($days)
|
||||
const chartRef = useRef<HTMLCanvasElement>(null)
|
||||
const chartInstance = useRef<Chart | null>(null)
|
||||
const prevDataRef = useRef(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.views_by_day?.length || !chartRef.current) return
|
||||
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy()
|
||||
}
|
||||
|
||||
const ctx = chartRef.current.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const sortedData = [...data.views_by_day].sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
const fontFamily = '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace'
|
||||
|
||||
chartInstance.current = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: sortedData.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })),
|
||||
datasets: [{
|
||||
data: sortedData.map(d => d.views),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.08)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#10b981',
|
||||
pointBorderColor: '#10b981',
|
||||
pointHoverRadius: 6,
|
||||
pointHoverBackgroundColor: '#10b981',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
titleColor: '#fafafa',
|
||||
bodyColor: '#fafafa',
|
||||
titleFont: { family: fontFamily, size: 11 },
|
||||
bodyFont: { family: fontFamily, size: 12 },
|
||||
padding: 10,
|
||||
cornerRadius: 0,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (ctx) => `${(ctx.parsed.y ?? 0).toLocaleString()} views`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: '#737373',
|
||||
font: { family: fontFamily, size: 10 },
|
||||
padding: 8,
|
||||
maxRotation: 0,
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: { color: '#e5e5e5' },
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: '#737373',
|
||||
font: { family: fontFamily, size: 10 },
|
||||
padding: 12,
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy()
|
||||
}
|
||||
}
|
||||
}, [data])
|
||||
|
||||
// Keep previous data while loading to prevent flickering
|
||||
if (data) {
|
||||
prevDataRef.current = data
|
||||
}
|
||||
const displayData = data || prevDataRef.current
|
||||
|
||||
if (error && !displayData) return <EmptyState Icon={Icons.AlertCircle} title="Failed to load analytics" description={error.message} />
|
||||
if (!displayData) return <AnalyticsPageSkeleton />
|
||||
|
||||
const change = formatChange(displayData.views_change)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Tabs
|
||||
value={String(days)}
|
||||
onChange={(v) => $days.set(Number(v))}
|
||||
tabs={periodTabs}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Stats row - 4 columns on lg, 2 on mobile */}
|
||||
<div className="relative">
|
||||
{/* Vertical dividers at column boundaries (lg: 4 cols, mobile: 2 cols) */}
|
||||
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border lg:hidden" />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '25%' }} />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '50%' }} />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '75%' }} />
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4">
|
||||
<div className="py-5 pl-6 lg:pl-10 pr-6">
|
||||
<div className="text-xs text-muted mb-1">Total Views</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{displayData.total_views.toLocaleString()}</div>
|
||||
<div className={`text-xs mt-1 ${change.positive ? 'text-success' : 'text-danger'}`}>
|
||||
{change.text} vs last period
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-5 px-6 lg:pr-6">
|
||||
<div className="text-xs text-muted mb-1">Page Views</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{displayData.total_page_views.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="py-5 px-6 lg:pl-6 border-t border-border lg:border-t-0">
|
||||
<div className="text-xs text-muted mb-1">Unique Visitors</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{displayData.unique_visitors.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="py-5 pr-6 lg:pr-10 pl-6 border-t border-border lg:border-t-0">
|
||||
<div className="text-xs text-muted mb-1">Bandwidth</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{formatBytes(displayData.total_bandwidth)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal divider */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Chart section */}
|
||||
{displayData.views_by_day.length > 0 && (
|
||||
<>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Views Over Time</div>
|
||||
<div className="h-48">
|
||||
<canvas ref={chartRef} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Breakdown sections - 2 columns */}
|
||||
<div className="relative">
|
||||
{/* Vertical divider at center on lg */}
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
|
||||
<div className="grid lg:grid-cols-2">
|
||||
{/* Top Pages */}
|
||||
{displayData.top_pages.length > 0 && (
|
||||
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Pages</div>
|
||||
<BreakdownList
|
||||
items={displayData.top_pages.map(p => ({
|
||||
label: p.path,
|
||||
value: p.views,
|
||||
percentage: (p.views / displayData.total_page_views) * 100,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Referrers */}
|
||||
{displayData.top_referrers.length > 0 && (
|
||||
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Referrers</div>
|
||||
<BreakdownList
|
||||
items={displayData.top_referrers.map(r => {
|
||||
const label = r.referrer || 'Direct'
|
||||
return {
|
||||
label,
|
||||
value: r.views,
|
||||
percentage: (r.views / displayData.total_views) * 100,
|
||||
Icon: getReferrerIcon(label),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Browsers & Devices */}
|
||||
{(displayData.browsers.length > 0 || displayData.devices.length > 0) && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="relative">
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
<div className="grid lg:grid-cols-2">
|
||||
{displayData.browsers.length > 0 && (
|
||||
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Browsers</div>
|
||||
<BreakdownList
|
||||
items={displayData.browsers.map(b => ({
|
||||
label: b.name,
|
||||
value: b.count,
|
||||
percentage: (b.count / displayData.unique_visitors) * 100,
|
||||
Icon: getBrowserIcon(b.name) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayData.devices.length > 0 && (
|
||||
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Devices</div>
|
||||
<BreakdownList
|
||||
items={displayData.devices.map(d => ({
|
||||
label: d.name,
|
||||
value: d.count,
|
||||
percentage: (d.count / displayData.unique_visitors) * 100,
|
||||
Icon: getDeviceIcon(d.name) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Row 3: Countries & OS */}
|
||||
{(displayData.countries.length > 0 || displayData.os.length > 0) && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="relative">
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
<div className="grid lg:grid-cols-2">
|
||||
{displayData.countries.length > 0 && (
|
||||
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Countries</div>
|
||||
<BreakdownList
|
||||
items={displayData.countries.map(c => ({
|
||||
label: c.name,
|
||||
value: c.count,
|
||||
percentage: (c.count / displayData.unique_visitors) * 100,
|
||||
flagUrl: getCountryFlagUrl(c.name, 40) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayData.os.length > 0 && (
|
||||
<div className="py-6 pr-6 lg:pr-10 pl-6">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Operating Systems</div>
|
||||
<BreakdownList
|
||||
items={displayData.os.map(o => ({
|
||||
label: o.name,
|
||||
value: o.count,
|
||||
percentage: (o.count / displayData.unique_visitors) * 100,
|
||||
Icon: getOSIcon(o.name) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue