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

20
studio/embed.go Normal file
View file

@ -0,0 +1,20 @@
package studio
import (
"embed"
"io/fs"
"net/http"
"path"
)
//go:embed dist/*
var distFS embed.FS
func Handler() http.Handler {
sub, _ := fs.Sub(distFS, "dist")
return http.FileServer(http.FS(sub))
}
func Read(name string) ([]byte, error) {
return distFS.ReadFile(path.Join("dist", name))
}

16
studio/index.html Normal file
View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WriteKit Studio</title>
<style>
:root { --accent: #3b82f6; }
@media (prefers-color-scheme: dark) { html { color-scheme: dark; } }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3732
studio/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

49
studio/package.json Normal file
View file

@ -0,0 +1,49 @@
{
"name": "writekit-studio",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@iconify-json/logos": "^1.2.10",
"@iconify-json/lucide": "^1.2.82",
"@monaco-editor/react": "^4.7.0",
"@nanostores/query": "^0.3.4",
"@nanostores/react": "^1.0.0",
"@nanostores/router": "^1.0.0",
"@tiptap/extension-code-block-lowlight": "^3.15.1",
"@tiptap/extension-image": "^3.15.1",
"@tiptap/extension-link": "^3.15.1",
"@tiptap/extension-placeholder": "^3.15.1",
"@tiptap/extension-table": "^3.15.1",
"@tiptap/extension-task-item": "^3.15.1",
"@tiptap/extension-task-list": "^3.15.1",
"@tiptap/markdown": "^3.15.1",
"@tiptap/pm": "^3.15.1",
"@tiptap/react": "^3.15.1",
"@tiptap/starter-kit": "^3.15.1",
"@tiptap/suggestion": "^3.15.2",
"@unocss/reset": "^66.5.12",
"chart.js": "^4.5.1",
"lowlight": "^3.3.0",
"monaco-editor": "^0.55.1",
"nanostores": "^1.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tippy.js": "^6.3.7",
"unocss": "^66.5.12"
},
"devDependencies": {
"@iconify-json/simple-icons": "^1.2.65",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.7.0",
"vite": "^7.3.0"
}
}

92
studio/src/App.tsx Normal file
View file

@ -0,0 +1,92 @@
import { lazy, Suspense } from 'react'
import { useStore } from '@nanostores/react'
import { $router } from './stores/router'
import { Sidebar, Header } from './components/layout'
import { Toasts } from './components/ui'
import { Icons } from './components/shared/Icons'
import {
PostsPageSkeleton,
AnalyticsPageSkeleton,
SettingsPageSkeleton,
GeneralPageSkeleton,
DesignPageSkeleton,
EngagementPageSkeleton,
APIPageSkeleton,
BillingPageSkeleton,
HomePageSkeleton,
} from './components/shared'
const HomePage = lazy(() => import('./pages/HomePage'))
const PostsPage = lazy(() => import('./pages/PostsPage'))
const PostEditorPage = lazy(() => import('./pages/PostEditorPage'))
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'))
const GeneralPage = lazy(() => import('./pages/GeneralPage'))
const DesignPage = lazy(() => import('./pages/DesignPage'))
const DomainPage = lazy(() => import('./pages/DomainPage'))
const EngagementPage = lazy(() => import('./pages/EngagementPage'))
const MonetizationPage = lazy(() => import('./pages/MonetizationPage'))
const PluginsPage = lazy(() => import('./pages/PluginsPage'))
const APIPage = lazy(() => import('./pages/APIPage'))
const DataPage = lazy(() => import('./pages/DataPage'))
const BillingPage = lazy(() => import('./pages/BillingPage'))
const routes = {
home: { Component: HomePage, Skeleton: HomePageSkeleton },
postNew: { Component: PostEditorPage, Skeleton: SettingsPageSkeleton, fullWidth: true },
postEdit: { Component: PostEditorPage, Skeleton: SettingsPageSkeleton, fullWidth: true },
posts: { Component: PostsPage, Skeleton: PostsPageSkeleton },
analytics: { Component: AnalyticsPage, Skeleton: AnalyticsPageSkeleton },
general: { Component: GeneralPage, Skeleton: GeneralPageSkeleton },
design: { Component: DesignPage, Skeleton: DesignPageSkeleton },
domain: { Component: DomainPage, Skeleton: SettingsPageSkeleton },
engagement: { Component: EngagementPage, Skeleton: EngagementPageSkeleton },
monetization: { Component: MonetizationPage, Skeleton: SettingsPageSkeleton },
plugins: { Component: PluginsPage, Skeleton: SettingsPageSkeleton },
api: { Component: APIPage, Skeleton: APIPageSkeleton },
data: { Component: DataPage, Skeleton: SettingsPageSkeleton },
billing: { Component: BillingPage, Skeleton: BillingPageSkeleton },
} as const
function Router() {
const page = useStore($router)
const routeKey = page?.route ?? 'home'
const route = routes[routeKey as keyof typeof routes] || routes.home
const { Component: PageComponent, Skeleton } = route
return (
<Suspense fallback={<Skeleton />}>
<PageComponent />
</Suspense>
)
}
export default function App() {
const page = useStore($router)
const routeKey = page?.route ?? 'home'
const route = routes[routeKey as keyof typeof routes]
const isFullWidth = route && 'fullWidth' in route && route.fullWidth
return (
<div className="min-h-screen bg-bg text-text font-sans antialiased">
<Header className={isFullWidth ? 'hidden' : ''} />
<a
href="/"
target="_blank"
className={`fixed top-4 right-4 z-40 items-center gap-2 px-3 py-1.5 text-xs text-muted border border-border bg-surface hover:text-text hover:border-muted transition-colors ${isFullWidth ? 'hidden' : 'hidden lg:flex'}`}
>
<Icons.ExternalLink className="text-sm" />
<span>View Site</span>
</a>
<div className="flex">
<div className={`fixed left-0 top-0 h-screen ${isFullWidth ? 'hidden' : 'hidden lg:block'}`}>
<Sidebar />
</div>
<main className={isFullWidth ? 'flex-1' : 'flex-1 lg:ml-56 p-6 lg:p-10'}>
<Router />
</main>
</div>
<Toasts />
</div>
)
}

77
studio/src/api.ts Normal file
View file

@ -0,0 +1,77 @@
import type { Post, Settings, InteractionConfig, Asset, APIKey, AnalyticsSummary } from './types'
const BASE = '/api/studio'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
const error = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(error.error || 'Request failed')
}
if (res.status === 204) return undefined as T
return res.json()
}
export const api = {
posts: {
list: () => request<Post[]>('/posts'),
get: (slug: string) => request<Post>(`/posts/${slug}`),
create: (data: Partial<Post>) => request<Post>('/posts', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (slug: string, data: Partial<Post>) => request<Post>(`/posts/${slug}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (slug: string) => request<void>(`/posts/${slug}`, { method: 'DELETE' }),
},
settings: {
get: () => request<Settings>('/settings'),
update: (data: Settings) => request<{ success: boolean }>('/settings', {
method: 'PUT',
body: JSON.stringify(data),
}),
},
interactions: {
get: () => request<InteractionConfig>('/interaction-config'),
update: (data: Partial<InteractionConfig>) => request<InteractionConfig>('/interaction-config', {
method: 'PUT',
body: JSON.stringify(data),
}),
},
assets: {
list: () => request<Asset[]>('/assets'),
upload: async (file: File): Promise<Asset> => {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${BASE}/assets`, { method: 'POST', body: form })
if (!res.ok) throw new Error('Upload failed')
return res.json()
},
delete: (id: string) => request<void>(`/assets/${id}`, { method: 'DELETE' }),
},
apiKeys: {
list: () => request<APIKey[]>('/api-keys'),
create: (name: string) => request<APIKey>('/api-keys', {
method: 'POST',
body: JSON.stringify({ name }),
}),
delete: (key: string) => request<void>(`/api-keys/${key}`, { method: 'DELETE' }),
},
analytics: {
get: (days = 30) => request<AnalyticsSummary>(`/analytics?days=${days}`),
getPost: (slug: string, days = 30) => request<AnalyticsSummary>(`/analytics/posts/${slug}?days=${days}`),
},
}

View file

