This commit is contained in:
Josh 2026-01-09 00:16:46 +02:00
commit d69342b2e9
160 changed files with 28681 additions and 0 deletions

View file

@ -0,0 +1,749 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { $apiKeys, createAPIKey, $deleteAPIKey, $creating } from '../stores/apiKeys'
import { $webhooks, createWebhook, updateWebhook, $deleteWebhook, testWebhook, fetchWebhookDeliveries } from '../stores/webhooks'
import { $billing } from '../stores/billing'
import { addToast } from '../stores/app'
import { EmptyState, APIPageSkeleton, PageHeader } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button, Input, Modal, UsageIndicator } from '../components/ui'
import type { Webhook, WebhookDelivery, WebhookEvent } from '../types'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
interface QueryParam {
name: string
description: string
default?: string
options?: string[] // For select dropdowns
}
interface Endpoint {
method: HttpMethod
path: string
description: string
queryParams?: QueryParam[]
requestBody?: string
responseExample: string
}
const endpoints: Endpoint[] = [
{
method: 'GET',
path: '/api/v1/posts',
description: 'List published posts with pagination',
queryParams: [
{ name: 'limit', description: 'Results per page (max 100)', default: '20' },
{ name: 'offset', description: 'Skip N results', default: '0' },
{ name: 'tag', description: 'Filter by tag' },
{ name: 'include', description: 'Include extra fields', options: ['', 'content'] },
],
responseExample: `{
"posts": [
{
"id": "uuid",
"slug": "hello-world",
"title": "Hello World",
"description": "My first post",
"tags": ["intro"],
"date": "2024-01-15",
"draft": false
}
],
"total": 42,
"limit": 20,
"offset": 0
}`,
},
{
method: 'POST',
path: '/api/v1/posts',
description: 'Create a new post',
requestBody: `{
"title": "My New Post",
"slug": "my-new-post",
"content": "# Markdown content",
"description": "Optional description",
"tags": ["tutorial"],
"draft": false
}`,
responseExample: `{
"id": "uuid",
"slug": "my-new-post",
"title": "My New Post",
...
}`,
},
{
method: 'GET',
path: '/api/v1/posts/{slug}',
description: 'Get a single post by slug (includes content)',
responseExample: `{
"id": "uuid",
"slug": "hello-world",
"title": "Hello World",
"description": "My first post",
"content": {
"markdown": "# Full markdown...",
"html": "<h1>Full markdown...</h1>"
},
"tags": ["intro"],
"date": "2024-01-15"
}`,
},
{
method: 'PUT',
path: '/api/v1/posts/{slug}',
description: 'Update an existing post',
requestBody: `{
"title": "Updated Title",
"content": "# Updated content"
}`,
responseExample: `{
"id": "uuid",
"slug": "hello-world",
"title": "Updated Title",
...
}`,
},
{
method: 'DELETE',
path: '/api/v1/posts/{slug}',
description: 'Delete a post permanently',
responseExample: `204 No Content`,
},
{
method: 'GET',
path: '/api/v1/settings',
description: 'Get public site configuration',
responseExample: `{
"site_name": "My Blog",
"site_description": "A developer blog",
"author_name": "Jane Doe",
"author_bio": "Software engineer",
"twitter_handle": "janedoe",
"accent_color": "#10b981"
}`,
},
]
const methodColors: Record<HttpMethod, string> = {
GET: 'bg-success/15 text-success',
POST: 'bg-blue-500/15 text-blue-500',
PUT: 'bg-warning/15 text-warning',
DELETE: 'bg-danger/15 text-danger',
}
const webhookEvents: { value: WebhookEvent; label: string }[] = [
{ value: 'post.published', label: 'Post Published' },
{ value: 'post.updated', label: 'Post Updated' },
{ value: 'post.deleted', label: 'Post Deleted' },
]
function EndpointCard({ endpoint, baseUrl, apiKey }: { endpoint: Endpoint; baseUrl: string; apiKey?: string }) {
const [expanded, setExpanded] = useState(false)
const [slugInput, setSlugInput] = useState('hello-world')
const [requestBody, setRequestBody] = useState(endpoint.requestBody || '')
const [queryParams, setQueryParams] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}
endpoint.queryParams?.forEach(p => {
initial[p.name] = p.default || ''
})
return initial
})
const [response, setResponse] = useState<{ status: number; body: string; time: number } | null>(null)
const [loading, setLoading] = useState(false)
const basePath = endpoint.path.replace('{slug}', slugInput)
const queryString = Object.entries(queryParams)
.filter(([_, v]) => v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&')
const actualPath = queryString ? `${basePath}?${queryString}` : basePath
const fullUrl = `${baseUrl}${actualPath}`
const curlExample = endpoint.requestBody
? `curl -X ${endpoint.method} "${fullUrl}" \\
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}" \\
-H "Content-Type: application/json" \\
-d '${endpoint.requestBody.replace(/\n\s*/g, ' ')}'`
: `curl -X ${endpoint.method} "${fullUrl}" \\
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}"`
const copyCode = (code: string) => {
navigator.clipboard.writeText(code)
addToast('Copied to clipboard', 'success')
}
const updateQueryParam = (name: string, value: string) => {
setQueryParams(prev => ({ ...prev, [name]: value }))
}
const sendRequest = async () => {
setLoading(true)
setResponse(null)
const start = Date.now()
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
const res = await fetch(actualPath, {
method: endpoint.method,
headers,
body: endpoint.requestBody ? requestBody : undefined,
})
const time = Date.now() - start
let body: string
try {
const json = await res.json()
body = JSON.stringify(json, null, 2)
} catch {
body = res.status === 204 ? '(No Content)' : await res.text()
}
setResponse({ status: res.status, body, time })
} catch (err) {
setResponse({ status: 0, body: `Error: ${err instanceof Error ? err.message : 'Request failed'}`, time: Date.now() - start })
} finally {
setLoading(false)
}
}
return (
<div className="border border-border hover:border-muted transition-colors">
<button
onClick={() => setExpanded(!expanded)}
className="group w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/5 transition-colors cursor-pointer"
>
<span className={`px-2 py-0.5 text-xs font-mono font-medium ${methodColors[endpoint.method]}`}>
{endpoint.method}
</span>
<code className="text-sm font-mono flex-1 group-hover:text-accent transition-colors">{endpoint.path}</code>
<span className="text-xs text-muted hidden sm:block">{endpoint.description}</span>
<Icons.ChevronRight className={`text-muted transition-transform group-hover:text-text ${expanded ? 'rotate-90' : ''}`} />
</button>
{expanded && (
<div className="border-t border-border bg-muted/5 p-4 space-y-4">
<p className="text-sm text-muted">{endpoint.description}</p>
{endpoint.path.includes('{slug}') && (
<div className="flex items-center gap-2">
<label className="text-xs text-muted w-16">slug:</label>
<Input
value={slugInput}
onChange={setSlugInput}
className="max-w-48 text-sm"
placeholder="post-slug"
/>
</div>
)}
{endpoint.queryParams && endpoint.queryParams.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-muted">Query Parameters</div>
{endpoint.queryParams.map(param => (
<div key={param.name} className="flex items-center gap-2">
<label className="text-xs text-muted w-16 shrink-0">{param.name}:</label>
{param.options ? (
<select
value={queryParams[param.name] || ''}
onChange={e => updateQueryParam(param.name, e.target.value)}
className="px-2 py-1.5 text-sm bg-surface border border-border focus:border-accent focus:outline-none"
>
{param.options.map(opt => (
<option key={opt} value={opt}>{opt || '(none)'}</option>
))}
</select>
) : (
<Input
value={queryParams[param.name] || ''}
onChange={v => updateQueryParam(param.name, v)}
className="max-w-32 text-sm"
placeholder={param.default || ''}
/>
)}
<span className="text-xs text-muted">{param.description}</span>
</div>
))}
</div>
)}
{endpoint.requestBody && (
<div>
<div className="text-xs text-muted mb-2">Request Body</div>
<textarea
value={requestBody}
onChange={e => setRequestBody(e.target.value)}
className="w-full p-3 bg-surface border border-border text-xs font-mono resize-y min-h-32"
spellCheck={false}
/>
</div>
)}
<Button
variant="primary"
Icon={loading ? undefined : Icons.Play}
onClick={sendRequest}
loading={loading}
>
{loading ? 'Sending...' : 'Try it'}
</Button>
{response && (
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-xs text-muted">Response</span>
<span className={`px-2 py-0.5 text-xs font-mono ${response.status >= 200 && response.status < 300 ? 'bg-success/15 text-success' : response.status >= 400 ? 'bg-danger/15 text-danger' : 'bg-warning/15 text-warning'}`}>
{response.status || 'Error'}
</span>
<span className="text-xs text-muted">{response.time}ms</span>
</div>
<pre className="p-3 bg-surface border border-border text-xs font-mono max-h-64 overflow-y-auto whitespace-pre-wrap break-words">
{response.body}
</pre>
</div>
)}
<div>
<div className="text-xs text-muted mb-2">Example Response</div>
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
{endpoint.responseExample}
</pre>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted">cURL</span>
<button onClick={() => copyCode(curlExample)} className="text-xs text-muted hover:text-text">
<Icons.Copy className="inline mr-1" />Copy
</button>
</div>
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto whitespace-pre-wrap">
{curlExample}
</pre>
</div>
</div>
)}
</div>
)
}
function WebhookCard({ webhook, onEdit, onDelete, onTest }: {
webhook: Webhook
onEdit: () => void
onDelete: () => void
onTest: () => void
}) {
const [showLogs, setShowLogs] = useState(false)
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([])
const [loadingLogs, setLoadingLogs] = useState(false)
const loadDeliveries = async () => {
setLoadingLogs(true)
try {
const data = await fetchWebhookDeliveries(webhook.id)
setDeliveries(data)
setShowLogs(true)
} catch {
addToast('Failed to load delivery logs', 'error')
} finally {
setLoadingLogs(false)
}
}
return (
<div className="border border-border p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full ${webhook.enabled ? (webhook.last_status === 'success' ? 'bg-success' : webhook.last_status === 'failed' ? 'bg-danger' : 'bg-muted') : 'bg-muted'}`} />
<span className="font-medium text-sm">{webhook.name}</span>
{!webhook.enabled && <span className="text-xs text-muted">(disabled)</span>}
</div>
<code className="text-xs text-muted break-all">{webhook.url}</code>
<div className="flex flex-wrap gap-1 mt-2">
{webhook.events.map(event => (
<span key={event} className="px-1.5 py-0.5 text-xs bg-muted/20 text-muted">
{event}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={loadDeliveries} className="p-1.5 text-muted hover:text-text" title="View logs">
<Icons.List className="w-4 h-4" />
</button>
<button onClick={onTest} className="p-1.5 text-muted hover:text-text" title="Test">
<Icons.Play className="w-4 h-4" />
</button>
<button onClick={onEdit} className="p-1.5 text-muted hover:text-text" title="Edit">
<Icons.Edit className="w-4 h-4" />
</button>
<button onClick={onDelete} className="p-1.5 text-danger hover:text-danger/80" title="Delete">
<Icons.Trash className="w-4 h-4" />
</button>
</div>
</div>
{showLogs && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted">Recent Deliveries</span>
<button onClick={() => setShowLogs(false)} className="text-xs text-muted hover:text-text">Close</button>
</div>
{loadingLogs ? (
<div className="text-xs text-muted">Loading...</div>
) : deliveries.length === 0 ? (
<div className="text-xs text-muted">No deliveries yet</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{deliveries.slice(0, 10).map(d => (
<div key={d.id} className="flex items-center gap-2 text-xs">
<span className={`w-2 h-2 rounded-full ${d.status === 'success' ? 'bg-success' : 'bg-danger'}`} />
<span className="text-muted">{d.event}</span>
<span className="text-muted"></span>
<span className={d.status === 'success' ? 'text-success' : 'text-danger'}>
{d.response_code || d.status}
</span>
<span className="text-muted ml-auto">
{new Date(d.created_at).toLocaleString()}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
export default function APIPage() {
const { data: keys } = useStore($apiKeys)
const { data: webhooks } = useStore($webhooks)
const { data: billing } = useStore($billing)
const creating = useStore($creating)
const deleteKey = useStore($deleteAPIKey)
const deleteWebhookMutation = useStore($deleteWebhook)
const [showCreateKey, setShowCreateKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [deleteKeyModal, setDeleteKeyModal] = useState<string | null>(null)
const [showWebhookModal, setShowWebhookModal] = useState(false)
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null)
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as WebhookEvent[], secret: '', enabled: true })
const [deleteWebhookModal, setDeleteWebhookModal] = useState<string | null>(null)
const [savingWebhook, setSavingWebhook] = useState(false)
const handleCreateKey = async () => {
if (!newKeyName.trim()) return
try {
const result = await createAPIKey(newKeyName)
setCreatedKey(result.key)
setNewKeyName('')
setShowCreateKey(false)
addToast('API key created', 'success')
} catch {
addToast('Failed to create API key', 'error')
}
}
const handleDeleteKey = async () => {
if (!deleteKeyModal) return
try {
await deleteKey.mutate(deleteKeyModal)
addToast('API key deleted', 'success')
setDeleteKeyModal(null)
} catch {
addToast('Failed to delete API key', 'error')
}
}
const copyKey = (key: string) => {
navigator.clipboard.writeText(key)
addToast('Copied to clipboard', 'success')
}
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Never'
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const openWebhookModal = (webhook?: Webhook) => {
if (webhook) {
setEditingWebhook(webhook)
setWebhookForm({ name: webhook.name, url: webhook.url, events: webhook.events, secret: webhook.secret || '', enabled: webhook.enabled })
} else {
setEditingWebhook(null)
setWebhookForm({ name: '', url: '', events: [], secret: '', enabled: true })
}
setShowWebhookModal(true)
}
const handleSaveWebhook = async () => {
if (!webhookForm.name || !webhookForm.url || webhookForm.events.length === 0) {
addToast('Name, URL, and at least one event are required', 'error')
return
}
setSavingWebhook(true)
try {
if (editingWebhook) {
await updateWebhook(editingWebhook.id, webhookForm)
addToast('Webhook updated', 'success')
} else {
await createWebhook(webhookForm)
addToast('Webhook created', 'success')
}
setShowWebhookModal(false)
} catch {
addToast('Failed to save webhook', 'error')
} finally {
setSavingWebhook(false)
}
}
const handleDeleteWebhook = async () => {
if (!deleteWebhookModal) return
try {
await deleteWebhookMutation.mutate(deleteWebhookModal)
addToast('Webhook deleted', 'success')
setDeleteWebhookModal(null)
} catch {
addToast('Failed to delete webhook', 'error')
}
}
const handleTestWebhook = async (id: string) => {
try {
await testWebhook(id)
addToast('Test webhook sent', 'success')
} catch {
addToast('Failed to send test webhook', 'error')
}
}
const toggleEvent = (event: WebhookEvent) => {
setWebhookForm(f => ({
...f,
events: f.events.includes(event) ? f.events.filter(e => e !== event) : [...f.events, event]
}))
}
if (!keys) return <APIPageSkeleton />
const baseUrl = window.location.origin
const firstKey = keys.length > 0 ? keys[0].key : undefined
return (
<div>
<PageHeader>
<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>
Create Key
</Button>
</PageHeader>
<div className="-mx-6 lg:-mx-10 mt-6">
{createdKey && (
<div className="px-6 lg:px-10 py-4 bg-success/10 border-b border-success">
<div className="flex items-center gap-2 mb-2">
<Icons.CheckCircle className="text-success" />
<span className="text-sm font-medium">API Key Created</span>
</div>
<p className="text-xs text-muted mb-3">Copy this key now. You won't be able to see it again.</p>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-surface border border-border text-xs font-mono truncate">{createdKey}</code>
<Button variant="secondary" Icon={Icons.Copy} onClick={() => copyKey(createdKey)}>Copy</Button>
</div>
<button onClick={() => setCreatedKey(null)} className="mt-2 text-xs text-muted hover:text-text">Dismiss</button>
</div>
)}
{/* API Keys */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Your API Keys</div>
<div className="text-xs text-muted mt-0.5">Use these keys to authenticate API requests</div>
</div>
<div className="border-t border-border">
{keys.length === 0 ? (
<div className="px-6 lg:px-10 py-12">
<EmptyState
Icon={Icons.ApiKeys}
title="No API keys"
description="Create an API key to access the API"
action={<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>Create Key</Button>}
/>
</div>
) : (
keys.map((key, i) => (
<div key={key.key} className={`flex flex-col sm:flex-row sm:items-center gap-4 px-6 lg:px-10 py-4 ${i > 0 ? 'border-t border-border' : ''}`}>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{key.name}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted mt-1">
<code className="font-mono">{key.key.slice(0, 8)}...{key.key.slice(-4)}</code>
<span className="hidden sm:inline"></span>
<span>Created {formatDate(key.created_at)}</span>
<span className="hidden sm:inline"></span>
<span>Last used {formatDate(key.last_used_at)}</span>
</div>
</div>
<Button variant="ghost" Icon={Icons.Trash} className="text-danger hover:text-danger self-start sm:self-center" onClick={() => setDeleteKeyModal(key.key)}>Delete</Button>
</div>
))
)}
</div>
<div className="border-t border-border" />
{/* Webhooks */}
<div className="px-6 lg:px-10 py-5">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium text-muted uppercase tracking-wide">Webhooks</div>
<div className="text-xs text-muted mt-0.5">Get notified when posts are published, updated, or deleted</div>
</div>
{billing && (
<UsageIndicator
used={billing.usage.webhooks}
max={billing.tiers[billing.current_tier].max_webhooks}
label="used"
/>
)}
</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-3">
{webhooks && webhooks.length > 0 ? (
webhooks.map(webhook => (
<WebhookCard
key={webhook.id}
webhook={webhook}
onEdit={() => openWebhookModal(webhook)}
onDelete={() => setDeleteWebhookModal(webhook.id)}
onTest={() => handleTestWebhook(webhook.id)}
/>
))
) : (
<div className="text-sm text-muted">No webhooks configured</div>
)}
{billing && billing.usage.webhooks >= billing.tiers[billing.current_tier].max_webhooks ? (
<div className="flex items-center gap-3 p-3 border border-warning/30 bg-warning/5">
<Icons.AlertCircle className="text-warning flex-shrink-0" />
<span className="text-sm text-muted">Webhook limit reached.</span>
<Button variant="primary" href="/studio/billing">Upgrade</Button>
</div>
) : (
<Button variant="secondary" Icon={Icons.Plus} onClick={() => openWebhookModal()}>Add Webhook</Button>
)}
</div>
<div className="border-t border-border" />
{/* API Reference */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">API Reference</div>
<div className="text-xs text-muted mt-0.5">Base URL: <code className="text-text">{baseUrl}</code></div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-2">
{endpoints.map((endpoint) => (
<EndpointCard key={`${endpoint.method}-${endpoint.path}`} endpoint={endpoint} baseUrl={baseUrl} apiKey={firstKey} />
))}
</div>
<div className="border-t border-border" />
{/* Authentication */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Authentication</div>
<div className="text-xs text-muted mt-0.5">How to authenticate your requests</div>
</div>
<div className="px-6 lg:px-10 py-6">
<p className="text-sm text-muted mb-3">All API requests require a Bearer token in the Authorization header:</p>
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
Authorization: Bearer {firstKey ? `${firstKey.slice(0, 8)}...` : 'YOUR_API_KEY'}
</pre>
<p className="text-xs text-muted mt-3">Keep API keys secure and never expose them in client-side code.</p>
</div>
</div>
{/* Create Key Modal */}
<Modal open={showCreateKey} onClose={() => setShowCreateKey(false)} title="Create API Key">
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Key Name</label>
<Input value={newKeyName} onChange={setNewKeyName} placeholder="My API Key" />
<p className="text-xs text-muted">A name to identify this key</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowCreateKey(false)}>Cancel</Button>
<Button variant="primary" onClick={handleCreateKey} loading={creating} disabled={!newKeyName.trim()}>Create</Button>
</div>
</div>
</Modal>
{/* Delete Key Modal */}
<Modal open={!!deleteKeyModal} onClose={() => setDeleteKeyModal(null)} title="Delete API Key">
<p className="text-sm text-muted mb-4">Are you sure? Applications using this key will stop working.</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteKeyModal(null)}>Cancel</Button>
<Button variant="danger" onClick={handleDeleteKey} loading={deleteKey.loading}>Delete</Button>
</div>
</Modal>
{/* Webhook Modal */}
<Modal open={showWebhookModal} onClose={() => setShowWebhookModal(false)} title={editingWebhook ? 'Edit Webhook' : 'Add Webhook'}>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Name</label>
<Input value={webhookForm.name} onChange={v => setWebhookForm(f => ({ ...f, name: v }))} placeholder="Discord Notification" />
</div>
<div className="space-y-1">
<label className="label">URL</label>
<Input value={webhookForm.url} onChange={v => setWebhookForm(f => ({ ...f, url: v }))} placeholder="https://your-server.com/webhook" />
</div>
<div className="space-y-2">
<label className="label">Events</label>
<div className="flex flex-wrap gap-2">
{webhookEvents.map(e => (
<button
key={e.value}
onClick={() => toggleEvent(e.value)}
className={`px-3 py-1.5 text-xs border transition-colors ${webhookForm.events.includes(e.value) ? 'border-accent bg-accent/10 text-accent' : 'border-border text-muted hover:border-muted'}`}
>
{e.label}
</button>
))}
</div>
</div>
<div className="space-y-1">
<label className="label">Secret (optional)</label>
<Input value={webhookForm.secret} onChange={v => setWebhookForm(f => ({ ...f, secret: v }))} placeholder="For HMAC signature verification" />
<p className="text-xs text-muted">Used to sign payloads with X-WriteKit-Signature header</p>
</div>
{editingWebhook && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={webhookForm.enabled} onChange={e => setWebhookForm(f => ({ ...f, enabled: e.target.checked }))} />
Enabled
</label>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setShowWebhookModal(false)}>Cancel</Button>
<Button variant="primary" onClick={handleSaveWebhook} loading={savingWebhook}>{editingWebhook ? 'Save' : 'Create'}</Button>
</div>
</div>
</Modal>
{/* Delete Webhook Modal */}
<Modal open={!!deleteWebhookModal} onClose={() => setDeleteWebhookModal(null)} title="Delete Webhook">
<p className="text-sm text-muted mb-4">Are you sure you want to delete this webhook?</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteWebhookModal(null)}>Cancel</Button>
<Button variant="danger" onClick={handleDeleteWebhook} loading={deleteWebhookMutation.loading}>Delete</Button>
</div>
</Modal>
</div>
)
}

View 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>
)
}

View file

@ -0,0 +1,251 @@
import { useState } from 'react'
import { useStore } from '@nanostores/react'
import { PageHeader, BillingPageSkeleton } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button } from '../components/ui'
import { $billing } from '../stores/billing'
import type { Tier, TierConfig } from '../types'
type BillingCycle = 'monthly' | 'annual'
function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(0)}`
}
function getFeatureList(config: TierConfig, tier: Tier): { name: string; included: boolean }[] {
if (tier === 'free') {
return [
{ name: 'Unlimited posts', included: true },
{ name: 'Comments & reactions', included: true },
{ name: 'writekit.dev subdomain', included: true },
{ name: `${config.analytics_retention}-day analytics`, included: true },
{ name: `API access (${config.api_rate_limit} req/hr)`, included: true },
{ name: `${config.max_webhooks} webhooks`, included: true },
{ name: `${config.max_plugins} plugins`, included: true },
{ name: 'Custom domain', included: false },
{ name: 'Remove "Powered by" badge', included: false },
]
}
return [
{ name: 'Unlimited posts', included: true },
{ name: 'Comments & reactions', included: true },
{ name: 'writekit.dev subdomain', included: true },
{ name: 'Custom domain', included: true },
{ name: 'No "Powered by" badge', included: true },
{ name: `${config.analytics_retention}-day analytics`, included: true },
{ name: `API access (${config.api_rate_limit} req/hr)`, included: true },
{ name: `${config.max_webhooks} webhooks`, included: true },
{ name: `${config.max_plugins} plugins`, included: true },
{ name: 'Priority support', included: true },
]
}
export default function BillingPage() {
const { data: billing } = useStore($billing)
const [billingCycle, setBillingCycle] = useState<BillingCycle>('annual')
if (!billing) return <BillingPageSkeleton />
const currentTier = billing.current_tier
const currentConfig = billing.tiers[currentTier]
const proConfig = billing.tiers.pro
const proFeatures = getFeatureList(proConfig, 'pro')
const annualSavings = (proConfig.monthly_price * 12 - proConfig.annual_price) / 100
return (
<div>
<PageHeader />
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Current Plan */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Current Plan</div>
</div>
<div className="px-6 lg:px-10 pb-6">
<div className="p-4 border border-accent bg-accent/5">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-medium">{currentConfig.name}</span>
<span className="px-2 py-0.5 bg-accent/20 text-accent text-xs font-medium">Active</span>
</div>
<div className="text-sm text-muted mt-1">{currentConfig.description}</div>
</div>
{currentTier === 'free' && (
<Button variant="primary" href="#upgrade">Upgrade to Pro</Button>
)}
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Upgrade Section */}
{currentTier === 'free' && (
<>
<div id="upgrade" className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Upgrade to Pro</div>
<div className="text-xs text-muted mt-0.5">Get custom domain, extended analytics, and more</div>
</div>
<div className="px-6 lg:px-10 pb-6">
{/* Billing Toggle */}
<div className="flex items-center justify-center gap-3 mb-6">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
billingCycle === 'monthly' ? 'text-text' : 'text-muted hover:text-text'
}`}
>
Monthly
</button>
<button
onClick={() => setBillingCycle('annual')}
className={`px-3 py-1.5 text-sm font-medium transition-colors flex items-center gap-2 ${
billingCycle === 'annual' ? 'text-text' : 'text-muted hover:text-text'
}`}
>
Annual
<span className="px-1.5 py-0.5 bg-success/20 text-success text-xs">
Save ${annualSavings}
</span>
</button>
</div>
{/* Pro Plan Card */}
<div className="max-w-md mx-auto p-6 border border-border">
<div className="text-center mb-6">
<div className="text-sm font-medium text-muted mb-2">Pro</div>
<div className="text-4xl font-semibold tracking-tight">
{formatPrice(billingCycle === 'monthly' ? proConfig.monthly_price : proConfig.annual_price)}
<span className="text-base font-normal text-muted">
/{billingCycle === 'monthly' ? 'mo' : 'yr'}
</span>
</div>
{billingCycle === 'annual' && (
<div className="text-xs text-muted mt-1">
{formatPrice(Math.round(proConfig.annual_price / 12))}/mo billed annually
</div>
)}
</div>
<ul className="space-y-3 mb-6">
{proFeatures.map((feature) => (
<li key={feature.name} className="flex items-center gap-2 text-sm">
<Icons.Check className="text-success flex-shrink-0" />
<span>{feature.name}</span>
</li>
))}
</ul>
<Button variant="primary" className="w-full">
Upgrade to Pro
</Button>
<p className="text-xs text-muted text-center mt-3">
Secure payment via Lemon Squeezy. Cancel anytime.
</p>
</div>
</div>
<div className="border-t border-border" />
</>
)}
{/* Feature Comparison */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Feature Comparison</div>
</div>
<div className="px-6 lg:px-10 pb-6">
<div className="border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/5">
<th className="text-left px-4 py-3 font-medium">Feature</th>
<th className="text-center px-4 py-3 font-medium w-24">Free</th>
<th className="text-center px-4 py-3 font-medium w-24">Pro</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border">
<td className="px-4 py-3">Custom domain</td>
<td className="text-center px-4 py-3"><Icons.Close className="text-muted/50 inline" /></td>
<td className="text-center px-4 py-3"><Icons.Check className="text-success inline" /></td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">"Powered by WriteKit" badge</td>
<td className="text-center px-4 py-3 text-muted">Required</td>
<td className="text-center px-4 py-3 text-muted">Hidden</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Analytics retention</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.analytics_retention} days</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.analytics_retention} days</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">API rate limit</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.api_rate_limit}/hr</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.api_rate_limit}/hr</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Webhooks</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.max_webhooks}</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.max_webhooks}</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Plugins</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.max_plugins}</td>
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.max_plugins}</td>
</tr>
<tr className="border-b border-border">
<td className="px-4 py-3">Posts</td>
<td className="text-center px-4 py-3 text-muted">Unlimited</td>
<td className="text-center px-4 py-3 text-muted">Unlimited</td>
</tr>
<tr>
<td className="px-4 py-3">Support</td>
<td className="text-center px-4 py-3 text-muted">Community</td>
<td className="text-center px-4 py-3 text-muted">Priority</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="border-t border-border" />
{/* FAQ */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Questions</div>
</div>
<div className="px-6 lg:px-10 pb-6 space-y-4">
<div>
<div className="text-sm font-medium mb-1">Can I cancel anytime?</div>
<div className="text-sm text-muted">
Yes. Cancel anytime and keep access until the end of your billing period.
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Can I switch from monthly to annual?</div>
<div className="text-sm text-muted">
Yes. Switch anytime and we'll prorate your payment.
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">What happens to my content if I downgrade?</div>
<div className="text-sm text-muted">
Your content stays. Custom domain will stop working, badge will appear, and analytics older than 7 days won't be accessible.
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Can I export my data?</div>
<div className="text-sm text-muted">
Yes. Export all your posts, settings, and assets anytime from the Data page. Your data is always yours.
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,106 @@
import { PageHeader } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button } from '../components/ui'
export default function DataPage() {
const handleExport = () => {
window.location.href = '/api/studio/export'
}
return (
<div>
<PageHeader />
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Import */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Import</div>
<div className="text-xs text-muted mt-0.5">Import posts from other platforms</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-4">
<div className="p-4 border border-dashed border-border text-center">
<Icons.Upload className="text-2xl text-muted mb-2" />
<p className="text-sm font-medium mb-1">Upload files</p>
<p className="text-xs text-muted mb-3">
Supports Markdown files or JSON exports
</p>
<Button variant="secondary" Icon={Icons.Upload}>
Choose Files
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
<div className="flex items-center gap-2 mb-1">
<Icons.Ghost className="text-muted" />
<span className="text-sm font-medium">Ghost</span>
</div>
<p className="text-xs text-muted">Import from Ghost JSON export</p>
</button>
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
<div className="flex items-center gap-2 mb-1">
<Icons.PenTool className="text-muted" />
<span className="text-sm font-medium">Substack</span>
</div>
<p className="text-xs text-muted">Import from Substack export</p>
</button>
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
<div className="flex items-center gap-2 mb-1">
<Icons.Posts className="text-muted" />
<span className="text-sm font-medium">Markdown</span>
</div>
<p className="text-xs text-muted">Import Markdown files</p>
</button>
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
<div className="flex items-center gap-2 mb-1">
<Icons.WordPress className="text-muted" />
<span className="text-sm font-medium">WordPress</span>
</div>
<p className="text-xs text-muted">Import from WordPress XML</p>
</button>
</div>
</div>
<div className="border-t border-border" />
{/* Export */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Export</div>
<div className="text-xs text-muted mt-0.5">Download all your blog data</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-3">
<p className="text-sm text-muted">
Export all your posts, settings, and assets as a ZIP file. This includes:
</p>
<ul className="text-sm text-muted space-y-1 list-disc list-inside">
<li>All posts as Markdown files</li>
<li>Settings as JSON</li>
<li>Uploaded images and assets</li>
</ul>
<Button variant="secondary" Icon={Icons.Download} onClick={handleExport}>
Export All Data
</Button>
</div>
<div className="border-t border-border" />
{/* Danger Zone */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Danger Zone</div>
<div className="text-xs text-muted mt-0.5">Irreversible actions</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="p-4 border border-danger/30 bg-danger/5">
<h4 className="text-sm font-medium text-danger mb-2">Delete All Content</h4>
<p className="text-xs text-muted mb-3">
Permanently delete all posts and assets. This cannot be undone.
</p>
<Button variant="danger" Icon={Icons.Trash}>
Delete All Content
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,236 @@
/* Blog Preview Styles - mirrors writekit/internal/build/assets/css/style.css */
.blog-preview {
/* Base colors - light mode */
--text: #18181b;
--text-muted: #71717a;
--bg: #ffffff;
--bg-secondary: #fafafa;
--border: #e4e4e7;
/* Defaults (cozy) */
--content-spacing: 1.75rem;
--paragraph-spacing: 1.25rem;
--heading-spacing: 2.5rem;
--line-height: 1.7;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
line-height: 1.6;
overflow: hidden;
}
/* Compactness: Compact */
.blog-preview.compactness-compact {
--content-spacing: 1.25rem;
--paragraph-spacing: 0.875rem;
--heading-spacing: 1.75rem;
--line-height: 1.55;
}
/* Compactness: Cozy (default) */
.blog-preview.compactness-cozy {
--content-spacing: 1.75rem;
--paragraph-spacing: 1.25rem;
--heading-spacing: 2.5rem;
--line-height: 1.7;
}
/* Compactness: Spacious */
.blog-preview.compactness-spacious {
--content-spacing: 2.25rem;
--paragraph-spacing: 1.5rem;
--heading-spacing: 3rem;
--line-height: 1.85;
}
/* Preview Chrome (browser frame) */
.preview-chrome {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.preview-chrome-dots {
display: flex;
gap: 0.375rem;
}
.preview-chrome-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
}
.preview-chrome-dot.red { background: #ef4444; }
.preview-chrome-dot.yellow { background: #eab308; }
.preview-chrome-dot.green { background: #22c55e; }
.preview-chrome-url {
font-size: 0.625rem;
color: var(--text-muted);
margin-left: 0.5rem;
}
/* Header */
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
}
.preview-site-name {
font-weight: 600;
font-size: 0.6875rem;
color: var(--text);
letter-spacing: -0.01em;
}
.preview-nav {
display: flex;
gap: 0.875rem;
font-size: 0.625rem;
color: var(--text-muted);
font-weight: 450;
}
.preview-nav a {
color: var(--text-muted);
text-decoration: none;
}
.preview-nav a:hover {
color: var(--text);
}
/* Layout: Minimal */
.blog-preview.layout-minimal .preview-header {
justify-content: center;
}
.blog-preview.layout-minimal .preview-nav {
display: none;
}
.blog-preview.layout-minimal .preview-footer {
border-top: none;
opacity: 0.7;
}
/* Layout: Magazine */
.blog-preview.layout-magazine .preview-posts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.625rem;
padding: 1rem;
}
.blog-preview.layout-magazine .preview-post-card {
border: 1px solid var(--border);
padding: 0.75rem;
background: var(--bg);
transition: border-color 0.2s, box-shadow 0.2s;
}
.blog-preview.layout-magazine .preview-post-card:hover {
border-color: var(--accent);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
}
/* Post Content */
.preview-content {
padding: 1rem;
}
.preview-date {
font-size: 0.5625rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.preview-title {
font-size: 0.875rem;
font-weight: 700;
margin: 0 0 0.375rem;
line-height: 1.2;
letter-spacing: -0.02em;
}
.preview-description {
font-size: 0.625rem;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: var(--paragraph-spacing);
}
/* Prose styling */
.preview-prose {
line-height: var(--line-height);
}
.preview-prose p {
margin: 0 0 var(--paragraph-spacing);
font-size: 0.625rem;
color: var(--text-muted);
}
/* Code block */
.preview-code {
margin: var(--content-spacing) 0;
padding: 0.5rem 0.75rem;
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
font-size: 0.5625rem;
border-radius: 0.25rem;
border: 1px solid var(--border);
overflow: hidden;
line-height: 1.6;
}
/* Tags - matches real blog: subtle background */
.preview-tags {
display: flex;
gap: 0.375rem;
margin-top: var(--paragraph-spacing);
}
.preview-tag {
font-size: 0.5rem;
color: var(--text-muted);
background: var(--bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: 0.125rem;
}
/* Footer */
.preview-footer {
padding: 0.625rem 1rem;
text-align: center;
font-size: 0.5rem;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
/* Magazine post card (simplified) */
.preview-post-card .preview-title {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: -0.015em;
}
.preview-post-card .preview-date {
font-size: 0.5rem;
}
.preview-post-card .preview-description {
font-size: 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.55;
}

View file

@ -0,0 +1,474 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
import { addToast } from '../stores/app'
import { SaveBar, DesignPageSkeleton } from '../components/shared'
import './DesignPage.preview.css'
const fontConfigs = {
system: { family: 'system-ui, -apple-system, sans-serif', url: '' },
inter: { family: "'Inter', sans-serif", url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap' },
georgia: { family: 'Georgia, serif', url: '' },
merriweather: { family: "'Merriweather', serif", url: 'https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap' },
'source-serif': { family: "'Source Serif 4', serif", url: 'https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;600&display=swap' },
'jetbrains-mono': { family: "'JetBrains Mono', monospace", url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap' },
}
const themePreviewColors: Record<string, { label: string; bg: string; text: string; keyword: string; string: string; comment: string }> = {
github: { label: 'GitHub Light', bg: '#f6f8fa', text: '#24292e', keyword: '#d73a49', string: '#032f62', comment: '#6a737d' },
'github-dark': { label: 'GitHub Dark', bg: '#0d1117', text: '#c9d1d9', keyword: '#ff7b72', string: '#a5d6ff', comment: '#8b949e' },
vs: { label: 'VS Light', bg: '#ffffff', text: '#000000', keyword: '#0000ff', string: '#a31515', comment: '#008000' },
xcode: { label: 'Xcode Light', bg: '#ffffff', text: '#000000', keyword: '#aa0d91', string: '#c41a16', comment: '#007400' },
'xcode-dark': { label: 'Xcode Dark', bg: '#1f1f24', text: '#ffffff', keyword: '#fc5fa3', string: '#fc6a5d', comment: '#6c7986' },
'solarized-light': { label: 'Solarized Light', bg: '#fdf6e3', text: '#657b83', keyword: '#859900', string: '#2aa198', comment: '#93a1a1' },
'solarized-dark': { label: 'Solarized Dark', bg: '#002b36', text: '#839496', keyword: '#859900', string: '#2aa198', comment: '#586e75' },
'gruvbox-light': { label: 'Gruvbox Light', bg: '#fbf1c7', text: '#3c3836', keyword: '#9d0006', string: '#79740e', comment: '#928374' },
gruvbox: { label: 'Gruvbox Dark', bg: '#282828', text: '#ebdbb2', keyword: '#fb4934', string: '#b8bb26', comment: '#928374' },
nord: { label: 'Nord', bg: '#2e3440', text: '#d8dee9', keyword: '#81a1c1', string: '#a3be8c', comment: '#616e88' },
onedark: { label: 'One Dark', bg: '#282c34', text: '#abb2bf', keyword: '#c678dd', string: '#98c379', comment: '#5c6370' },
dracula: { label: 'Dracula', bg: '#282a36', text: '#f8f8f2', keyword: '#ff79c6', string: '#f1fa8c', comment: '#6272a4' },
monokai: { label: 'Monokai', bg: '#272822', text: '#f8f8f2', keyword: '#f92672', string: '#e6db74', comment: '#75715e' },
}
const defaultDarkColors = { bg: '#1e1e1e', text: '#d4d4d4', keyword: '#569cd6', string: '#ce9178', comment: '#6a9955' }
const defaultLightColors = { bg: '#ffffff', text: '#000000', keyword: '#0000ff', string: '#a31515', comment: '#008000' }
function getThemeColors(theme: string) {
if (themePreviewColors[theme]) return themePreviewColors[theme]
const isDark = theme.includes('dark') || ['monokai', 'dracula', 'nord', 'gruvbox', 'onedark', 'vim', 'emacs'].some(d => theme.includes(d))
return { label: theme, ...(isDark ? defaultDarkColors : defaultLightColors) }
}
function formatThemeLabel(theme: string): string {
if (themePreviewColors[theme]) return themePreviewColors[theme].label
return theme.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
}
const fonts = [
{ value: 'system', label: 'System Default' },
{ value: 'inter', label: 'Inter' },
{ value: 'georgia', label: 'Georgia' },
{ value: 'merriweather', label: 'Merriweather' },
{ value: 'source-serif', label: 'Source Serif' },
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
]
const layouts = [
{ value: 'default', label: 'Classic' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'magazine', label: 'Magazine' },
]
function useFontLoader(fontKey: string) {
useEffect(() => {
const config = fontConfigs[fontKey as keyof typeof fontConfigs]
if (!config?.url) return
const existing = document.querySelector(`link[href="${config.url}"]`)
if (existing) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = config.url
document.head.appendChild(link)
}, [fontKey])
}
function CodePreview({ theme }: { theme: string }) {
const colors = getThemeColors(theme)
return (
<div
className="p-3 text-xs font-mono overflow-hidden"
style={{ background: colors.bg, color: colors.text }}
>
<div><span style={{ color: colors.keyword }}>function</span> greet(name) {'{'}</div>
<div className="pl-4"><span style={{ color: colors.keyword }}>return</span> <span style={{ color: colors.string }}>`Hello, ${'{'}name{'}'}`</span></div>
<div>{'}'}</div>
<div style={{ color: colors.comment }}>// Welcome to your blog</div>
</div>
)
}
function LayoutPreview({ layout, selected }: { layout: string; selected: boolean }) {
const accent = selected ? 'var(--color-accent)' : 'var(--color-border)'
const mutedBar = selected ? 'var(--color-accent)' : 'var(--color-muted)'
if (layout === 'minimal') {
return (
<svg viewBox="0 0 120 80" className="w-full h-auto">
<rect x="45" y="8" width="30" height="4" rx="1" fill={mutedBar} opacity="0.5" />
<rect x="30" y="20" width="60" height="6" rx="1" fill={accent} />
<rect x="20" y="32" width="80" height="3" rx="1" fill={mutedBar} opacity="0.3" />
<rect x="25" y="40" width="70" height="3" rx="1" fill={mutedBar} opacity="0.3" />
<rect x="30" y="48" width="60" height="3" rx="1" fill={mutedBar} opacity="0.3" />
<rect x="50" y="64" width="20" height="3" rx="1" fill={mutedBar} opacity="0.2" />
</svg>
)
}
if (layout === 'magazine') {
return (
<svg viewBox="0 0 120 80" className="w-full h-auto">
<rect x="8" y="8" width="30" height="4" rx="1" fill={mutedBar} opacity="0.5" />
<rect x="8" y="20" width="32" height="24" rx="2" fill={accent} opacity="0.3" />
<rect x="44" y="20" width="32" height="24" rx="2" fill={accent} opacity="0.3" />
<rect x="80" y="20" width="32" height="24" rx="2" fill={accent} opacity="0.3" />
<rect x="8" y="48" width="28" height="3" rx="1" fill={mutedBar} opacity="0.4" />
<rect x="44" y="48" width="28" height="3" rx="1" fill={mutedBar} opacity="0.4" />
<rect x="80" y="48" width="28" height="3" rx="1" fill={mutedBar} opacity="0.4" />
<rect x="8" y="54" width="20" height="2" rx="1" fill={mutedBar} opacity="0.2" />
<rect x="44" y="54" width="20" height="2" rx="1" fill={mutedBar} opacity="0.2" />
<rect x="80" y="54" width="20" height="2" rx="1" fill={mutedBar} opacity="0.2" />
</svg>
)
}
// Default layout
return (
<svg viewBox="0 0 120 80" className="w-full h-auto">
<rect x="8" y="8" width="30" height="4" rx="1" fill={mutedBar} opacity="0.5" />
<rect x="85" y="8" width="27" height="4" rx="1" fill={mutedBar} opacity="0.3" />
<rect x="8" y="24" width="70" height="6" rx="1" fill={accent} />
<rect x="8" y="36" width="90" height="3" rx="1" fill={mutedBar} opacity="0.3" />
<rect x="8" y="44" width="85" height="3" rx="1" fill={mutedBar} opacity="0.3" />
<rect x="8" y="52" width="75" height="3" rx="1" fill={mutedBar} opacity="0.3" />
<rect x="8" y="64" width="50" height="3" rx="1" fill={mutedBar} opacity="0.2" />
</svg>
)
}
function PreviewCodeBlock({ theme }: { theme: string }) {
const colors = getThemeColors(theme)
return (
<div className="preview-code" style={{ background: colors.bg, color: colors.text }}>
<div><span style={{ color: colors.keyword }}>const</span> api = <span style={{ color: colors.keyword }}>await</span> fetch(<span style={{ color: colors.string }}>'/posts'</span>)</div>
</div>
)
}
function PreviewPostCard() {
return (
<div className="preview-post-card">
<div className="preview-date">Jan 15, 2024</div>
<h3 className="preview-title">Building APIs</h3>
<p className="preview-description">A deep dive into REST patterns and best practices.</p>
</div>
)
}
function LivePreview({ settings }: { settings: Record<string, string> }) {
const fontKey = settings.font || 'system'
const fontConfig = fontConfigs[fontKey as keyof typeof fontConfigs] || fontConfigs.system
const codeTheme = settings.code_theme || 'github'
const accent = settings.accent_color || '#10b981'
const layout = settings.layout || 'default'
const compactness = settings.compactness || 'cozy'
useFontLoader(fontKey)
return (
<div
className={`blog-preview layout-${layout} compactness-${compactness} border border-border`}
style={{ '--accent': accent, '--font-body': fontConfig.family } as React.CSSProperties}
>
{/* Browser chrome */}
<div className="preview-chrome">
<div className="preview-chrome-dots">
<div className="preview-chrome-dot red" />
<div className="preview-chrome-dot yellow" />
<div className="preview-chrome-dot green" />
</div>
<span className="preview-chrome-url">yourblog.writekit.dev</span>
</div>
{/* Header */}
<header className="preview-header">
<span className="preview-site-name">Your Blog</span>
<nav className="preview-nav">
<a href="#">Posts</a>
<span>About</span>
</nav>
</header>
{/* Content - varies by layout */}
{layout === 'magazine' ? (
<div className="preview-posts">
<PreviewPostCard />
<PreviewPostCard />
</div>
) : (
<div className="preview-content">
<div className="preview-date">Jan 15, 2024</div>
<h3 className="preview-title">Building Better APIs</h3>
<p className="preview-description">
A deep dive into REST design patterns and best practices for modern web development.
</p>
<div className="preview-prose">
<PreviewCodeBlock theme={codeTheme} />
</div>
<div className="preview-tags">
<span className="preview-tag">typescript</span>
<span className="preview-tag">react</span>
</div>
</div>
)}
{/* Footer */}
<footer className="preview-footer">
&copy; 2024 Your Blog
</footer>
</div>
)
}
export default function DesignPage() {
const settings = useStore($settings)
const { data } = useStore($settingsData)
const hasChanges = useStore($hasChanges)
const changedFields = useStore($changedFields)
const saveSettings = useStore($saveSettings)
const [availableThemes, setAvailableThemes] = useState<string[]>(Object.keys(themePreviewColors))
useEffect(() => {
fetch('/api/studio/code-themes')
.then(r => r.json())
.then((themes: string[]) => setAvailableThemes(themes))
.catch(() => {})
}, [])
// Load all fonts for previews
Object.keys(fontConfigs).forEach(useFontLoader)
const handleSave = async () => {
try {
await saveSettings.mutate(settings)
addToast('Settings saved', 'success')
} catch {
addToast('Failed to save settings', 'error')
}
}
if (!data) return <DesignPageSkeleton />
return (
<div className="pb-20">
<div className="mb-6">
<h1 className="text-xl font-semibold">Design</h1>
<p className="text-sm text-muted mt-1">Customize how your blog looks</p>
</div>
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10">
{/* Live Preview */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Live Preview</div>
</div>
<div className="px-6 lg:px-10 py-6">
<LivePreview settings={settings as Record<string, string>} />
</div>
<div className="border-t border-border" />
{/* Presets */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Quick Presets</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{([
{ name: 'Developer', desc: 'Monospace, dark code, minimal', font: 'jetbrains-mono', code_theme: 'onedark', layout: 'minimal', compactness: 'compact', accent_color: '#10b981' },
{ name: 'Writer', desc: 'Serif, light code, spacious', font: 'merriweather', code_theme: 'github', layout: 'default', compactness: 'spacious', accent_color: '#6366f1' },
{ name: 'Magazine', desc: 'Sans-serif, grid layout', font: 'inter', code_theme: 'nord', layout: 'magazine', compactness: 'cozy', accent_color: '#f59e0b' },
] as const).map(preset => (
<button
key={preset.name}
onClick={() => {
$settings.setKey('font', preset.font)
$settings.setKey('code_theme', preset.code_theme)
$settings.setKey('layout', preset.layout)
$settings.setKey('compactness', preset.compactness)
$settings.setKey('accent_color', preset.accent_color)
}}
className="p-4 border border-border text-left hover:border-accent hover:bg-accent/5 transition-colors"
>
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs text-muted mt-1">{preset.desc}</div>
</button>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Accent Color */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Accent Color</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={settings.accent_color ?? '#10b981'}
onChange={e => $settings.setKey('accent_color', e.target.value)}
className="w-12 h-12 border border-border cursor-pointer bg-transparent"
/>
<input
type="text"
value={settings.accent_color ?? '#10b981'}
onChange={e => $settings.setKey('accent_color', e.target.value)}
className="input w-32 font-mono text-sm"
placeholder="#10b981"
/>
<div className="flex gap-1.5">
{['#10b981', '#6366f1', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'].map(color => (
<button
key={color}
onClick={() => $settings.setKey('accent_color', color)}
className={`w-7 h-7 border-2 transition-transform hover:scale-110 ${
settings.accent_color === color ? 'border-text scale-110' : 'border-transparent'
}`}
style={{ background: color }}
/>
))}
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Typography */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Typography</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{fonts.map(font => {
const config = fontConfigs[font.value as keyof typeof fontConfigs]
return (
<button
key={font.value}
onClick={() => $settings.setKey('font', font.value)}
className={`p-4 border text-left transition-all ${
settings.font === font.value
? 'border-accent bg-accent/5'
: 'border-border hover:border-muted'
}`}
>
<div
className="text-lg mb-1 truncate"
style={{ fontFamily: config?.family }}
>
The quick brown fox
</div>
<div className="text-xs text-muted">{font.label}</div>
</button>
)
})}
</div>
</div>
<div className="border-t border-border" />
{/* Code Theme */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Code Theme</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{availableThemes.map(theme => (
<button
key={theme}
onClick={() => $settings.setKey('code_theme', theme)}
className={`border text-left transition-all overflow-hidden ${
settings.code_theme === theme
? 'border-accent ring-1 ring-accent'
: 'border-border hover:border-muted'
}`}
>
<CodePreview theme={theme} />
<div className="px-3 py-2 text-xs border-t border-border">{formatThemeLabel(theme)}</div>
</button>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Layout */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Layout</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="grid grid-cols-3 gap-3">
{layouts.map(layout => (
<button
key={layout.value}
onClick={() => $settings.setKey('layout', layout.value)}
className={`border p-4 transition-all ${
settings.layout === layout.value
? 'border-accent bg-accent/5'
: 'border-border hover:border-muted'
}`}
>
<div className="mb-3">
<LayoutPreview
layout={layout.value}
selected={settings.layout === layout.value}
/>
</div>
<div className="text-sm font-medium">{layout.label}</div>
</button>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Density */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Content Density</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="flex border border-border divide-x divide-border">
{([
{ value: 'compact', label: 'Compact', lines: 4 },
{ value: 'cozy', label: 'Cozy', lines: 3 },
{ value: 'spacious', label: 'Spacious', lines: 2 },
] as const).map(option => (
<button
key={option.value}
onClick={() => $settings.setKey('compactness', option.value)}
className={`flex-1 py-4 px-3 transition-colors ${
settings.compactness === option.value
? 'bg-accent/10'
: 'hover:bg-secondary/50'
}`}
>
<div className="flex flex-col items-center gap-1 mb-2">
{Array.from({ length: option.lines }).map((_, i) => (
<div
key={i}
className={`h-1 rounded-full transition-colors ${
settings.compactness === option.value ? 'bg-accent' : 'bg-muted/40'
}`}
style={{ width: `${60 - i * 10}%` }}
/>
))}
</div>
<div className={`text-sm ${
settings.compactness === option.value ? 'font-medium' : ''
}`}>
{option.label}
</div>
</button>
))}
</div>
</div>
</div>
{hasChanges && <SaveBar onSave={handleSave} loading={saveSettings.loading} changes={changedFields} />}
</div>
)
}

View file

@ -0,0 +1,82 @@
import { Field, PageHeader } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button } from '../components/ui'
export default function DomainPage() {
return (
<div>
<PageHeader />
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Subdomain */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Subdomain</div>
<div className="text-xs text-muted mt-0.5">Your default blog address</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-3">
<div className="flex items-center gap-2">
<input
type="text"
className="input flex-1"
placeholder="myblog"
defaultValue=""
/>
<span className="text-sm text-muted">.writekit.dev</span>
</div>
<p className="text-xs text-muted">
This is your default blog URL. You can also add a custom domain below.
</p>
</div>
<div className="border-t border-border" />
{/* Custom Domain */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Custom Domain</div>
<div className="text-xs text-muted mt-0.5">Use your own domain for your blog</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-4">
<Field
label="Domain"
value=""
onChange={() => {}}
placeholder="blog.example.com"
hint="Enter your custom domain without https://"
/>
<div className="p-4 bg-border/30 space-y-3">
<h4 className="text-sm font-medium">DNS Configuration</h4>
<p className="text-xs text-muted">
Point your domain to our servers by adding these DNS records:
</p>
<div className="space-y-2 overflow-x-auto">
<div className="flex items-center gap-4 p-2 bg-surface border border-border text-xs font-mono min-w-max">
<span className="text-muted w-16">Type</span>
<span className="text-muted w-24">Name</span>
<span className="text-text">Value</span>
</div>
<div className="flex items-center gap-4 p-2 bg-surface border border-border text-xs font-mono min-w-max">
<span className="w-16">CNAME</span>
<span className="w-24">blog</span>
<span>cname.writekit.dev</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-warning" />
<span className="text-sm text-muted">DNS: Pending</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-muted" />
<span className="text-sm text-muted">SSL: Not configured</span>
</div>
</div>
<Button variant="secondary" Icon={Icons.Refresh}>
Check DNS
</Button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,118 @@
import { useStore } from '@nanostores/react'
import { $interactions, $interactionsData, $hasInteractionChanges, $saveInteractions, $changedInteractionFields } from '../stores/interactions'
import { addToast } from '../stores/app'
import { SaveBar, EngagementPageSkeleton, PageHeader } from '../components/shared'
import { Toggle, Input } from '../components/ui'
export default function EngagementPage() {
const config = useStore($interactions)
const { data } = useStore($interactionsData)
const hasChanges = useStore($hasInteractionChanges)
const changedFields = useStore($changedInteractionFields)
const saveInteractions = useStore($saveInteractions)
const handleSave = async () => {
try {
await saveInteractions.mutate(config)
addToast('Settings saved', 'success')
} catch {
addToast('Failed to save settings', 'error')
}
}
if (!data) return <EngagementPageSkeleton />
return (
<div className="pb-20">
<PageHeader />
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Comments - single row toggle */}
<div className="px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">Comments</div>
<div className="text-xs text-muted mt-0.5">Allow readers to comment on your posts</div>
</div>
<Toggle
checked={config.comments_enabled}
onChange={v => $interactions.setKey('comments_enabled', v)}
/>
</div>
<div className="border-t border-border" />
{/* Reactions - toggle with expandable options */}
<div className="px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">Reactions</div>
<div className="text-xs text-muted mt-0.5">Let readers react to your posts</div>
</div>
<Toggle
checked={config.reactions_enabled}
onChange={v => $interactions.setKey('reactions_enabled', v)}
/>
</div>
{config.reactions_enabled && (
<div className="px-6 lg:px-10 py-6 space-y-4">
<div className="space-y-1">
<label className="label">Reaction Mode</label>
<div className="flex gap-2">
{['emoji', 'upvote'].map(mode => (
<button
key={mode}
onClick={() => $interactions.setKey('reaction_mode', mode)}
className={`flex-1 p-3 border capitalize transition-colors ${
config.reaction_mode === mode
? 'border-accent bg-accent/5'
: 'border-border hover:border-muted'
}`}
>
{mode}
</button>
))}
</div>
</div>
{config.reaction_mode === 'emoji' && (
<div className="space-y-1">
<label className="label">Reaction Emojis</label>
<Input
value={config.reaction_emojis}
onChange={v => $interactions.setKey('reaction_emojis', v)}
placeholder="👍 ❤️ 🎉 🤔"
/>
<p className="text-xs text-muted">Space-separated list of emoji reactions</p>
</div>
)}
{config.reaction_mode === 'upvote' && (
<div className="space-y-1">
<label className="label">Upvote Icon</label>
<Input
value={config.upvote_icon}
onChange={v => $interactions.setKey('upvote_icon', v)}
placeholder="👍"
/>
</div>
)}
<div className="flex items-center justify-between gap-4 pt-2">
<div>
<div className="text-sm font-medium">Require Authentication</div>
<div className="text-xs text-muted mt-0.5">Users must be logged in to react</div>
</div>
<Toggle
checked={config.reactions_require_auth}
onChange={v => $interactions.setKey('reactions_require_auth', v)}
/>
</div>
</div>
)}
</div>
{hasChanges && <SaveBar onSave={handleSave} loading={saveInteractions.loading} changes={changedFields} />}
</div>
)
}

View file

@ -0,0 +1,125 @@
import { useStore } from '@nanostores/react'
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
import { addToast } from '../stores/app'
import { Field, SaveBar, GeneralPageSkeleton, PageHeader } from '../components/shared'
export default function GeneralPage() {
const settings = useStore($settings)
const { data } = useStore($settingsData)
const hasChanges = useStore($hasChanges)
const changedFields = useStore($changedFields)
const saveSettings = useStore($saveSettings)
const handleSave = async () => {
try {
await saveSettings.mutate(settings)
addToast('Settings saved', 'success')
} catch {
addToast('Failed to save settings', 'error')
}
}
if (!data) return <GeneralPageSkeleton />
return (
<div className="pb-20">
<PageHeader />
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Site Information */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Site Information</div>
<div className="text-xs text-muted mt-0.5">Basic details about your blog</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-4">
<Field
label="Site Name"
value={settings.site_name ?? ''}
onChange={v => $settings.setKey('site_name', v)}
placeholder="My Blog"
/>
<Field
label="Description"
value={settings.site_description ?? ''}
onChange={v => $settings.setKey('site_description', v)}
placeholder="A short description of your blog"
multiline
/>
</div>
<div className="border-t border-border" />
{/* Author */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Author</div>
<div className="text-xs text-muted mt-0.5">Information about the blog author</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-4">
<Field
label="Name"
value={settings.author_name ?? ''}
onChange={v => $settings.setKey('author_name', v)}
placeholder="John Doe"
/>
<Field
label="Role"
value={settings.author_role ?? ''}
onChange={v => $settings.setKey('author_role', v)}
placeholder="Software Engineer"
/>
<Field
label="Bio"
value={settings.author_bio ?? ''}
onChange={v => $settings.setKey('author_bio', v)}
placeholder="A short bio about yourself"
multiline
/>
<Field
label="Photo URL"
value={settings.author_photo ?? ''}
onChange={v => $settings.setKey('author_photo', v)}
placeholder="https://example.com/photo.jpg"
hint="URL to your profile photo"
/>
</div>
<div className="border-t border-border" />
{/* Social Links */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Social Links</div>
<div className="text-xs text-muted mt-0.5">Connect your social profiles</div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-4">
<Field
label="Twitter Handle"
value={settings.twitter_handle ?? ''}
onChange={v => $settings.setKey('twitter_handle', v)}
placeholder="@username"
/>
<Field
label="GitHub Handle"
value={settings.github_handle ?? ''}
onChange={v => $settings.setKey('github_handle', v)}
placeholder="username"
/>
<Field
label="LinkedIn Handle"
value={settings.linkedin_handle ?? ''}
onChange={v => $settings.setKey('linkedin_handle', v)}
placeholder="username"
/>
<Field
label="Email"
value={settings.email ?? ''}
onChange={v => $settings.setKey('email', v)}
placeholder="hello@example.com"
/>
</div>
</div>
{hasChanges && <SaveBar onSave={handleSave} loading={saveSettings.loading} changes={changedFields} />}
</div>
)
}

View file

@ -0,0 +1,229 @@
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>
)
}

View file

@ -0,0 +1,75 @@
import { EmptyState, PageHeader } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button } from '../components/ui'
export default function MonetizationPage() {
return (
<div>
<PageHeader />
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Membership Tiers */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Membership Tiers</div>
<div className="text-xs text-muted mt-0.5">Create subscription tiers for your readers</div>
</div>
<div className="px-6 lg:px-10 py-6">
<EmptyState
Icon={Icons.Crown}
title="No tiers created"
description="Create membership tiers to offer exclusive content"
action={<Button variant="primary" Icon={Icons.Plus}>Create Tier</Button>}
/>
</div>
<div className="border-t border-border" />
{/* Pricing */}
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Pricing</div>
<div className="text-xs text-muted mt-0.5">Set up payments for your membership</div>
</div>
<div className="px-6 lg:px-10 py-6">
<div className="p-4 bg-border/30">
<div className="flex items-center gap-3 mb-3">
<Icons.Info className="text-muted" />
<span className="text-sm font-medium">Payment Integration</span>
</div>
<p className="text-xs text-muted mb-4">
Connect your Stripe account to start accepting payments from your members.
</p>
<Button variant="secondary" Icon={Icons.Monetization}>
Connect Stripe
</Button>
</div>
</div>
{/* Member-Only Content - inline toggle rows */}
<div className="border-t border-border px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">Free Preview</div>
<div className="text-xs text-muted mt-0.5">Show a preview before the paywall</div>
</div>
<label className="relative inline-flex cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-9 h-5 bg-border rounded-full peer peer-checked:bg-accent transition-colors" />
<div className="absolute left-0.5 top-0.5 w-4 h-4 bg-surface rounded-full transition-transform peer-checked:translate-x-4" />
</label>
</div>
<div className="border-t border-border px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">Show Member Count</div>
<div className="text-xs text-muted mt-0.5">Display number of members publicly</div>
</div>
<label className="relative inline-flex cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-9 h-5 bg-border rounded-full peer peer-checked:bg-accent transition-colors" />
<div className="absolute left-0.5 top-0.5 w-4 h-4 bg-surface rounded-full transition-transform peer-checked:translate-x-4" />
</label>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,784 @@
import { useState, useEffect } from 'react'
import { useStore } from '@nanostores/react'
import { $plugins, $currentPlugin, $compileResult, $isCompiling, $savePlugin, $deletePlugin, $togglePlugin, compilePlugin, type Plugin } from '../stores/plugins'
import { $secrets, $createSecret, $deleteSecret } from '../stores/secrets'
import { $hooks, fetchTemplate } from '../stores/hooks'
import { $billing } from '../stores/billing'
import { addToast } from '../stores/app'
import { SettingsPageSkeleton, EmptyState, PageHeader } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button, Input, Modal, Toggle, Tabs, Dropdown, UsageIndicator } from '../components/ui'
import type { Tab } from '../components/ui'
import { PluginEditor } from '../components/editor'
interface TestResult {
success: boolean
phase: 'compile' | 'execute'
output?: string
logs?: string[]
errors?: string[]
error?: string
compile_ms?: number
run_ms?: number
}
const LANGUAGE_TABS: Tab<'typescript' | 'go'>[] = [
{ value: 'typescript', label: 'TypeScript', Icon: Icons.TypeScript },
{ value: 'go', label: 'Go', Icon: Icons.Go },
]
export default function PluginsPage() {
const { data: plugins } = useStore($plugins)
const { data: secrets } = useStore($secrets)
const { data: hooks } = useStore($hooks)
const { data: billing } = useStore($billing)
const currentPlugin = useStore($currentPlugin)
const compileResult = useStore($compileResult)
const isCompiling = useStore($isCompiling)
const [view, setView] = useState<'list' | 'edit'>('list')
const [editingId, setEditingId] = useState<string | null>(null)
const [showSecretsPanel, setShowSecretsPanel] = useState(false)
const [showTestPanel, setShowTestPanel] = useState(false)
const [showSecretModal, setShowSecretModal] = useState(false)
const [newSecretKey, setNewSecretKey] = useState('')
const [newSecretValue, setNewSecretValue] = useState('')
const [isTesting, setIsTesting] = useState(false)
const [testResult, setTestResult] = useState<TestResult | null>(null)
const [testData, setTestData] = useState<string>('')
const handleNew = async () => {
const template = await fetchTemplate('post.published', 'typescript')
$currentPlugin.set({
name: '',
language: 'typescript',
source: template,
hooks: ['post.published'],
enabled: true,
})
setEditingId(null)
setView('edit')
}
const handleEdit = (plugin: Plugin) => {
$currentPlugin.set(plugin)
setEditingId(plugin.id)
setView('edit')
}
const handleLanguageChange = async (lang: string) => {
const prevLang = currentPlugin.language
$currentPlugin.setKey('language', lang as Plugin['language'])
// Update template if this is a new plugin or source matches the old template
const currentHook = currentPlugin.hooks?.[0] || 'post.published'
if (!editingId) {
const template = await fetchTemplate(currentHook, lang)
$currentPlugin.setKey('source', template)
} else {
// Check if current source is the default template for the old language
const oldTemplate = await fetchTemplate(currentHook, prevLang || 'typescript')
if (currentPlugin.source === oldTemplate) {
const newTemplate = await fetchTemplate(currentHook, lang)
$currentPlugin.setKey('source', newTemplate)
}
}
}
const handleHookChange = async (hook: string) => {
const prevHook = currentPlugin.hooks?.[0]
$currentPlugin.setKey('hooks', [hook])
// Update test data for new hook from backend
const hookInfo = hooks?.find(h => h.name === hook)
if (hookInfo?.test_data) {
setTestData(JSON.stringify(hookInfo.test_data, null, 2))
}
setTestResult(null)
// Update template if this is a new plugin or source matches the old template
const lang = currentPlugin.language || 'typescript'
if (!editingId) {
const template = await fetchTemplate(hook, lang)
$currentPlugin.setKey('source', template)
} else {
// Check if current source is the default template for the old hook
const oldTemplate = await fetchTemplate(prevHook || 'post.published', lang)
if (currentPlugin.source === oldTemplate) {
const newTemplate = await fetchTemplate(hook, lang)
$currentPlugin.setKey('source', newTemplate)
}
}
}
const handleTest = async () => {
if (!currentPlugin.source) return
setIsTesting(true)
setTestResult(null)
try {
let testDataObj = {}
try {
testDataObj = testData ? JSON.parse(testData) : {}
} catch {
addToast('Invalid test data JSON', 'error')
setIsTesting(false)
return
}
const res = await fetch('/api/studio/plugins/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
language: currentPlugin.language || 'typescript',
source: currentPlugin.source,
hook: currentPlugin.hooks?.[0] || 'post.published',
test_data: testDataObj,
}),
})
const result = await res.json()
setTestResult(result)
if (result.success) {
addToast('Test passed', 'success')
} else if (result.phase === 'compile') {
addToast('Compilation failed', 'error')
} else {
addToast('Execution failed', 'error')
}
} catch {
addToast('Test request failed', 'error')
} finally {
setIsTesting(false)
}
}
// Initialize test data when entering edit mode
useEffect(() => {
if (view === 'edit' && hooks && hooks.length > 0) {
const hook = currentPlugin.hooks?.[0] || 'post.published'
const hookInfo = hooks.find(h => h.name === hook)
if (hookInfo?.test_data) {
setTestData(JSON.stringify(hookInfo.test_data, null, 2))
}
setTestResult(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, hooks])
const handleCompile = async () => {
const result = await compilePlugin(currentPlugin.language!, currentPlugin.source!)
if (result.success) {
addToast('Compiled successfully', 'success')
} else {
addToast('Compilation failed', 'error')
}
}
const handleSave = async () => {
if (!currentPlugin.name) {
addToast('Plugin name is required', 'error')
return
}
const result = await compilePlugin(currentPlugin.language!, currentPlugin.source!)
if (!result.success) {
addToast('Fix compilation errors before saving', 'error')
return
}
try {
await $savePlugin.mutate({
...(currentPlugin as Plugin),
id: editingId || crypto.randomUUID(),
wasm: result.wasm,
})
addToast('Plugin saved', 'success')
setView('list')
} catch (err) {
console.error('Save plugin error:', err)
addToast(`Failed to save plugin: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
}
}
const handleDelete = async (id: string) => {
if (!confirm('Delete this plugin?')) return
try {
await $deletePlugin.mutate(id)
addToast('Plugin deleted', 'success')
} catch {
addToast('Failed to delete plugin', 'error')
}
}
const handleToggle = async (id: string, enabled: boolean) => {
await $togglePlugin.mutate({ id, enabled })
}
const handleAddSecret = async () => {
if (!newSecretKey || !newSecretValue) return
try {
await $createSecret.mutate({ key: newSecretKey, value: newSecretValue })
addToast('Secret added', 'success')
setNewSecretKey('')
setNewSecretValue('')
setShowSecretModal(false)
} catch {
addToast('Failed to add secret', 'error')
}
}
const handleDeleteSecret = async (key: string) => {
if (!confirm(`Delete secret "${key}"?`)) return
try {
await $deleteSecret.mutate(key)
addToast('Secret deleted', 'success')
} catch {
addToast('Failed to delete secret', 'error')
}
}
if (!plugins || !secrets || !hooks) return <SettingsPageSkeleton />
// ═══════════════════════════════════════════════════════════════════════════
// EDIT VIEW - Full-screen editor experience
// ═══════════════════════════════════════════════════════════════════════════
if (view === 'edit') {
return (
<div className="fixed inset-0 z-40 flex flex-col bg-bg">
{/* Header Bar */}
<div className="flex-none border-b border-border bg-surface px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Left: Back + Name */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
onClick={() => setView('list')}
className="p-1.5 -ml-1.5 text-muted hover:text-text hover:bg-bg rounded transition-colors"
title="Back to plugins"
>
<Icons.ArrowLeft className="w-5 h-5" />
</button>
<input
type="text"
value={currentPlugin.name || ''}
onChange={e => $currentPlugin.setKey('name', e.target.value)}
placeholder="Plugin name..."
className="flex-1 min-w-0 text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted/50"
/>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2 flex-none">
<button
onClick={() => { setShowTestPanel(!showTestPanel); setShowSecretsPanel(false) }}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded transition-colors ${
showTestPanel ? 'bg-accent/10 text-accent' : 'text-muted hover:text-text hover:bg-bg'
}`}
>
<Icons.Play className="w-4 h-4" />
<span className="hidden sm:inline">Test</span>
</button>
<button
onClick={() => { setShowSecretsPanel(!showSecretsPanel); setShowTestPanel(false) }}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded transition-colors ${
showSecretsPanel ? 'bg-accent/10 text-accent' : 'text-muted hover:text-text hover:bg-bg'
}`}
>
<Icons.Key className="w-4 h-4" />
<span className="hidden sm:inline">Secrets</span>
{secrets?.length ? (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-bg rounded-full">{secrets.length}</span>
) : null}
</button>
<div className="w-px h-6 bg-border mx-1" />
<Button variant="secondary" onClick={handleCompile} disabled={isCompiling}>
{isCompiling ? (
<>
<Icons.Loader className="w-4 h-4 animate-spin" />
<span className="hidden sm:inline ml-1.5">Compiling...</span>
</>
) : (
<>
<Icons.Check className="w-4 h-4" />
<span className="hidden sm:inline ml-1.5">Compile</span>
</>
)}
</Button>
<Button variant="primary" onClick={handleSave} disabled={isCompiling}>
<Icons.Save className="w-4 h-4" />
<span className="hidden sm:inline ml-1.5">Save</span>
</Button>
</div>
</div>
{/* Configuration Row */}
<div className="flex items-center gap-4 mt-3">
<Tabs
value={(currentPlugin.language || 'typescript') as 'typescript' | 'go'}
onChange={handleLanguageChange}
tabs={LANGUAGE_TABS}
/>
<div className="w-px h-6 bg-border" />
<div className="flex items-center gap-2">
<label className="text-xs text-muted">Hook</label>
<Dropdown
value={currentPlugin.hooks?.[0] || 'post.published'}
onChange={handleHookChange}
options={(hooks || []).map(h => ({ value: h.name, label: h.label, description: h.description }))}
className="w-56"
/>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Editor */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 min-h-0">
<PluginEditor
language={currentPlugin.language || 'typescript'}
value={currentPlugin.source || ''}
onChange={v => $currentPlugin.setKey('source', v)}
height="100%"
secretKeys={secrets?.map(s => s.key) || []}
hook={currentPlugin.hooks?.[0] || 'post.published'}
/>
</div>
{/* Compile Status Bar */}
{compileResult && (
<div className={`flex-none px-4 py-2 text-sm font-mono border-t ${
compileResult.success
? 'bg-success/5 text-success border-success/20'
: 'bg-danger/5 text-danger border-danger/20'
}`}>
{compileResult.success ? (
<div className="flex items-center gap-2">
<Icons.Check className="w-4 h-4" />
<span>Compiled successfully</span>
<span className="text-muted">·</span>
<span className="text-muted">{(compileResult.size! / 1024).toFixed(1)} KB</span>
<span className="text-muted">·</span>
<span className="text-muted">{compileResult.time_ms}ms</span>
</div>
) : (
<div className="flex items-start gap-2">
<Icons.AlertCircle className="w-4 h-4 mt-0.5 flex-none" />
<pre className="whitespace-pre-wrap overflow-auto max-h-32">{compileResult.errors?.join('\n')}</pre>
</div>
)}
</div>
)}
</div>
{/* Test Panel (Slide-in) */}
{showTestPanel && (
<div className="w-96 flex-none border-l border-border bg-surface overflow-y-auto flex flex-col">
<div className="p-4 border-b border-border flex-none">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">Test Plugin</h3>
<button
onClick={() => setShowTestPanel(false)}
className="p-1 text-muted hover:text-text rounded"
>
<Icons.Close className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-muted">
Run your plugin with test data to verify it works correctly.
</p>
</div>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Test Data Input */}
<div className="p-4 border-b border-border flex-none">
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
Test Data (JSON)
</label>
<textarea
value={testData}
onChange={e => setTestData(e.target.value)}
className="w-full h-40 p-3 text-xs font-mono bg-bg border border-border rounded resize-none focus:outline-none focus:border-accent"
placeholder="{}"
/>
</div>
{/* Run Button */}
<div className="p-4 border-b border-border flex-none">
<Button
variant="primary"
className="w-full"
onClick={handleTest}
disabled={isTesting}
>
{isTesting ? (
<>
<Icons.Loader className="w-4 h-4 animate-spin" />
Running...
</>
) : (
<>
<Icons.Play className="w-4 h-4" />
Run Test
</>
)}
</Button>
</div>
{/* Test Results */}
{testResult && (
<div className="flex-1 overflow-y-auto p-4">
<div className={`mb-4 p-3 rounded ${
testResult.success
? 'bg-success/10 text-success border border-success/20'
: 'bg-danger/10 text-danger border border-danger/20'
}`}>
<div className="flex items-center gap-2 text-sm font-medium">
{testResult.success ? (
<>
<Icons.Check className="w-4 h-4" />
Test Passed
</>
) : (
<>
<Icons.AlertCircle className="w-4 h-4" />
Test Failed ({testResult.phase})
</>
)}
</div>
{(testResult.compile_ms != null || testResult.run_ms != null) && (
<div className="text-xs mt-1 opacity-80">
{testResult.compile_ms != null && <span>Compile: {testResult.compile_ms}ms</span>}
{testResult.compile_ms != null && testResult.run_ms != null && <span> · </span>}
{testResult.run_ms != null && <span>Run: {testResult.run_ms}ms</span>}
</div>
)}
</div>
{/* Errors */}
{testResult.errors && testResult.errors.length > 0 && (
<div className="mb-4">
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
Compile Errors
</label>
<pre className="p-3 text-xs font-mono bg-danger/5 text-danger border border-danger/20 rounded overflow-x-auto whitespace-pre-wrap">
{testResult.errors.join('\n')}
</pre>
</div>
)}
{testResult.error && (
<div className="mb-4">
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
Runtime Error
</label>
<pre className="p-3 text-xs font-mono bg-danger/5 text-danger border border-danger/20 rounded overflow-x-auto whitespace-pre-wrap">
{testResult.error}
</pre>
</div>
)}
{/* Logs */}
{testResult.logs && testResult.logs.length > 0 && (
<div className="mb-4">
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
Logs
</label>
<div className="p-3 text-xs font-mono bg-bg border border-border rounded overflow-x-auto">
{testResult.logs.map((log, i) => (
<div key={i} className={`${
log.startsWith('[HTTP]') ? 'text-cyan-500' :
log.startsWith('[KV]') ? 'text-yellow-500' :
log.startsWith('[LOG]') ? 'text-text' : 'text-muted'
}`}>
{log}
</div>
))}
</div>
</div>
)}
{/* Output */}
{testResult.output && (
<div>
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
Output
</label>
<pre className="p-3 text-xs font-mono bg-bg border border-border rounded overflow-x-auto whitespace-pre-wrap">
{testResult.output}
</pre>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Secrets Panel (Slide-in) */}
{showSecretsPanel && (
<div className="w-72 flex-none border-l border-border bg-surface overflow-y-auto">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">Secrets</h3>
<button
onClick={() => setShowSecretsPanel(false)}
className="p-1 text-muted hover:text-text rounded"
>
<Icons.Close className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-muted mb-4">
Access secrets in your code as <code className="px-1 py-0.5 bg-bg rounded">Runner.secrets.KEY_NAME</code>
</p>
{secrets?.length ? (
<div className="space-y-2 mb-4">
{secrets.map(s => (
<div key={s.key} className="flex items-center justify-between p-2 bg-bg rounded group">
<code className="text-xs font-mono truncate">{s.key}</code>
<button
onClick={() => handleDeleteSecret(s.key)}
className="p-1 text-muted hover:text-danger opacity-0 group-hover:opacity-100 transition-opacity"
>
<Icons.Trash className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
) : (
<div className="py-6 text-center text-sm text-muted">
<Icons.Key className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p>No secrets yet</p>
</div>
)}
<Button variant="secondary" className="w-full" onClick={() => setShowSecretModal(true)}>
<Icons.Plus className="w-4 h-4" />
Add Secret
</Button>
</div>
</div>
)}
</div>
{/* Secret Modal */}
<Modal open={showSecretModal} onClose={() => setShowSecretModal(false)} title="Add Secret">
<div className="space-y-4">
<div>
<label className="label">Key</label>
<Input
value={newSecretKey}
onChange={v => setNewSecretKey(v.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
placeholder="SLACK_WEBHOOK"
/>
<p className="text-xs text-muted mt-1.5">Use UPPER_SNAKE_CASE</p>
</div>
<div>
<label className="label">Value</label>
<Input
type="password"
value={newSecretValue}
onChange={setNewSecretValue}
placeholder="https://hooks.slack.com/..."
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowSecretModal(false)}>Cancel</Button>
<Button variant="primary" onClick={handleAddSecret}>Add Secret</Button>
</div>
</div>
</Modal>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// LIST VIEW - Plugin management
// ═══════════════════════════════════════════════════════════════════════════
return (
<div>
<PageHeader>
{billing && billing.usage.plugins >= billing.tiers[billing.current_tier].max_plugins ? (
<div className="flex items-center gap-3">
<span className="text-sm text-muted">Plugin limit reached</span>
<Button variant="primary" href="/studio/billing">Upgrade</Button>
</div>
) : (
<Button variant="primary" onClick={handleNew}>
<Icons.Plus className="w-4 h-4" />
New Plugin
</Button>
)}
</PageHeader>
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10">
{/* Plugin List */}
<div className="px-6 lg:px-10 py-5">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Your Plugins</div>
{billing && (
<UsageIndicator
used={billing.usage.plugins}
max={billing.tiers[billing.current_tier].max_plugins}
label="used"
/>
)}
</div>
</div>
<div className="border-t border-border">
{!plugins?.length ? (
<div className="px-6 lg:px-10 py-12">
<EmptyState
Icon={Icons.Code}
title="No plugins yet"
description="Create plugins to add notifications, integrations, content moderation, and more"
/>
<div className="flex justify-center mt-6">
<Button variant="primary" onClick={handleNew}>
<Icons.Plus className="w-4 h-4" />
Create your first plugin
</Button>
</div>
</div>
) : (
plugins.map((plugin, i) => {
const hookInfo = hooks?.find(h => h.name === plugin.hooks[0])
const langTab = LANGUAGE_TABS.find(l => l.value === plugin.language)
const LangIcon = langTab?.Icon
return (
<div
key={plugin.id}
className={`group flex items-center justify-between px-6 lg:px-10 py-4 hover:bg-surface/50 transition-colors ${i > 0 ? 'border-t border-border' : ''}`}
>
<div className="flex items-center gap-4 min-w-0">
<div
className={`w-2.5 h-2.5 rounded-full flex-none ${
plugin.enabled ? 'bg-success' : 'bg-border'
}`}
title={plugin.enabled ? 'Enabled' : 'Disabled'}
/>
<div className="min-w-0">
<div className="font-medium truncate">{plugin.name}</div>
<div className="flex items-center gap-2 text-xs text-muted mt-0.5">
<span>{hookInfo?.label || plugin.hooks[0]}</span>
<span>·</span>
<span className="flex items-center gap-1">
{LangIcon && <LangIcon className="w-3.5 h-3.5" />}
{langTab?.label || plugin.language}
</span>
{plugin.wasm_size ? (
<>
<span>·</span>
<span>{(plugin.wasm_size / 1024).toFixed(1)} KB</span>
</>
) : null}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Toggle
checked={plugin.enabled}
onChange={v => handleToggle(plugin.id, v)}
/>
<button
onClick={() => handleEdit(plugin)}
className="p-2 text-muted hover:text-text hover:bg-bg rounded transition-colors"
title="Edit"
>
<Icons.Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(plugin.id)}
className="p-2 text-muted hover:text-danger hover:bg-danger/5 rounded transition-colors"
title="Delete"
>
<Icons.Trash className="w-4 h-4" />
</button>
</div>
</div>
)
})
)}
</div>
<div className="border-t border-border" />
{/* Secrets Section */}
<div className="px-6 lg:px-10 py-5 flex items-center justify-between">
<div>
<div className="text-xs font-medium text-muted uppercase tracking-wide">Secrets</div>
<div className="text-xs text-muted mt-0.5">API keys and sensitive data available to all plugins</div>
</div>
<Button variant="secondary" onClick={() => setShowSecretModal(true)}>
<Icons.Plus className="w-4 h-4" />
Add Secret
</Button>
</div>
<div className="px-6 lg:px-10 py-6">
{!secrets?.length ? (
<div className="py-8 text-center border border-dashed border-border">
<Icons.Key className="w-8 h-8 mx-auto text-muted/30 mb-2" />
<p className="text-sm text-muted">No secrets configured</p>
<p className="text-xs text-muted mt-1">Add API keys for services like Slack, Resend, or OpenAI</p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{secrets.map(s => (
<div key={s.key} className="flex items-center justify-between p-3 bg-surface border border-border group">
<div className="flex items-center gap-2 min-w-0">
<Icons.Key className="w-4 h-4 text-muted flex-none" />
<code className="text-sm font-mono truncate">{s.key}</code>
</div>
<button
onClick={() => handleDeleteSecret(s.key)}
className="p-1.5 text-muted hover:text-danger hover:bg-danger/5 rounded opacity-0 group-hover:opacity-100 transition-all"
title="Delete"
>
<Icons.Trash className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Add Secret Modal */}
<Modal open={showSecretModal} onClose={() => setShowSecretModal(false)} title="Add Secret">
<div className="space-y-4">
<div>
<label className="label">Key</label>
<Input
value={newSecretKey}
onChange={v => setNewSecretKey(v.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
placeholder="SLACK_WEBHOOK"
/>
<p className="text-xs text-muted mt-1.5">Use UPPER_SNAKE_CASE for consistency</p>
</div>
<div>
<label className="label">Value</label>
<Input
type="password"
value={newSecretValue}
onChange={setNewSecretValue}
placeholder="https://hooks.slack.com/..."
/>
<p className="text-xs text-muted mt-1.5">Stored encrypted, never displayed again</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowSecretModal(false)}>Cancel</Button>
<Button variant="primary" onClick={handleAddSecret}>Add Secret</Button>
</div>
</div>
</Modal>
</div>
)
}

View file

@ -0,0 +1,285 @@
import { useState, useEffect } from 'react'
import { useStore } from '@nanostores/react'
import { $router } from '../stores/router'
import {
$editorMode,
$editorPost,
$hasChanges,
$isNewPost,
$hasDraft,
$isSaving,
$isPublishing,
$savePost,
initNewPost,
loadPost,
updateTitle,
triggerAutoSave,
discardDraft,
publishPost,
unpublishPost,
getPreviewUrl,
type EditorMode
} from '../stores/editor'
import { addToast } from '../stores/app'
import { SettingsPageSkeleton } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { Button, Tabs } from '../components/ui'
import type { Tab } from '../components/ui'
import { PostEditor } from '../components/editor/PostEditor'
import { SourceEditor } from '../components/editor/SourceEditor'
import { MetadataPanel } from '../components/editor/MetadataPanel'
const MODE_TABS: Tab<EditorMode>[] = [
{ value: 'edit', label: 'Edit' },
{ value: 'source', label: 'Source' },
]
export default function PostEditorPage() {
const router = useStore($router)
const mode = useStore($editorMode)
const post = useStore($editorPost)
const hasChanges = useStore($hasChanges)
const isNew = useStore($isNewPost)
const hasDraft = useStore($hasDraft)
const isSaving = useStore($isSaving)
const isPublishing = useStore($isPublishing)
const savePostStore = useStore($savePost)
const [showMetadata, setShowMetadata] = useState(true)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const init = async () => {
setIsLoading(true)
if (router?.route === 'postNew') {
initNewPost()
} else if (router?.route === 'postEdit' && router.params && 'slug' in router.params) {
const success = await loadPost(router.params.slug as string)
if (!success) {
addToast('Post not found', 'error')
$router.open('posts')
return
}
}
setIsLoading(false)
}
init()
}, [router])
const handleContentChange = (markdown: string) => {
$editorPost.setKey('content', markdown)
triggerAutoSave()
}
const handleTitleChange = (title: string) => {
updateTitle(title)
triggerAutoSave()
}
const handleCreate = async () => {
if (!post.title) {
addToast('Title is required', 'error')
return
}
try {
await savePostStore.mutate(post)
addToast('Post created', 'success')
if (post.slug) {
window.history.replaceState(null, '', `/studio/posts/${post.slug}/edit`)
}
} catch (err) {
addToast(err instanceof Error ? err.message : 'Failed to create post', 'error')
}
}
const handlePublish = async () => {
const success = await publishPost()
if (success) {
addToast('Published successfully', 'success')
} else {
addToast('Failed to publish', 'error')
}
}
const handleUnpublish = async () => {
const success = await unpublishPost()
if (success) {
addToast('Unpublished', 'success')
} else {
addToast('Failed to unpublish', 'error')
}
}
const handleDiscardDraft = async () => {
if (!confirm('Discard all changes and revert to the published version?')) {
return
}
const success = await discardDraft()
if (success) {
addToast('Draft discarded', 'success')
} else {
addToast('Failed to discard draft', 'error')
}
}
const handlePreview = () => {
const url = getPreviewUrl()
if (url) window.open(url, '_blank')
}
const handleBack = () => {
if (hasChanges && !confirm('You have unsaved changes. Leave anyway?')) {
return
}
$router.open('posts')
}
if (isLoading) return <SettingsPageSkeleton />
return (
<div className="fixed inset-0 z-40 flex flex-col bg-bg">
{/* Header Bar */}
<div className="flex-none border-b border-border bg-surface px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Left: Back + Title */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
onClick={handleBack}
className="p-1.5 -ml-1.5 text-muted hover:text-text hover:bg-bg rounded transition-colors"
title="Back to posts"
>
<Icons.ArrowLeft className="w-5 h-5" />
</button>
<input
type="text"
value={post.title}
onChange={e => handleTitleChange(e.target.value)}
placeholder="Post title..."
className="flex-1 min-w-0 text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted/50"
/>
</div>
{/* Center: Mode Toggle */}
<div className="flex-none">
<Tabs
value={mode}
onChange={(v) => $editorMode.set(v as EditorMode)}
tabs={MODE_TABS}
/>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2 flex-none">
<button
onClick={() => setShowMetadata(!showMetadata)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded transition-colors ${
showMetadata ? 'bg-accent/10 text-accent' : 'text-muted hover:text-text hover:bg-bg'
}`}
title="Toggle metadata panel"
>
<Icons.Settings className="w-4 h-4" />
</button>
<Button
variant="secondary"
onClick={handlePreview}
disabled={!post.slug}
>
<Icons.Eye className="w-4 h-4" />
<span className="hidden sm:inline ml-1.5">Preview</span>
</Button>
{isNew ? (
<Button
variant="primary"
onClick={handleCreate}
disabled={savePostStore.loading}
>
{savePostStore.loading ? (
<>
<Icons.Loader className="w-4 h-4 animate-spin" />
<span className="hidden sm:inline ml-1.5">Creating...</span>
</>
) : (
<>
<Icons.Plus className="w-4 h-4" />
<span className="hidden sm:inline ml-1.5">Create</span>
</>
)}
</Button>
) : (
<>
{isSaving && (
<span className="flex items-center gap-1.5 text-xs text-muted">
<Icons.Loader className="w-3.5 h-3.5 animate-spin" />
<span className="hidden sm:inline">Saving...</span>
</span>
)}
{!isSaving && hasChanges && (
<span className="text-xs text-muted">Unsaved</span>
)}
{hasDraft && (
<Button
variant="secondary"
onClick={handleDiscardDraft}
disabled={isSaving || isPublishing}
>
<Icons.Undo className="w-4 h-4" />
<span className="hidden sm:inline ml-1.5">Discard</span>
</Button>
)}
{!post.draft ? (
<Button
variant="secondary"
onClick={handleUnpublish}
disabled={isPublishing}
>
<Icons.EyeOff className="w-4 h-4" />
<span className="hidden sm:inline ml-1.5">Unpublish</span>
</Button>
) : (
<Button
variant="primary"
onClick={handlePublish}
disabled={isPublishing}
>
{isPublishing ? (
<>
<Icons.Loader className="w-4 h-4 animate-spin" />
<span className="hidden sm:inline ml-1.5">Publishing...</span>
</>
) : (
<>
<Icons.Send className="w-4 h-4" />
<span className="hidden sm:inline ml-1.5">Publish</span>
</>
)}
</Button>
)}
</>
)}
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Editor */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-auto">
{mode === 'edit' ? (
<PostEditor onChange={handleContentChange} />
) : (
<SourceEditor onChange={handleContentChange} />
)}
</div>
</div>
{/* Metadata Panel (Slide-in) */}
{showMetadata && (
<MetadataPanel onClose={() => setShowMetadata(false)} />
)}
</div>
</div>
)
}

View file

@ -0,0 +1,302 @@
import { useStore } from '@nanostores/react'
import { $posts, $deletePost } from '../stores/posts'
import { $analytics } from '../stores/analytics'
import { addToast } from '../stores/app'
import { Button, Modal, Badge, ActionMenu, Input, Tabs } from '../components/ui'
import { EmptyState, PostsPageSkeleton, PageHeader } from '../components/shared'
import { Icons } from '../components/shared/Icons'
import { useState, useMemo } from 'react'
import type { Post } from '../types'
type FilterTab = 'all' | 'published' | 'drafts'
export default function PostsPage() {
const { data, error } = useStore($posts)
const { data: analytics } = useStore($analytics)
const deletePostMutation = useStore($deletePost)
const [deleteModal, setDeleteModal] = useState<Post | null>(null)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<FilterTab>('all')
const handleDelete = async () => {
if (!deleteModal) return
try {
await deletePostMutation.mutate(deleteModal.slug)
addToast('Post deleted', 'success')
setDeleteModal(null)
} catch {
addToast('Failed to delete post', 'error')
}
}
const copyUrl = (slug: string) => {
const url = `${window.location.origin}/posts/${slug}`
navigator.clipboard.writeText(url)
addToast('URL copied', 'success')
}
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 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' })
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}
const formatViews = (views: number) => {
if (views >= 1000) return `${(views / 1000).toFixed(1)}k`
return views.toString()
}
const filteredPosts = useMemo(() => {
if (!data) return []
let posts = [...data]
if (filter === 'published') {
posts = posts.filter(p => !p.draft)
} else if (filter === 'drafts') {
posts = posts.filter(p => p.draft)
}
if (search.trim()) {
const q = search.toLowerCase()
posts = posts.filter(p =>
p.title.toLowerCase().includes(q) ||
p.slug.toLowerCase().includes(q) ||
p.tags.some(t => t.toLowerCase().includes(q))
)
}
return posts.sort((a, b) => {
if (a.draft && !b.draft) return -1
if (!a.draft && b.draft) return 1
const dateA = a.draft ? new Date(a.updated_at) : new Date(a.date)
const dateB = b.draft ? new Date(b.updated_at) : new Date(b.date)
return dateB.getTime() - dateA.getTime()
})
}, [data, filter, search])
if (!data) return <PostsPageSkeleton />
if (error) return <EmptyState Icon={Icons.AlertCircle} title="Failed to load posts" description={error.message} />
const totalPosts = data.length
const draftCount = data.filter(p => p.draft).length
const publishedCount = data.filter(p => !p.draft).length
const totalViews = analytics?.total_views || 0
if (data.length === 0) {
return (
<div className="space-y-6">
<PageHeader />
<EmptyState
Icon={Icons.Posts}
title="No posts yet"
description="Create your first post to get started"
action={<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">New Post</Button>}
/>
</div>
)
}
const tabs = [
{ value: 'all' as FilterTab, label: `All (${totalPosts})` },
{ value: 'published' as FilterTab, label: `Published (${publishedCount})` },
{ value: 'drafts' as FilterTab, label: `Drafts (${draftCount})` },
]
return (
<div>
<PageHeader>
<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">New Post</Button>
</PageHeader>
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Stats row */}
<div className="relative">
<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-4 pl-6 lg:pl-10 pr-6">
<div className="text-xs text-muted mb-0.5">Total Posts</div>
<div className="text-xl font-semibold tracking-tight">{totalPosts}</div>
</div>
<div className="py-4 px-6">
<div className="text-xs text-muted mb-0.5">Published</div>
<div className="text-xl font-semibold tracking-tight">{publishedCount}</div>
</div>
<div className="py-4 pr-6 lg:pr-10 pl-6">
<div className="text-xs text-muted mb-0.5">Total Views</div>
<div className="text-xl font-semibold tracking-tight">{totalViews.toLocaleString()}</div>
</div>
</div>
</div>
<div className="border-t border-border" />
{/* Filter and Search */}
<div className="px-6 lg:px-10 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<Tabs value={filter} onChange={setFilter} tabs={tabs} />
<div className="flex-1 sm:max-w-xs sm:ml-auto">
<Input
value={search}
onChange={setSearch}
placeholder="Search posts..."
Icon={Icons.Search}
/>
</div>
</div>
<div className="border-t border-border" />
{/* Posts list */}
{filteredPosts.length === 0 ? (
<div className="px-6 lg:px-10 py-12">
<EmptyState
Icon={Icons.Search}
title="No posts found"
description={search ? `No posts matching "${search}"` : 'No posts in this category'}
/>
</div>
) : (
filteredPosts.map((post, i) => {
const views = getPostViews(post.slug)
const isDraft = post.draft
const hasWarning = !isDraft && !post.description
return (
<div
key={post.id}
className={`group relative flex items-center gap-4 px-6 lg:px-10 py-4 transition-colors hover:bg-surface/50 cursor-pointer ${i > 0 ? 'border-t border-border' : ''}`}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[data-action-menu]')) return
window.location.href = `/studio/posts/${post.slug}/edit`
}}
>
<a
href={`/studio/posts/${post.slug}/edit`}
className="flex-1 min-w-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2.5 mb-1">
{isDraft && <Badge variant="draft">Draft</Badge>}
<h3 className="text-sm font-medium text-text truncate group-hover:text-accent transition-colors">
{post.title || 'Untitled'}
</h3>
{post.members_only && (
<Icons.Crown className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" title="Members only" />
)}
{hasWarning && (
<span className="flex-shrink-0 text-[10px] text-warning/80 font-medium uppercase tracking-wide">
No description
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted">
<code className="text-[11px] opacity-60">/posts/{post.slug}</code>
<span className="text-border">|</span>
{isDraft ? (
<span className="flex items-center gap-1">
<Icons.Clock className="w-3 h-3 opacity-60" />
{formatRelativeTime(post.updated_at)}
</span>
) : (
<>
<span className="font-medium">{formatDate(post.date)}</span>
{views > 0 && (
<>
<span className="text-border">|</span>
<span className="flex items-center gap-1">
<Icons.Eye className="w-3 h-3 opacity-60" />
{formatViews(views)}
</span>
</>
)}
</>
)}
{post.tags.length > 0 && (
<>
<span className="text-border hidden sm:inline">|</span>
<span className="truncate opacity-70 hidden sm:inline">
{post.tags.slice(0, 2).join(', ')}
{post.tags.length > 2 && ` +${post.tags.length - 2}`}
</span>
</>
)}
</div>
</a>
<ActionMenu
items={[
{
label: 'Edit',
Icon: Icons.Edit,
href: `/studio/posts/${post.slug}/edit`,
},
...(!isDraft ? [{
label: 'View',
Icon: Icons.Eye,
href: `/posts/${post.slug}`,
external: true,
}] : []),
{
label: 'Copy URL',
Icon: Icons.Copy,
onClick: () => copyUrl(post.slug),
},
{
label: 'Delete',
Icon: Icons.Trash,
variant: 'danger' as const,
onClick: () => setDeleteModal(post),
},
]}
/>
</div>
)
})
)}
</div>
<Modal
open={!!deleteModal}
onClose={() => setDeleteModal(null)}
title="Delete Post"
>
<p className="text-sm text-muted mb-6">
Are you sure you want to delete <span className="font-medium text-text">"{deleteModal?.title}"</span>? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setDeleteModal(null)}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete} loading={deletePostMutation.loading}>
Delete Post
</Button>
</div>
</Modal>
</div>
)
}

10
studio/src/pages/index.ts Normal file
View file

@ -0,0 +1,10 @@
export { default as PostsPage } from './PostsPage'
export { default as AnalyticsPage } from './AnalyticsPage'
export { default as GeneralPage } from './GeneralPage'
export { default as DesignPage } from './DesignPage'
export { default as DomainPage } from './DomainPage'
export { default as EngagementPage } from './EngagementPage'
export { default as MonetizationPage } from './MonetizationPage'
export { default as APIPage } from './APIPage'
export { default as DataPage } from './DataPage'
export { default as BillingPage } from './BillingPage'