refactor: move studio to frontends workspace
- 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>
This commit is contained in:
parent
c662e41b97
commit
bef5dd4437
108 changed files with 8650 additions and 441 deletions
|
|
@ -1,229 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue