- Move studio from root to frontends/studio/ - Add owner-tools frontend for live blog admin UI - Add shared ui component library - Set up npm workspaces for frontends - Add enhanced code block extension for editor Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|