writekit/studio/src/pages/HomePage.tsx

230 lines
9.3 KiB
TypeScript
Raw Normal View History

2026-01-09 00:16:46 +02:00
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>
)
}