init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
749
studio/src/pages/APIPage.tsx
Normal file
749
studio/src/pages/APIPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
318
studio/src/pages/AnalyticsPage.tsx
Normal file
318
studio/src/pages/AnalyticsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
251
studio/src/pages/BillingPage.tsx
Normal file
251
studio/src/pages/BillingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
studio/src/pages/DataPage.tsx
Normal file
106
studio/src/pages/DataPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
236
studio/src/pages/DesignPage.preview.css
Normal file
236
studio/src/pages/DesignPage.preview.css
Normal 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;
|
||||
}
|
||||
474
studio/src/pages/DesignPage.tsx
Normal file
474
studio/src/pages/DesignPage.tsx
Normal 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">
|
||||
© 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>
|
||||
)
|
||||
}
|
||||
82
studio/src/pages/DomainPage.tsx
Normal file
82
studio/src/pages/DomainPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
studio/src/pages/EngagementPage.tsx
Normal file
118
studio/src/pages/EngagementPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
studio/src/pages/GeneralPage.tsx
Normal file
125
studio/src/pages/GeneralPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
studio/src/pages/HomePage.tsx
Normal file
229
studio/src/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
studio/src/pages/MonetizationPage.tsx
Normal file
75
studio/src/pages/MonetizationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
784
studio/src/pages/PluginsPage.tsx
Normal file
784
studio/src/pages/PluginsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
285
studio/src/pages/PostEditorPage.tsx
Normal file
285
studio/src/pages/PostEditorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
302
studio/src/pages/PostsPage.tsx
Normal file
302
studio/src/pages/PostsPage.tsx
Normal 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
10
studio/src/pages/index.ts
Normal 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'
|
||||
Loading…
Add table
Add a link
Reference in a new issue