229 lines
9.3 KiB
TypeScript
229 lines
9.3 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { $posts } from '../stores/posts'
|
|
import { $analytics } from '../stores/analytics'
|
|
import { Button } from '../components/ui'
|
|
import { BreakdownList, EmptyState, HomePageSkeleton, PageHeader } from '../components/shared'
|
|
import { Icons, getReferrerIcon } from '../components/shared/Icons'
|
|
|
|
function formatChange(change: number): { text: string; positive: boolean } {
|
|
const sign = change >= 0 ? '+' : ''
|
|
return {
|
|
text: `${sign}${change.toFixed(1)}%`,
|
|
positive: change >= 0,
|
|
}
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string) {
|
|
const date = new Date(dateStr)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMs / 3600000)
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
|
|
|
if (diffMins < 1) return 'Just now'
|
|
if (diffMins < 60) return `${diffMins}m ago`
|
|
if (diffHours < 24) return `${diffHours}h ago`
|
|
if (diffDays < 7) return `${diffDays}d ago`
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
function formatDate(dateStr: string) {
|
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
function formatViews(views: number) {
|
|
if (views >= 1000) return `${(views / 1000).toFixed(1)}k`
|
|
return views.toString()
|
|
}
|
|
|
|
export default function HomePage() {
|
|
const { data: posts, error: postsError } = useStore($posts)
|
|
const { data: analytics } = useStore($analytics)
|
|
|
|
if (!posts) return <HomePageSkeleton />
|
|
if (postsError) return <EmptyState Icon={Icons.AlertCircle} title="Failed to load data" description={postsError.message} />
|
|
|
|
const drafts = posts
|
|
.filter(p => p.draft)
|
|
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
|
.slice(0, 3)
|
|
|
|
const published = posts
|
|
.filter(p => !p.draft)
|
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
.slice(0, 5)
|
|
|
|
const publishedCount = posts.filter(p => !p.draft).length
|
|
const draftCount = posts.filter(p => p.draft).length
|
|
|
|
const getPostViews = (slug: string): number => {
|
|
if (!analytics?.top_pages) return 0
|
|
const page = analytics.top_pages.find(p => p.path === `/posts/${slug}`)
|
|
return page?.views || 0
|
|
}
|
|
|
|
const change = analytics ? formatChange(analytics.views_change) : null
|
|
|
|
if (posts.length === 0) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader />
|
|
<EmptyState
|
|
Icon={Icons.PenTool}
|
|
title="Welcome to WriteKit"
|
|
description="Create your first post to get started"
|
|
action={<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">Write Your First Post</Button>}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader>
|
|
<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">New Post</Button>
|
|
</PageHeader>
|
|
|
|
{/* Panel container - uses negative margins for full-bleed borders */}
|
|
<div className="-mx-6 lg:-mx-10 mt-6">
|
|
{/* Stats row */}
|
|
<div className="relative">
|
|
{/* Vertical dividers at column boundaries */}
|
|
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '33.333%' }} />
|
|
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '66.666%' }} />
|
|
|
|
<div className="grid grid-cols-3">
|
|
<div className="py-5 pl-6 lg:pl-10 pr-6">
|
|
<div className="text-xs text-muted mb-1">Views</div>
|
|
<div className="text-2xl font-semibold tracking-tight">{analytics?.total_views.toLocaleString() || '0'}</div>
|
|
{change && (
|
|
<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">
|
|
<div className="text-xs text-muted mb-1">Visitors</div>
|
|
<div className="text-2xl font-semibold tracking-tight">{analytics?.unique_visitors.toLocaleString() || '0'}</div>
|
|
</div>
|
|
<div className="py-5 pr-6 lg:pr-10 pl-6">
|
|
<div className="text-xs text-muted mb-1">Posts</div>
|
|
<div className="text-2xl font-semibold tracking-tight">{publishedCount}</div>
|
|
{draftCount > 0 && (
|
|
<div className="text-xs text-muted mt-1">{draftCount} draft{draftCount > 1 ? 's' : ''}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Full-bleed horizontal divider */}
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Content sections with vertical divider */}
|
|
<div className="relative">
|
|
{/* Vertical divider at exact center */}
|
|
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
|
|
|
<div className="grid grid-cols-2">
|
|
{/* Left column: Posts */}
|
|
<div className="pl-6 lg:pl-10 pr-6 py-6 space-y-6">
|
|
{/* Drafts */}
|
|
{drafts.length > 0 && (
|
|
<div>
|
|
<div className="mb-3">
|
|
<span className="text-xs font-medium text-muted uppercase tracking-wide">Continue Writing</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{drafts.map((post) => (
|
|
<a
|
|
key={post.id}
|
|
href={`/studio/posts/${post.slug}/edit`}
|
|
className="group flex items-center justify-between py-2 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<Icons.Ghost className="text-muted opacity-40 flex-shrink-0 text-sm" />
|
|
<span className="text-sm text-text truncate group-hover:text-accent transition-colors">
|
|
{post.title || 'Untitled'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<span className="text-xs text-muted">{formatRelativeTime(post.updated_at)}</span>
|
|
<Icons.ArrowRight className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent posts */}
|
|
{published.length > 0 && (
|
|
<div>
|
|
<div className="mb-3">
|
|
<span className="text-xs font-medium text-muted uppercase tracking-wide">Recent Posts</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{published.map((post) => {
|
|
const views = getPostViews(post.slug)
|
|
return (
|
|
<a
|
|
key={post.id}
|
|
href={`/studio/posts/${post.slug}/edit`}
|
|
className="group flex items-center justify-between py-2 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<Icons.Clock className="text-muted opacity-40 flex-shrink-0 text-sm" />
|
|
<span className="text-sm text-text truncate group-hover:text-accent transition-colors">
|
|
{post.title}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-shrink-0 text-xs text-muted">
|
|
{views > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<Icons.Eye className="opacity-50 text-xs" />
|
|
{formatViews(views)}
|
|
</span>
|
|
)}
|
|
<span>{formatDate(post.date)}</span>
|
|
<Icons.ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-50 transition-opacity" />
|
|
</div>
|
|
</a>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right column: Referrers */}
|
|
<div className="pr-6 lg:pr-10 pl-6 py-6">
|
|
<div className="mb-3">
|
|
<span className="text-xs font-medium text-muted uppercase tracking-wide">Top Referrers</span>
|
|
</div>
|
|
{analytics && analytics.top_referrers.length > 0 ? (
|
|
<BreakdownList
|
|
items={analytics.top_referrers.slice(0, 5).map(r => {
|
|
const label = r.referrer || 'Direct'
|
|
return {
|
|
label,
|
|
value: r.views,
|
|
percentage: (r.views / analytics.total_views) * 100,
|
|
Icon: getReferrerIcon(label),
|
|
}
|
|
})}
|
|
limit={5}
|
|
/>
|
|
) : (
|
|
<div className="text-sm text-muted py-8">No referrer data yet</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|