@ -0,0 +1,198 @@
import { useState } from 'react'
import { useStore } from '@nanostores/react'
import { $editorPost, $versions, $isNewPost, restoreVersion } from '../../stores/editor'
import { addToast } from '../../stores/app'
import { Icons } from '../shared/Icons'
import { Input, Textarea, Toggle } from '../ui'
interface MetadataPanelProps {
onClose: () => void
}
export function MetadataPanel({ onClose }: MetadataPanelProps) {
const post = useStore($editorPost)
const versions = useStore($versions)
const isNew = useStore($isNewPost)
const [restoringId, setRestoringId] = useState<number | null>(null)
const handleTagsChange = (value: string) => {
const tags = value.split(',').map(t => t.trim()).filter(Boolean)
$editorPost.setKey('tags', tags)
}
const handleRestore = async (versionId: number) => {
if (!confirm('Restore this version? Your current draft will be replaced.')) return
setRestoringId(versionId)
const success = await restoreVersion(versionId)
setRestoringId(null)
if (success) {
addToast('Version restored', 'success')
} else {
addToast('Failed to restore version', 'error')
}
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
return (
<div className="w-80 flex-none border-l border-border bg-surface overflow-y-auto">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<h3 className="font-medium">Post Settings</h3>
<button
onClick={onClose}
className="p-1 text-muted hover:text-text rounded"
>
<Icons.Close className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-4 space-y-5">
{/* Slug */}
<div>
<label className="label">URL Slug</label>
<Input
value={post.slug}
onChange={v => $editorPost.setKey('slug', v.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
placeholder="my-post-title"
/>
<p className="text-xs text-muted mt-1">/posts/{post.slug || 'my-post-title'}</p>
</div>
{/* Description */}
<div>
<label className="label">Description</label>
<Textarea
value={post.description}
onChange={v => $editorPost.setKey('description', v)}
placeholder="Brief summary for SEO and social shares..."
rows={3}
/>
</div>
{/* Cover Image */}
<div>
<label className="label">Cover Image</label>
{post.cover_image ? (
<div className="space-y-2">
<div className="relative aspect-video bg-border/50 overflow-hidden rounded">
<img
src={post.cover_image}
alt="Cover"
className="w-full h-full object-cover"
/>
<button
onClick={() => $editorPost.setKey('cover_image', '')}
className="absolute top-2 right-2 p-1 bg-bg/80 hover:bg-bg rounded text-muted hover:text-text transition-colors"
title="Remove cover"
>
<Icons.Close className="w-4 h-4" />
</button>
</div>
</div>
) : (
<Input
value={post.cover_image || ''}
onChange={v => $editorPost.setKey('cover_image', v)}
placeholder="https://example.com/image.jpg"
/>
)}
<p className="text-xs text-muted mt-1">Used for social sharing and post headers</p>
</div>
{/* Date */}
<div>
<label className="label">Date</label>
<Input
type="date"
value={post.date}
onChange={v => $editorPost.setKey('date', v)}
/>
</div>
{/* Tags */}
<div>
<label className="label">Tags</label>
<Input
value={post.tags.join(', ')}
onChange={handleTagsChange}
placeholder="react, typescript, tutorial"
/>
<p className="text-xs text-muted mt-1">Comma-separated</p>
</div>
{/* Divider */}
<div className="border-t border-border pt-4">
<h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-3">Publishing</h4>
{/* Draft Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium">Draft</div>
<div className="text-xs text-muted">Not visible to readers</div>
</div>
<Toggle
checked={post.draft}
onChange={v => $editorPost.setKey('draft', v)}
/>
</div>
{/* Members Only Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm font-medium">Members only</div>
<div className="text-xs text-muted">Requires login to view</div>
</div>
<Toggle
checked={post.members_only}
onChange={v => $editorPost.setKey('members_only', v)}
/>
</div>
</div>
{/* Version History */}
{!isNew && versions.length > 0 && (
<div className="border-t border-border pt-4">
<h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-3">
<Icons.History className="w-3.5 h-3.5 inline-block mr-1.5 -mt-0.5" />
Version History
</h4>
<div className="space-y-2">
{versions.map((version) => (
<div
key={version.id}
className="flex items-center justify-between py-2 px-2 -mx-2 rounded hover:bg-bg"
>
<div className="min-w-0">
<div className="text-sm truncate">{version.title}</div>
<div className="text-xs text-muted">{formatDate(version.created_at)}</div>
</div>
<button
className="px-2 py-1 text-xs text-muted hover:text-text hover:bg-bg/50 rounded transition-colors disabled:opacity-50"
onClick={() => handleRestore(version.id)}
disabled={restoringId !== null}
>
{restoringId === version.id ? (
<Icons.Loader className="w-3 h-3 animate-spin" />
) : (
'Restore'
)}
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,722 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import Editor, { type Monaco } from '@monaco-editor/react'
import type { editor, languages, IDisposable, Position } from 'monaco-editor'
interface PluginEditorProps {
language: 'typescript' | 'go'
value: string
onChange: (value: string) => void
height?: string
secretKeys?: string[]
hook?: string
}
interface SDKFunction {
name: string
signature: string
insertText: string
documentation: string
}
interface SDKNamespace {
functions?: SDKFunction[]
}
interface SDKField {
name: string
type: string
doc?: string
}
interface SDKEventType {
fields: SDKField[]
}
interface SDKSchema {
Runner: SDKNamespace
events?: Record<string, SDKEventType>
nestedTypes?: Record<string, SDKField[]>
}
const LANGUAGE_MAP: Record<string, string> = {
typescript: 'typescript',
go: 'go',
}
// TypeScript SDK type definitions - well-formatted for hover display
const getTypeScriptSDK = (secretKeys: string[]) => {
const secretsType = secretKeys.length > 0
? `{\n${secretKeys.map(k => ` /** Secret: ${k} */\n ${k}: string;`).join('\n')}\n }`
: 'Record<string, string>'
return `
/**
* Runner provides all plugin capabilities
*/
declare namespace Runner {
/** Log a message (visible in plugin logs) */
function log(message: string): void;
/** Make an HTTP request to external services */
function httpRequest(options: HttpRequestOptions): HttpResponse;
/** Access your configured secrets */
const secrets: ${secretsType};
}
/** Options for making HTTP requests */
interface HttpRequestOptions {
/** The URL to request */
url: string;
/** HTTP method (default: GET) */
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
/** HTTP headers to send */
headers?: Record<string, string>;
/** Request body (for POST/PUT/PATCH) */
body?: string;
}
/** Response from an HTTP request */
interface HttpResponse {
/** HTTP status code */
status: number;
/** Response headers */
headers: Record<string, string>;
/** Response body as string */
body: string;
}
/** Result for validation hooks (comment.validate, etc.) */
interface ValidationResult {
/** Whether the action is allowed */
allowed: boolean;
/** Reason for rejection (shown to user if allowed=false) */
reason?: string;
}
/** Blog post information */
interface Post {
/** URL-safe identifier */
slug: string;
/** Post title */
title: string;
/** Full URL to the post */
url: string;
/** Short excerpt of the content */
excerpt: string;
/** ISO date when published */
publishedAt: string;
/** ISO date when last updated */
updatedAt?: string;
/** Post tags */
tags: string[];
/** Estimated reading time in minutes */
readingTime: number;
}
/** Author information */
interface Author {
/** Author's display name */
name: string;
/** Author's email */
email: string;
/** URL to author's avatar image */
avatar?: string;
}
/** Blog information */
interface Blog {
/** Blog name */
name: string;
/** Blog URL */
url: string;
}
/**
* Event fired when a post is published
* @example
* export function onPostPublished(event: PostPublishedEvent): void {
* Runner.log(\`Published: \${event.post.title}\`);
* }
*/
interface PostPublishedEvent {
/** The published post */
post: Post;
/** The post author */
author: Author;
/** The blog where it was published */
blog: Blog;
}
/**
* Event fired when a post is updated
*/
interface PostUpdatedEvent {
/** The updated post */
post: Post;
/** The author who made the update */
author: Author;
/** What changed in this update */
changes: {
/** Title change (old and new values) */
title?: { old: string; new: string };
/** Whether content was modified */
content?: boolean;
/** Tags that were added or removed */
tags?: { added: string[]; removed: string[] };
};
}
/** Comment information */
interface Comment {
/** Unique comment ID */
id: string;
/** Comment content (may contain markdown) */
content: string;
/** Commenter's display name */
authorName: string;
/** Commenter's email */
authorEmail: string;
/** Slug of the post being commented on */
postSlug: string;
/** Parent comment ID for replies */
parentId?: string;
/** ISO date when created */
createdAt: string;
}
/**
* Event fired when a comment is created
*/
interface CommentCreatedEvent {
/** The new comment */
comment: Comment;
/** The post being commented on */
post: {
slug: string;
title: string;
url: string;
};
}
/** Member/subscriber information */
interface Member {
/** Member's email */
email: string;
/** Member's name (if provided) */
name?: string;
/** ISO date when subscribed */
subscribedAt: string;
}
/** Subscription tier information */
interface Tier {
/** Tier name (e.g., "Free", "Premium") */
name: string;
/** Monthly price in cents (0 for free tier) */
price: number;
}
/**
* Event fired when a member subscribes
*/
interface MemberSubscribedEvent {
/** The new member */
member: Member;
/** The subscription tier they signed up for */
tier: Tier;
}
/**
* Event fired when an asset (image, file) is uploaded
*/
interface AssetUploadedEvent {
/** Unique asset ID */
id: string;
/** Public URL to access the asset */
url: string;
/** MIME type (e.g., "image/png") */
contentType: string;
/** File size in bytes */
size: number;
/** Image width in pixels (for images only) */
width?: number;
/** Image height in pixels (for images only) */
height?: number;
}
/**
* Event fired when analytics data is synced
*/
interface AnalyticsSyncEvent {
/** Time period for this analytics data */
period: {
/** ISO date for period start */
start: string;
/** ISO date for period end */
end: string;
};
/** Total pageviews in this period */
pageviews: number;
/** Unique visitors in this period */
visitors: number;
/** Top pages by view count */
topPages: Array<{
/** Page path (e.g., "/posts/my-post") */
path: string;
/** Number of views */
views: number;
}>;
}
/**
* Input for comment validation hook
* Return ValidationResult to allow or reject the comment
*/
interface CommentInput {
/** Comment content to validate */
content: string;
/** Commenter's name */
authorName: string;
/** Commenter's email */
authorEmail: string;
/** Post slug being commented on */
postSlug: string;
/** Parent comment ID (for replies) */
parentId?: string;
}
/**
* Input for content rendering hook
*/
interface ContentRenderInput {
/** HTML content to transform */
html: string;
/** Post metadata */
post: {
slug: string;
title: string;
tags: string[];
};
}
/**
* Output for content rendering hook
*/
interface ContentRenderOutput {
/** Transformed HTML content */
html: string;
}
`
}
const defineWriteKitTheme = (monaco: Monaco) => {
monaco.editor.defineTheme('writekit-dark', {
base: 'vs-dark',
inherit: false,
rules: [
// Base text - warm gray for readability
{ token: '', foreground: 'c4c4c4' },
// Comments - muted, italic
{ token: 'comment', foreground: '525252', fontStyle: 'italic' },
{ token: 'comment.doc', foreground: '5c5c5c', fontStyle: 'italic' },
// Keywords - subtle off-white, not too bright
{ token: 'keyword', foreground: 'd4d4d4' },
{ token: 'keyword.control', foreground: 'd4d4d4' },
{ token: 'keyword.operator', foreground: '9ca3af' },
// Strings - emerald accent (WriteKit brand)
{ token: 'string', foreground: '34d399' },
{ token: 'string.key', foreground: 'a3a3a3' },
{ token: 'string.escape', foreground: '6ee7b7' },
// Numbers - muted amber for contrast
{ token: 'number', foreground: 'fbbf24' },
{ token: 'number.hex', foreground: 'f59e0b' },
// Types/Interfaces - cyan accent
{ token: 'type', foreground: '22d3ee' },
{ token: 'type.identifier', foreground: '22d3ee' },
{ token: 'class', foreground: '22d3ee' },
{ token: 'interface', foreground: '67e8f9' },
{ token: 'namespace', foreground: '22d3ee' },
// Functions - clean white for emphasis
{ token: 'function', foreground: 'f5f5f5' },
{ token: 'function.declaration', foreground: 'ffffff' },
{ token: 'method', foreground: 'f5f5f5' },
// Variables and parameters
{ token: 'variable', foreground: 'c4c4c4' },
{ token: 'variable.predefined', foreground: '67e8f9' },
{ token: 'parameter', foreground: 'e5e5e5' },
{ token: 'property', foreground: 'a3a3a3' },
// Constants - emerald like strings
{ token: 'constant', foreground: '34d399' },
{ token: 'constant.language', foreground: 'fb923c' },
// Operators and delimiters - subdued
{ token: 'operator', foreground: '737373' },
{ token: 'delimiter', foreground: '525252' },
{ token: 'delimiter.bracket', foreground: '737373' },
// HTML/JSX
{ token: 'tag', foreground: '22d3ee' },
{ token: 'attribute.name', foreground: 'a3a3a3' },
{ token: 'attribute.value', foreground: '34d399' },
// Regex
{ token: 'regexp', foreground: 'f472b6' },
],
colors: {
// Editor chrome - slightly lighter than pure dark
'editor.background': '#1c1c1e',
'editor.foreground': '#c4c4c4',
// Line highlighting - subtle
'editor.lineHighlightBackground': '#232326',
'editor.lineHighlightBorder': '#00000000',
// Selection - emerald tint
'editor.selectionBackground': '#10b98135',
'editor.inactiveSelectionBackground': '#10b98118',
'editor.selectionHighlightBackground': '#10b98115',
// Cursor - emerald brand color
'editorCursor.foreground': '#10b981',
// Line numbers - muted
'editorLineNumber.foreground': '#3f3f46',
'editorLineNumber.activeForeground': '#71717a',
// Indent guides
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#3f3f46',
// Bracket matching
'editorBracketMatch.background': '#10b98125',
'editorBracketMatch.border': '#10b98170',
// Whitespace
'editorWhitespace.foreground': '#2e2e33',
// Scrollbar - subtle
'scrollbarSlider.background': '#3f3f4660',
'scrollbarSlider.hoverBackground': '#52525b80',
'scrollbarSlider.activeBackground': '#71717a80',
// Widgets (autocomplete, hover)
'editorWidget.background': '#1e1e21',
'editorWidget.border': '#3f3f46',
'editorSuggestWidget.background': '#1e1e21',
'editorSuggestWidget.border': '#3f3f46',
'editorSuggestWidget.selectedBackground': '#2a2a2e',
'editorSuggestWidget.highlightForeground': '#34d399',
'editorHoverWidget.background': '#1e1e21',
'editorHoverWidget.border': '#3f3f46',
// Input fields
'input.background': '#1e1e21',
'input.border': '#3f3f46',
'input.foreground': '#c4c4c4',
// Focus
'focusBorder': '#10b981',
},
})
}
export function PluginEditor({ language, value, onChange, height = '500px', secretKeys = [], hook = 'post.published' }: PluginEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const monacoRef = useRef<Monaco | null>(null)
const disposablesRef = useRef<IDisposable[]>([])
const [sdkSchema, setSdkSchema] = useState<SDKSchema | null>(null)
// Fetch SDK schema when language changes
useEffect(() => {
fetch(`/api/studio/sdk?language=${language}`)
.then(r => r.json())
.then(setSdkSchema)
.catch(() => {})
}, [language])
// Helper to get fields for a type from nested types or event fields
const getFieldsForType = useCallback((typeName: string): SDKField[] => {
if (!sdkSchema) return []
// Check nested types first
if (sdkSchema.nestedTypes?.[typeName]) {
return sdkSchema.nestedTypes[typeName]
}
return []
}, [sdkSchema])
// Track TypeScript lib disposable separately
const tsLibDisposableRef = useRef<IDisposable | null>(null)
// Update TypeScript SDK when secrets change
useEffect(() => {
const monaco = monacoRef.current
if (!monaco || language !== 'typescript') return
// Dispose previous SDK lib if it exists
if (tsLibDisposableRef.current) {
tsLibDisposableRef.current.dispose()
}
// Add updated SDK with new secrets
const sdk = getTypeScriptSDK(secretKeys)
tsLibDisposableRef.current = monaco.languages.typescript.typescriptDefaults.addExtraLib(
sdk,
'file:///node_modules/@writekit/sdk/index.d.ts'
)
return () => {
if (tsLibDisposableRef.current) {
tsLibDisposableRef.current.dispose()
}
}
}, [language, secretKeys])
// Register completion providers when SDK schema or language changes
useEffect(() => {
const monaco = monacoRef.current
if (!monaco || !sdkSchema) return
// Dispose previous providers
disposablesRef.current.forEach(d => d.dispose())
disposablesRef.current = []
// Register Go completion providers
if (language === 'go') {
const langId = LANGUAGE_MAP[language]
// Completion provider for Runner. and event.
const completionProvider = monaco.languages.registerCompletionItemProvider(langId, {
triggerCharacters: ['.'],
provideCompletionItems: (model: editor.ITextModel, position: Position) => {
const textBefore = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
})
const suggestions: languages.CompletionItem[] = []
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column
}
// Runner. completions
if (textBefore.endsWith('Runner.')) {
// Add functions
sdkSchema.Runner.functions?.forEach(fn => {
suggestions.push({
label: fn.name,
kind: monaco.languages.CompletionItemKind.Method,
insertText: fn.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: `**${fn.signature}**\n\n${fn.documentation}` },
detail: fn.signature,
range
})
})
// Add secrets namespace
suggestions.push({
label: 'Secrets',
kind: monaco.languages.CompletionItemKind.Module,
insertText: 'Secrets',
documentation: { value: 'Access your configured secrets' },
detail: 'namespace',
range
})
}
// Runner.Secrets. completions (dynamic based on user's secrets)
if (textBefore.endsWith('Runner.Secrets.')) {
secretKeys.forEach(key => {
// Convert to PascalCase for Go/C#
const secretName = key.split('_').map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join('')
suggestions.push({
label: secretName,
kind: monaco.languages.CompletionItemKind.Constant,
insertText: secretName,
documentation: { value: `Secret: \`${key}\`` },
detail: 'string',
range
})
})
}
// event. completions (based on current hook)
if (textBefore.endsWith('event.')) {
const eventType = sdkSchema.events?.[hook]
if (eventType) {
eventType.fields.forEach(field => {
suggestions.push({
label: field.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: field.name,
documentation: { value: field.doc || `${field.type}` },
detail: field.type,
range
})
})
}
}
// Nested type completions (e.g., event.Post.)
// Match patterns like: event.Post. or event.Author.
const nestedMatch = textBefore.match(/event\.(\w+)\.$/)
if (nestedMatch) {
const parentField = nestedMatch[1]
// Find the type of this field from the event
const eventType = sdkSchema.events?.[hook]
const field = eventType?.fields.find(f => f.name === parentField)
if (field) {
// Get nested type fields
const nestedFields = getFieldsForType(field.type)
nestedFields.forEach(nestedField => {
suggestions.push({
label: nestedField.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: nestedField.name,
documentation: { value: nestedField.doc || `${nestedField.type}` },
detail: nestedField.type,
range
})
})
}
}
return { suggestions }
}
})
disposablesRef.current.push(completionProvider)
// Hover provider
const hoverProvider = monaco.languages.registerHoverProvider(langId, {
provideHover: (model: editor.ITextModel, position: Position) => {
const word = model.getWordAtPosition(position)
if (!word) return null
// Check Runner functions
const runnerFn = sdkSchema.Runner.functions?.find(f => f.name === word.word)
if (runnerFn) {
return {
contents: [
{ value: `**Runner.${runnerFn.name}**` },
{ value: `\`\`\`\n${runnerFn.signature}\n\`\`\`` },
{ value: runnerFn.documentation }
]
}
}
return null
}
})
disposablesRef.current.push(hoverProvider)
}
return () => {
disposablesRef.current.forEach(d => d.dispose())
disposablesRef.current = []
}
}, [language, sdkSchema, secretKeys, hook, getFieldsForType])
// Configure Monaco before editor mounts
const handleBeforeMount = useCallback((monaco: Monaco) => {
// Define theme for all languages
defineWriteKitTheme(monaco)
// TypeScript-specific configuration
if (language === 'typescript') {
// Configure TypeScript compiler options
// Don't set 'lib' explicitly - let Monaco use defaults which include all ES libs
// This ensures JSON, Array, Object, Math, etc. are available
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
module: monaco.languages.typescript.ModuleKind.ESNext,
strict: true,
noEmit: true,
esModuleInterop: true,
skipLibCheck: true,
allowNonTsExtensions: true,
})
// Enable full diagnostics for hover info and error checking
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
})
// Eager model sync ensures types are available immediately
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
// Add WriteKit SDK types
const sdk = getTypeScriptSDK(secretKeys)
monaco.languages.typescript.typescriptDefaults.addExtraLib(
sdk,
'file:///node_modules/@writekit/sdk/index.d.ts'
)
}
}, [language, secretKeys])
const handleMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
editorRef.current = editor
monacoRef.current = monaco
// Theme is already defined in beforeMount, just apply it
monaco.editor.setTheme('writekit-dark')
}, [])
const isFullHeight = height === '100%'
return (
<div className={`overflow-hidden ${isFullHeight ? 'h-full' : 'border border-border'}`}>
<Editor
height={height}
language={LANGUAGE_MAP[language]}
value={value}
onChange={v => onChange(v || '')}
beforeMount={handleBeforeMount}
onMount={handleMount}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: '"JetBrains Mono", "SF Mono", Consolas, monospace',
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: language === 'go' ? 4 : 2,
padding: { top: 16, bottom: 16 },
renderLineHighlight: 'line',
renderLineHighlightOnlyWhenFocus: true,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
cursorBlinking: 'blink',
cursorSmoothCaretAnimation: 'off',
smoothScrolling: true,
suggestOnTriggerCharacters: true,
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10,
useShadows: false,
},
bracketPairColorization: {
enabled: true,
},
}}
/>
</div>
)
}

View file

@ -0,0 +1,354 @@
import { useEditor, EditorContent } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { Markdown } from '@tiptap/markdown'
import { common, createLowlight } from 'lowlight'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useStore } from '@nanostores/react'
import { $editorPost, broadcastPreview } from '../../stores/editor'
import { $settings } from '../../stores/settings'
import { Icons } from '../shared/Icons'
import { SlashCommands } from './SlashCommands'
const lowlight = createLowlight(common)
interface PostEditorProps {
onChange?: (markdown: string) => void
}
async function uploadImage(file: File): Promise<string | null> {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/studio/assets', {
method: 'POST',
body: formData,
})
if (!response.ok) return null
const data = await response.json()
return data.url
} catch {
return null
}
}
export function PostEditor({ onChange }: PostEditorProps) {
const post = useStore($editorPost)
const settings = useStore($settings)
const isInitialMount = useRef(true)
const skipNextUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
link: false,
}),
Link.configure({
openOnClick: false,
autolink: true,
}),
Image.configure({
allowBase64: true,
inline: false,
}),
Placeholder.configure({
placeholder: 'Start writing...',
}),
Table.configure({
resizable: true,
}),
TableRow,
TableCell,
TableHeader,
TaskList,
TaskItem.configure({
nested: true,
}),
CodeBlockLowlight.configure({
lowlight,
}),
Markdown,
SlashCommands,
],
content: post.content || '',
contentType: 'markdown',
editorProps: {
attributes: {
class: 'prose max-w-none focus:outline-none min-h-[50vh] p-6',
style: 'font-size: 1.0625rem; line-height: 1.7;',
},
handleDrop: (_view, event, _slice, moved) => {
if (moved || !event.dataTransfer?.files.length) return false
const file = event.dataTransfer.files[0]
if (!file.type.startsWith('image/')) return false
event.preventDefault()
handleImageUpload(file)
return true
},
handlePaste: (_view, event) => {
const items = event.clipboardData?.items
if (!items) return false
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault()
const file = item.getAsFile()
if (file) handleImageUpload(file)
return true
}
}
return false
},
},
onUpdate: ({ editor }) => {
if (skipNextUpdate.current) {
skipNextUpdate.current = false
return
}
const markdown = editor.getMarkdown()
onChange?.(markdown)
broadcastPreview(markdown)
},
})
const handleImageUpload = useCallback(async (file: File) => {
if (!editor) return
const url = await uploadImage(file)
if (url) {
editor.chain().focus().setImage({ src: url }).run()
}
}, [editor])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true)
}
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (e.currentTarget === e.target || !wrapperRef.current?.contains(e.relatedTarget as Node)) {
setIsDragging(false)
}
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleWrapperClick = useCallback((e: React.MouseEvent) => {
if (e.target === wrapperRef.current && editor) {
editor.chain().focus().run()
}
}, [editor])
const setLink = useCallback(() => {
if (!editor) return
if (linkUrl === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
} else {
const url = linkUrl.startsWith('http') ? linkUrl : `https://${linkUrl}`
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
setShowLinkInput(false)
setLinkUrl('')
}, [editor, linkUrl])
const openLinkInput = useCallback(() => {
if (!editor) return
const previousUrl = editor.getAttributes('link').href || ''
setLinkUrl(previousUrl)
setShowLinkInput(true)
}, [editor])
useEffect(() => {
if (!editor) return
if (isInitialMount.current) {
isInitialMount.current = false
return
}
const currentContent = editor.getMarkdown()
if (currentContent !== post.content) {
skipNextUpdate.current = true
editor.commands.setContent(post.content || '', { contentType: 'markdown' })
}
}, [editor, post.content])
useEffect(() => {
const codeTheme = settings.code_theme || 'github'
const id = 'code-theme-css'
let link = document.getElementById(id) as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.id = id
link.rel = 'stylesheet'
document.head.appendChild(link)
}
link.href = `/api/studio/code-theme.css?theme=${codeTheme}`
}, [settings.code_theme])
if (!editor) return null
return (
<div
ref={wrapperRef}
className={`editor-wrapper h-full overflow-auto cursor-text relative ${isDragging ? 'bg-accent/5' : ''}`}
onClick={handleWrapperClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="px-4 py-2 bg-accent text-white text-sm font-medium rounded">
Drop image to upload
</div>
</div>
)}
<BubbleMenu
editor={editor}
className="flex items-center gap-0.5 p-1 bg-surface border border-border shadow-lg rounded"
>
{showLinkInput ? (
<div className="flex items-center gap-1 px-1">
<input
type="text"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
setLink()
}
if (e.key === 'Escape') {
setShowLinkInput(false)
setLinkUrl('')
}
}}
placeholder="Enter URL..."
className="w-48 px-2 py-1 text-xs bg-bg border border-border rounded focus:outline-none focus:border-accent"
autoFocus
/>
<button
onClick={setLink}
className="p-1.5 text-accent hover:bg-accent/10 rounded"
>
<Icons.Check className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
setShowLinkInput(false)
setLinkUrl('')
}}
className="p-1.5 text-muted hover:bg-bg rounded"
>
<Icons.Close className="w-3.5 h-3.5" />
</button>
</div>
) : (
<>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<Icons.Bold className="w-3.5 h-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<Icons.Italic className="w-3.5 h-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Strikethrough"
>
<Icons.Strikethrough className="w-3.5 h-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title="Inline Code (Ctrl+E)"
>
<Icons.CodeInline className="w-3.5 h-3.5" />
</ToolbarButton>
<div className="w-px h-4 bg-border mx-1" />
<ToolbarButton
onClick={openLinkInput}
isActive={editor.isActive('link')}
title="Add Link (Ctrl+K)"
>
<Icons.Link className="w-3.5 h-3.5" />
</ToolbarButton>
{editor.isActive('link') && (
<ToolbarButton
onClick={() => editor.chain().focus().unsetLink().run()}
title="Remove Link"
>
<Icons.LinkOff className="w-3.5 h-3.5" />
</ToolbarButton>
)}
</>
)}
</BubbleMenu>
<EditorContent editor={editor} />
</div>
)
}
function ToolbarButton({
onClick,
isActive,
title,
children,
}: {
onClick: () => void
isActive?: boolean
title: string
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
title={title}
className={`p-1.5 rounded transition-colors ${
isActive
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-text hover:bg-bg'
}`}
>
{children}
</button>
)
}

View file

@ -0,0 +1,270 @@
import { Extension } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion'
import tippy, { type Instance as TippyInstance } from 'tippy.js'
import {
forwardRef,
useEffect,
useImperativeHandle,
useState,
useCallback,
} from 'react'
import { Icons } from '../shared/Icons'
interface CommandItem {
title: string
description: string
icon: React.ReactNode
command: (props: { editor: any; range: any }) => void
}
const commands: CommandItem[] = [
{
title: 'Heading 1',
description: 'Large section heading',
icon: <Icons.Heading1 className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run()
},
},
{
title: 'Heading 2',
description: 'Medium section heading',
icon: <Icons.Heading2 className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run()
},
},
{
title: 'Heading 3',
description: 'Small section heading',
icon: <Icons.Heading3 className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run()
},
},
{
title: 'Bullet List',
description: 'Create a bullet list',
icon: <Icons.List className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run()
},
},
{
title: 'Numbered List',
description: 'Create a numbered list',
icon: <Icons.ListOrdered className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run()
},
},
{
title: 'Task List',
description: 'Create a task list with checkboxes',
icon: <Icons.Check className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run()
},
},
{
title: 'Code Block',
description: 'Insert a code block',
icon: <Icons.Code className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run()
},
},
{
title: 'Blockquote',
description: 'Insert a quote',
icon: <Icons.Quote className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run()
},
},
{
title: 'Horizontal Rule',
description: 'Insert a divider',
icon: <span className="w-4 h-4 flex items-center justify-center"></span>,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run()
},
},
{
title: 'Image',
description: 'Insert an image from URL',
icon: <Icons.Image className="w-4 h-4" />,
command: ({ editor, range }) => {
const url = window.prompt('Enter image URL:')
if (url) {
editor.chain().focus().deleteRange(range).setImage({ src: url }).run()
}
},
},
]
interface CommandListProps {
items: CommandItem[]
command: (item: CommandItem) => void
}
interface CommandListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
}
const CommandList = forwardRef<CommandListRef, CommandListProps>(
({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = useCallback(
(index: number) => {
const item = items[index]
if (item) {
command(item)
}
},
[items, command]
)
useEffect(() => {
setSelectedIndex(0)
}, [items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((prev) => (prev + items.length - 1) % items.length)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % items.length)
return true
}
if (event.key === 'Enter') {
selectItem(selectedIndex)
return true
}
return false
},
}))
if (items.length === 0) {
return (
<div className="bg-surface border border-border shadow-lg rounded p-2 text-sm text-muted">
No results
</div>
)
}
return (
<div className="bg-surface border border-border shadow-lg rounded py-1 min-w-[200px] max-h-[300px] overflow-y-auto">
{items.map((item, index) => (
<button
key={item.title}
onClick={() => selectItem(index)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors ${
index === selectedIndex
? 'bg-accent/10 text-text'
: 'text-muted hover:bg-bg hover:text-text'
}`}
>
<span className="flex-shrink-0 text-muted">{item.icon}</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-text">{item.title}</div>
<div className="text-xs text-muted truncate">{item.description}</div>
</div>
</button>
))}
</div>
)
}
)
CommandList.displayName = 'CommandList'
const suggestion: Omit<SuggestionOptions<CommandItem>, 'editor'> = {
items: ({ query }) => {
return commands.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase())
)
},
render: () => {
let component: ReactRenderer<CommandListRef> | null = null
let popup: TippyInstance[] | null = null
return {
onStart: (props) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy('body', {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
})
},
onUpdate: (props) => {
component?.updateProps(props)
if (!props.clientRect) return
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
})
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
popup?.[0]?.hide()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit: () => {
popup?.[0]?.destroy()
component?.destroy()
},
}
},
}
export const SlashCommands = Extension.create({
name: 'slashCommands',
addOptions() {
return {
suggestion: {
char: '/',
command: ({ editor, range, props }: { editor: any; range: any; props: CommandItem }) => {
props.command({ editor, range })
},
},
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...suggestion,
...this.options.suggestion,
}),
]
},
})

View file

@ -0,0 +1,105 @@
import { useCallback, useRef } from 'react'
import Editor, { type Monaco } from '@monaco-editor/react'
import type { editor } from 'monaco-editor'
import { useStore } from '@nanostores/react'
import { $editorPost } from '../../stores/editor'
interface SourceEditorProps {
onChange?: (markdown: string) => void
}
const defineWriteKitTheme = (monaco: Monaco) => {
monaco.editor.defineTheme('writekit-dark', {
base: 'vs-dark',
inherit: false,
rules: [
{ token: '', foreground: 'c4c4c4' },
{ token: 'comment', foreground: '525252', fontStyle: 'italic' },
{ token: 'keyword', foreground: 'd4d4d4' },
{ token: 'string', foreground: '34d399' },
{ token: 'number', foreground: 'fbbf24' },
{ token: 'type', foreground: '22d3ee' },
{ token: 'function', foreground: 'f5f5f5' },
{ token: 'variable', foreground: 'c4c4c4' },
{ token: 'operator', foreground: '737373' },
{ token: 'delimiter', foreground: '525252' },
{ token: 'tag', foreground: '22d3ee' },
{ token: 'attribute.name', foreground: 'a3a3a3' },
{ token: 'attribute.value', foreground: '34d399' },
{ token: 'string.link', foreground: '34d399', fontStyle: 'underline' },
],
colors: {
'editor.background': '#1c1c1e',
'editor.foreground': '#c4c4c4',
'editor.lineHighlightBackground': '#232326',
'editor.lineHighlightBorder': '#00000000',
'editor.selectionBackground': '#10b98135',
'editor.inactiveSelectionBackground': '#10b98118',
'editorCursor.foreground': '#10b981',
'editorLineNumber.foreground': '#3f3f46',
'editorLineNumber.activeForeground': '#71717a',
'editorIndentGuide.background1': '#27272a',
'editorIndentGuide.activeBackground1': '#3f3f46',
'scrollbarSlider.background': '#3f3f4660',
'scrollbarSlider.hoverBackground': '#52525b80',
'editorWidget.background': '#1e1e21',
'editorWidget.border': '#3f3f46',
},
})
}
export function SourceEditor({ onChange }: SourceEditorProps) {
const post = useStore($editorPost)
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const handleBeforeMount = useCallback((monaco: Monaco) => {
defineWriteKitTheme(monaco)
}, [])
const handleMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
editorRef.current = editor
monaco.editor.setTheme('writekit-dark')
}, [])
const handleChange = useCallback((value: string | undefined) => {
onChange?.(value || '')
}, [onChange])
return (
<div className="h-full overflow-hidden">
<Editor
height="100%"
language="markdown"
value={post.content}
onChange={handleChange}
beforeMount={handleBeforeMount}
onMount={handleMount}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
fontFamily: '"JetBrains Mono", "SF Mono", Consolas, monospace',
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
padding: { top: 16, bottom: 16 },
renderLineHighlight: 'line',
renderLineHighlightOnlyWhenFocus: true,
wordWrap: 'on',
lineHeight: 1.6,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
cursorBlinking: 'blink',
smoothScrolling: true,
scrollbar: {
vertical: 'auto',
horizontal: 'hidden',
verticalScrollbarSize: 10,
useShadows: false,
},
}}
/>
</div>
)
}

View file

@ -0,0 +1,4 @@
export { PluginEditor } from './PluginEditor'
export { PostEditor } from './PostEditor'
export { SourceEditor } from './SourceEditor'
export { MetadataPanel } from './MetadataPanel'

View file

@ -0,0 +1,76 @@
import { useState } from 'react'
import { useStore } from '@nanostores/react'
import { $router } from '../../stores/router'
import { Icons, type IconComponent } from '../shared/Icons'
interface NavItem {
route: string
label: string
Icon: IconComponent
}
const navItems: NavItem[] = [
{ route: 'posts', label: 'Posts', Icon: Icons.Posts },
{ route: 'analytics', label: 'Analytics', Icon: Icons.Analytics },
{ route: 'general', label: 'General', Icon: Icons.Settings },
{ route: 'design', label: 'Design', Icon: Icons.Design },
{ route: 'domain', label: 'Domain', Icon: Icons.Domain },
{ route: 'engagement', label: 'Engagement', Icon: Icons.Engagement },
{ route: 'monetization', label: 'Monetization', Icon: Icons.Monetization },
{ route: 'api', label: 'API Keys', Icon: Icons.ApiKeys },
{ route: 'data', label: 'Data', Icon: Icons.Data },
{ route: 'billing', label: 'Billing', Icon: Icons.Billing },
]
interface HeaderProps {
className?: string
}
export function Header({ className = '' }: HeaderProps) {
const [menuOpen, setMenuOpen] = useState(false)
const page = useStore($router)
const currentRoute = page?.route ?? 'posts'
return (
<header className={`lg:hidden bg-surface border-b border-border sticky top-0 z-50 ${className}`}>
<div className="h-14 flex items-center justify-between px-4">
<a href="/" className="block">
<div className="text-[15px] font-bold tracking-tight text-text">WriteKit</div>
<div className="text-[11px] font-medium text-muted tracking-wide">Studio</div>
</a>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-9 h-9 flex items-center justify-center hover:bg-border transition-colors"
>
{menuOpen ? <Icons.Close className="text-lg" /> : <Icons.Menu className="text-lg" />}
</button>
</div>
{menuOpen && (
<nav className="p-2 border-t border-border bg-surface">
{navItems.map(item => (
<a
key={item.route}
href={`/studio/${item.route}`}
onClick={() => setMenuOpen(false)}
className={currentRoute === item.route ? 'nav-item-active' : 'nav-item'}
>
<item.Icon className={`text-sm ${currentRoute === item.route ? 'text-accent opacity-100' : 'opacity-50'}`} />
<span>{item.label}</span>
</a>
))}
<div className="border-t border-border mt-2 pt-2">
<a
href="/"
target="_blank"
className="nav-item"
>
<Icons.ExternalLink className="text-sm opacity-50" />
<span>View Site</span>
</a>
</div>
</nav>
)}
</header>
)
}

View file

@ -0,0 +1,102 @@
import { useStore } from '@nanostores/react'
import { $router } from '../../stores/router'
import { Icons, type IconComponent } from '../shared/Icons'
interface NavItem {
route: string
label: string
Icon: IconComponent
}
interface NavSection {
title: string
items: NavItem[]
}
const navigation: NavSection[] = [
{
title: '',
items: [
{ route: 'home', label: 'Home', Icon: Icons.Home },
],
},
{
title: 'Content',
items: [
{ route: 'posts', label: 'Posts', Icon: Icons.Posts },
{ route: 'analytics', label: 'Analytics', Icon: Icons.Analytics },
],
},
{
title: 'Site',
items: [
{ route: 'general', label: 'General', Icon: Icons.Settings },
{ route: 'design', label: 'Design', Icon: Icons.Design },
{ route: 'domain', label: 'Domain', Icon: Icons.Domain },
],
},
{
title: 'Readers',
items: [
{ route: 'engagement', label: 'Engagement', Icon: Icons.Engagement },
{ route: 'monetization', label: 'Monetization', Icon: Icons.Monetization },
],
},
{
title: 'Developer',
items: [
{ route: 'plugins', label: 'Plugins', Icon: Icons.Code },
{ route: 'api', label: 'API Keys', Icon: Icons.ApiKeys },
{ route: 'data', label: 'Data', Icon: Icons.Data },
],
},
{
title: 'Account',
items: [
{ route: 'billing', label: 'Billing', Icon: Icons.Billing },
],
},
]
export function Sidebar() {
const page = useStore($router)
const currentRoute = page?.route ?? 'posts'
return (
<aside className="w-56 h-screen bg-bg border-r border-border flex flex-col">
<div className="px-4 py-6">
<a href="/" className="block group">
<div className="text-[15px] font-bold tracking-tight text-text">
WriteKit
</div>
<div className="text-[11px] font-medium text-muted tracking-wide">
Studio
</div>
</a>
</div>
<nav className="flex-1 overflow-y-auto px-3">
{navigation.map((section, idx) => (
<div key={section.title || idx} className="mb-1">
{section.title && <div className="nav-section">{section.title}</div>}
<div className="space-y-0.5">
{section.items.map(item => {
const href = item.route === 'home' ? '/studio' : `/studio/${item.route}`
return (
<a
key={item.route}
href={href}
className={currentRoute === item.route ? 'nav-item-active' : 'nav-item'}
>
<item.Icon className={`text-sm ${currentRoute === item.route ? 'text-accent opacity-100' : 'opacity-50'}`} />
<span>{item.label}</span>
</a>
)
})}
</div>
</div>
))}
</nav>
</aside>
)
}

View file

@ -0,0 +1,2 @@
export { Sidebar } from './Sidebar'
export { Header } from './Header'

View file

@ -0,0 +1,45 @@
import { useStore } from '@nanostores/react'
import { $router } from '../../stores/router'
import { Icons } from './Icons'
const routeLabels: Record<string, string> = {
home: 'Home',
posts: 'Posts',
postNew: 'New Post',
postEdit: 'Edit Post',
analytics: 'Analytics',
general: 'General',
design: 'Design',
domain: 'Domain',
engagement: 'Engagement',
monetization: 'Monetization',
plugins: 'Plugins',
api: 'API Keys',
data: 'Data',
billing: 'Billing',
}
interface BreadcrumbProps {
title?: string
}
export function Breadcrumb({ title }: BreadcrumbProps) {
const page = useStore($router)
const routeKey = page?.route ?? 'home'
const displayTitle = title || routeLabels[routeKey] || ''
const isHome = routeKey === 'home'
if (isHome) {
return <span className="font-medium text-text">{displayTitle}</span>
}
return (
<div className="flex items-center gap-1.5 text-sm">
<a href="/studio" className="text-muted hover:text-text transition-colors">
Studio
</a>
<Icons.ChevronRight className="text-muted opacity-50" />
<span className="font-medium text-text">{displayTitle}</span>
</div>
)
}

View file

@ -0,0 +1,51 @@
import type { IconComponent } from './Icons'
interface BreakdownItem {
label: string
value: number
percentage: number
Icon?: IconComponent
flagUrl?: string
}
interface BreakdownListProps {
items: BreakdownItem[]
limit?: number
}
export function BreakdownList({ items, limit = 6 }: BreakdownListProps) {
const displayItems = items.slice(0, limit)
return (
<div className="space-y-3">
{displayItems.map(item => (
<div key={item.label} className="flex items-center gap-3">
{item.flagUrl ? (
<div className="w-5 h-5 flex-shrink-0 rounded-sm overflow-hidden bg-border">
<img
src={item.flagUrl}
alt={item.label}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
) : item.Icon ? (
<item.Icon className="text-sm flex-shrink-0 opacity-50" />
) : null}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-text truncate">{item.label}</span>
<span className="text-muted ml-2 flex-shrink-0">{item.percentage.toFixed(0)}%</span>
</div>
<div className="h-1.5 bg-border overflow-hidden">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(item.percentage, 100)}%` }}
/>
</div>
</div>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
import type { IconComponent } from './Icons'
interface EmptyStateProps {
Icon: IconComponent
title: string
description?: string
action?: ReactNode
}
export function EmptyState({ Icon, title, description, action }: EmptyStateProps) {
return (
<div className="card text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-border flex items-center justify-center">
<Icon className="text-muted text-2xl" />
</div>
<h3 className="text-sm font-medium text-text mb-1">{title}</h3>
{description && (
<p className="text-xs text-muted mb-4">{description}</p>
)}
{action}
</div>
)
}

View file

@ -0,0 +1,27 @@
import { Input, Textarea } from '../ui'
interface FieldProps {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
multiline?: boolean
hint?: string
}
export function Field({ label, value, onChange, placeholder, multiline, hint }: FieldProps) {
const InputComponent = multiline ? Textarea : Input
return (
<div>
<label className="label">{label}</label>
<InputComponent
value={value}
onChange={onChange}
placeholder={placeholder}
/>
{hint && (
<p className="text-xs text-muted mt-1">{hint}</p>
)}
</div>
)
}

View file

@ -0,0 +1,316 @@
import type { ComponentProps, ReactElement } from 'react'
export type IconProps = ComponentProps<'span'>
export type IconComponent = (props: IconProps) => ReactElement
function createIcon(className: string): IconComponent {
return function Icon(props: IconProps) {
return <span {...props} className={`${className}${props.className ? ' ' + props.className : ''}`} />
}
}
export const Icons = {
// Navigation
Home: createIcon('i-lucide-home'),
Posts: createIcon('i-lucide-file-text'),
Analytics: createIcon('i-lucide-bar-chart-2'),
Settings: createIcon('i-lucide-settings'),
Design: createIcon('i-lucide-palette'),
Domain: createIcon('i-lucide-globe'),
Engagement: createIcon('i-lucide-message-circle'),
Monetization: createIcon('i-lucide-credit-card'),
ApiKeys: createIcon('i-lucide-key'),
Data: createIcon('i-lucide-database'),
Billing: createIcon('i-lucide-receipt'),
ExternalLink: createIcon('i-lucide-external-link'),
// Actions
Plus: createIcon('i-lucide-plus'),
Edit: createIcon('i-lucide-pencil'),
Trash: createIcon('i-lucide-trash-2'),
Copy: createIcon('i-lucide-copy'),
Download: createIcon('i-lucide-download'),
Upload: createIcon('i-lucide-upload'),
Refresh: createIcon('i-lucide-refresh-cw'),
Search: createIcon('i-lucide-search'),
Close: createIcon('i-lucide-x'),
Menu: createIcon('i-lucide-menu'),
Check: createIcon('i-lucide-check'),
CheckCircle: createIcon('i-lucide-check-circle'),
AlertCircle: createIcon('i-lucide-alert-circle'),
Info: createIcon('i-lucide-info'),
Eye: createIcon('i-lucide-eye'),
Loader: createIcon('i-lucide-loader-2'),
ArrowLeft: createIcon('i-lucide-arrow-left'),
Key: createIcon('i-lucide-key'),
Play: createIcon('i-lucide-play'),
Save: createIcon('i-lucide-save'),
// Content
PenTool: createIcon('i-lucide-pen-tool'),
Image: createIcon('i-lucide-image'),
Link: createIcon('i-lucide-link'),
Crown: createIcon('i-lucide-crown'),
Ghost: createIcon('i-lucide-ghost'),
// Languages
TypeScript: createIcon('i-logos-typescript-icon'),
Go: createIcon('i-logos-go'),
CSharp: createIcon('i-logos-c-sharp'),
JavaScript: createIcon('i-logos-javascript'),
Rust: createIcon('i-logos-rust'),
Python: createIcon('i-logos-python'),
// Text formatting
Bold: createIcon('i-lucide-bold'),
Italic: createIcon('i-lucide-italic'),
Strikethrough: createIcon('i-lucide-strikethrough'),
CodeInline: createIcon('i-lucide-code'),
Heading1: createIcon('i-lucide-heading-1'),
Heading2: createIcon('i-lucide-heading-2'),
Heading3: createIcon('i-lucide-heading-3'),
List: createIcon('i-lucide-list'),
ListOrdered: createIcon('i-lucide-list-ordered'),
Quote: createIcon('i-lucide-quote'),
LinkOff: createIcon('i-lucide-link-2-off'),
// UI
ChevronDown: createIcon('i-lucide-chevron-down'),
ChevronUp: createIcon('i-lucide-chevron-up'),
ChevronRight: createIcon('i-lucide-chevron-right'),
Send: createIcon('i-lucide-send'),
Undo: createIcon('i-lucide-undo'),
History: createIcon('i-lucide-history'),
EyeOff: createIcon('i-lucide-eye-off'),
MoreHorizontal: createIcon('i-lucide-more-horizontal'),
TrendingUp: createIcon('i-lucide-trending-up'),
TrendingDown: createIcon('i-lucide-trending-down'),
AlertTriangle: createIcon('i-lucide-alert-triangle'),
Clock: createIcon('i-lucide-clock'),
ArrowRight: createIcon('i-lucide-arrow-right'),
// Code / Developer
Code: createIcon('i-lucide-code-2'),
WordPress: createIcon('i-lucide-pen-tool'),
// Devices
Desktop: createIcon('i-lucide-monitor'),
Mobile: createIcon('i-lucide-smartphone'),
Tablet: createIcon('i-lucide-tablet'),
// Browsers
Chrome: createIcon('i-simple-icons-googlechrome'),
Firefox: createIcon('i-simple-icons-firefox'),
Safari: createIcon('i-simple-icons-safari'),
Edge: createIcon('i-simple-icons-microsoftedge'),
Opera: createIcon('i-simple-icons-opera'),
Brave: createIcon('i-simple-icons-brave'),
Vivaldi: createIcon('i-simple-icons-vivaldi'),
Arc: createIcon('i-simple-icons-arc'),
Samsung: createIcon('i-simple-icons-samsung'),
// Operating Systems
Windows: createIcon('i-simple-icons-windows'),
MacOS: createIcon('i-simple-icons-apple'),
Linux: createIcon('i-simple-icons-linux'),
Android: createIcon('i-simple-icons-android'),
iOS: createIcon('i-simple-icons-apple'),
ChromeOS: createIcon('i-simple-icons-googlechrome'),
Ubuntu: createIcon('i-simple-icons-ubuntu'),
// Referrer brand icons
Google: createIcon('i-simple-icons-google'),
Bing: createIcon('i-simple-icons-bing'),
DuckDuckGo: createIcon('i-simple-icons-duckduckgo'),
Twitter: createIcon('i-simple-icons-x'),
Facebook: createIcon('i-simple-icons-facebook'),
LinkedIn: createIcon('i-simple-icons-linkedin'),
Instagram: createIcon('i-simple-icons-instagram'),
Pinterest: createIcon('i-simple-icons-pinterest'),
Reddit: createIcon('i-simple-icons-reddit'),
GitHub: createIcon('i-simple-icons-github'),
DevTo: createIcon('i-simple-icons-devdotto'),
HackerNews: createIcon('i-simple-icons-ycombinator'),
YouTube: createIcon('i-simple-icons-youtube'),
Medium: createIcon('i-simple-icons-medium'),
Substack: createIcon('i-simple-icons-substack'),
Mastodon: createIcon('i-simple-icons-mastodon'),
Bluesky: createIcon('i-simple-icons-bluesky'),
Discord: createIcon('i-simple-icons-discord'),
Slack: createIcon('i-simple-icons-slack'),
ProductHunt: createIcon('i-simple-icons-producthunt'),
StackOverflow: createIcon('i-simple-icons-stackoverflow'),
Direct: createIcon('i-lucide-globe'),
Mail: createIcon('i-lucide-mail'),
RSS: createIcon('i-lucide-rss'),
} as const
const referrerIconMap: Record<string, IconComponent> = {
'direct': Icons.Direct,
'google': Icons.Google,
'bing': Icons.Bing,
'duckduckgo': Icons.DuckDuckGo,
'twitter': Icons.Twitter,
'x.com': Icons.Twitter,
't.co': Icons.Twitter,
'facebook': Icons.Facebook,
'linkedin': Icons.LinkedIn,
'instagram': Icons.Instagram,
'pinterest': Icons.Pinterest,
'reddit': Icons.Reddit,
'github': Icons.GitHub,
'dev.to': Icons.DevTo,
'hacker news': Icons.HackerNews,
'ycombinator': Icons.HackerNews,
'youtube': Icons.YouTube,
'youtu.be': Icons.YouTube,
'medium': Icons.Medium,
'substack': Icons.Substack,
'mastodon': Icons.Mastodon,
'bluesky': Icons.Bluesky,
'bsky': Icons.Bluesky,
'discord': Icons.Discord,
'slack': Icons.Slack,
'producthunt': Icons.ProductHunt,
'product hunt': Icons.ProductHunt,
'stackoverflow': Icons.StackOverflow,
'stack overflow': Icons.StackOverflow,
}
export function getReferrerIcon(name: string): IconComponent {
if (!name || name === 'Direct') return Icons.Direct
const lower = name.toLowerCase()
for (const [key, icon] of Object.entries(referrerIconMap)) {
if (lower.includes(key)) return icon
}
return Icons.Link
}
const countryNameToCode: Record<string, string> = {
'united states': 'us',
'united kingdom': 'gb',
'canada': 'ca',
'australia': 'au',
'germany': 'de',
'france': 'fr',
'japan': 'jp',
'china': 'cn',
'india': 'in',
'brazil': 'br',
'mexico': 'mx',
'spain': 'es',
'italy': 'it',
'netherlands': 'nl',
'sweden': 'se',
'norway': 'no',
'denmark': 'dk',
'finland': 'fi',
'poland': 'pl',
'russia': 'ru',
'ukraine': 'ua',
'south korea': 'kr',
'taiwan': 'tw',
'singapore': 'sg',
'hong kong': 'hk',
'indonesia': 'id',
'thailand': 'th',
'vietnam': 'vn',
'philippines': 'ph',
'malaysia': 'my',
'new zealand': 'nz',
'ireland': 'ie',
'switzerland': 'ch',
'austria': 'at',
'belgium': 'be',
'portugal': 'pt',
'czech republic': 'cz',
'czechia': 'cz',
'romania': 'ro',
'hungary': 'hu',
'greece': 'gr',
'turkey': 'tr',
'israel': 'il',
'united arab emirates': 'ae',
'saudi arabia': 'sa',
'south africa': 'za',
'argentina': 'ar',
'chile': 'cl',
'colombia': 'co',
'peru': 'pe',
'egypt': 'eg',
'nigeria': 'ng',
'kenya': 'ke',
'pakistan': 'pk',
'bangladesh': 'bd',
'sri lanka': 'lk',
}
export function getCountryCode(name: string): string | null {
if (!name) return null
const lower = name.toLowerCase()
return countryNameToCode[lower] || null
}
export function getCountryFlagUrl(name: string, size: number = 24): string | null {
const code = getCountryCode(name)
if (!code) return null
return `https://flagcdn.com/w${size}/${code}.png`
}
const browserIconMap: Record<string, IconComponent> = {
'chrome': Icons.Chrome,
'firefox': Icons.Firefox,
'safari': Icons.Safari,
'edge': Icons.Edge,
'opera': Icons.Opera,
'brave': Icons.Brave,
'vivaldi': Icons.Vivaldi,
'arc': Icons.Arc,
'samsung': Icons.Samsung,
}
export function getBrowserIcon(name: string): IconComponent | null {
if (!name) return null
const lower = name.toLowerCase()
for (const [key, icon] of Object.entries(browserIconMap)) {
if (lower.includes(key)) return icon
}
return null
}
const deviceIconMap: Record<string, IconComponent> = {
'desktop': Icons.Desktop,
'mobile': Icons.Mobile,
'tablet': Icons.Tablet,
'phone': Icons.Mobile,
}
export function getDeviceIcon(name: string): IconComponent | null {
if (!name) return null
const lower = name.toLowerCase()
for (const [key, icon] of Object.entries(deviceIconMap)) {
if (lower.includes(key)) return icon
}
return null
}
const osIconMap: Record<string, IconComponent> = {
'windows': Icons.Windows,
'macos': Icons.MacOS,
'mac os': Icons.MacOS,
'ios': Icons.iOS,
'android': Icons.Android,
'linux': Icons.Linux,
'ubuntu': Icons.Ubuntu,
'chrome os': Icons.ChromeOS,
'chromeos': Icons.ChromeOS,
}
export function getOSIcon(name: string): IconComponent | null {
if (!name) return null
const lower = name.toLowerCase()
for (const [key, icon] of Object.entries(osIconMap)) {
if (lower.includes(key)) return icon
}
return null
}

View file

@ -0,0 +1,9 @@
import { Icons } from './Icons'
export function LoadingState() {
return (
<div className="flex items-center justify-center py-12">
<Icons.Loader className="animate-spin text-muted text-2xl" />
</div>
)
}

View file

@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { Breadcrumb } from './Breadcrumb'
interface PageHeaderProps {
title?: string
children?: ReactNode
}
export function PageHeader({ title, children }: PageHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<Breadcrumb title={title} />
{children && <div className="flex items-center gap-2">{children}</div>}
</div>
)
}

View file

@ -0,0 +1,39 @@
import { Button } from '../ui'
interface SaveBarProps {
onSave: () => void
loading?: boolean
disabled?: boolean
changes?: string[]
}
export function SaveBar({ onSave, loading, disabled, changes = [] }: SaveBarProps) {
return (
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-bg/95 backdrop-blur-sm">
<div className="max-w-4xl mx-auto px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<span className="text-sm">
{changes.length > 0 ? (
<>
<span className="text-muted">Changed: </span>
<span className="font-medium">{changes.slice(0, 3).join(', ')}</span>
{changes.length > 3 && <span className="text-muted"> +{changes.length - 3} more</span>}
</>
) : (
<span className="text-muted">Unsaved changes</span>
)}
</span>
</div>
<Button
variant="primary"
onClick={onSave}
loading={loading}
disabled={disabled}
>
Save
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface SectionProps {
title: string
description?: string
children: ReactNode
}
export function Section({ title, description, children }: SectionProps) {
return (
<div className="section">
<div className="mb-4">
<h2 className="text-sm font-medium text-text">{title}</h2>
{description && (
<p className="text-xs text-muted mt-1">{description}</p>
)}
</div>
<div className="space-y-4">{children}</div>
</div>
)
}

View file

@ -0,0 +1,596 @@
import { PageHeader } from './PageHeader'
interface SkeletonProps {
className?: string
}
export function Skeleton({ className = '' }: SkeletonProps) {
return <div className={`bg-border/60 animate-shimmer ${className}`} />
}
export function SkeletonText({ className = '' }: SkeletonProps) {
return <Skeleton className={`h-4 rounded ${className}`} />
}
export function SkeletonCard({ className = '' }: SkeletonProps) {
return <Skeleton className={`h-24 ${className}`} />
}
// Page header skeleton with optional action button placeholder
interface PageHeaderSkeletonProps {
showAction?: boolean
}
export function PageHeaderSkeleton({ showAction = true }: PageHeaderSkeletonProps) {
return (
<PageHeader>
{showAction && <Skeleton className="w-28 h-9" />}
</PageHeader>
)
}
export function PostsPageSkeleton() {
return (
<div>
<PageHeader>
<Skeleton className="w-24 h-9" />
</PageHeader>
<div className="-mx-6 lg:-mx-10 mt-6">
<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>
<Skeleton className="w-10 h-6" />
</div>
<div className="py-4 px-6">
<div className="text-xs text-muted mb-0.5">Published</div>
<Skeleton className="w-10 h-6" />
</div>
<div className="py-4 pr-6 lg:pr-10 pl-6">
<div className="text-xs text-muted mb-0.5">Total Views</div>
<Skeleton className="w-16 h-6" />
</div>
</div>
</div>
<div className="border-t border-border" />
<div className="px-6 lg:px-10 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex bg-border/50 border border-border">
{['All', 'Published', 'Drafts'].map((label, i) => (
<div
key={label}
className={`px-3 py-1.5 text-xs font-medium tracking-wide text-muted ${
i === 0 ? 'bg-surface border-x border-border/50' : ''
}`}
>
{label}
</div>
))}
</div>
<div className="flex-1 sm:max-w-xs sm:ml-auto">
<Skeleton className="h-10" />
</div>
</div>
<div className="border-t border-border" />
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className={`flex items-center gap-4 px-6 lg:px-10 py-4 ${i > 1 ? 'border-t border-border' : ''}`}>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2.5">
{i <= 2 && <Skeleton className="w-12 h-5" />}
<SkeletonText className={i <= 2 ? 'w-40' : 'w-56'} />
</div>
<div className="flex items-center gap-3">
<SkeletonText className="w-24 h-3" />
<SkeletonText className="w-16 h-3" />
</div>
</div>
<Skeleton className="w-8 h-8" />
</div>
))}
</div>
</div>
)
}
export function AnalyticsPageSkeleton() {
return (
<div>
<PageHeader>
<div className="flex bg-border/50 border border-border">
{['7d', '30d', '90d'].map((label, i) => (
<div
key={label}
className={`px-3 py-1.5 text-xs font-medium tracking-wide text-muted ${
i === 0 ? 'bg-surface border-x border-border/50' : ''
}`}
>
{label}
</div>
))}
</div>
</PageHeader>
<div className="-mx-6 lg:-mx-10 mt-6">
<div className="relative">
<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>
<Skeleton className="w-20 h-8" />
<div className="text-xs mt-1 text-muted">
<Skeleton className="w-24 h-3 inline-block" />
</div>
</div>
<div className="py-5 px-6 lg:pr-6">
<div className="text-xs text-muted mb-1">Page Views</div>
<Skeleton className="w-16 h-8" />
</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>
<Skeleton className="w-14 h-8" />
</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>
<Skeleton className="w-16 h-8" />
</div>
</div>
</div>
<div className="border-t border-border" />
<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>
<Skeleton className="h-48" />
</div>
<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">
<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>
{[1, 2, 3].map(j => (
<div key={j} className="space-y-2 mb-3">
<div className="flex justify-between">
<SkeletonText className="w-32" />
<SkeletonText className="w-8" />
</div>
<Skeleton className="h-1.5" />
</div>
))}
</div>
<div className="py-6 pr-6 lg:pr-10 pl-6">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Referrers</div>
{[1, 2, 3].map(j => (
<div key={j} className="space-y-2 mb-3">
<div className="flex justify-between">
<SkeletonText className="w-24" />
<SkeletonText className="w-8" />
</div>
<Skeleton className="h-1.5" />
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
// Generic settings page skeleton
export function SettingsPageSkeleton() {
return (
<div className="space-y-6">
<PageHeaderSkeleton showAction={false} />
{[1, 2].map(i => (
<div key={i} className="card p-6 space-y-4">
<div className="space-y-1">
<SkeletonText className="w-24" />
<SkeletonText className="w-48 h-3" />
</div>
<div className="space-y-4">
{[1, 2, 3].map(j => (
<div key={j} className="space-y-1">
<SkeletonText className="w-16 h-3" />
<Skeleton className="h-10" />
</div>
))}
</div>
</div>
))}
</div>
)
}
// General page skeleton - 3 sections with fields
export function GeneralPageSkeleton() {
return (
<div className="space-y-6 pb-20">
<PageHeaderSkeleton showAction={false} />
{/* Site Information */}
<div className="card p-6 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-medium text-text">Site Information</h3>
<p className="text-xs text-muted">Basic details about your blog</p>
</div>
<div className="space-y-4">
<div className="space-y-1">
<span className="text-xs text-muted">Site Title</span>
<Skeleton className="h-10" />
</div>
<div className="space-y-1">
<span className="text-xs text-muted">Description</span>
<Skeleton className="h-20" />
</div>
</div>
</div>
{/* Author */}
<div className="card p-6 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-medium text-text">Author</h3>
<p className="text-xs text-muted">Information displayed on your posts</p>
</div>
<div className="space-y-4">
{['Name', 'Email', 'Avatar URL', 'Bio'].map((label, i) => (
<div key={label} className="space-y-1">
<span className="text-xs text-muted">{label}</span>
<Skeleton className={i === 3 ? 'h-20' : 'h-10'} />
</div>
))}
</div>
</div>
{/* Social Links */}
<div className="card p-6 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-medium text-text">Social Links</h3>
<p className="text-xs text-muted">Links to your social profiles</p>
</div>
<div className="space-y-4">
{['Twitter', 'GitHub', 'LinkedIn', 'Website'].map(label => (
<div key={label} className="space-y-1">
<span className="text-xs text-muted">{label}</span>
<Skeleton className="h-10" />
</div>
))}
</div>
</div>
</div>
)
}
// Design page skeleton - preview, colors, grids
export function DesignPageSkeleton() {
return (
<div className="space-y-8 pb-20">
<PageHeaderSkeleton showAction={false} />
{/* Live Preview */}
<div>
<span className="text-xs text-muted block mb-3">Live Preview</span>
<Skeleton className="h-64 border border-border" />
</div>
{/* Accent Color */}
<div>
<span className="text-xs text-muted block mb-3">Accent Color</span>
<div className="flex items-center gap-3">
<Skeleton className="w-12 h-12" />
<Skeleton className="w-32 h-10" />
<div className="flex gap-1.5">
{[1, 2, 3, 4, 5, 6].map(i => (
<Skeleton key={i} className="w-7 h-7" />
))}
</div>
</div>
</div>
{/* Typography */}
<div>
<span className="text-xs text-muted block mb-3">Typography</span>
<div className="grid grid-cols-2 gap-2">
{[1, 2, 3, 4, 5, 6].map(i => (
<Skeleton key={i} className="h-20 border border-border" />
))}
</div>
</div>
{/* Code Theme */}
<div>
<span className="text-xs text-muted block mb-3">Code Theme</span>
<div className="grid grid-cols-2 gap-2">
{[1, 2, 3, 4, 5].map(i => (
<Skeleton key={i} className="h-24 border border-border" />
))}
</div>
</div>
{/* Layout */}
<div>
<span className="text-xs text-muted block mb-3">Layout</span>
<div className="grid grid-cols-3 gap-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-28 border border-border" />
))}
</div>
</div>
</div>
)
}
// Engagement page skeleton - toggles and options
export function EngagementPageSkeleton() {
return (
<div className="space-y-6 pb-20">
<PageHeaderSkeleton showAction={false} />
{/* Comments section */}
<div className="card p-6 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-medium text-text">Comments</h3>
<p className="text-xs text-muted">Let readers comment on your posts</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<span className="text-sm text-text">Enable Comments</span>
<SkeletonText className="w-48 h-3" />
</div>
<Skeleton className="w-12 h-6 rounded-full" />
</div>
</div>
{/* Reactions section */}
<div className="card p-6 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-medium text-text">Reactions</h3>
<p className="text-xs text-muted">Let readers react to posts</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<span className="text-sm text-text">Enable Reactions</span>
<SkeletonText className="w-44 h-3" />
</div>
<Skeleton className="w-12 h-6 rounded-full" />
</div>
</div>
</div>
)
}
export function APIPageSkeleton() {
return (
<div>
<PageHeader>
<Skeleton className="w-28 h-9" />
</PageHeader>
<div className="-mx-6 lg:-mx-10 mt-6">
<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">
{[1, 2].map(i => (
<div key={i} className={`flex flex-col sm:flex-row sm:items-center gap-4 px-6 lg:px-10 py-4 ${i > 1 ? 'border-t border-border' : ''}`}>
<div className="flex-1 min-w-0 space-y-2">
<SkeletonText className="w-24" />
<div className="flex items-center gap-3">
<SkeletonText className="w-20 h-3" />
<SkeletonText className="w-28 h-3" />
<SkeletonText className="w-24 h-3" />
</div>
</div>
<Skeleton className="w-16 h-8" />
</div>
))}
</div>
<div className="border-t border-border" />
<div className="px-6 lg:px-10 py-5">
<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>
<div className="px-6 lg:px-10 py-6 space-y-3">
<div className="border border-border p-4 space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="w-2 h-2 rounded-full" />
<SkeletonText className="w-24" />
</div>
<SkeletonText className="w-48 h-3" />
<div className="flex gap-1">
<Skeleton className="w-20 h-5" />
<Skeleton className="w-16 h-5" />
</div>
</div>
<Skeleton className="w-28 h-9" />
</div>
<div className="border-t border-border" />
<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: <Skeleton className="w-32 h-3 inline-block" /></div>
</div>
<div className="px-6 lg:px-10 py-6 space-y-2">
{['GET', 'POST', 'GET', 'PUT', 'DELETE'].map((method, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3 border border-border">
<Skeleton className={`w-12 h-5 ${method === 'GET' ? 'bg-success/15' : method === 'POST' ? 'bg-blue-500/15' : method === 'PUT' ? 'bg-warning/15' : 'bg-danger/15'}`} />
<SkeletonText className="w-32" />
<SkeletonText className="flex-1 hidden sm:block" />
<Skeleton className="w-4 h-4" />
</div>
))}
</div>
</div>
</div>
)
}
export function HomePageSkeleton() {
return (
<div>
<PageHeader>
<Skeleton className="w-24 h-9" />
</PageHeader>
<div className="-mx-6 lg:-mx-10 mt-6">
<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-5 pl-6 lg:pl-10 pr-6">
<div className="text-xs text-muted mb-1">Views</div>
<Skeleton className="w-16 h-8" />
<div className="text-xs mt-1 text-muted">
<Skeleton className="w-24 h-3 inline-block" />
</div>
</div>
<div className="py-5 px-6">
<div className="text-xs text-muted mb-1">Visitors</div>
<Skeleton className="w-12 h-8" />
</div>
<div className="py-5 pr-6 lg:pr-10 pl-6">
<div className="text-xs text-muted mb-1">Posts</div>
<Skeleton className="w-8 h-8" />
<SkeletonText className="w-16 h-3 mt-1" />
</div>
</div>
</div>
<div className="border-t border-border" />
<div className="relative">
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border" />
<div className="grid grid-cols-2">
<div className="pl-6 lg:pl-10 pr-6 py-6 space-y-6">
<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">
{[1, 2].map(i => (
<div key={i} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2.5">
<Skeleton className="w-3.5 h-3.5" />
<SkeletonText className="w-32" />
</div>
<div className="flex items-center gap-2">
<SkeletonText className="w-10 h-3" />
<Skeleton className="w-4 h-4" />
</div>
</div>
))}
</div>
</div>
<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">
{[1, 2, 3, 4].map(i => (
<div key={i} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2.5">
<Skeleton className="w-3.5 h-3.5" />
<SkeletonText className="w-40" />
</div>
<div className="flex items-center gap-3 text-xs text-muted">
<SkeletonText className="w-6 h-3" />
<SkeletonText className="w-12 h-3" />
</div>
</div>
))}
</div>
</div>
</div>
<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>
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="space-y-2">
<div className="flex justify-between">
<SkeletonText className="w-24" />
<SkeletonText className="w-8" />
</div>
<Skeleton className="h-1.5" />
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export function BillingPageSkeleton() {
return (
<div>
<PageHeader />
<div className="-mx-6 lg:-mx-10 mt-6">
<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-border">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="w-24 h-6" />
<SkeletonText className="w-32 h-3" />
</div>
<Skeleton className="w-32 h-9" />
</div>
</div>
</div>
<div className="border-t border-border" />
<div className="px-6 lg:px-10 py-5">
<div className="text-xs font-medium text-muted uppercase tracking-wide">Upgrade to Pro</div>
</div>
<div className="px-6 lg:px-10 pb-6">
<div className="flex items-center justify-center gap-3 mb-6">
<Skeleton className="w-20 h-8" />
<Skeleton className="w-24 h-8" />
</div>
<div className="max-w-md mx-auto p-6 border border-border">
<div className="text-center mb-6 space-y-2">
<SkeletonText className="w-12 mx-auto" />
<Skeleton className="w-24 h-10 mx-auto" />
</div>
<div className="space-y-3 mb-6">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="w-4 h-4" />
<SkeletonText className="w-40" />
</div>
))}
</div>
<Skeleton className="w-full h-10" />
</div>
</div>
<div className="border-t border-border" />
<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">
<Skeleton className="w-full h-64" />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,26 @@
interface StatCardProps {
label: string
value: string
icon?: string
change?: string
positive?: boolean
}
export function StatCard({ label, value, icon, change, positive }: StatCardProps) {
return (
<div className="card">
{icon && (
<div className="w-10 h-10 bg-accent/10 flex items-center justify-center mb-3">
<span className={`${icon} text-accent text-lg`} />
</div>
)}
<div className="text-xs text-muted uppercase tracking-wide mb-1">{label}</div>
<div className="text-2xl font-semibold text-text">{value}</div>
{change && (
<div className={`text-xs mt-1 ${positive ? 'text-success' : 'text-danger'}`}>
{change}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,23 @@
export { Section } from './Section'
export { Field } from './Field'
export { StatCard } from './StatCard'
export { BreakdownList } from './BreakdownList'
export { EmptyState } from './EmptyState'
export { LoadingState } from './LoadingState'
export { SaveBar } from './SaveBar'
export { Breadcrumb } from './Breadcrumb'
export { PageHeader } from './PageHeader'
export {
Skeleton,
SkeletonText,
SkeletonCard,
PostsPageSkeleton,
AnalyticsPageSkeleton,
SettingsPageSkeleton,
GeneralPageSkeleton,
DesignPageSkeleton,
EngagementPageSkeleton,
APIPageSkeleton,
BillingPageSkeleton,
HomePageSkeleton,
} from './Skeleton'

View file

@ -0,0 +1,92 @@
import { useState, useRef, useEffect } from 'react'
import type { IconComponent } from '../shared/Icons'
import { Icons } from '../shared/Icons'
export interface ActionMenuItem {
label: string
Icon?: IconComponent
onClick?: () => void
href?: string
external?: boolean
variant?: 'default' | 'danger'
}
interface ActionMenuProps {
items: ActionMenuItem[]
className?: string
}
export function ActionMenu({ items, className = '' }: ActionMenuProps) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div ref={containerRef} className={`relative ${className}`} data-action-menu>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpen(!open)
}}
className="p-2 text-muted/60 hover:text-text hover:bg-bg rounded transition-all duration-150"
aria-label="Actions"
>
<Icons.MoreHorizontal className="w-4 h-4" />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-36 bg-surface border border-border shadow-lg z-[100] py-1">
{items.map((item, i) => {
const itemClass = `w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
item.variant === 'danger'
? 'text-danger hover:bg-danger/5'
: 'text-text hover:bg-bg'
}`
if (item.href) {
return (
<a
key={i}
href={item.href}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
className={itemClass}
onClick={() => setOpen(false)}
>
{item.Icon && <item.Icon className="w-3.5 h-3.5 opacity-60" />}
{item.label}
</a>
)
}
return (
<button
key={i}
type="button"
onClick={() => {
setOpen(false)
item.onClick?.()
}}
className={itemClass}
>
{item.Icon && <item.Icon className="w-3.5 h-3.5 opacity-70" />}
{item.label}
</button>
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,20 @@
type BadgeVariant = 'draft' | 'published' | 'default'
interface BadgeProps {
variant?: BadgeVariant
children: string
}
const variantClasses: Record<BadgeVariant, string> = {
draft: 'badge-draft',
published: 'badge-published',
default: 'badge text-muted border-border',
}
export function Badge({ variant = 'default', children }: BadgeProps) {
return (
<span className={variantClasses[variant]}>
{children}
</span>
)
}

View file

@ -0,0 +1,63 @@
import type { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react'
import { Icons, type IconComponent } from '../shared/Icons'
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonBaseProps = {
variant?: ButtonVariant
loading?: boolean
Icon?: IconComponent
children: ReactNode
}
type ButtonAsButton = ButtonBaseProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: never }
type ButtonAsLink = ButtonBaseProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }
type ButtonProps = ButtonAsButton | ButtonAsLink
const variantClasses: Record<ButtonVariant, string> = {
primary: 'btn-primary',
secondary: 'btn-secondary',
danger: 'btn-danger',
ghost: 'btn-ghost',
}
export function Button({
variant = 'secondary',
loading = false,
Icon,
children,
className = '',
...props
}: ButtonProps) {
const content = (
<>
{loading ? (
<Icons.Loader className="animate-spin text-xs" />
) : Icon ? (
<Icon className="text-xs opacity-70" />
) : null}
<span>{children}</span>
</>
)
if ('href' in props && props.href) {
const { href, ...linkProps } = props
return (
<a href={href} className={`${variantClasses[variant]} ${className}`} {...linkProps}>
{content}
</a>
)
}
const { disabled, ...buttonProps } = props as ButtonAsButton
return (
<button
className={`${variantClasses[variant]} ${className}`}
disabled={disabled || loading}
{...buttonProps}
>
{content}
</button>
)
}

View file

@ -0,0 +1,86 @@
import { useState, useRef, useEffect } from 'react'
import type { IconComponent } from '../shared/Icons'
import { Icons } from '../shared/Icons'
export interface DropdownOption<T extends string = string> {
value: T
label: string
Icon?: IconComponent
description?: string
}
interface DropdownProps<T extends string = string> {
value: T
onChange: (value: T) => void
options: DropdownOption<T>[]
placeholder?: string
className?: string
}
export function Dropdown<T extends string = string>({
value,
onChange,
options,
placeholder = 'Select...',
className = '',
}: DropdownProps<T>) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const selected = options.find(opt => opt.value === value)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div ref={containerRef} className={`relative ${className}`}>
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm bg-bg border border-border transition-colors hover:border-muted focus:outline-none focus:border-muted"
>
<span className="flex items-center gap-2 min-w-0">
{selected?.Icon && <selected.Icon className="w-4 h-4 flex-shrink-0" />}
<span className="truncate">{selected?.label || placeholder}</span>
</span>
<Icons.ChevronDown className={`w-4 h-4 flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-50 w-full mt-1 bg-surface border border-border shadow-lg max-h-60 overflow-auto">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`w-full flex items-start gap-2 px-3 py-2 text-sm text-left transition-colors ${
opt.value === value ? 'bg-accent/5 border-l-2 border-l-accent' : 'hover:bg-bg'
}`}
>
{opt.Icon && <opt.Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />}
<div className="min-w-0 flex-1">
<div className="truncate">{opt.label}</div>
{opt.description && (
<div className="text-xs text-muted truncate">{opt.description}</div>
)}
</div>
{opt.value === value && (
<Icons.Check className="w-4 h-4 flex-shrink-0 text-accent" />
)}
</button>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,50 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'
import type { IconComponent } from '../shared/Icons'
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value: string
onChange: (value: string) => void
Icon?: IconComponent
}
interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string
onChange: (value: string) => void
rows?: number
}
export function Input({ value, onChange, Icon, className = '', ...props }: InputProps) {
if (Icon) {
return (
<div className="relative">
<Icon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted text-sm" />
<input
className={`input pl-9 ${className}`}
value={value}
onChange={e => onChange(e.target.value)}
{...props}
/>
</div>
)
}
return (
<input
className={`input ${className}`}
value={value}
onChange={e => onChange(e.target.value)}
{...props}
/>
)
}
export function Textarea({ value, onChange, rows = 4, className = '', ...props }: TextareaProps) {
return (
<textarea
className={`input resize-none ${className}`}
value={value}
onChange={e => onChange(e.target.value)}
rows={rows}
{...props}
/>
)
}

View file

@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import { Icons } from '../shared/Icons'
interface ModalProps {
open: boolean
onClose: () => void
title: string
children: ReactNode
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (open) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
<div className="relative bg-surface border border-border shadow-lg w-full max-w-md mx-4">
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-sm font-medium text-text">{title}</h2>
<button
onClick={onClose}
className="btn-ghost p-1 -m-1"
>
<Icons.Close />
</button>
</div>
<div className="p-4">
{children}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,33 @@
interface SelectOption {
value: string
label: string
}
interface SelectProps {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
className?: string
}
export function Select({ value, onChange, options, placeholder, className = '' }: SelectProps) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className={`input ${className}`}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
}

View file

@ -0,0 +1,74 @@
import { useRef, useState, useLayoutEffect } from 'react'
import type { IconComponent } from '../shared/Icons'
export interface Tab<T extends string = string> {
value: T
label: string
Icon?: IconComponent
}
interface TabsProps<T extends string = string> {
value: T
onChange: (value: T) => void
tabs: Tab<T>[]
className?: string
}
export function Tabs<T extends string = string>({
value,
onChange,
tabs,
className = '',
}: TabsProps<T>) {
const containerRef = useRef<HTMLDivElement>(null)
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 })
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const activeIndex = tabs.findIndex(tab => tab.value === value)
const buttons = container.querySelectorAll('button')
const activeButton = buttons[activeIndex]
if (activeButton) {
setIndicatorStyle({
left: activeButton.offsetLeft,
width: activeButton.offsetWidth,
})
}
}, [value, tabs])
return (
<div
ref={containerRef}
className={`relative flex bg-border/50 border border-border ${className}`}
>
<div
className="absolute inset-y-0 bg-surface border-x border-border/50 transition-all duration-200 ease-out"
style={{
left: indicatorStyle.left,
width: indicatorStyle.width,
}}
/>
{tabs.map((tab) => {
const isActive = tab.value === value
return (
<button
key={tab.value}
type="button"
onClick={() => onChange(tab.value)}
className={`relative z-10 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium tracking-wide transition-colors duration-150 ${
isActive
? 'text-text'
: 'text-muted hover:text-text/70'
}`}
>
{tab.Icon && <tab.Icon className="w-3.5 h-3.5" />}
<span>{tab.label}</span>
</button>
)
})}
</div>
)
}

View file

@ -0,0 +1,33 @@
import { useStore } from '@nanostores/react'
import { $toasts, removeToast } from '../../stores/app'
import { Icons } from '../shared/Icons'
export function Toasts() {
const toasts = useStore($toasts)
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`flex items-center gap-3 px-4 py-3 border shadow-lg ${
toast.type === 'success'
? 'bg-success text-white border-success'
: 'bg-danger text-white border-danger'
}`}
>
{toast.type === 'success' ? <Icons.CheckCircle /> : <Icons.AlertCircle />}
<span className="text-sm font-medium">{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
className="ml-2 opacity-70 hover:opacity-100"
>
<Icons.Close className="text-sm" />
</button>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,29 @@
interface ToggleProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
description?: string
}
export function Toggle({ checked, onChange, label, description }: ToggleProps) {
return (
<label className="flex items-start gap-3 cursor-pointer">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={checked}
onChange={e => onChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-10 h-6 bg-border rounded-full peer-checked:bg-accent transition-colors" />
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full shadow transition-transform peer-checked:translate-x-4" />
</div>
<div className="flex-1">
<div className="text-sm font-medium text-text">{label || ""}</div>
{description && (
<div className="text-xs text-muted mt-0.5">{description}</div>
)}
</div>
</label>
)
}

View file

@ -0,0 +1,42 @@
interface UsageIndicatorProps {
used: number
max: number
label: string
}
export function UsageIndicator({ used, max, label }: UsageIndicatorProps) {
const atLimit = used >= max
const segments = Array.from({ length: max }, (_, i) => i < used)
return (
<div className={`flex items-center gap-3 px-3 py-2 rounded-md border ${atLimit ? 'bg-warning/5 border-warning/30' : 'bg-accent/5 border-accent/20'}`}>
{/* Segmented dots */}
<div className="flex gap-1.5">
{segments.map((filled, i) => (
<div
key={i}
className={`
w-2.5 h-2.5 rounded-full transition-all duration-200
${filled
? atLimit
? 'bg-warning shadow-[0_0_6px_rgba(245,158,11,0.5)]'
: 'bg-accent shadow-[0_0_6px_rgba(16,185,129,0.4)]'
: 'bg-border'
}
`}
/>
))}
</div>
{/* Count */}
<div className="flex items-baseline gap-1">
<span className={`text-sm font-bold tabular-nums ${atLimit ? 'text-warning' : 'text-accent'}`}>
{used}
</span>
<span className="text-muted text-xs font-medium">/</span>
<span className="text-muted text-xs font-medium tabular-nums">{max}</span>
<span className="text-muted text-xs ml-0.5">{label}</span>
</div>
</div>
)
}

View file

@ -0,0 +1,11 @@
export { Button } from './Button'
export { Input, Textarea } from './Input'
export { Toggle } from './Toggle'
export { Modal } from './Modal'
export { Toasts } from './Toast'
export { Select } from './Select'
export { Dropdown, type DropdownOption } from './Dropdown'
export { Tabs, type Tab } from './Tabs'
export { Badge } from './Badge'
export { ActionMenu, type ActionMenuItem } from './ActionMenu'
export { UsageIndicator } from './UsageIndicator'

11
studio/src/main.tsx Normal file
View file

@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

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

View file

@ -0,0 +1,318 @@
import { useEffect, useRef } from 'react'
import { useStore } from '@nanostores/react'
import { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip } from 'chart.js'
import { $analytics, $days } from '../stores/analytics'
import { BreakdownList, AnalyticsPageSkeleton, EmptyState, PageHeader } from '../components/shared'
import { Tabs } from '../components/ui'
import { Icons, getReferrerIcon, getCountryFlagUrl, getBrowserIcon, getDeviceIcon, getOSIcon } from '../components/shared/Icons'
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip)
const periodTabs = [
{ value: '7', label: '7d' },
{ value: '30', label: '30d' },
{ value: '90', label: '90d' },
]
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function formatChange(change: number): { text: string; positive: boolean } {
const sign = change >= 0 ? '+' : ''
return {
text: `${sign}${change.toFixed(1)}%`,
positive: change >= 0,
}
}
export default function AnalyticsPage() {
const { data, error } = useStore($analytics)
const days = useStore($days)
const chartRef = useRef<HTMLCanvasElement>(null)
const chartInstance = useRef<Chart | null>(null)
const prevDataRef = useRef(data)
useEffect(() => {
if (!data?.views_by_day?.length || !chartRef.current) return
if (chartInstance.current) {
chartInstance.current.destroy()
}
const ctx = chartRef.current.getContext('2d')
if (!ctx) return
const sortedData = [...data.views_by_day].sort((a, b) => a.date.localeCompare(b.date))
const fontFamily = '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace'
chartInstance.current = new Chart(ctx, {
type: 'line',
data: {
labels: sortedData.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })),
datasets: [{
data: sortedData.map(d => d.views),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#10b981',
pointBorderColor: '#10b981',
pointHoverRadius: 6,
pointHoverBackgroundColor: '#10b981',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
tooltip: {
backgroundColor: '#0a0a0a',
titleColor: '#fafafa',
bodyColor: '#fafafa',
titleFont: { family: fontFamily, size: 11 },
bodyFont: { family: fontFamily, size: 12 },
padding: 10,
cornerRadius: 0,
displayColors: false,
callbacks: {
label: (ctx) => `${(ctx.parsed.y ?? 0).toLocaleString()} views`
}
}
},
scales: {
x: {
grid: { display: false },
border: { display: false },
ticks: {
color: '#737373',
font: { family: fontFamily, size: 10 },
padding: 8,
maxRotation: 0,
}
},
y: {
grid: { color: '#e5e5e5' },
border: { display: false },
ticks: {
color: '#737373',
font: { family: fontFamily, size: 10 },
padding: 12,
},
beginAtZero: true
}
}
}
})
return () => {
if (chartInstance.current) {
chartInstance.current.destroy()
}
}
}, [data])
// Keep previous data while loading to prevent flickering
if (data) {
prevDataRef.current = data
}
const displayData = data || prevDataRef.current
if (error && !displayData) return <EmptyState Icon={Icons.AlertCircle} title="Failed to load analytics" description={error.message} />
if (!displayData) return <AnalyticsPageSkeleton />
const change = formatChange(displayData.views_change)
return (
<div>
<PageHeader>
<Tabs
value={String(days)}
onChange={(v) => $days.set(Number(v))}
tabs={periodTabs}
/>
</PageHeader>
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Stats row - 4 columns on lg, 2 on mobile */}
<div className="relative">
{/* Vertical dividers at column boundaries (lg: 4 cols, mobile: 2 cols) */}
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border lg:hidden" />
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '25%' }} />
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '50%' }} />
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '75%' }} />
<div className="grid grid-cols-2 lg:grid-cols-4">
<div className="py-5 pl-6 lg:pl-10 pr-6">
<div className="text-xs text-muted mb-1">Total Views</div>
<div className="text-2xl font-semibold tracking-tight">{displayData.total_views.toLocaleString()}</div>
<div className={`text-xs mt-1 ${change.positive ? 'text-success' : 'text-danger'}`}>
{change.text} vs last period
</div>
</div>
<div className="py-5 px-6 lg:pr-6">
<div className="text-xs text-muted mb-1">Page Views</div>
<div className="text-2xl font-semibold tracking-tight">{displayData.total_page_views.toLocaleString()}</div>
</div>
<div className="py-5 px-6 lg:pl-6 border-t border-border lg:border-t-0">
<div className="text-xs text-muted mb-1">Unique Visitors</div>
<div className="text-2xl font-semibold tracking-tight">{displayData.unique_visitors.toLocaleString()}</div>
</div>
<div className="py-5 pr-6 lg:pr-10 pl-6 border-t border-border lg:border-t-0">
<div className="text-xs text-muted mb-1">Bandwidth</div>
<div className="text-2xl font-semibold tracking-tight">{formatBytes(displayData.total_bandwidth)}</div>
</div>
</div>
</div>
{/* Horizontal divider */}
<div className="border-t border-border" />
{/* Chart section */}
{displayData.views_by_day.length > 0 && (
<>
<div className="px-6 lg:px-10 py-6">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Views Over Time</div>
<div className="h-48">
<canvas ref={chartRef} />
</div>
</div>
<div className="border-t border-border" />
</>
)}
{/* Breakdown sections - 2 columns */}
<div className="relative">
{/* Vertical divider at center on lg */}
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
<div className="grid lg:grid-cols-2">
{/* Top Pages */}
{displayData.top_pages.length > 0 && (
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Pages</div>
<BreakdownList
items={displayData.top_pages.map(p => ({
label: p.path,
value: p.views,
percentage: (p.views / displayData.total_page_views) * 100,
}))}
/>
</div>
)}
{/* Top Referrers */}
{displayData.top_referrers.length > 0 && (
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Referrers</div>
<BreakdownList
items={displayData.top_referrers.map(r => {
const label = r.referrer || 'Direct'
return {
label,
value: r.views,
percentage: (r.views / displayData.total_views) * 100,
Icon: getReferrerIcon(label),
}
})}
/>
</div>
)}
</div>
</div>
{/* Row 2: Browsers & Devices */}
{(displayData.browsers.length > 0 || displayData.devices.length > 0) && (
<>
<div className="border-t border-border" />
<div className="relative">
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
<div className="grid lg:grid-cols-2">
{displayData.browsers.length > 0 && (
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Browsers</div>
<BreakdownList
items={displayData.browsers.map(b => ({
label: b.name,
value: b.count,
percentage: (b.count / displayData.unique_visitors) * 100,
Icon: getBrowserIcon(b.name) || undefined,
}))}
/>
</div>
)}
{displayData.devices.length > 0 && (
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Devices</div>
<BreakdownList
items={displayData.devices.map(d => ({
label: d.name,
value: d.count,
percentage: (d.count / displayData.unique_visitors) * 100,
Icon: getDeviceIcon(d.name) || undefined,
}))}
/>
</div>
)}
</div>
</div>
</>
)}
{/* Row 3: Countries & OS */}
{(displayData.countries.length > 0 || displayData.os.length > 0) && (
<>
<div className="border-t border-border" />
<div className="relative">
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
<div className="grid lg:grid-cols-2">
{displayData.countries.length > 0 && (
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Countries</div>
<BreakdownList
items={displayData.countries.map(c => ({
label: c.name,
value: c.count,
percentage: (c.count / displayData.unique_visitors) * 100,
flagUrl: getCountryFlagUrl(c.name, 40) || undefined,
}))}
/>
</div>
)}
{displayData.os.length > 0 && (
<div className="py-6 pr-6 lg:pr-10 pl-6">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Operating Systems</div>
<BreakdownList
items={displayData.os.map(o => ({
label: o.name,
value: o.count,
percentage: (o.count / displayData.unique_visitors) * 100,
Icon: getOSIcon(o.name) || undefined,
}))}
/>
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,10 @@
import { atom } from 'nanostores'
import { createFetcherStore } from './fetcher'
import type { AnalyticsSummary } from '../types'
export const $days = atom(30)
export const $analytics = createFetcherStore<AnalyticsSummary>([
'/api/studio/analytics?days=',
$days,
])

View file

@ -0,0 +1,34 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore, invalidateKeys } from './fetcher'
import type { APIKey } from '../types'
export const $apiKeys = createFetcherStore<APIKey[]>(['/api/studio/api-keys'])
export const $creating = atom(false)
export const $newKey = atom<string | null>(null)
export async function createAPIKey(name: string): Promise<APIKey> {
$creating.set(true)
try {
const res = await fetch('/api/studio/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error('Failed to create API key')
const key = await res.json()
$newKey.set(key.key)
invalidateKeys('/api/studio/api-keys')
return key
} finally {
$creating.set(false)
}
}
export const $deleteAPIKey = createMutatorStore<string>(
async ({ data: key, invalidate }) => {
const res = await fetch(`/api/studio/api-keys/${key}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete API key')
invalidate('/api/studio/api-keys')
}
)

19
studio/src/stores/app.ts Normal file
View file

@ -0,0 +1,19 @@
import { atom } from 'nanostores'
export interface Toast {
id: string
message: string
type: 'success' | 'error'
}
export const $toasts = atom<Toast[]>([])
export const addToast = (message: string, type: 'success' | 'error' = 'success') => {
const id = Math.random().toString(36).slice(2)
$toasts.set([...$toasts.get(), { id, message, type }])
setTimeout(() => removeToast(id), 4000)
}
export const removeToast = (id: string) => {
$toasts.set($toasts.get().filter(t => t.id !== id))
}

View file

@ -0,0 +1,33 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore, invalidateKeys } from './fetcher'
import type { Asset } from '../types'
export const $assets = createFetcherStore<Asset[]>(['/api/studio/assets'])
export const $uploading = atom(false)
export async function uploadAsset(file: File): Promise<Asset> {
$uploading.set(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/studio/assets', {
method: 'POST',
body: formData,
})
if (!res.ok) throw new Error('Failed to upload')
const asset = await res.json()
invalidateKeys('/api/studio/assets')
return asset
} finally {
$uploading.set(false)
}
}
export const $deleteAsset = createMutatorStore<string>(
async ({ data: id, invalidate }) => {
const res = await fetch(`/api/studio/assets/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete asset')
invalidate('/api/studio/assets')
}
)

View file

@ -0,0 +1,4 @@
import { createFetcherStore } from './fetcher'
import type { BillingInfo } from '../types'
export const $billing = createFetcherStore<BillingInfo>('/api/studio/billing')

467
studio/src/stores/editor.ts Normal file
View file

@ -0,0 +1,467 @@
import { atom, map, computed } from 'nanostores'
import { createMutatorStore } from './fetcher'
import type { Post } from '../types'
export type EditorMode = 'edit' | 'source'
export interface EditorPost {
id?: string
slug: string
title: string
description: string
content: string
date: string
draft: boolean
members_only: boolean
tags: string[]
cover_image?: string
}
export interface PostVersion {
id: number
title: string
created_at: string
}
export interface DraftState {
slug: string
title: string
description: string
content: string
tags: string[]
cover_image: string
members_only: boolean
modified_at: string
}
const defaultPost: EditorPost = {
slug: '',
title: '',
description: '',
content: '',
date: new Date().toISOString().split('T')[0],
draft: true,
members_only: false,
tags: [],
cover_image: '',
}
export const $editorMode = atom<EditorMode>('edit')
export const $editorPost = map<EditorPost>({ ...defaultPost })
export const $initialPost = atom<EditorPost | null>(null)
export const $isNewPost = atom(true)
export const $hasDraft = atom(false)
export const $versions = atom<PostVersion[]>([])
export const $isSaving = atom(false)
export const $isPublishing = atom(false)
export const $hasChanges = computed(
[$editorPost, $initialPost],
(current, initial) => initial !== null && JSON.stringify(current) !== JSON.stringify(initial)
)
export const $changedFields = computed(
[$editorPost, $initialPost],
(current, initial) => {
if (!initial) return []
const changes: string[] = []
const labels: Record<keyof EditorPost, string> = {
id: 'ID',
slug: 'Slug',
title: 'Title',
description: 'Description',
content: 'Content',
date: 'Date',
draft: 'Draft',
members_only: 'Members only',
tags: 'Tags',
cover_image: 'Cover image',
}
for (const key of Object.keys(current) as (keyof EditorPost)[]) {
const currentVal = JSON.stringify(current[key])
const initialVal = JSON.stringify(initial[key])
if (currentVal !== initialVal) {
changes.push(labels[key] || key)
}
}
return changes
}
)
export function initNewPost() {
const newPost = {
...defaultPost,
date: new Date().toISOString().split('T')[0],
}
$editorPost.set(newPost)
$initialPost.set({ ...newPost })
$isNewPost.set(true)
$hasDraft.set(false)
$versions.set([])
$editorMode.set('edit')
}
export async function loadPost(slug: string): Promise<boolean> {
try {
const res = await fetch(`/api/studio/posts/${slug}`)
if (!res.ok) return false
const post: Post = await res.json()
const editorPost: EditorPost = {
id: post.id,
slug: post.slug,
title: post.title,
description: post.description,
content: post.content || '',
date: post.date,
draft: post.draft,
members_only: post.members_only,
tags: post.tags || [],
cover_image: '',
}
$editorPost.set(editorPost)
$initialPost.set({ ...editorPost })
$isNewPost.set(false)
$editorMode.set('edit')
if (post.id) {
loadDraft(post.id)
loadVersions(post.id)
}
return true
} catch {
return false
}
}
async function loadDraft(postId: string) {
try {
const res = await fetch(`/api/studio/posts/${postId}/draft`)
if (!res.ok) {
$hasDraft.set(false)
return
}
const draft = await res.json()
if (draft) {
$hasDraft.set(true)
$editorPost.set({
...$editorPost.get(),
slug: draft.slug,
title: draft.title,
description: draft.description,
content: draft.content,
tags: draft.tags || [],
cover_image: draft.cover_image || '',
members_only: draft.members_only,
})
$initialPost.set({ ...$editorPost.get() })
} else {
$hasDraft.set(false)
}
} catch {
$hasDraft.set(false)
}
}
async function loadVersions(postId: string) {
try {
const res = await fetch(`/api/studio/posts/${postId}/versions`)
if (res.ok) {
const versions = await res.json()
$versions.set(versions || [])
}
} catch {
$versions.set([])
}
}
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export function updateTitle(title: string) {
const current = $editorPost.get()
const isNew = $isNewPost.get()
const initial = $initialPost.get()
$editorPost.setKey('title', title)
if (isNew || (initial && current.slug === slugify(initial.title))) {
$editorPost.setKey('slug', slugify(title))
}
}
export const $savePost = createMutatorStore<EditorPost>(
async ({ data: post, invalidate }) => {
const isNew = $isNewPost.get()
const url = isNew ? '/api/studio/posts' : `/api/studio/posts/${post.slug}`
const method = isNew ? 'POST' : 'PUT'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || 'Failed to save post')
}
const saved: Post = await res.json()
const editorPost: EditorPost = {
id: saved.id,
slug: saved.slug,
title: saved.title,
description: saved.description,
content: saved.content || post.content,
date: saved.date,
draft: saved.draft,
members_only: saved.members_only,
tags: saved.tags || [],
cover_image: post.cover_image,
}
$editorPost.set(editorPost)
$initialPost.set({ ...editorPost })
$isNewPost.set(false)
invalidate('/api/studio/posts')
return editorPost
}
)
export async function saveDraft(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
$isSaving.set(true)
try {
const res = await fetch(`/api/studio/posts/${post.id}/draft`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
tags: post.tags,
cover_image: post.cover_image,
members_only: post.members_only,
}),
})
if (!res.ok) {
throw new Error('Failed to save draft')
}
$hasDraft.set(true)
$initialPost.set({ ...post })
return true
} catch {
return false
} finally {
$isSaving.set(false)
}
}
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
export function triggerAutoSave() {
if ($isNewPost.get()) return
if (!$hasChanges.get()) return
if (autoSaveTimer) clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(async () => {
await saveDraft()
}, 2000)
}
export async function discardDraft(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
try {
const res = await fetch(`/api/studio/posts/${post.id}/draft`, {
method: 'DELETE',
})
if (!res.ok) {
throw new Error('Failed to discard draft')
}
$hasDraft.set(false)
await loadPost(post.slug)
return true
} catch {
return false
}
}
export async function publishPost(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
$isPublishing.set(true)
try {
if ($hasChanges.get()) {
await saveDraft()
}
const res = await fetch(`/api/studio/posts/${post.id}/publish`, {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to publish')
}
const saved: Post = await res.json()
const editorPost: EditorPost = {
id: saved.id,
slug: saved.slug,
title: saved.title,
description: saved.description,
content: saved.content || post.content,
date: saved.date,
draft: saved.draft,
members_only: saved.members_only,
tags: saved.tags || [],
cover_image: post.cover_image,
}
$editorPost.set(editorPost)
$initialPost.set({ ...editorPost })
$hasDraft.set(false)
if (post.id) {
loadVersions(post.id)
}
return true
} catch {
return false
} finally {
$isPublishing.set(false)
}
}
export async function unpublishPost(): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
$isPublishing.set(true)
try {
const res = await fetch(`/api/studio/posts/${post.id}/unpublish`, {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to unpublish')
}
const saved: Post = await res.json()
$editorPost.setKey('draft', saved.draft)
$initialPost.set({ ...$editorPost.get() })
return true
} catch {
return false
} finally {
$isPublishing.set(false)
}
}
export async function restoreVersion(versionId: number): Promise<boolean> {
const post = $editorPost.get()
if (!post.id) return false
try {
const res = await fetch(`/api/studio/posts/${post.id}/versions/${versionId}/restore`, {
method: 'POST',
})
if (!res.ok) {
throw new Error('Failed to restore version')
}
await loadPost(post.slug)
return true
} catch {
return false
}
}
export function getPreviewUrl(): string {
const post = $editorPost.get()
if (!post.slug) return ''
return `/posts/${post.slug}?preview=true`
}
const previewChannel = new BroadcastChannel('writekit-preview')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let pendingMarkdown: string | null = null
async function sendPreview(markdown: string) {
const post = $editorPost.get()
if (!post.id) return
try {
const res = await fetch(`/api/studio/posts/${post.id}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markdown }),
})
if (!res.ok) return
const { html } = await res.json()
previewChannel.postMessage({
type: 'content-update',
slug: post.slug,
title: post.title,
description: post.description,
html,
})
} catch {
// Silently fail - preview is non-critical
}
}
export function broadcastPreview(markdown: string) {
pendingMarkdown = markdown
previewChannel.postMessage({
type: 'rebuilding',
slug: $editorPost.get().slug,
})
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
if (pendingMarkdown) {
sendPreview(pendingMarkdown)
pendingMarkdown = null
}
}, 500)
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.hidden && pendingMarkdown) {
if (debounceTimer) clearTimeout(debounceTimer)
sendPreview(pendingMarkdown)
pendingMarkdown = null
}
})
}

View file

@ -0,0 +1,24 @@
import { nanoquery } from '@nanostores/query'
export const [createFetcherStore, createMutatorStore, { invalidateKeys }] = nanoquery({
fetcher: async (...keys: unknown[]) => {
const url = keys.map(k => String(k)).join('')
const res = await fetch(url)
if (!res.ok) throw new Error(`${res.status}`)
return res.json()
},
dedupeTime: 60_000,
cacheLifetime: 300_000,
})
const blogChannel = new BroadcastChannel('writekit-studio')
export function createBlogMutatorStore<T>(
mutator: (args: { data: T }) => Promise<T>
) {
return createMutatorStore<T>(async (args) => {
const result = await mutator(args)
blogChannel.postMessage({ type: 'settings-changed' })
return result
})
}

View file

@ -0,0 +1,35 @@
import { map } from 'nanostores'
import { createFetcherStore } from './fetcher'
export interface HookInfo {
name: string
label: string
description: string
pattern: string
test_data: Record<string, unknown>
}
export const $hooks = createFetcherStore<HookInfo[]>(['/api/studio/hooks'])
export const $templates = map<Record<string, string>>({})
export async function fetchTemplate(hook: string, language: string): Promise<string> {
const key = `${hook}:${language}`
const cached = $templates.get()[key]
if (cached) return cached
try {
const res = await fetch(`/api/studio/template?hook=${encodeURIComponent(hook)}&language=${encodeURIComponent(language)}`)
if (!res.ok) return ''
const data = await res.json()
const template = data.template || ''
$templates.setKey(key, template)
return template
} catch {
return ''
}
}
export function getTemplate(hook: string, language: string): string {
return $templates.get()[`${hook}:${language}`] || ''
}

View file

@ -0,0 +1,9 @@
export * from './router'
export * from './fetcher'
export * from './app'
export * from './posts'
export * from './settings'
export * from './analytics'
export * from './interactions'
export * from './assets'
export * from './apiKeys'

View file

@ -0,0 +1,74 @@
import { atom, map, computed, onMount } from 'nanostores'
import { createFetcherStore, createBlogMutatorStore } from './fetcher'
import type { InteractionConfig } from '../types'
const defaultConfig: InteractionConfig = {
comments_enabled: false,
reactions_enabled: false,
reaction_mode: 'emoji',
reaction_emojis: '👍,❤️,🎉,🚀',
upvote_icon: 'arrow',
reactions_require_auth: false,
}
export const $interactionsData = createFetcherStore<InteractionConfig>(['/api/studio/interaction-config'])
export const $interactions = map<InteractionConfig>({ ...defaultConfig })
export const $initialInteractions = atom<InteractionConfig | null>(null)
export const $hasInteractionChanges = computed(
[$interactions, $initialInteractions],
(current, initial) => initial !== null && JSON.stringify(current) !== JSON.stringify(initial)
)
export const $changedInteractionFields = computed(
[$interactions, $initialInteractions],
(current, initial) => {
if (!initial) return []
const changes: string[] = []
const labels: Record<string, string> = {
comments_enabled: 'Comments',
reactions_enabled: 'Reactions',
reaction_mode: 'Reaction mode',
reaction_emojis: 'Emojis',
upvote_icon: 'Upvote icon',
reactions_require_auth: 'Auth required',
}
for (const key of Object.keys(current) as (keyof InteractionConfig)[]) {
if (current[key] !== initial[key]) {
changes.push(labels[key] || key)
}
}
return changes
}
)
onMount($interactionsData, () => {
$interactionsData.listen(({ data }) => {
if (data) {
const merged = { ...defaultConfig, ...data }
$interactions.set(merged)
$initialInteractions.set(merged)
}
})
})
export const $saveInteractions = createBlogMutatorStore<InteractionConfig>(
async ({ data: config }) => {
const previous = $initialInteractions.get()
$initialInteractions.set({ ...config })
const res = await fetch('/api/studio/interaction-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!res.ok) {
$initialInteractions.set(previous)
throw new Error('Failed to save interactions')
}
return config
}
)

View file

@ -0,0 +1,102 @@
import { atom, map } from 'nanostores'
import { createFetcherStore, createMutatorStore } from './fetcher'
export interface Plugin {
id: string
name: string
language: 'typescript' | 'go'
source: string
hooks: string[]
enabled: boolean
wasm?: string
wasm_size?: number
created_at: string
updated_at: string
}
export interface CompileResult {
success: boolean
wasm?: string
size?: number
errors?: string[]
time_ms?: number
}
export const $plugins = createFetcherStore<Plugin[]>(['/api/studio/plugins'])
export const $currentPlugin = map<Partial<Plugin>>({
name: '',
language: 'typescript',
source: '',
hooks: [],
enabled: true,
})
export const $compileResult = atom<CompileResult | null>(null)
export const $isCompiling = atom(false)
export const $savePlugin = createMutatorStore(async ({ data, invalidate }: { data: Plugin; invalidate: (key: string) => void }) => {
const res = await fetch('/api/studio/plugins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to save plugin')
invalidate('/api/studio/plugins')
return res.json()
})
export const $deletePlugin = createMutatorStore(async ({ data: id, invalidate }: { data: string; invalidate: (key: string) => void }) => {
const res = await fetch(`/api/studio/plugins/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete plugin')
invalidate('/api/studio/plugins')
})
export const $togglePlugin = createMutatorStore(async ({ data, invalidate }: { data: { id: string; enabled: boolean }; invalidate: (key: string) => void }) => {
const res = await fetch(`/api/studio/plugins/${data.id}/toggle`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: data.enabled }),
})
if (!res.ok) throw new Error('Failed to toggle plugin')
invalidate('/api/studio/plugins')
})
export async function compilePlugin(language: string, source: string): Promise<CompileResult> {
$isCompiling.set(true)
$compileResult.set(null)
try {
const res = await fetch('/api/studio/plugins/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language, source }),
})
const result = await res.json()
$compileResult.set(result)
return result
} finally {
$isCompiling.set(false)
}
}
export async function testPlugin(pluginId: string, hook: string, testData: object): Promise<any> {
const res = await fetch(`/api/studio/plugins/${pluginId}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hook, data: testData }),
})
return res.json()
}
export const AVAILABLE_HOOKS = [
{ value: 'post.published', label: 'Post Published', description: 'Triggered when a post is published' },
{ value: 'post.updated', label: 'Post Updated', description: 'Triggered when a published post is updated' },
{ value: 'comment.created', label: 'Comment Created', description: 'Triggered when a new comment is posted' },
{ value: 'subscriber.created', label: 'Subscriber Created', description: 'Triggered when someone subscribes' },
]
export const LANGUAGES = [
{ value: 'typescript', label: 'TypeScript', extension: '.ts' },
{ value: 'go', label: 'Go', extension: '.go' },
]

View file

@ -0,0 +1,41 @@
import { atom, computed } from 'nanostores'
import { createFetcherStore, createMutatorStore } from './fetcher'
import type { Post } from '../types'
export const $posts = createFetcherStore<Post[]>(['/api/studio/posts'])
export const $search = atom('')
export const $filterStatus = atom<'all' | 'published' | 'draft'>('all')
export const $filteredPosts = computed(
[$posts, $search, $filterStatus],
(postsStore, search, status) => {
const posts = postsStore.data ?? []
return posts.filter(p => {
const searchLower = search.toLowerCase()
if (search && !p.title.toLowerCase().includes(searchLower) && !p.slug.toLowerCase().includes(searchLower)) {
return false
}
if (status === 'published' && p.draft) return false
if (status === 'draft' && !p.draft) return false
return true
})
}
)
export const $postCounts = computed([$posts], (postsStore) => {
const posts = postsStore.data ?? []
return {
all: posts.length,
published: posts.filter(p => !p.draft).length,
draft: posts.filter(p => p.draft).length,
}
})
export const $deletePost = createMutatorStore<string>(
async ({ data: slug, invalidate }) => {
const res = await fetch(`/api/studio/posts/${slug}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete post')
invalidate('/api/studio/posts')
}
)

View file

@ -0,0 +1,18 @@
import { createRouter } from '@nanostores/router'
export const $router = createRouter({
postNew: '/studio/posts/new',
postEdit: '/studio/posts/:slug/edit',
posts: '/studio/posts',
analytics: '/studio/analytics',
general: '/studio/general',
design: '/studio/design',
domain: '/studio/domain',
engagement: '/studio/engagement',
monetization: '/studio/monetization',
plugins: '/studio/plugins',
api: '/studio/api',
data: '/studio/data',
billing: '/studio/billing',
home: '/studio',
})

View file

@ -0,0 +1,36 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore } from './fetcher'
export interface Secret {
key: string
created_at: string
updated_at: string
}
export const $secrets = createFetcherStore<Secret[]>(['/api/studio/secrets'])
export const $createSecret = createMutatorStore(async ({ data, invalidate }: { data: { key: string; value: string }; invalidate: (key: string) => void }) => {
const res = await fetch('/api/studio/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create secret')
invalidate('/api/studio/secrets')
})
export const $deleteSecret = createMutatorStore(async ({ data: key, invalidate }: { data: string; invalidate: (key: string) => void }) => {
const res = await fetch(`/api/studio/secrets/${encodeURIComponent(key)}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Failed to delete secret')
invalidate('/api/studio/secrets')
})
export const $secretKeys = atom<string[]>([])
$secrets.subscribe(state => {
if (state.data) {
$secretKeys.set(state.data.map(s => s.key))
}
})

View file

@ -0,0 +1,93 @@
import { atom, map, computed, onMount } from 'nanostores'
import { createFetcherStore, createBlogMutatorStore } from './fetcher'
import type { Settings } from '../types'
const defaultSettings: Settings = {
site_name: '',
site_description: '',
author_name: '',
author_role: '',
author_bio: '',
author_photo: '',
twitter_handle: '',
github_handle: '',
linkedin_handle: '',
email: '',
show_powered_by: 'true',
accent_color: '#10b981',
code_theme: 'github',
font: 'system',
layout: 'default',
compactness: 'cozy',
custom_css: '',
}
export const $settingsData = createFetcherStore<Settings>(['/api/studio/settings'])
export const $settings = map<Settings>({ ...defaultSettings })
export const $initialSettings = atom<Settings | null>(null)
export const $hasChanges = computed(
[$settings, $initialSettings],
(current, initial) => initial !== null && JSON.stringify(current) !== JSON.stringify(initial)
)
export const $changedFields = computed(
[$settings, $initialSettings],
(current, initial) => {
if (!initial) return []
const changes: string[] = []
const labels: Record<string, string> = {
site_name: 'Site name',
site_description: 'Description',
accent_color: 'Accent color',
font: 'Font',
code_theme: 'Code theme',
layout: 'Layout',
compactness: 'Density',
author_name: 'Author name',
author_bio: 'Bio',
custom_css: 'Custom CSS',
}
for (const key of Object.keys(current) as (keyof Settings)[]) {
if (current[key] !== initial[key]) {
changes.push(labels[key] || key)
}
}
return changes
}
)
onMount($settingsData, () => {
$settingsData.listen(({ data }) => {
if (data) {
const merged = { ...defaultSettings, ...data }
$settings.set(merged)
$initialSettings.set(merged)
}
})
})
export const $saveSettings = createBlogMutatorStore<Settings>(
async ({ data: settings }) => {
const previous = $initialSettings.get()
$initialSettings.set({ ...settings })
if (settings.accent_color) {
document.documentElement.style.setProperty('--accent', settings.accent_color)
}
const res = await fetch('/api/studio/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
if (!res.ok) {
$initialSettings.set(previous)
throw new Error('Failed to save settings')
}
return settings
}
)

View file

@ -0,0 +1,69 @@
import { atom } from 'nanostores'
import { createFetcherStore, createMutatorStore, invalidateKeys } from './fetcher'
import type { Webhook, WebhookDelivery } from '../types'
export const $webhooks = createFetcherStore<Webhook[]>(['/api/studio/webhooks'])
export const $creating = atom(false)
export async function createWebhook(data: {
name: string
url: string
events: string[]
secret?: string
}): Promise<Webhook> {
$creating.set(true)
try {
const res = await fetch('/api/studio/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to create webhook')
const webhook = await res.json()
invalidateKeys('/api/studio/webhooks')
return webhook
} finally {
$creating.set(false)
}
}
export async function updateWebhook(
id: string,
data: {
name: string
url: string
events: string[]
secret?: string
enabled: boolean
}
): Promise<Webhook> {
const res = await fetch(`/api/studio/webhooks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Failed to update webhook')
const webhook = await res.json()
invalidateKeys('/api/studio/webhooks')
return webhook
}
export const $deleteWebhook = createMutatorStore<string>(
async ({ data: id, invalidate }) => {
const res = await fetch(`/api/studio/webhooks/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete webhook')
invalidate('/api/studio/webhooks')
}
)
export async function testWebhook(id: string): Promise<void> {
const res = await fetch(`/api/studio/webhooks/${id}/test`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to test webhook')
}
export async function fetchWebhookDeliveries(id: string): Promise<WebhookDelivery[]> {
const res = await fetch(`/api/studio/webhooks/${id}/deliveries`)
if (!res.ok) throw new Error('Failed to fetch deliveries')
return res.json()
}

128
studio/src/types.ts Normal file
View file

@ -0,0 +1,128 @@
export interface Post {
id: string
slug: string
title: string
description: string
cover_image?: string
content?: string
date: string
draft: boolean
members_only: boolean
tags: string[]
created_at: string
updated_at: string
}
export interface Settings {
site_name?: string
site_description?: string
author_name?: string
author_role?: string
author_bio?: string
author_photo?: string
twitter_handle?: string
github_handle?: string
linkedin_handle?: string
email?: string
show_powered_by?: string
accent_color?: string
code_theme?: string
font?: string
layout?: string
compactness?: 'compact' | 'cozy' | 'spacious'
custom_css?: string
}
export interface InteractionConfig {
comments_enabled: boolean
reactions_enabled: boolean
reaction_mode: string
reaction_emojis: string
upvote_icon: string
reactions_require_auth: boolean
}
export interface Asset {
id: string
filename: string
url: string
content_type: string
size: number
created_at: string
}
export interface APIKey {
key: string
name: string
created_at: string
last_used_at: string | null
}
export interface AnalyticsSummary {
total_views: number
total_page_views: number
unique_visitors: number
total_bandwidth: number
views_change: number
top_pages: { path: string; views: number }[]
top_referrers: { referrer: string; views: number }[]
views_by_day: { date: string; views: number; visitors: number }[]
browsers: { name: string; count: number }[]
os: { name: string; count: number }[]
devices: { name: string; count: number }[]
countries: { name: string; count: number }[]
}
export type WebhookEvent = 'post.published' | 'post.updated' | 'post.deleted'
export interface Webhook {
id: string
name: string
url: string
events: WebhookEvent[]
secret?: string
enabled: boolean
created_at: string
last_triggered_at: string | null
last_status: 'success' | 'failed' | null
}
export interface WebhookDelivery {
id: number
webhook_id: string
event: string
payload: string
status: 'success' | 'failed'
response_code: number | null
response_body: string | null
attempts: number
created_at: string
}
export type Tier = 'free' | 'pro'
export interface TierConfig {
name: string
description: string
monthly_price: number
annual_price: number
custom_domain: boolean
badge_required: boolean
analytics_retention: number
api_rate_limit: number
max_webhooks: number
webhook_deliveries: number
max_plugins: number
plugin_executions: number
}
export interface Usage {
webhooks: number
plugins: number
}
export interface BillingInfo {
current_tier: Tier
tiers: Record<Tier, TierConfig>
usage: Usage
}

20
studio/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

259
studio/uno.config.ts Normal file
View file

@ -0,0 +1,259 @@
import { defineConfig, presetWind4, presetIcons, presetTypography } from 'unocss'
export default defineConfig({
presets: [
presetWind4(),
presetTypography({
cssExtend: {
':not(pre) > code': {
'font-family': '"SF Mono", "Fira Code", Consolas, monospace',
'font-size': '0.875em',
'background': '#fafafa',
'padding': '0.175rem 0.375rem',
'border-radius': '0.25rem',
},
'p': {
'margin': '1.25rem 0',
'line-height': '1.7',
},
'h1': {
'margin-top': '0',
'margin-bottom': '1.25rem',
'font-size': '2rem',
'font-weight': '700',
'letter-spacing': '-0.025em',
'line-height': '1.2',
},
'h2': {
'margin-top': '2.5rem',
'margin-bottom': '1.25rem',
'font-size': '1.5rem',
'font-weight': '650',
'letter-spacing': '-0.02em',
'line-height': '1.3',
},
'h3': {
'margin-top': '2rem',
'margin-bottom': '1rem',
'font-size': '1.25rem',
'font-weight': '600',
'letter-spacing': '-0.015em',
'line-height': '1.35',
},
'h4': {
'margin-top': '1.625rem',
'margin-bottom': '0.8rem',
'font-size': '1.0625rem',
'font-weight': '600',
'letter-spacing': '-0.01em',
},
'ul': {
'list-style-type': 'disc',
'margin': '1.25rem 0',
'padding-left': '1.375rem',
},
'ol': {
'list-style-type': 'decimal',
'margin': '1.25rem 0',
'padding-left': '1.375rem',
},
'li': {
'margin': '0.5rem 0',
'padding-left': '0.25rem',
},
'li::marker': {
'color': '#71717a',
},
'a': {
'color': '#2563eb',
'text-decoration': 'underline',
'text-underline-offset': '2px',
'text-decoration-color': 'rgba(37, 99, 235, 0.4)',
},
'a:hover': {
'text-decoration-color': '#2563eb',
},
'pre': {
'margin': '1.75rem 0',
'padding': '1.125rem 1.25rem',
'background': '#fafafa',
'border': '1px solid #e4e4e7',
'border-radius': '0.375rem',
'overflow-x': 'auto',
'font-family': '"SF Mono", "Fira Code", Consolas, monospace',
'font-size': '0.875rem',
'line-height': '1.6',
},
'pre code': {
'background': 'transparent',
'padding': '0',
'font-size': 'inherit',
},
'blockquote': {
'margin': '1.75rem 0',
'padding': '0 0 0 1.25rem',
'border-left': '2px solid #e4e4e7',
'color': '#71717a',
'font-style': 'normal',
},
'blockquote p': {
'margin': '0',
},
'hr': {
'border': 'none',
'border-top': '1px solid #e4e4e7',
'margin': '2.5rem 0',
},
'img': {
'max-width': '100%',
'height': 'auto',
'border-radius': '0.375rem',
'margin': '1.75rem 0',
},
'table': {
'width': '100%',
'margin': '1.75rem 0',
'border-collapse': 'collapse',
'font-size': '0.9375rem',
},
'th': {
'padding': '0.625rem 0.75rem',
'border': '1px solid #e4e4e7',
'text-align': 'left',
'background': '#fafafa',
'font-weight': '600',
},
'td': {
'padding': '0.625rem 0.75rem',
'border': '1px solid #e4e4e7',
'text-align': 'left',
},
'strong': {
'font-weight': '600',
},
},
}),
presetIcons({
scale: 1.2,
cdn: 'https://esm.sh/',
extraProperties: {
'display': 'inline-block',
'vertical-align': 'middle',
},
}),
],
rules: [
['animate-shimmer', { animation: 'shimmer 1.5s ease-in-out infinite' }],
],
preflights: [
{
getCSS: () => `
@keyframes shimmer {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ProseMirror editor placeholder */
.ProseMirror p.is-editor-empty:first-child::before {
color: #a3a3a3;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Task list styling */
.prose ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.prose ul[data-type="taskList"] li {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.prose ul[data-type="taskList"] li > label {
flex-shrink: 0;
user-select: none;
display: flex;
align-items: center;
height: 1.5em;
}
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
cursor: pointer;
accent-color: #10b981;
width: 1em;
height: 1em;
margin: 0;
}
.prose ul[data-type="taskList"] li > div {
flex: 1;
}
/* Code block syntax highlighting */
.prose pre .hljs {
background: transparent;
}
`,
},
],
theme: {
colors: {
bg: '#fafafa',
surface: '#ffffff',
text: '#0a0a0a',
muted: '#737373',
border: '#e5e5e5',
accent: '#10b981',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
},
fontFamily: {
sans: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace',
},
},
shortcuts: {
// Buttons - dark bg with light text for primary
'btn': 'inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-medium border border-border transition-all duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
'btn-primary': 'btn bg-text text-bg border-text hover:bg-[#1a1a1a]',
'btn-secondary': 'btn bg-bg text-text hover:border-muted',
'btn-danger': 'btn text-danger border-danger hover:bg-danger hover:text-white hover:border-danger',
'btn-ghost': 'btn border-transparent hover:bg-border',
// Inputs - clean borders, minimal styling
'input': 'w-full px-3 py-2 text-sm bg-bg border border-border font-sans focus:outline-none focus:border-muted transition-colors placeholder:text-muted/60',
'textarea': 'input resize-none',
// Labels - small, muted
'label': 'block text-xs text-muted font-medium mb-1',
// Cards - minimal borders, no shadows
'card': 'bg-surface border border-border p-6',
'section': 'card',
// Navigation - matching old dashboard
'nav-item': 'relative flex items-center gap-2.5 px-2.5 py-2 text-[13px] font-[450] text-muted transition-all duration-150 hover:text-text hover:bg-black/4',
'nav-item-active': 'relative flex items-center gap-2.5 px-2.5 py-2 text-[13px] font-[450] text-text bg-black/4 before:content-[""] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-[3px] before:h-4 before:bg-accent before:rounded-r-sm',
'nav-section': 'text-[10px] uppercase tracking-[0.06em] text-muted font-semibold px-2.5 pt-3 pb-1.5',
// Badges
'badge': 'inline-flex items-center text-xs px-2 py-0.5 border',
'badge-draft': 'badge text-warning border-warning/40 bg-warning/10',
'badge-published': 'badge text-success border-success/40 bg-success/10',
// Page structure
'page-header': 'flex items-center justify-between mb-6',
'page-title': 'text-base font-medium text-text',
},
})

47
studio/vite.config.ts Normal file
View file

@ -0,0 +1,47 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'
export default defineConfig(({ command }) => ({
plugins: [UnoCSS(), react()],
base: '/studio',
build: {
outDir: 'dist',
},
optimizeDeps: {
include: [
// Pre-bundle heavy Tiptap dependencies
'@tiptap/react',
'@tiptap/starter-kit',
'@tiptap/extension-link',
'@tiptap/extension-image',
'@tiptap/extension-placeholder',
'@tiptap/extension-table',
'@tiptap/extension-task-list',
'@tiptap/extension-task-item',
'@tiptap/extension-code-block-lowlight',
'@tiptap/markdown',
'lowlight',
// Monaco editor
'@monaco-editor/react',
],
},
...(command === 'serve' && {
server: {
host: true,
strictPort: true,
allowedHosts: true,
hmr: {
clientPort: 80,
path: '/studio',
},
warmup: {
clientFiles: [
'./src/components/editor/PostEditor.tsx',
'./src/components/editor/SourceEditor.tsx',
'./src/pages/PostEditorPage.tsx',
],
},
},
}),
}))