init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
20
studio/embed.go
Normal file
20
studio/embed.go
Normal 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
16
studio/index.html
Normal 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
3732
studio/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
49
studio/package.json
Normal file
49
studio/package.json
Normal 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
92
studio/src/App.tsx
Normal 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
77
studio/src/api.ts
Normal 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}`),
|
||||
},
|
||||
}
|
||||
198
studio/src/components/editor/MetadataPanel.tsx
Normal file
198
studio/src/components/editor/MetadataPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
722
studio/src/components/editor/PluginEditor.tsx
Normal file
722
studio/src/components/editor/PluginEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
354
studio/src/components/editor/PostEditor.tsx
Normal file
354
studio/src/components/editor/PostEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
270
studio/src/components/editor/SlashCommands.tsx
Normal file
270
studio/src/components/editor/SlashCommands.tsx
Normal 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,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
105
studio/src/components/editor/SourceEditor.tsx
Normal file
105
studio/src/components/editor/SourceEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
studio/src/components/editor/index.ts
Normal file
4
studio/src/components/editor/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { PluginEditor } from './PluginEditor'
|
||||
export { PostEditor } from './PostEditor'
|
||||
export { SourceEditor } from './SourceEditor'
|
||||
export { MetadataPanel } from './MetadataPanel'
|
||||
76
studio/src/components/layout/Header.tsx
Normal file
76
studio/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
studio/src/components/layout/Sidebar.tsx
Normal file
102
studio/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
studio/src/components/layout/index.ts
Normal file
2
studio/src/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Sidebar } from './Sidebar'
|
||||
export { Header } from './Header'
|
||||
45
studio/src/components/shared/Breadcrumb.tsx
Normal file
45
studio/src/components/shared/Breadcrumb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
studio/src/components/shared/BreakdownList.tsx
Normal file
51
studio/src/components/shared/BreakdownList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
studio/src/components/shared/EmptyState.tsx
Normal file
24
studio/src/components/shared/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
studio/src/components/shared/Field.tsx
Normal file
27
studio/src/components/shared/Field.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
316
studio/src/components/shared/Icons.tsx
Normal file
316
studio/src/components/shared/Icons.tsx
Normal 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
|
||||
}
|
||||
9
studio/src/components/shared/LoadingState.tsx
Normal file
9
studio/src/components/shared/LoadingState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
studio/src/components/shared/PageHeader.tsx
Normal file
16
studio/src/components/shared/PageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
studio/src/components/shared/SaveBar.tsx
Normal file
39
studio/src/components/shared/SaveBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
studio/src/components/shared/Section.tsx
Normal file
21
studio/src/components/shared/Section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
596
studio/src/components/shared/Skeleton.tsx
Normal file
596
studio/src/components/shared/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
studio/src/components/shared/StatCard.tsx
Normal file
26
studio/src/components/shared/StatCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
studio/src/components/shared/index.ts
Normal file
23
studio/src/components/shared/index.ts
Normal 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'
|
||||
92
studio/src/components/ui/ActionMenu.tsx
Normal file
92
studio/src/components/ui/ActionMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
studio/src/components/ui/Badge.tsx
Normal file
20
studio/src/components/ui/Badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
studio/src/components/ui/Button.tsx
Normal file
63
studio/src/components/ui/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
studio/src/components/ui/Dropdown.tsx
Normal file
86
studio/src/components/ui/Dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
studio/src/components/ui/Input.tsx
Normal file
50
studio/src/components/ui/Input.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
studio/src/components/ui/Modal.tsx
Normal file
51
studio/src/components/ui/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
studio/src/components/ui/Select.tsx
Normal file
33
studio/src/components/ui/Select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
studio/src/components/ui/Tabs.tsx
Normal file
74
studio/src/components/ui/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
studio/src/components/ui/Toast.tsx
Normal file
33
studio/src/components/ui/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
studio/src/components/ui/Toggle.tsx
Normal file
29
studio/src/components/ui/Toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
studio/src/components/ui/UsageIndicator.tsx
Normal file
42
studio/src/components/ui/UsageIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
studio/src/components/ui/index.ts
Normal file
11
studio/src/components/ui/index.ts
Normal 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
11
studio/src/main.tsx
Normal 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>,
|
||||
)
|
||||
749
studio/src/pages/APIPage.tsx
Normal file
749
studio/src/pages/APIPage.tsx
Normal file
|
|
@ -0,0 +1,749 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { $apiKeys, createAPIKey, $deleteAPIKey, $creating } from '../stores/apiKeys'
|
||||
import { $webhooks, createWebhook, updateWebhook, $deleteWebhook, testWebhook, fetchWebhookDeliveries } from '../stores/webhooks'
|
||||
import { $billing } from '../stores/billing'
|
||||
import { addToast } from '../stores/app'
|
||||
import { EmptyState, APIPageSkeleton, PageHeader } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { Button, Input, Modal, UsageIndicator } from '../components/ui'
|
||||
import type { Webhook, WebhookDelivery, WebhookEvent } from '../types'
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
|
||||
interface QueryParam {
|
||||
name: string
|
||||
description: string
|
||||
default?: string
|
||||
options?: string[] // For select dropdowns
|
||||
}
|
||||
|
||||
interface Endpoint {
|
||||
method: HttpMethod
|
||||
path: string
|
||||
description: string
|
||||
queryParams?: QueryParam[]
|
||||
requestBody?: string
|
||||
responseExample: string
|
||||
}
|
||||
|
||||
const endpoints: Endpoint[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/posts',
|
||||
description: 'List published posts with pagination',
|
||||
queryParams: [
|
||||
{ name: 'limit', description: 'Results per page (max 100)', default: '20' },
|
||||
{ name: 'offset', description: 'Skip N results', default: '0' },
|
||||
{ name: 'tag', description: 'Filter by tag' },
|
||||
{ name: 'include', description: 'Include extra fields', options: ['', 'content'] },
|
||||
],
|
||||
responseExample: `{
|
||||
"posts": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"slug": "hello-world",
|
||||
"title": "Hello World",
|
||||
"description": "My first post",
|
||||
"tags": ["intro"],
|
||||
"date": "2024-01-15",
|
||||
"draft": false
|
||||
}
|
||||
],
|
||||
"total": 42,
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
}`,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/posts',
|
||||
description: 'Create a new post',
|
||||
requestBody: `{
|
||||
"title": "My New Post",
|
||||
"slug": "my-new-post",
|
||||
"content": "# Markdown content",
|
||||
"description": "Optional description",
|
||||
"tags": ["tutorial"],
|
||||
"draft": false
|
||||
}`,
|
||||
responseExample: `{
|
||||
"id": "uuid",
|
||||
"slug": "my-new-post",
|
||||
"title": "My New Post",
|
||||
...
|
||||
}`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/posts/{slug}',
|
||||
description: 'Get a single post by slug (includes content)',
|
||||
responseExample: `{
|
||||
"id": "uuid",
|
||||
"slug": "hello-world",
|
||||
"title": "Hello World",
|
||||
"description": "My first post",
|
||||
"content": {
|
||||
"markdown": "# Full markdown...",
|
||||
"html": "<h1>Full markdown...</h1>"
|
||||
},
|
||||
"tags": ["intro"],
|
||||
"date": "2024-01-15"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/api/v1/posts/{slug}',
|
||||
description: 'Update an existing post',
|
||||
requestBody: `{
|
||||
"title": "Updated Title",
|
||||
"content": "# Updated content"
|
||||
}`,
|
||||
responseExample: `{
|
||||
"id": "uuid",
|
||||
"slug": "hello-world",
|
||||
"title": "Updated Title",
|
||||
...
|
||||
}`,
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/posts/{slug}',
|
||||
description: 'Delete a post permanently',
|
||||
responseExample: `204 No Content`,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/settings',
|
||||
description: 'Get public site configuration',
|
||||
responseExample: `{
|
||||
"site_name": "My Blog",
|
||||
"site_description": "A developer blog",
|
||||
"author_name": "Jane Doe",
|
||||
"author_bio": "Software engineer",
|
||||
"twitter_handle": "janedoe",
|
||||
"accent_color": "#10b981"
|
||||
}`,
|
||||
},
|
||||
]
|
||||
|
||||
const methodColors: Record<HttpMethod, string> = {
|
||||
GET: 'bg-success/15 text-success',
|
||||
POST: 'bg-blue-500/15 text-blue-500',
|
||||
PUT: 'bg-warning/15 text-warning',
|
||||
DELETE: 'bg-danger/15 text-danger',
|
||||
}
|
||||
|
||||
const webhookEvents: { value: WebhookEvent; label: string }[] = [
|
||||
{ value: 'post.published', label: 'Post Published' },
|
||||
{ value: 'post.updated', label: 'Post Updated' },
|
||||
{ value: 'post.deleted', label: 'Post Deleted' },
|
||||
]
|
||||
|
||||
function EndpointCard({ endpoint, baseUrl, apiKey }: { endpoint: Endpoint; baseUrl: string; apiKey?: string }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [slugInput, setSlugInput] = useState('hello-world')
|
||||
const [requestBody, setRequestBody] = useState(endpoint.requestBody || '')
|
||||
const [queryParams, setQueryParams] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {}
|
||||
endpoint.queryParams?.forEach(p => {
|
||||
initial[p.name] = p.default || ''
|
||||
})
|
||||
return initial
|
||||
})
|
||||
const [response, setResponse] = useState<{ status: number; body: string; time: number } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const basePath = endpoint.path.replace('{slug}', slugInput)
|
||||
const queryString = Object.entries(queryParams)
|
||||
.filter(([_, v]) => v !== '')
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&')
|
||||
const actualPath = queryString ? `${basePath}?${queryString}` : basePath
|
||||
const fullUrl = `${baseUrl}${actualPath}`
|
||||
|
||||
const curlExample = endpoint.requestBody
|
||||
? `curl -X ${endpoint.method} "${fullUrl}" \\
|
||||
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${endpoint.requestBody.replace(/\n\s*/g, ' ')}'`
|
||||
: `curl -X ${endpoint.method} "${fullUrl}" \\
|
||||
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}"`
|
||||
|
||||
const copyCode = (code: string) => {
|
||||
navigator.clipboard.writeText(code)
|
||||
addToast('Copied to clipboard', 'success')
|
||||
}
|
||||
|
||||
const updateQueryParam = (name: string, value: string) => {
|
||||
setQueryParams(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const sendRequest = async () => {
|
||||
setLoading(true)
|
||||
setResponse(null)
|
||||
const start = Date.now()
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
const res = await fetch(actualPath, {
|
||||
method: endpoint.method,
|
||||
headers,
|
||||
body: endpoint.requestBody ? requestBody : undefined,
|
||||
})
|
||||
const time = Date.now() - start
|
||||
let body: string
|
||||
try {
|
||||
const json = await res.json()
|
||||
body = JSON.stringify(json, null, 2)
|
||||
} catch {
|
||||
body = res.status === 204 ? '(No Content)' : await res.text()
|
||||
}
|
||||
setResponse({ status: res.status, body, time })
|
||||
} catch (err) {
|
||||
setResponse({ status: 0, body: `Error: ${err instanceof Error ? err.message : 'Request failed'}`, time: Date.now() - start })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border hover:border-muted transition-colors">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="group w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className={`px-2 py-0.5 text-xs font-mono font-medium ${methodColors[endpoint.method]}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-sm font-mono flex-1 group-hover:text-accent transition-colors">{endpoint.path}</code>
|
||||
<span className="text-xs text-muted hidden sm:block">{endpoint.description}</span>
|
||||
<Icons.ChevronRight className={`text-muted transition-transform group-hover:text-text ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border bg-muted/5 p-4 space-y-4">
|
||||
<p className="text-sm text-muted">{endpoint.description}</p>
|
||||
|
||||
{endpoint.path.includes('{slug}') && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted w-16">slug:</label>
|
||||
<Input
|
||||
value={slugInput}
|
||||
onChange={setSlugInput}
|
||||
className="max-w-48 text-sm"
|
||||
placeholder="post-slug"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.queryParams && endpoint.queryParams.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted">Query Parameters</div>
|
||||
{endpoint.queryParams.map(param => (
|
||||
<div key={param.name} className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted w-16 shrink-0">{param.name}:</label>
|
||||
{param.options ? (
|
||||
<select
|
||||
value={queryParams[param.name] || ''}
|
||||
onChange={e => updateQueryParam(param.name, e.target.value)}
|
||||
className="px-2 py-1.5 text-sm bg-surface border border-border focus:border-accent focus:outline-none"
|
||||
>
|
||||
{param.options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt || '(none)'}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
value={queryParams[param.name] || ''}
|
||||
onChange={v => updateQueryParam(param.name, v)}
|
||||
className="max-w-32 text-sm"
|
||||
placeholder={param.default || ''}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted">{param.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endpoint.requestBody && (
|
||||
<div>
|
||||
<div className="text-xs text-muted mb-2">Request Body</div>
|
||||
<textarea
|
||||
value={requestBody}
|
||||
onChange={e => setRequestBody(e.target.value)}
|
||||
className="w-full p-3 bg-surface border border-border text-xs font-mono resize-y min-h-32"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
Icon={loading ? undefined : Icons.Play}
|
||||
onClick={sendRequest}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Sending...' : 'Try it'}
|
||||
</Button>
|
||||
|
||||
{response && (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xs text-muted">Response</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-mono ${response.status >= 200 && response.status < 300 ? 'bg-success/15 text-success' : response.status >= 400 ? 'bg-danger/15 text-danger' : 'bg-warning/15 text-warning'}`}>
|
||||
{response.status || 'Error'}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{response.time}ms</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-surface border border-border text-xs font-mono max-h-64 overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{response.body}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted mb-2">Example Response</div>
|
||||
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
|
||||
{endpoint.responseExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted">cURL</span>
|
||||
<button onClick={() => copyCode(curlExample)} className="text-xs text-muted hover:text-text">
|
||||
<Icons.Copy className="inline mr-1" />Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{curlExample}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebhookCard({ webhook, onEdit, onDelete, onTest }: {
|
||||
webhook: Webhook
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onTest: () => void
|
||||
}) {
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([])
|
||||
const [loadingLogs, setLoadingLogs] = useState(false)
|
||||
|
||||
const loadDeliveries = async () => {
|
||||
setLoadingLogs(true)
|
||||
try {
|
||||
const data = await fetchWebhookDeliveries(webhook.id)
|
||||
setDeliveries(data)
|
||||
setShowLogs(true)
|
||||
} catch {
|
||||
addToast('Failed to load delivery logs', 'error')
|
||||
} finally {
|
||||
setLoadingLogs(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`w-2 h-2 rounded-full ${webhook.enabled ? (webhook.last_status === 'success' ? 'bg-success' : webhook.last_status === 'failed' ? 'bg-danger' : 'bg-muted') : 'bg-muted'}`} />
|
||||
<span className="font-medium text-sm">{webhook.name}</span>
|
||||
{!webhook.enabled && <span className="text-xs text-muted">(disabled)</span>}
|
||||
</div>
|
||||
<code className="text-xs text-muted break-all">{webhook.url}</code>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{webhook.events.map(event => (
|
||||
<span key={event} className="px-1.5 py-0.5 text-xs bg-muted/20 text-muted">
|
||||
{event}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={loadDeliveries} className="p-1.5 text-muted hover:text-text" title="View logs">
|
||||
<Icons.List className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onTest} className="p-1.5 text-muted hover:text-text" title="Test">
|
||||
<Icons.Play className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onEdit} className="p-1.5 text-muted hover:text-text" title="Edit">
|
||||
<Icons.Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onDelete} className="p-1.5 text-danger hover:text-danger/80" title="Delete">
|
||||
<Icons.Trash className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted">Recent Deliveries</span>
|
||||
<button onClick={() => setShowLogs(false)} className="text-xs text-muted hover:text-text">Close</button>
|
||||
</div>
|
||||
{loadingLogs ? (
|
||||
<div className="text-xs text-muted">Loading...</div>
|
||||
) : deliveries.length === 0 ? (
|
||||
<div className="text-xs text-muted">No deliveries yet</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{deliveries.slice(0, 10).map(d => (
|
||||
<div key={d.id} className="flex items-center gap-2 text-xs">
|
||||
<span className={`w-2 h-2 rounded-full ${d.status === 'success' ? 'bg-success' : 'bg-danger'}`} />
|
||||
<span className="text-muted">{d.event}</span>
|
||||
<span className="text-muted">•</span>
|
||||
<span className={d.status === 'success' ? 'text-success' : 'text-danger'}>
|
||||
{d.response_code || d.status}
|
||||
</span>
|
||||
<span className="text-muted ml-auto">
|
||||
{new Date(d.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function APIPage() {
|
||||
const { data: keys } = useStore($apiKeys)
|
||||
const { data: webhooks } = useStore($webhooks)
|
||||
const { data: billing } = useStore($billing)
|
||||
const creating = useStore($creating)
|
||||
const deleteKey = useStore($deleteAPIKey)
|
||||
const deleteWebhookMutation = useStore($deleteWebhook)
|
||||
|
||||
const [showCreateKey, setShowCreateKey] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null)
|
||||
const [deleteKeyModal, setDeleteKeyModal] = useState<string | null>(null)
|
||||
|
||||
const [showWebhookModal, setShowWebhookModal] = useState(false)
|
||||
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null)
|
||||
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as WebhookEvent[], secret: '', enabled: true })
|
||||
const [deleteWebhookModal, setDeleteWebhookModal] = useState<string | null>(null)
|
||||
const [savingWebhook, setSavingWebhook] = useState(false)
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return
|
||||
try {
|
||||
const result = await createAPIKey(newKeyName)
|
||||
setCreatedKey(result.key)
|
||||
setNewKeyName('')
|
||||
setShowCreateKey(false)
|
||||
addToast('API key created', 'success')
|
||||
} catch {
|
||||
addToast('Failed to create API key', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
if (!deleteKeyModal) return
|
||||
try {
|
||||
await deleteKey.mutate(deleteKeyModal)
|
||||
addToast('API key deleted', 'success')
|
||||
setDeleteKeyModal(null)
|
||||
} catch {
|
||||
addToast('Failed to delete API key', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const copyKey = (key: string) => {
|
||||
navigator.clipboard.writeText(key)
|
||||
addToast('Copied to clipboard', 'success')
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const openWebhookModal = (webhook?: Webhook) => {
|
||||
if (webhook) {
|
||||
setEditingWebhook(webhook)
|
||||
setWebhookForm({ name: webhook.name, url: webhook.url, events: webhook.events, secret: webhook.secret || '', enabled: webhook.enabled })
|
||||
} else {
|
||||
setEditingWebhook(null)
|
||||
setWebhookForm({ name: '', url: '', events: [], secret: '', enabled: true })
|
||||
}
|
||||
setShowWebhookModal(true)
|
||||
}
|
||||
|
||||
const handleSaveWebhook = async () => {
|
||||
if (!webhookForm.name || !webhookForm.url || webhookForm.events.length === 0) {
|
||||
addToast('Name, URL, and at least one event are required', 'error')
|
||||
return
|
||||
}
|
||||
setSavingWebhook(true)
|
||||
try {
|
||||
if (editingWebhook) {
|
||||
await updateWebhook(editingWebhook.id, webhookForm)
|
||||
addToast('Webhook updated', 'success')
|
||||
} else {
|
||||
await createWebhook(webhookForm)
|
||||
addToast('Webhook created', 'success')
|
||||
}
|
||||
setShowWebhookModal(false)
|
||||
} catch {
|
||||
addToast('Failed to save webhook', 'error')
|
||||
} finally {
|
||||
setSavingWebhook(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteWebhook = async () => {
|
||||
if (!deleteWebhookModal) return
|
||||
try {
|
||||
await deleteWebhookMutation.mutate(deleteWebhookModal)
|
||||
addToast('Webhook deleted', 'success')
|
||||
setDeleteWebhookModal(null)
|
||||
} catch {
|
||||
addToast('Failed to delete webhook', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestWebhook = async (id: string) => {
|
||||
try {
|
||||
await testWebhook(id)
|
||||
addToast('Test webhook sent', 'success')
|
||||
} catch {
|
||||
addToast('Failed to send test webhook', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEvent = (event: WebhookEvent) => {
|
||||
setWebhookForm(f => ({
|
||||
...f,
|
||||
events: f.events.includes(event) ? f.events.filter(e => e !== event) : [...f.events, event]
|
||||
}))
|
||||
}
|
||||
|
||||
if (!keys) return <APIPageSkeleton />
|
||||
|
||||
const baseUrl = window.location.origin
|
||||
const firstKey = keys.length > 0 ? keys[0].key : undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{createdKey && (
|
||||
<div className="px-6 lg:px-10 py-4 bg-success/10 border-b border-success">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icons.CheckCircle className="text-success" />
|
||||
<span className="text-sm font-medium">API Key Created</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-3">Copy this key now. You won't be able to see it again.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-surface border border-border text-xs font-mono truncate">{createdKey}</code>
|
||||
<Button variant="secondary" Icon={Icons.Copy} onClick={() => copyKey(createdKey)}>Copy</Button>
|
||||
</div>
|
||||
<button onClick={() => setCreatedKey(null)} className="mt-2 text-xs text-muted hover:text-text">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Your API Keys</div>
|
||||
<div className="text-xs text-muted mt-0.5">Use these keys to authenticate API requests</div>
|
||||
</div>
|
||||
<div className="border-t border-border">
|
||||
{keys.length === 0 ? (
|
||||
<div className="px-6 lg:px-10 py-12">
|
||||
<EmptyState
|
||||
Icon={Icons.ApiKeys}
|
||||
title="No API keys"
|
||||
description="Create an API key to access the API"
|
||||
action={<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>Create Key</Button>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
keys.map((key, i) => (
|
||||
<div key={key.key} className={`flex flex-col sm:flex-row sm:items-center gap-4 px-6 lg:px-10 py-4 ${i > 0 ? 'border-t border-border' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{key.name}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted mt-1">
|
||||
<code className="font-mono">{key.key.slice(0, 8)}...{key.key.slice(-4)}</code>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>Created {formatDate(key.created_at)}</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>Last used {formatDate(key.last_used_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" Icon={Icons.Trash} className="text-danger hover:text-danger self-start sm:self-center" onClick={() => setDeleteKeyModal(key.key)}>Delete</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Webhooks */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Webhooks</div>
|
||||
<div className="text-xs text-muted mt-0.5">Get notified when posts are published, updated, or deleted</div>
|
||||
</div>
|
||||
{billing && (
|
||||
<UsageIndicator
|
||||
used={billing.usage.webhooks}
|
||||
max={billing.tiers[billing.current_tier].max_webhooks}
|
||||
label="used"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-3">
|
||||
{webhooks && webhooks.length > 0 ? (
|
||||
webhooks.map(webhook => (
|
||||
<WebhookCard
|
||||
key={webhook.id}
|
||||
webhook={webhook}
|
||||
onEdit={() => openWebhookModal(webhook)}
|
||||
onDelete={() => setDeleteWebhookModal(webhook.id)}
|
||||
onTest={() => handleTestWebhook(webhook.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted">No webhooks configured</div>
|
||||
)}
|
||||
{billing && billing.usage.webhooks >= billing.tiers[billing.current_tier].max_webhooks ? (
|
||||
<div className="flex items-center gap-3 p-3 border border-warning/30 bg-warning/5">
|
||||
<Icons.AlertCircle className="text-warning flex-shrink-0" />
|
||||
<span className="text-sm text-muted">Webhook limit reached.</span>
|
||||
<Button variant="primary" href="/studio/billing">Upgrade</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="secondary" Icon={Icons.Plus} onClick={() => openWebhookModal()}>Add Webhook</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* API Reference */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">API Reference</div>
|
||||
<div className="text-xs text-muted mt-0.5">Base URL: <code className="text-text">{baseUrl}</code></div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-2">
|
||||
{endpoints.map((endpoint) => (
|
||||
<EndpointCard key={`${endpoint.method}-${endpoint.path}`} endpoint={endpoint} baseUrl={baseUrl} apiKey={firstKey} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Authentication</div>
|
||||
<div className="text-xs text-muted mt-0.5">How to authenticate your requests</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<p className="text-sm text-muted mb-3">All API requests require a Bearer token in the Authorization header:</p>
|
||||
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
|
||||
Authorization: Bearer {firstKey ? `${firstKey.slice(0, 8)}...` : 'YOUR_API_KEY'}
|
||||
</pre>
|
||||
<p className="text-xs text-muted mt-3">Keep API keys secure and never expose them in client-side code.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Key Modal */}
|
||||
<Modal open={showCreateKey} onClose={() => setShowCreateKey(false)} title="Create API Key">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="label">Key Name</label>
|
||||
<Input value={newKeyName} onChange={setNewKeyName} placeholder="My API Key" />
|
||||
<p className="text-xs text-muted">A name to identify this key</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowCreateKey(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleCreateKey} loading={creating} disabled={!newKeyName.trim()}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Key Modal */}
|
||||
<Modal open={!!deleteKeyModal} onClose={() => setDeleteKeyModal(null)} title="Delete API Key">
|
||||
<p className="text-sm text-muted mb-4">Are you sure? Applications using this key will stop working.</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setDeleteKeyModal(null)}>Cancel</Button>
|
||||
<Button variant="danger" onClick={handleDeleteKey} loading={deleteKey.loading}>Delete</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Webhook Modal */}
|
||||
<Modal open={showWebhookModal} onClose={() => setShowWebhookModal(false)} title={editingWebhook ? 'Edit Webhook' : 'Add Webhook'}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="label">Name</label>
|
||||
<Input value={webhookForm.name} onChange={v => setWebhookForm(f => ({ ...f, name: v }))} placeholder="Discord Notification" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">URL</label>
|
||||
<Input value={webhookForm.url} onChange={v => setWebhookForm(f => ({ ...f, url: v }))} placeholder="https://your-server.com/webhook" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="label">Events</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{webhookEvents.map(e => (
|
||||
<button
|
||||
key={e.value}
|
||||
onClick={() => toggleEvent(e.value)}
|
||||
className={`px-3 py-1.5 text-xs border transition-colors ${webhookForm.events.includes(e.value) ? 'border-accent bg-accent/10 text-accent' : 'border-border text-muted hover:border-muted'}`}
|
||||
>
|
||||
{e.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">Secret (optional)</label>
|
||||
<Input value={webhookForm.secret} onChange={v => setWebhookForm(f => ({ ...f, secret: v }))} placeholder="For HMAC signature verification" />
|
||||
<p className="text-xs text-muted">Used to sign payloads with X-WriteKit-Signature header</p>
|
||||
</div>
|
||||
{editingWebhook && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={webhookForm.enabled} onChange={e => setWebhookForm(f => ({ ...f, enabled: e.target.checked }))} />
|
||||
Enabled
|
||||
</label>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowWebhookModal(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSaveWebhook} loading={savingWebhook}>{editingWebhook ? 'Save' : 'Create'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Webhook Modal */}
|
||||
<Modal open={!!deleteWebhookModal} onClose={() => setDeleteWebhookModal(null)} title="Delete Webhook">
|
||||
<p className="text-sm text-muted mb-4">Are you sure you want to delete this webhook?</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setDeleteWebhookModal(null)}>Cancel</Button>
|
||||
<Button variant="danger" onClick={handleDeleteWebhook} loading={deleteWebhookMutation.loading}>Delete</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
318
studio/src/pages/AnalyticsPage.tsx
Normal file
318
studio/src/pages/AnalyticsPage.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip } from 'chart.js'
|
||||
import { $analytics, $days } from '../stores/analytics'
|
||||
import { BreakdownList, AnalyticsPageSkeleton, EmptyState, PageHeader } from '../components/shared'
|
||||
import { Tabs } from '../components/ui'
|
||||
import { Icons, getReferrerIcon, getCountryFlagUrl, getBrowserIcon, getDeviceIcon, getOSIcon } from '../components/shared/Icons'
|
||||
|
||||
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip)
|
||||
|
||||
const periodTabs = [
|
||||
{ value: '7', label: '7d' },
|
||||
{ value: '30', label: '30d' },
|
||||
{ value: '90', label: '90d' },
|
||||
]
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatChange(change: number): { text: string; positive: boolean } {
|
||||
const sign = change >= 0 ? '+' : ''
|
||||
return {
|
||||
text: `${sign}${change.toFixed(1)}%`,
|
||||
positive: change >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const { data, error } = useStore($analytics)
|
||||
const days = useStore($days)
|
||||
const chartRef = useRef<HTMLCanvasElement>(null)
|
||||
const chartInstance = useRef<Chart | null>(null)
|
||||
const prevDataRef = useRef(data)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.views_by_day?.length || !chartRef.current) return
|
||||
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy()
|
||||
}
|
||||
|
||||
const ctx = chartRef.current.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const sortedData = [...data.views_by_day].sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
const fontFamily = '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace'
|
||||
|
||||
chartInstance.current = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: sortedData.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })),
|
||||
datasets: [{
|
||||
data: sortedData.map(d => d.views),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.08)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#10b981',
|
||||
pointBorderColor: '#10b981',
|
||||
pointHoverRadius: 6,
|
||||
pointHoverBackgroundColor: '#10b981',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
titleColor: '#fafafa',
|
||||
bodyColor: '#fafafa',
|
||||
titleFont: { family: fontFamily, size: 11 },
|
||||
bodyFont: { family: fontFamily, size: 12 },
|
||||
padding: 10,
|
||||
cornerRadius: 0,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (ctx) => `${(ctx.parsed.y ?? 0).toLocaleString()} views`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: '#737373',
|
||||
font: { family: fontFamily, size: 10 },
|
||||
padding: 8,
|
||||
maxRotation: 0,
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: { color: '#e5e5e5' },
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: '#737373',
|
||||
font: { family: fontFamily, size: 10 },
|
||||
padding: 12,
|
||||
},
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy()
|
||||
}
|
||||
}
|
||||
}, [data])
|
||||
|
||||
// Keep previous data while loading to prevent flickering
|
||||
if (data) {
|
||||
prevDataRef.current = data
|
||||
}
|
||||
const displayData = data || prevDataRef.current
|
||||
|
||||
if (error && !displayData) return <EmptyState Icon={Icons.AlertCircle} title="Failed to load analytics" description={error.message} />
|
||||
if (!displayData) return <AnalyticsPageSkeleton />
|
||||
|
||||
const change = formatChange(displayData.views_change)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Tabs
|
||||
value={String(days)}
|
||||
onChange={(v) => $days.set(Number(v))}
|
||||
tabs={periodTabs}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Stats row - 4 columns on lg, 2 on mobile */}
|
||||
<div className="relative">
|
||||
{/* Vertical dividers at column boundaries (lg: 4 cols, mobile: 2 cols) */}
|
||||
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border lg:hidden" />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '25%' }} />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '50%' }} />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '75%' }} />
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4">
|
||||
<div className="py-5 pl-6 lg:pl-10 pr-6">
|
||||
<div className="text-xs text-muted mb-1">Total Views</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{displayData.total_views.toLocaleString()}</div>
|
||||
<div className={`text-xs mt-1 ${change.positive ? 'text-success' : 'text-danger'}`}>
|
||||
{change.text} vs last period
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-5 px-6 lg:pr-6">
|
||||
<div className="text-xs text-muted mb-1">Page Views</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{displayData.total_page_views.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="py-5 px-6 lg:pl-6 border-t border-border lg:border-t-0">
|
||||
<div className="text-xs text-muted mb-1">Unique Visitors</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{displayData.unique_visitors.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="py-5 pr-6 lg:pr-10 pl-6 border-t border-border lg:border-t-0">
|
||||
<div className="text-xs text-muted mb-1">Bandwidth</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{formatBytes(displayData.total_bandwidth)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal divider */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Chart section */}
|
||||
{displayData.views_by_day.length > 0 && (
|
||||
<>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Views Over Time</div>
|
||||
<div className="h-48">
|
||||
<canvas ref={chartRef} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Breakdown sections - 2 columns */}
|
||||
<div className="relative">
|
||||
{/* Vertical divider at center on lg */}
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
|
||||
<div className="grid lg:grid-cols-2">
|
||||
{/* Top Pages */}
|
||||
{displayData.top_pages.length > 0 && (
|
||||
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Pages</div>
|
||||
<BreakdownList
|
||||
items={displayData.top_pages.map(p => ({
|
||||
label: p.path,
|
||||
value: p.views,
|
||||
percentage: (p.views / displayData.total_page_views) * 100,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Referrers */}
|
||||
{displayData.top_referrers.length > 0 && (
|
||||
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Referrers</div>
|
||||
<BreakdownList
|
||||
items={displayData.top_referrers.map(r => {
|
||||
const label = r.referrer || 'Direct'
|
||||
return {
|
||||
label,
|
||||
value: r.views,
|
||||
percentage: (r.views / displayData.total_views) * 100,
|
||||
Icon: getReferrerIcon(label),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Browsers & Devices */}
|
||||
{(displayData.browsers.length > 0 || displayData.devices.length > 0) && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="relative">
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
<div className="grid lg:grid-cols-2">
|
||||
{displayData.browsers.length > 0 && (
|
||||
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Browsers</div>
|
||||
<BreakdownList
|
||||
items={displayData.browsers.map(b => ({
|
||||
label: b.name,
|
||||
value: b.count,
|
||||
percentage: (b.count / displayData.unique_visitors) * 100,
|
||||
Icon: getBrowserIcon(b.name) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayData.devices.length > 0 && (
|
||||
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Devices</div>
|
||||
<BreakdownList
|
||||
items={displayData.devices.map(d => ({
|
||||
label: d.name,
|
||||
value: d.count,
|
||||
percentage: (d.count / displayData.unique_visitors) * 100,
|
||||
Icon: getDeviceIcon(d.name) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Row 3: Countries & OS */}
|
||||
{(displayData.countries.length > 0 || displayData.os.length > 0) && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
<div className="relative">
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
<div className="grid lg:grid-cols-2">
|
||||
{displayData.countries.length > 0 && (
|
||||
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Countries</div>
|
||||
<BreakdownList
|
||||
items={displayData.countries.map(c => ({
|
||||
label: c.name,
|
||||
value: c.count,
|
||||
percentage: (c.count / displayData.unique_visitors) * 100,
|
||||
flagUrl: getCountryFlagUrl(c.name, 40) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayData.os.length > 0 && (
|
||||
<div className="py-6 pr-6 lg:pr-10 pl-6">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Operating Systems</div>
|
||||
<BreakdownList
|
||||
items={displayData.os.map(o => ({
|
||||
label: o.name,
|
||||
value: o.count,
|
||||
percentage: (o.count / displayData.unique_visitors) * 100,
|
||||
Icon: getOSIcon(o.name) || undefined,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
studio/src/pages/BillingPage.tsx
Normal file
251
studio/src/pages/BillingPage.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { PageHeader, BillingPageSkeleton } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { Button } from '../components/ui'
|
||||
import { $billing } from '../stores/billing'
|
||||
import type { Tier, TierConfig } from '../types'
|
||||
|
||||
type BillingCycle = 'monthly' | 'annual'
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(0)}`
|
||||
}
|
||||
|
||||
function getFeatureList(config: TierConfig, tier: Tier): { name: string; included: boolean }[] {
|
||||
if (tier === 'free') {
|
||||
return [
|
||||
{ name: 'Unlimited posts', included: true },
|
||||
{ name: 'Comments & reactions', included: true },
|
||||
{ name: 'writekit.dev subdomain', included: true },
|
||||
{ name: `${config.analytics_retention}-day analytics`, included: true },
|
||||
{ name: `API access (${config.api_rate_limit} req/hr)`, included: true },
|
||||
{ name: `${config.max_webhooks} webhooks`, included: true },
|
||||
{ name: `${config.max_plugins} plugins`, included: true },
|
||||
{ name: 'Custom domain', included: false },
|
||||
{ name: 'Remove "Powered by" badge', included: false },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ name: 'Unlimited posts', included: true },
|
||||
{ name: 'Comments & reactions', included: true },
|
||||
{ name: 'writekit.dev subdomain', included: true },
|
||||
{ name: 'Custom domain', included: true },
|
||||
{ name: 'No "Powered by" badge', included: true },
|
||||
{ name: `${config.analytics_retention}-day analytics`, included: true },
|
||||
{ name: `API access (${config.api_rate_limit} req/hr)`, included: true },
|
||||
{ name: `${config.max_webhooks} webhooks`, included: true },
|
||||
{ name: `${config.max_plugins} plugins`, included: true },
|
||||
{ name: 'Priority support', included: true },
|
||||
]
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const { data: billing } = useStore($billing)
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>('annual')
|
||||
|
||||
if (!billing) return <BillingPageSkeleton />
|
||||
|
||||
const currentTier = billing.current_tier
|
||||
const currentConfig = billing.tiers[currentTier]
|
||||
const proConfig = billing.tiers.pro
|
||||
const proFeatures = getFeatureList(proConfig, 'pro')
|
||||
|
||||
const annualSavings = (proConfig.monthly_price * 12 - proConfig.annual_price) / 100
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Current Plan */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Current Plan</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 pb-6">
|
||||
<div className="p-4 border border-accent bg-accent/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-medium">{currentConfig.name}</span>
|
||||
<span className="px-2 py-0.5 bg-accent/20 text-accent text-xs font-medium">Active</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted mt-1">{currentConfig.description}</div>
|
||||
</div>
|
||||
{currentTier === 'free' && (
|
||||
<Button variant="primary" href="#upgrade">Upgrade to Pro</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Upgrade Section */}
|
||||
{currentTier === 'free' && (
|
||||
<>
|
||||
<div id="upgrade" className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Upgrade to Pro</div>
|
||||
<div className="text-xs text-muted mt-0.5">Get custom domain, extended analytics, and more</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 lg:px-10 pb-6">
|
||||
{/* Billing Toggle */}
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
billingCycle === 'monthly' ? 'text-text' : 'text-muted hover:text-text'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('annual')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||
billingCycle === 'annual' ? 'text-text' : 'text-muted hover:text-text'
|
||||
}`}
|
||||
>
|
||||
Annual
|
||||
<span className="px-1.5 py-0.5 bg-success/20 text-success text-xs">
|
||||
Save ${annualSavings}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan Card */}
|
||||
<div className="max-w-md mx-auto p-6 border border-border">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-sm font-medium text-muted mb-2">Pro</div>
|
||||
<div className="text-4xl font-semibold tracking-tight">
|
||||
{formatPrice(billingCycle === 'monthly' ? proConfig.monthly_price : proConfig.annual_price)}
|
||||
<span className="text-base font-normal text-muted">
|
||||
/{billingCycle === 'monthly' ? 'mo' : 'yr'}
|
||||
</span>
|
||||
</div>
|
||||
{billingCycle === 'annual' && (
|
||||
<div className="text-xs text-muted mt-1">
|
||||
{formatPrice(Math.round(proConfig.annual_price / 12))}/mo billed annually
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-6">
|
||||
{proFeatures.map((feature) => (
|
||||
<li key={feature.name} className="flex items-center gap-2 text-sm">
|
||||
<Icons.Check className="text-success flex-shrink-0" />
|
||||
<span>{feature.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button variant="primary" className="w-full">
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted text-center mt-3">
|
||||
Secure payment via Lemon Squeezy. Cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Feature Comparison */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Feature Comparison</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 pb-6">
|
||||
<div className="border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/5">
|
||||
<th className="text-left px-4 py-3 font-medium">Feature</th>
|
||||
<th className="text-center px-4 py-3 font-medium w-24">Free</th>
|
||||
<th className="text-center px-4 py-3 font-medium w-24">Pro</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border">
|
||||
<td className="px-4 py-3">Custom domain</td>
|
||||
<td className="text-center px-4 py-3"><Icons.Close className="text-muted/50 inline" /></td>
|
||||
<td className="text-center px-4 py-3"><Icons.Check className="text-success inline" /></td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="px-4 py-3">"Powered by WriteKit" badge</td>
|
||||
<td className="text-center px-4 py-3 text-muted">Required</td>
|
||||
<td className="text-center px-4 py-3 text-muted">Hidden</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="px-4 py-3">Analytics retention</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.analytics_retention} days</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.analytics_retention} days</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="px-4 py-3">API rate limit</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.api_rate_limit}/hr</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.api_rate_limit}/hr</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="px-4 py-3">Webhooks</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.max_webhooks}</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.max_webhooks}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="px-4 py-3">Plugins</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.free.max_plugins}</td>
|
||||
<td className="text-center px-4 py-3 text-muted">{billing.tiers.pro.max_plugins}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="px-4 py-3">Posts</td>
|
||||
<td className="text-center px-4 py-3 text-muted">Unlimited</td>
|
||||
<td className="text-center px-4 py-3 text-muted">Unlimited</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">Support</td>
|
||||
<td className="text-center px-4 py-3 text-muted">Community</td>
|
||||
<td className="text-center px-4 py-3 text-muted">Priority</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Questions</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 pb-6 space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Can I cancel anytime?</div>
|
||||
<div className="text-sm text-muted">
|
||||
Yes. Cancel anytime and keep access until the end of your billing period.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Can I switch from monthly to annual?</div>
|
||||
<div className="text-sm text-muted">
|
||||
Yes. Switch anytime and we'll prorate your payment.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">What happens to my content if I downgrade?</div>
|
||||
<div className="text-sm text-muted">
|
||||
Your content stays. Custom domain will stop working, badge will appear, and analytics older than 7 days won't be accessible.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Can I export my data?</div>
|
||||
<div className="text-sm text-muted">
|
||||
Yes. Export all your posts, settings, and assets anytime from the Data page. Your data is always yours.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
studio/src/pages/DataPage.tsx
Normal file
106
studio/src/pages/DataPage.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { PageHeader } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { Button } from '../components/ui'
|
||||
|
||||
export default function DataPage() {
|
||||
const handleExport = () => {
|
||||
window.location.href = '/api/studio/export'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Import */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Import</div>
|
||||
<div className="text-xs text-muted mt-0.5">Import posts from other platforms</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-4">
|
||||
<div className="p-4 border border-dashed border-border text-center">
|
||||
<Icons.Upload className="text-2xl text-muted mb-2" />
|
||||
<p className="text-sm font-medium mb-1">Upload files</p>
|
||||
<p className="text-xs text-muted mb-3">
|
||||
Supports Markdown files or JSON exports
|
||||
</p>
|
||||
<Button variant="secondary" Icon={Icons.Upload}>
|
||||
Choose Files
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icons.Ghost className="text-muted" />
|
||||
<span className="text-sm font-medium">Ghost</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted">Import from Ghost JSON export</p>
|
||||
</button>
|
||||
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icons.PenTool className="text-muted" />
|
||||
<span className="text-sm font-medium">Substack</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted">Import from Substack export</p>
|
||||
</button>
|
||||
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icons.Posts className="text-muted" />
|
||||
<span className="text-sm font-medium">Markdown</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted">Import Markdown files</p>
|
||||
</button>
|
||||
<button className="p-3 border border-border text-left hover:border-muted transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icons.WordPress className="text-muted" />
|
||||
<span className="text-sm font-medium">WordPress</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted">Import from WordPress XML</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Export */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Export</div>
|
||||
<div className="text-xs text-muted mt-0.5">Download all your blog data</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-3">
|
||||
<p className="text-sm text-muted">
|
||||
Export all your posts, settings, and assets as a ZIP file. This includes:
|
||||
</p>
|
||||
<ul className="text-sm text-muted space-y-1 list-disc list-inside">
|
||||
<li>All posts as Markdown files</li>
|
||||
<li>Settings as JSON</li>
|
||||
<li>Uploaded images and assets</li>
|
||||
</ul>
|
||||
<Button variant="secondary" Icon={Icons.Download} onClick={handleExport}>
|
||||
Export All Data
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Danger Zone</div>
|
||||
<div className="text-xs text-muted mt-0.5">Irreversible actions</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="p-4 border border-danger/30 bg-danger/5">
|
||||
<h4 className="text-sm font-medium text-danger mb-2">Delete All Content</h4>
|
||||
<p className="text-xs text-muted mb-3">
|
||||
Permanently delete all posts and assets. This cannot be undone.
|
||||
</p>
|
||||
<Button variant="danger" Icon={Icons.Trash}>
|
||||
Delete All Content
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
236
studio/src/pages/DesignPage.preview.css
Normal file
236
studio/src/pages/DesignPage.preview.css
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/* Blog Preview Styles - mirrors writekit/internal/build/assets/css/style.css */
|
||||
|
||||
.blog-preview {
|
||||
/* Base colors - light mode */
|
||||
--text: #18181b;
|
||||
--text-muted: #71717a;
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #fafafa;
|
||||
--border: #e4e4e7;
|
||||
|
||||
/* Defaults (cozy) */
|
||||
--content-spacing: 1.75rem;
|
||||
--paragraph-spacing: 1.25rem;
|
||||
--heading-spacing: 2.5rem;
|
||||
--line-height: 1.7;
|
||||
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Compactness: Compact */
|
||||
.blog-preview.compactness-compact {
|
||||
--content-spacing: 1.25rem;
|
||||
--paragraph-spacing: 0.875rem;
|
||||
--heading-spacing: 1.75rem;
|
||||
--line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Compactness: Cozy (default) */
|
||||
.blog-preview.compactness-cozy {
|
||||
--content-spacing: 1.75rem;
|
||||
--paragraph-spacing: 1.25rem;
|
||||
--heading-spacing: 2.5rem;
|
||||
--line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Compactness: Spacious */
|
||||
.blog-preview.compactness-spacious {
|
||||
--content-spacing: 2.25rem;
|
||||
--paragraph-spacing: 1.5rem;
|
||||
--heading-spacing: 3rem;
|
||||
--line-height: 1.85;
|
||||
}
|
||||
|
||||
/* Preview Chrome (browser frame) */
|
||||
.preview-chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview-chrome-dots {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.preview-chrome-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.preview-chrome-dot.red { background: #ef4444; }
|
||||
.preview-chrome-dot.yellow { background: #eab308; }
|
||||
.preview-chrome-dot.green { background: #22c55e; }
|
||||
|
||||
.preview-chrome-url {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
.preview-site-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.preview-nav {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.preview-nav a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.preview-nav a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Layout: Minimal */
|
||||
.blog-preview.layout-minimal .preview-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.blog-preview.layout-minimal .preview-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blog-preview.layout-minimal .preview-footer {
|
||||
border-top: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Layout: Magazine */
|
||||
.blog-preview.layout-magazine .preview-posts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.625rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.blog-preview.layout-magazine .preview-post-card {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.blog-preview.layout-magazine .preview-post-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Post Content */
|
||||
.preview-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.preview-date {
|
||||
font-size: 0.5625rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.375rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.preview-description {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--paragraph-spacing);
|
||||
}
|
||||
|
||||
/* Prose styling */
|
||||
.preview-prose {
|
||||
line-height: var(--line-height);
|
||||
}
|
||||
|
||||
.preview-prose p {
|
||||
margin: 0 0 var(--paragraph-spacing);
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.preview-code {
|
||||
margin: var(--content-spacing) 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 0.5625rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Tags - matches real blog: subtle background */
|
||||
.preview-tags {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin-top: var(--paragraph-spacing);
|
||||
}
|
||||
|
||||
.preview-tag {
|
||||
font-size: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.preview-footer {
|
||||
padding: 0.625rem 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Magazine post card (simplified) */
|
||||
.preview-post-card .preview-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.preview-post-card .preview-date {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-post-card .preview-description {
|
||||
font-size: 0.5rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.55;
|
||||
}
|
||||
474
studio/src/pages/DesignPage.tsx
Normal file
474
studio/src/pages/DesignPage.tsx
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
|
||||
import { addToast } from '../stores/app'
|
||||
import { SaveBar, DesignPageSkeleton } from '../components/shared'
|
||||
import './DesignPage.preview.css'
|
||||
|
||||
const fontConfigs = {
|
||||
system: { family: 'system-ui, -apple-system, sans-serif', url: '' },
|
||||
inter: { family: "'Inter', sans-serif", url: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap' },
|
||||
georgia: { family: 'Georgia, serif', url: '' },
|
||||
merriweather: { family: "'Merriweather', serif", url: 'https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap' },
|
||||
'source-serif': { family: "'Source Serif 4', serif", url: 'https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;600&display=swap' },
|
||||
'jetbrains-mono': { family: "'JetBrains Mono', monospace", url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap' },
|
||||
}
|
||||
|
||||
const themePreviewColors: Record<string, { label: string; bg: string; text: string; keyword: string; string: string; comment: string }> = {
|
||||
github: { label: 'GitHub Light', bg: '#f6f8fa', text: '#24292e', keyword: '#d73a49', string: '#032f62', comment: '#6a737d' },
|
||||
'github-dark': { label: 'GitHub Dark', bg: '#0d1117', text: '#c9d1d9', keyword: '#ff7b72', string: '#a5d6ff', comment: '#8b949e' },
|
||||
vs: { label: 'VS Light', bg: '#ffffff', text: '#000000', keyword: '#0000ff', string: '#a31515', comment: '#008000' },
|
||||
xcode: { label: 'Xcode Light', bg: '#ffffff', text: '#000000', keyword: '#aa0d91', string: '#c41a16', comment: '#007400' },
|
||||
'xcode-dark': { label: 'Xcode Dark', bg: '#1f1f24', text: '#ffffff', keyword: '#fc5fa3', string: '#fc6a5d', comment: '#6c7986' },
|
||||
'solarized-light': { label: 'Solarized Light', bg: '#fdf6e3', text: '#657b83', keyword: '#859900', string: '#2aa198', comment: '#93a1a1' },
|
||||
'solarized-dark': { label: 'Solarized Dark', bg: '#002b36', text: '#839496', keyword: '#859900', string: '#2aa198', comment: '#586e75' },
|
||||
'gruvbox-light': { label: 'Gruvbox Light', bg: '#fbf1c7', text: '#3c3836', keyword: '#9d0006', string: '#79740e', comment: '#928374' },
|
||||
gruvbox: { label: 'Gruvbox Dark', bg: '#282828', text: '#ebdbb2', keyword: '#fb4934', string: '#b8bb26', comment: '#928374' },
|
||||
nord: { label: 'Nord', bg: '#2e3440', text: '#d8dee9', keyword: '#81a1c1', string: '#a3be8c', comment: '#616e88' },
|
||||
onedark: { label: 'One Dark', bg: '#282c34', text: '#abb2bf', keyword: '#c678dd', string: '#98c379', comment: '#5c6370' },
|
||||
dracula: { label: 'Dracula', bg: '#282a36', text: '#f8f8f2', keyword: '#ff79c6', string: '#f1fa8c', comment: '#6272a4' },
|
||||
monokai: { label: 'Monokai', bg: '#272822', text: '#f8f8f2', keyword: '#f92672', string: '#e6db74', comment: '#75715e' },
|
||||
}
|
||||
|
||||
const defaultDarkColors = { bg: '#1e1e1e', text: '#d4d4d4', keyword: '#569cd6', string: '#ce9178', comment: '#6a9955' }
|
||||
const defaultLightColors = { bg: '#ffffff', text: '#000000', keyword: '#0000ff', string: '#a31515', comment: '#008000' }
|
||||
|
||||
function getThemeColors(theme: string) {
|
||||
if (themePreviewColors[theme]) return themePreviewColors[theme]
|
||||
const isDark = theme.includes('dark') || ['monokai', 'dracula', 'nord', 'gruvbox', 'onedark', 'vim', 'emacs'].some(d => theme.includes(d))
|
||||
return { label: theme, ...(isDark ? defaultDarkColors : defaultLightColors) }
|
||||
}
|
||||
|
||||
function formatThemeLabel(theme: string): string {
|
||||
if (themePreviewColors[theme]) return themePreviewColors[theme].label
|
||||
return theme.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
const fonts = [
|
||||
{ value: 'system', label: 'System Default' },
|
||||
{ value: 'inter', label: 'Inter' },
|
||||
{ value: 'georgia', label: 'Georgia' },
|
||||
{ value: 'merriweather', label: 'Merriweather' },
|
||||
{ value: 'source-serif', label: 'Source Serif' },
|
||||
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||||
]
|
||||
|
||||
|
||||
const layouts = [
|
||||
{ value: 'default', label: 'Classic' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'magazine', label: 'Magazine' },
|
||||
]
|
||||
|
||||
function useFontLoader(fontKey: string) {
|
||||
useEffect(() => {
|
||||
const config = fontConfigs[fontKey as keyof typeof fontConfigs]
|
||||
if (!config?.url) return
|
||||
|
||||
const existing = document.querySelector(`link[href="${config.url}"]`)
|
||||
if (existing) return
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = config.url
|
||||
document.head.appendChild(link)
|
||||
}, [fontKey])
|
||||
}
|
||||
|
||||
function CodePreview({ theme }: { theme: string }) {
|
||||
const colors = getThemeColors(theme)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3 text-xs font-mono overflow-hidden"
|
||||
style={{ background: colors.bg, color: colors.text }}
|
||||
>
|
||||
<div><span style={{ color: colors.keyword }}>function</span> greet(name) {'{'}</div>
|
||||
<div className="pl-4"><span style={{ color: colors.keyword }}>return</span> <span style={{ color: colors.string }}>`Hello, ${'{'}name{'}'}`</span></div>
|
||||
<div>{'}'}</div>
|
||||
<div style={{ color: colors.comment }}>// Welcome to your blog</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutPreview({ layout, selected }: { layout: string; selected: boolean }) {
|
||||
const accent = selected ? 'var(--color-accent)' : 'var(--color-border)'
|
||||
const mutedBar = selected ? 'var(--color-accent)' : 'var(--color-muted)'
|
||||
|
||||
if (layout === 'minimal') {
|
||||
return (
|
||||
<svg viewBox="0 0 120 80" className="w-full h-auto">
|
||||
<rect x="45" y="8" width="30" height="4" rx="1" fill={mutedBar} opacity="0.5" />
|
||||
<rect x="30" y="20" width="60" height="6" rx="1" fill={accent} />
|
||||
<rect x="20" y="32" width="80" height="3" rx="1" fill={mutedBar} opacity="0.3" />
|
||||
<rect x="25" y="40" width="70" height="3" rx="1" fill={mutedBar} opacity="0.3" />
|
||||
<rect x="30" y="48" width="60" height="3" rx="1" fill={mutedBar} opacity="0.3" />
|
||||
<rect x="50" y="64" width="20" height="3" rx="1" fill={mutedBar} opacity="0.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'magazine') {
|
||||
return (
|
||||
<svg viewBox="0 0 120 80" className="w-full h-auto">
|
||||
<rect x="8" y="8" width="30" height="4" rx="1" fill={mutedBar} opacity="0.5" />
|
||||
<rect x="8" y="20" width="32" height="24" rx="2" fill={accent} opacity="0.3" />
|
||||
<rect x="44" y="20" width="32" height="24" rx="2" fill={accent} opacity="0.3" />
|
||||
<rect x="80" y="20" width="32" height="24" rx="2" fill={accent} opacity="0.3" />
|
||||
<rect x="8" y="48" width="28" height="3" rx="1" fill={mutedBar} opacity="0.4" />
|
||||
<rect x="44" y="48" width="28" height="3" rx="1" fill={mutedBar} opacity="0.4" />
|
||||
<rect x="80" y="48" width="28" height="3" rx="1" fill={mutedBar} opacity="0.4" />
|
||||
<rect x="8" y="54" width="20" height="2" rx="1" fill={mutedBar} opacity="0.2" />
|
||||
<rect x="44" y="54" width="20" height="2" rx="1" fill={mutedBar} opacity="0.2" />
|
||||
<rect x="80" y="54" width="20" height="2" rx="1" fill={mutedBar} opacity="0.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Default layout
|
||||
return (
|
||||
<svg viewBox="0 0 120 80" className="w-full h-auto">
|
||||
<rect x="8" y="8" width="30" height="4" rx="1" fill={mutedBar} opacity="0.5" />
|
||||
<rect x="85" y="8" width="27" height="4" rx="1" fill={mutedBar} opacity="0.3" />
|
||||
<rect x="8" y="24" width="70" height="6" rx="1" fill={accent} />
|
||||
<rect x="8" y="36" width="90" height="3" rx="1" fill={mutedBar} opacity="0.3" />
|
||||
<rect x="8" y="44" width="85" height="3" rx="1" fill={mutedBar} opacity="0.3" />
|
||||
<rect x="8" y="52" width="75" height="3" rx="1" fill={mutedBar} opacity="0.3" />
|
||||
<rect x="8" y="64" width="50" height="3" rx="1" fill={mutedBar} opacity="0.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewCodeBlock({ theme }: { theme: string }) {
|
||||
const colors = getThemeColors(theme)
|
||||
return (
|
||||
<div className="preview-code" style={{ background: colors.bg, color: colors.text }}>
|
||||
<div><span style={{ color: colors.keyword }}>const</span> api = <span style={{ color: colors.keyword }}>await</span> fetch(<span style={{ color: colors.string }}>'/posts'</span>)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewPostCard() {
|
||||
return (
|
||||
<div className="preview-post-card">
|
||||
<div className="preview-date">Jan 15, 2024</div>
|
||||
<h3 className="preview-title">Building APIs</h3>
|
||||
<p className="preview-description">A deep dive into REST patterns and best practices.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LivePreview({ settings }: { settings: Record<string, string> }) {
|
||||
const fontKey = settings.font || 'system'
|
||||
const fontConfig = fontConfigs[fontKey as keyof typeof fontConfigs] || fontConfigs.system
|
||||
const codeTheme = settings.code_theme || 'github'
|
||||
const accent = settings.accent_color || '#10b981'
|
||||
const layout = settings.layout || 'default'
|
||||
const compactness = settings.compactness || 'cozy'
|
||||
|
||||
useFontLoader(fontKey)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`blog-preview layout-${layout} compactness-${compactness} border border-border`}
|
||||
style={{ '--accent': accent, '--font-body': fontConfig.family } as React.CSSProperties}
|
||||
>
|
||||
{/* Browser chrome */}
|
||||
<div className="preview-chrome">
|
||||
<div className="preview-chrome-dots">
|
||||
<div className="preview-chrome-dot red" />
|
||||
<div className="preview-chrome-dot yellow" />
|
||||
<div className="preview-chrome-dot green" />
|
||||
</div>
|
||||
<span className="preview-chrome-url">yourblog.writekit.dev</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="preview-header">
|
||||
<span className="preview-site-name">Your Blog</span>
|
||||
<nav className="preview-nav">
|
||||
<a href="#">Posts</a>
|
||||
<span>About</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Content - varies by layout */}
|
||||
{layout === 'magazine' ? (
|
||||
<div className="preview-posts">
|
||||
<PreviewPostCard />
|
||||
<PreviewPostCard />
|
||||
</div>
|
||||
) : (
|
||||
<div className="preview-content">
|
||||
<div className="preview-date">Jan 15, 2024</div>
|
||||
<h3 className="preview-title">Building Better APIs</h3>
|
||||
<p className="preview-description">
|
||||
A deep dive into REST design patterns and best practices for modern web development.
|
||||
</p>
|
||||
<div className="preview-prose">
|
||||
<PreviewCodeBlock theme={codeTheme} />
|
||||
</div>
|
||||
<div className="preview-tags">
|
||||
<span className="preview-tag">typescript</span>
|
||||
<span className="preview-tag">react</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="preview-footer">
|
||||
© 2024 Your Blog
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DesignPage() {
|
||||
const settings = useStore($settings)
|
||||
const { data } = useStore($settingsData)
|
||||
const hasChanges = useStore($hasChanges)
|
||||
const changedFields = useStore($changedFields)
|
||||
const saveSettings = useStore($saveSettings)
|
||||
const [availableThemes, setAvailableThemes] = useState<string[]>(Object.keys(themePreviewColors))
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/studio/code-themes')
|
||||
.then(r => r.json())
|
||||
.then((themes: string[]) => setAvailableThemes(themes))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load all fonts for previews
|
||||
Object.keys(fontConfigs).forEach(useFontLoader)
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings.mutate(settings)
|
||||
addToast('Settings saved', 'success')
|
||||
} catch {
|
||||
addToast('Failed to save settings', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return <DesignPageSkeleton />
|
||||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold">Design</h1>
|
||||
<p className="text-sm text-muted mt-1">Customize how your blog looks</p>
|
||||
</div>
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10">
|
||||
{/* Live Preview */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Live Preview</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<LivePreview settings={settings as Record<string, string>} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Presets */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Quick Presets</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{([
|
||||
{ name: 'Developer', desc: 'Monospace, dark code, minimal', font: 'jetbrains-mono', code_theme: 'onedark', layout: 'minimal', compactness: 'compact', accent_color: '#10b981' },
|
||||
{ name: 'Writer', desc: 'Serif, light code, spacious', font: 'merriweather', code_theme: 'github', layout: 'default', compactness: 'spacious', accent_color: '#6366f1' },
|
||||
{ name: 'Magazine', desc: 'Sans-serif, grid layout', font: 'inter', code_theme: 'nord', layout: 'magazine', compactness: 'cozy', accent_color: '#f59e0b' },
|
||||
] as const).map(preset => (
|
||||
<button
|
||||
key={preset.name}
|
||||
onClick={() => {
|
||||
$settings.setKey('font', preset.font)
|
||||
$settings.setKey('code_theme', preset.code_theme)
|
||||
$settings.setKey('layout', preset.layout)
|
||||
$settings.setKey('compactness', preset.compactness)
|
||||
$settings.setKey('accent_color', preset.accent_color)
|
||||
}}
|
||||
className="p-4 border border-border text-left hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
>
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
<div className="text-xs text-muted mt-1">{preset.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Accent Color */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Accent Color</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={settings.accent_color ?? '#10b981'}
|
||||
onChange={e => $settings.setKey('accent_color', e.target.value)}
|
||||
className="w-12 h-12 border border-border cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.accent_color ?? '#10b981'}
|
||||
onChange={e => $settings.setKey('accent_color', e.target.value)}
|
||||
className="input w-32 font-mono text-sm"
|
||||
placeholder="#10b981"
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{['#10b981', '#6366f1', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'].map(color => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => $settings.setKey('accent_color', color)}
|
||||
className={`w-7 h-7 border-2 transition-transform hover:scale-110 ${
|
||||
settings.accent_color === color ? 'border-text scale-110' : 'border-transparent'
|
||||
}`}
|
||||
style={{ background: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Typography */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Typography</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{fonts.map(font => {
|
||||
const config = fontConfigs[font.value as keyof typeof fontConfigs]
|
||||
return (
|
||||
<button
|
||||
key={font.value}
|
||||
onClick={() => $settings.setKey('font', font.value)}
|
||||
className={`p-4 border text-left transition-all ${
|
||||
settings.font === font.value
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-border hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="text-lg mb-1 truncate"
|
||||
style={{ fontFamily: config?.family }}
|
||||
>
|
||||
The quick brown fox
|
||||
</div>
|
||||
<div className="text-xs text-muted">{font.label}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Code Theme */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Code Theme</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{availableThemes.map(theme => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => $settings.setKey('code_theme', theme)}
|
||||
className={`border text-left transition-all overflow-hidden ${
|
||||
settings.code_theme === theme
|
||||
? 'border-accent ring-1 ring-accent'
|
||||
: 'border-border hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<CodePreview theme={theme} />
|
||||
<div className="px-3 py-2 text-xs border-t border-border">{formatThemeLabel(theme)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Layout */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Layout</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{layouts.map(layout => (
|
||||
<button
|
||||
key={layout.value}
|
||||
onClick={() => $settings.setKey('layout', layout.value)}
|
||||
className={`border p-4 transition-all ${
|
||||
settings.layout === layout.value
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-border hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<LayoutPreview
|
||||
layout={layout.value}
|
||||
selected={settings.layout === layout.value}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm font-medium">{layout.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Density */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Content Density</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="flex border border-border divide-x divide-border">
|
||||
{([
|
||||
{ value: 'compact', label: 'Compact', lines: 4 },
|
||||
{ value: 'cozy', label: 'Cozy', lines: 3 },
|
||||
{ value: 'spacious', label: 'Spacious', lines: 2 },
|
||||
] as const).map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => $settings.setKey('compactness', option.value)}
|
||||
className={`flex-1 py-4 px-3 transition-colors ${
|
||||
settings.compactness === option.value
|
||||
? 'bg-accent/10'
|
||||
: 'hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 mb-2">
|
||||
{Array.from({ length: option.lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 rounded-full transition-colors ${
|
||||
settings.compactness === option.value ? 'bg-accent' : 'bg-muted/40'
|
||||
}`}
|
||||
style={{ width: `${60 - i * 10}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={`text-sm ${
|
||||
settings.compactness === option.value ? 'font-medium' : ''
|
||||
}`}>
|
||||
{option.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && <SaveBar onSave={handleSave} loading={saveSettings.loading} changes={changedFields} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
studio/src/pages/DomainPage.tsx
Normal file
82
studio/src/pages/DomainPage.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Field, PageHeader } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { Button } from '../components/ui'
|
||||
|
||||
export default function DomainPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Subdomain */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Subdomain</div>
|
||||
<div className="text-xs text-muted mt-0.5">Your default blog address</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder="myblog"
|
||||
defaultValue=""
|
||||
/>
|
||||
<span className="text-sm text-muted">.writekit.dev</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
This is your default blog URL. You can also add a custom domain below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Custom Domain */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Custom Domain</div>
|
||||
<div className="text-xs text-muted mt-0.5">Use your own domain for your blog</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-4">
|
||||
<Field
|
||||
label="Domain"
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="blog.example.com"
|
||||
hint="Enter your custom domain without https://"
|
||||
/>
|
||||
<div className="p-4 bg-border/30 space-y-3">
|
||||
<h4 className="text-sm font-medium">DNS Configuration</h4>
|
||||
<p className="text-xs text-muted">
|
||||
Point your domain to our servers by adding these DNS records:
|
||||
</p>
|
||||
<div className="space-y-2 overflow-x-auto">
|
||||
<div className="flex items-center gap-4 p-2 bg-surface border border-border text-xs font-mono min-w-max">
|
||||
<span className="text-muted w-16">Type</span>
|
||||
<span className="text-muted w-24">Name</span>
|
||||
<span className="text-text">Value</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-2 bg-surface border border-border text-xs font-mono min-w-max">
|
||||
<span className="w-16">CNAME</span>
|
||||
<span className="w-24">blog</span>
|
||||
<span>cname.writekit.dev</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-warning" />
|
||||
<span className="text-sm text-muted">DNS: Pending</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-muted" />
|
||||
<span className="text-sm text-muted">SSL: Not configured</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" Icon={Icons.Refresh}>
|
||||
Check DNS
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
studio/src/pages/EngagementPage.tsx
Normal file
118
studio/src/pages/EngagementPage.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { $interactions, $interactionsData, $hasInteractionChanges, $saveInteractions, $changedInteractionFields } from '../stores/interactions'
|
||||
import { addToast } from '../stores/app'
|
||||
import { SaveBar, EngagementPageSkeleton, PageHeader } from '../components/shared'
|
||||
import { Toggle, Input } from '../components/ui'
|
||||
|
||||
export default function EngagementPage() {
|
||||
const config = useStore($interactions)
|
||||
const { data } = useStore($interactionsData)
|
||||
const hasChanges = useStore($hasInteractionChanges)
|
||||
const changedFields = useStore($changedInteractionFields)
|
||||
const saveInteractions = useStore($saveInteractions)
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveInteractions.mutate(config)
|
||||
addToast('Settings saved', 'success')
|
||||
} catch {
|
||||
addToast('Failed to save settings', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return <EngagementPageSkeleton />
|
||||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<PageHeader />
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Comments - single row toggle */}
|
||||
<div className="px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Comments</div>
|
||||
<div className="text-xs text-muted mt-0.5">Allow readers to comment on your posts</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={config.comments_enabled}
|
||||
onChange={v => $interactions.setKey('comments_enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Reactions - toggle with expandable options */}
|
||||
<div className="px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Reactions</div>
|
||||
<div className="text-xs text-muted mt-0.5">Let readers react to your posts</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={config.reactions_enabled}
|
||||
onChange={v => $interactions.setKey('reactions_enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.reactions_enabled && (
|
||||
<div className="px-6 lg:px-10 py-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="label">Reaction Mode</label>
|
||||
<div className="flex gap-2">
|
||||
{['emoji', 'upvote'].map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => $interactions.setKey('reaction_mode', mode)}
|
||||
className={`flex-1 p-3 border capitalize transition-colors ${
|
||||
config.reaction_mode === mode
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-border hover:border-muted'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.reaction_mode === 'emoji' && (
|
||||
<div className="space-y-1">
|
||||
<label className="label">Reaction Emojis</label>
|
||||
<Input
|
||||
value={config.reaction_emojis}
|
||||
onChange={v => $interactions.setKey('reaction_emojis', v)}
|
||||
placeholder="👍 ❤️ 🎉 🤔"
|
||||
/>
|
||||
<p className="text-xs text-muted">Space-separated list of emoji reactions</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.reaction_mode === 'upvote' && (
|
||||
<div className="space-y-1">
|
||||
<label className="label">Upvote Icon</label>
|
||||
<Input
|
||||
value={config.upvote_icon}
|
||||
onChange={v => $interactions.setKey('upvote_icon', v)}
|
||||
placeholder="👍"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4 pt-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Require Authentication</div>
|
||||
<div className="text-xs text-muted mt-0.5">Users must be logged in to react</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={config.reactions_require_auth}
|
||||
onChange={v => $interactions.setKey('reactions_require_auth', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChanges && <SaveBar onSave={handleSave} loading={saveInteractions.loading} changes={changedFields} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
studio/src/pages/GeneralPage.tsx
Normal file
125
studio/src/pages/GeneralPage.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
|
||||
import { addToast } from '../stores/app'
|
||||
import { Field, SaveBar, GeneralPageSkeleton, PageHeader } from '../components/shared'
|
||||
|
||||
export default function GeneralPage() {
|
||||
const settings = useStore($settings)
|
||||
const { data } = useStore($settingsData)
|
||||
const hasChanges = useStore($hasChanges)
|
||||
const changedFields = useStore($changedFields)
|
||||
const saveSettings = useStore($saveSettings)
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings.mutate(settings)
|
||||
addToast('Settings saved', 'success')
|
||||
} catch {
|
||||
addToast('Failed to save settings', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return <GeneralPageSkeleton />
|
||||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
<PageHeader />
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Site Information */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Site Information</div>
|
||||
<div className="text-xs text-muted mt-0.5">Basic details about your blog</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-4">
|
||||
<Field
|
||||
label="Site Name"
|
||||
value={settings.site_name ?? ''}
|
||||
onChange={v => $settings.setKey('site_name', v)}
|
||||
placeholder="My Blog"
|
||||
/>
|
||||
<Field
|
||||
label="Description"
|
||||
value={settings.site_description ?? ''}
|
||||
onChange={v => $settings.setKey('site_description', v)}
|
||||
placeholder="A short description of your blog"
|
||||
multiline
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Author */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Author</div>
|
||||
<div className="text-xs text-muted mt-0.5">Information about the blog author</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-4">
|
||||
<Field
|
||||
label="Name"
|
||||
value={settings.author_name ?? ''}
|
||||
onChange={v => $settings.setKey('author_name', v)}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<Field
|
||||
label="Role"
|
||||
value={settings.author_role ?? ''}
|
||||
onChange={v => $settings.setKey('author_role', v)}
|
||||
placeholder="Software Engineer"
|
||||
/>
|
||||
<Field
|
||||
label="Bio"
|
||||
value={settings.author_bio ?? ''}
|
||||
onChange={v => $settings.setKey('author_bio', v)}
|
||||
placeholder="A short bio about yourself"
|
||||
multiline
|
||||
/>
|
||||
<Field
|
||||
label="Photo URL"
|
||||
value={settings.author_photo ?? ''}
|
||||
onChange={v => $settings.setKey('author_photo', v)}
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
hint="URL to your profile photo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Social Links</div>
|
||||
<div className="text-xs text-muted mt-0.5">Connect your social profiles</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-4">
|
||||
<Field
|
||||
label="Twitter Handle"
|
||||
value={settings.twitter_handle ?? ''}
|
||||
onChange={v => $settings.setKey('twitter_handle', v)}
|
||||
placeholder="@username"
|
||||
/>
|
||||
<Field
|
||||
label="GitHub Handle"
|
||||
value={settings.github_handle ?? ''}
|
||||
onChange={v => $settings.setKey('github_handle', v)}
|
||||
placeholder="username"
|
||||
/>
|
||||
<Field
|
||||
label="LinkedIn Handle"
|
||||
value={settings.linkedin_handle ?? ''}
|
||||
onChange={v => $settings.setKey('linkedin_handle', v)}
|
||||
placeholder="username"
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
value={settings.email ?? ''}
|
||||
onChange={v => $settings.setKey('email', v)}
|
||||
placeholder="hello@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && <SaveBar onSave={handleSave} loading={saveSettings.loading} changes={changedFields} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
229
studio/src/pages/HomePage.tsx
Normal file
229
studio/src/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { $posts } from '../stores/posts'
|
||||
import { $analytics } from '../stores/analytics'
|
||||
import { Button } from '../components/ui'
|
||||
import { BreakdownList, EmptyState, HomePageSkeleton, PageHeader } from '../components/shared'
|
||||
import { Icons, getReferrerIcon } from '../components/shared/Icons'
|
||||
|
||||
function formatChange(change: number): { text: string; positive: boolean } {
|
||||
const sign = change >= 0 ? '+' : ''
|
||||
return {
|
||||
text: `${sign}${change.toFixed(1)}%`,
|
||||
positive: change >= 0,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string) {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatViews(views: number) {
|
||||
if (views >= 1000) return `${(views / 1000).toFixed(1)}k`
|
||||
return views.toString()
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { data: posts, error: postsError } = useStore($posts)
|
||||
const { data: analytics } = useStore($analytics)
|
||||
|
||||
if (!posts) return <HomePageSkeleton />
|
||||
if (postsError) return <EmptyState Icon={Icons.AlertCircle} title="Failed to load data" description={postsError.message} />
|
||||
|
||||
const drafts = posts
|
||||
.filter(p => p.draft)
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.slice(0, 3)
|
||||
|
||||
const published = posts
|
||||
.filter(p => !p.draft)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 5)
|
||||
|
||||
const publishedCount = posts.filter(p => !p.draft).length
|
||||
const draftCount = posts.filter(p => p.draft).length
|
||||
|
||||
const getPostViews = (slug: string): number => {
|
||||
if (!analytics?.top_pages) return 0
|
||||
const page = analytics.top_pages.find(p => p.path === `/posts/${slug}`)
|
||||
return page?.views || 0
|
||||
}
|
||||
|
||||
const change = analytics ? formatChange(analytics.views_change) : null
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader />
|
||||
<EmptyState
|
||||
Icon={Icons.PenTool}
|
||||
title="Welcome to WriteKit"
|
||||
description="Create your first post to get started"
|
||||
action={<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">Write Your First Post</Button>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">New Post</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Panel container - uses negative margins for full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Stats row */}
|
||||
<div className="relative">
|
||||
{/* Vertical dividers at column boundaries */}
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '33.333%' }} />
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '66.666%' }} />
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="py-5 pl-6 lg:pl-10 pr-6">
|
||||
<div className="text-xs text-muted mb-1">Views</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{analytics?.total_views.toLocaleString() || '0'}</div>
|
||||
{change && (
|
||||
<div className={`text-xs mt-1 ${change.positive ? 'text-success' : 'text-danger'}`}>
|
||||
{change.text} vs last period
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-5 px-6">
|
||||
<div className="text-xs text-muted mb-1">Visitors</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{analytics?.unique_visitors.toLocaleString() || '0'}</div>
|
||||
</div>
|
||||
<div className="py-5 pr-6 lg:pr-10 pl-6">
|
||||
<div className="text-xs text-muted mb-1">Posts</div>
|
||||
<div className="text-2xl font-semibold tracking-tight">{publishedCount}</div>
|
||||
{draftCount > 0 && (
|
||||
<div className="text-xs text-muted mt-1">{draftCount} draft{draftCount > 1 ? 's' : ''}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-bleed horizontal divider */}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Content sections with vertical divider */}
|
||||
<div className="relative">
|
||||
{/* Vertical divider at exact center */}
|
||||
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
{/* Left column: Posts */}
|
||||
<div className="pl-6 lg:pl-10 pr-6 py-6 space-y-6">
|
||||
{/* Drafts */}
|
||||
{drafts.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Continue Writing</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{drafts.map((post) => (
|
||||
<a
|
||||
key={post.id}
|
||||
href={`/studio/posts/${post.slug}/edit`}
|
||||
className="group flex items-center justify-between py-2 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Icons.Ghost className="text-muted opacity-40 flex-shrink-0 text-sm" />
|
||||
<span className="text-sm text-text truncate group-hover:text-accent transition-colors">
|
||||
{post.title || 'Untitled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-muted">{formatRelativeTime(post.updated_at)}</span>
|
||||
<Icons.ArrowRight className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent posts */}
|
||||
{published.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Recent Posts</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{published.map((post) => {
|
||||
const views = getPostViews(post.slug)
|
||||
return (
|
||||
<a
|
||||
key={post.id}
|
||||
href={`/studio/posts/${post.slug}/edit`}
|
||||
className="group flex items-center justify-between py-2 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Icons.Clock className="text-muted opacity-40 flex-shrink-0 text-sm" />
|
||||
<span className="text-sm text-text truncate group-hover:text-accent transition-colors">
|
||||
{post.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0 text-xs text-muted">
|
||||
{views > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icons.Eye className="opacity-50 text-xs" />
|
||||
{formatViews(views)}
|
||||
</span>
|
||||
)}
|
||||
<span>{formatDate(post.date)}</span>
|
||||
<Icons.ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-50 transition-opacity" />
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: Referrers */}
|
||||
<div className="pr-6 lg:pr-10 pl-6 py-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Top Referrers</span>
|
||||
</div>
|
||||
{analytics && analytics.top_referrers.length > 0 ? (
|
||||
<BreakdownList
|
||||
items={analytics.top_referrers.slice(0, 5).map(r => {
|
||||
const label = r.referrer || 'Direct'
|
||||
return {
|
||||
label,
|
||||
value: r.views,
|
||||
percentage: (r.views / analytics.total_views) * 100,
|
||||
Icon: getReferrerIcon(label),
|
||||
}
|
||||
})}
|
||||
limit={5}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-muted py-8">No referrer data yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
studio/src/pages/MonetizationPage.tsx
Normal file
75
studio/src/pages/MonetizationPage.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { EmptyState, PageHeader } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { Button } from '../components/ui'
|
||||
|
||||
export default function MonetizationPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Membership Tiers */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Membership Tiers</div>
|
||||
<div className="text-xs text-muted mt-0.5">Create subscription tiers for your readers</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<EmptyState
|
||||
Icon={Icons.Crown}
|
||||
title="No tiers created"
|
||||
description="Create membership tiers to offer exclusive content"
|
||||
action={<Button variant="primary" Icon={Icons.Plus}>Create Tier</Button>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Pricing</div>
|
||||
<div className="text-xs text-muted mt-0.5">Set up payments for your membership</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="p-4 bg-border/30">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icons.Info className="text-muted" />
|
||||
<span className="text-sm font-medium">Payment Integration</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-4">
|
||||
Connect your Stripe account to start accepting payments from your members.
|
||||
</p>
|
||||
<Button variant="secondary" Icon={Icons.Monetization}>
|
||||
Connect Stripe
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member-Only Content - inline toggle rows */}
|
||||
<div className="border-t border-border px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Free Preview</div>
|
||||
<div className="text-xs text-muted mt-0.5">Show a preview before the paywall</div>
|
||||
</div>
|
||||
<label className="relative inline-flex cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-9 h-5 bg-border rounded-full peer peer-checked:bg-accent transition-colors" />
|
||||
<div className="absolute left-0.5 top-0.5 w-4 h-4 bg-surface rounded-full transition-transform peer-checked:translate-x-4" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-6 lg:px-10 py-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Show Member Count</div>
|
||||
<div className="text-xs text-muted mt-0.5">Display number of members publicly</div>
|
||||
</div>
|
||||
<label className="relative inline-flex cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-9 h-5 bg-border rounded-full peer peer-checked:bg-accent transition-colors" />
|
||||
<div className="absolute left-0.5 top-0.5 w-4 h-4 bg-surface rounded-full transition-transform peer-checked:translate-x-4" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
784
studio/src/pages/PluginsPage.tsx
Normal file
784
studio/src/pages/PluginsPage.tsx
Normal file
|
|
@ -0,0 +1,784 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $plugins, $currentPlugin, $compileResult, $isCompiling, $savePlugin, $deletePlugin, $togglePlugin, compilePlugin, type Plugin } from '../stores/plugins'
|
||||
import { $secrets, $createSecret, $deleteSecret } from '../stores/secrets'
|
||||
import { $hooks, fetchTemplate } from '../stores/hooks'
|
||||
import { $billing } from '../stores/billing'
|
||||
import { addToast } from '../stores/app'
|
||||
import { SettingsPageSkeleton, EmptyState, PageHeader } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { Button, Input, Modal, Toggle, Tabs, Dropdown, UsageIndicator } from '../components/ui'
|
||||
import type { Tab } from '../components/ui'
|
||||
import { PluginEditor } from '../components/editor'
|
||||
|
||||
interface TestResult {
|
||||
success: boolean
|
||||
phase: 'compile' | 'execute'
|
||||
output?: string
|
||||
logs?: string[]
|
||||
errors?: string[]
|
||||
error?: string
|
||||
compile_ms?: number
|
||||
run_ms?: number
|
||||
}
|
||||
|
||||
const LANGUAGE_TABS: Tab<'typescript' | 'go'>[] = [
|
||||
{ value: 'typescript', label: 'TypeScript', Icon: Icons.TypeScript },
|
||||
{ value: 'go', label: 'Go', Icon: Icons.Go },
|
||||
]
|
||||
|
||||
export default function PluginsPage() {
|
||||
const { data: plugins } = useStore($plugins)
|
||||
const { data: secrets } = useStore($secrets)
|
||||
const { data: hooks } = useStore($hooks)
|
||||
const { data: billing } = useStore($billing)
|
||||
const currentPlugin = useStore($currentPlugin)
|
||||
const compileResult = useStore($compileResult)
|
||||
const isCompiling = useStore($isCompiling)
|
||||
|
||||
const [view, setView] = useState<'list' | 'edit'>('list')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [showSecretsPanel, setShowSecretsPanel] = useState(false)
|
||||
const [showTestPanel, setShowTestPanel] = useState(false)
|
||||
const [showSecretModal, setShowSecretModal] = useState(false)
|
||||
const [newSecretKey, setNewSecretKey] = useState('')
|
||||
const [newSecretValue, setNewSecretValue] = useState('')
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||
const [testData, setTestData] = useState<string>('')
|
||||
|
||||
const handleNew = async () => {
|
||||
const template = await fetchTemplate('post.published', 'typescript')
|
||||
$currentPlugin.set({
|
||||
name: '',
|
||||
language: 'typescript',
|
||||
source: template,
|
||||
hooks: ['post.published'],
|
||||
enabled: true,
|
||||
})
|
||||
setEditingId(null)
|
||||
setView('edit')
|
||||
}
|
||||
|
||||
const handleEdit = (plugin: Plugin) => {
|
||||
$currentPlugin.set(plugin)
|
||||
setEditingId(plugin.id)
|
||||
setView('edit')
|
||||
}
|
||||
|
||||
const handleLanguageChange = async (lang: string) => {
|
||||
const prevLang = currentPlugin.language
|
||||
$currentPlugin.setKey('language', lang as Plugin['language'])
|
||||
|
||||
// Update template if this is a new plugin or source matches the old template
|
||||
const currentHook = currentPlugin.hooks?.[0] || 'post.published'
|
||||
if (!editingId) {
|
||||
const template = await fetchTemplate(currentHook, lang)
|
||||
$currentPlugin.setKey('source', template)
|
||||
} else {
|
||||
// Check if current source is the default template for the old language
|
||||
const oldTemplate = await fetchTemplate(currentHook, prevLang || 'typescript')
|
||||
if (currentPlugin.source === oldTemplate) {
|
||||
const newTemplate = await fetchTemplate(currentHook, lang)
|
||||
$currentPlugin.setKey('source', newTemplate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleHookChange = async (hook: string) => {
|
||||
const prevHook = currentPlugin.hooks?.[0]
|
||||
$currentPlugin.setKey('hooks', [hook])
|
||||
|
||||
// Update test data for new hook from backend
|
||||
const hookInfo = hooks?.find(h => h.name === hook)
|
||||
if (hookInfo?.test_data) {
|
||||
setTestData(JSON.stringify(hookInfo.test_data, null, 2))
|
||||
}
|
||||
setTestResult(null)
|
||||
|
||||
// Update template if this is a new plugin or source matches the old template
|
||||
const lang = currentPlugin.language || 'typescript'
|
||||
if (!editingId) {
|
||||
const template = await fetchTemplate(hook, lang)
|
||||
$currentPlugin.setKey('source', template)
|
||||
} else {
|
||||
// Check if current source is the default template for the old hook
|
||||
const oldTemplate = await fetchTemplate(prevHook || 'post.published', lang)
|
||||
if (currentPlugin.source === oldTemplate) {
|
||||
const newTemplate = await fetchTemplate(hook, lang)
|
||||
$currentPlugin.setKey('source', newTemplate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!currentPlugin.source) return
|
||||
|
||||
setIsTesting(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
let testDataObj = {}
|
||||
try {
|
||||
testDataObj = testData ? JSON.parse(testData) : {}
|
||||
} catch {
|
||||
addToast('Invalid test data JSON', 'error')
|
||||
setIsTesting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch('/api/studio/plugins/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
language: currentPlugin.language || 'typescript',
|
||||
source: currentPlugin.source,
|
||||
hook: currentPlugin.hooks?.[0] || 'post.published',
|
||||
test_data: testDataObj,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
setTestResult(result)
|
||||
|
||||
if (result.success) {
|
||||
addToast('Test passed', 'success')
|
||||
} else if (result.phase === 'compile') {
|
||||
addToast('Compilation failed', 'error')
|
||||
} else {
|
||||
addToast('Execution failed', 'error')
|
||||
}
|
||||
} catch {
|
||||
addToast('Test request failed', 'error')
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize test data when entering edit mode
|
||||
useEffect(() => {
|
||||
if (view === 'edit' && hooks && hooks.length > 0) {
|
||||
const hook = currentPlugin.hooks?.[0] || 'post.published'
|
||||
const hookInfo = hooks.find(h => h.name === hook)
|
||||
if (hookInfo?.test_data) {
|
||||
setTestData(JSON.stringify(hookInfo.test_data, null, 2))
|
||||
}
|
||||
setTestResult(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, hooks])
|
||||
|
||||
const handleCompile = async () => {
|
||||
const result = await compilePlugin(currentPlugin.language!, currentPlugin.source!)
|
||||
if (result.success) {
|
||||
addToast('Compiled successfully', 'success')
|
||||
} else {
|
||||
addToast('Compilation failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentPlugin.name) {
|
||||
addToast('Plugin name is required', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await compilePlugin(currentPlugin.language!, currentPlugin.source!)
|
||||
if (!result.success) {
|
||||
addToast('Fix compilation errors before saving', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await $savePlugin.mutate({
|
||||
...(currentPlugin as Plugin),
|
||||
id: editingId || crypto.randomUUID(),
|
||||
wasm: result.wasm,
|
||||
})
|
||||
addToast('Plugin saved', 'success')
|
||||
setView('list')
|
||||
} catch (err) {
|
||||
console.error('Save plugin error:', err)
|
||||
addToast(`Failed to save plugin: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this plugin?')) return
|
||||
try {
|
||||
await $deletePlugin.mutate(id)
|
||||
addToast('Plugin deleted', 'success')
|
||||
} catch {
|
||||
addToast('Failed to delete plugin', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
await $togglePlugin.mutate({ id, enabled })
|
||||
}
|
||||
|
||||
const handleAddSecret = async () => {
|
||||
if (!newSecretKey || !newSecretValue) return
|
||||
try {
|
||||
await $createSecret.mutate({ key: newSecretKey, value: newSecretValue })
|
||||
addToast('Secret added', 'success')
|
||||
setNewSecretKey('')
|
||||
setNewSecretValue('')
|
||||
setShowSecretModal(false)
|
||||
} catch {
|
||||
addToast('Failed to add secret', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSecret = async (key: string) => {
|
||||
if (!confirm(`Delete secret "${key}"?`)) return
|
||||
try {
|
||||
await $deleteSecret.mutate(key)
|
||||
addToast('Secret deleted', 'success')
|
||||
} catch {
|
||||
addToast('Failed to delete secret', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (!plugins || !secrets || !hooks) return <SettingsPageSkeleton />
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EDIT VIEW - Full-screen editor experience
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
if (view === 'edit') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex flex-col bg-bg">
|
||||
{/* Header Bar */}
|
||||
<div className="flex-none border-b border-border bg-surface px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left: Back + Name */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className="p-1.5 -ml-1.5 text-muted hover:text-text hover:bg-bg rounded transition-colors"
|
||||
title="Back to plugins"
|
||||
>
|
||||
<Icons.ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={currentPlugin.name || ''}
|
||||
onChange={e => $currentPlugin.setKey('name', e.target.value)}
|
||||
placeholder="Plugin name..."
|
||||
className="flex-1 min-w-0 text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<button
|
||||
onClick={() => { setShowTestPanel(!showTestPanel); setShowSecretsPanel(false) }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
showTestPanel ? 'bg-accent/10 text-accent' : 'text-muted hover:text-text hover:bg-bg'
|
||||
}`}
|
||||
>
|
||||
<Icons.Play className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Test</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSecretsPanel(!showSecretsPanel); setShowTestPanel(false) }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
showSecretsPanel ? 'bg-accent/10 text-accent' : 'text-muted hover:text-text hover:bg-bg'
|
||||
}`}
|
||||
>
|
||||
<Icons.Key className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Secrets</span>
|
||||
{secrets?.length ? (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs bg-bg rounded-full">{secrets.length}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button variant="secondary" onClick={handleCompile} disabled={isCompiling}>
|
||||
{isCompiling ? (
|
||||
<>
|
||||
<Icons.Loader className="w-4 h-4 animate-spin" />
|
||||
<span className="hidden sm:inline ml-1.5">Compiling...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Check className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-1.5">Compile</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={isCompiling}>
|
||||
<Icons.Save className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-1.5">Save</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Row */}
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<Tabs
|
||||
value={(currentPlugin.language || 'typescript') as 'typescript' | 'go'}
|
||||
onChange={handleLanguageChange}
|
||||
tabs={LANGUAGE_TABS}
|
||||
/>
|
||||
<div className="w-px h-6 bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted">Hook</label>
|
||||
<Dropdown
|
||||
value={currentPlugin.hooks?.[0] || 'post.published'}
|
||||
onChange={handleHookChange}
|
||||
options={(hooks || []).map(h => ({ value: h.name, label: h.label, description: h.description }))}
|
||||
className="w-56"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* Editor */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 min-h-0">
|
||||
<PluginEditor
|
||||
language={currentPlugin.language || 'typescript'}
|
||||
value={currentPlugin.source || ''}
|
||||
onChange={v => $currentPlugin.setKey('source', v)}
|
||||
height="100%"
|
||||
secretKeys={secrets?.map(s => s.key) || []}
|
||||
hook={currentPlugin.hooks?.[0] || 'post.published'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compile Status Bar */}
|
||||
{compileResult && (
|
||||
<div className={`flex-none px-4 py-2 text-sm font-mono border-t ${
|
||||
compileResult.success
|
||||
? 'bg-success/5 text-success border-success/20'
|
||||
: 'bg-danger/5 text-danger border-danger/20'
|
||||
}`}>
|
||||
{compileResult.success ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.Check className="w-4 h-4" />
|
||||
<span>Compiled successfully</span>
|
||||
<span className="text-muted">·</span>
|
||||
<span className="text-muted">{(compileResult.size! / 1024).toFixed(1)} KB</span>
|
||||
<span className="text-muted">·</span>
|
||||
<span className="text-muted">{compileResult.time_ms}ms</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
<Icons.AlertCircle className="w-4 h-4 mt-0.5 flex-none" />
|
||||
<pre className="whitespace-pre-wrap overflow-auto max-h-32">{compileResult.errors?.join('\n')}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Panel (Slide-in) */}
|
||||
{showTestPanel && (
|
||||
<div className="w-96 flex-none border-l border-border bg-surface overflow-y-auto flex flex-col">
|
||||
<div className="p-4 border-b border-border flex-none">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium">Test Plugin</h3>
|
||||
<button
|
||||
onClick={() => setShowTestPanel(false)}
|
||||
className="p-1 text-muted hover:text-text rounded"
|
||||
>
|
||||
<Icons.Close className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Run your plugin with test data to verify it works correctly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Test Data Input */}
|
||||
<div className="p-4 border-b border-border flex-none">
|
||||
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
|
||||
Test Data (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={testData}
|
||||
onChange={e => setTestData(e.target.value)}
|
||||
className="w-full h-40 p-3 text-xs font-mono bg-bg border border-border rounded resize-none focus:outline-none focus:border-accent"
|
||||
placeholder="{}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Run Button */}
|
||||
<div className="p-4 border-b border-border flex-none">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleTest}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Icons.Loader className="w-4 h-4 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Play className="w-4 h-4" />
|
||||
Run Test
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResult && (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className={`mb-4 p-3 rounded ${
|
||||
testResult.success
|
||||
? 'bg-success/10 text-success border border-success/20'
|
||||
: 'bg-danger/10 text-danger border border-danger/20'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{testResult.success ? (
|
||||
<>
|
||||
<Icons.Check className="w-4 h-4" />
|
||||
Test Passed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.AlertCircle className="w-4 h-4" />
|
||||
Test Failed ({testResult.phase})
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(testResult.compile_ms != null || testResult.run_ms != null) && (
|
||||
<div className="text-xs mt-1 opacity-80">
|
||||
{testResult.compile_ms != null && <span>Compile: {testResult.compile_ms}ms</span>}
|
||||
{testResult.compile_ms != null && testResult.run_ms != null && <span> · </span>}
|
||||
{testResult.run_ms != null && <span>Run: {testResult.run_ms}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{testResult.errors && testResult.errors.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
|
||||
Compile Errors
|
||||
</label>
|
||||
<pre className="p-3 text-xs font-mono bg-danger/5 text-danger border border-danger/20 rounded overflow-x-auto whitespace-pre-wrap">
|
||||
{testResult.errors.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult.error && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
|
||||
Runtime Error
|
||||
</label>
|
||||
<pre className="p-3 text-xs font-mono bg-danger/5 text-danger border border-danger/20 rounded overflow-x-auto whitespace-pre-wrap">
|
||||
{testResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs */}
|
||||
{testResult.logs && testResult.logs.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
|
||||
Logs
|
||||
</label>
|
||||
<div className="p-3 text-xs font-mono bg-bg border border-border rounded overflow-x-auto">
|
||||
{testResult.logs.map((log, i) => (
|
||||
<div key={i} className={`${
|
||||
log.startsWith('[HTTP]') ? 'text-cyan-500' :
|
||||
log.startsWith('[KV]') ? 'text-yellow-500' :
|
||||
log.startsWith('[LOG]') ? 'text-text' : 'text-muted'
|
||||
}`}>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output */}
|
||||
{testResult.output && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted uppercase tracking-wide mb-2 block">
|
||||
Output
|
||||
</label>
|
||||
<pre className="p-3 text-xs font-mono bg-bg border border-border rounded overflow-x-auto whitespace-pre-wrap">
|
||||
{testResult.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secrets Panel (Slide-in) */}
|
||||
{showSecretsPanel && (
|
||||
<div className="w-72 flex-none border-l border-border bg-surface overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium">Secrets</h3>
|
||||
<button
|
||||
onClick={() => setShowSecretsPanel(false)}
|
||||
className="p-1 text-muted hover:text-text rounded"
|
||||
>
|
||||
<Icons.Close className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted mb-4">
|
||||
Access secrets in your code as <code className="px-1 py-0.5 bg-bg rounded">Runner.secrets.KEY_NAME</code>
|
||||
</p>
|
||||
|
||||
{secrets?.length ? (
|
||||
<div className="space-y-2 mb-4">
|
||||
{secrets.map(s => (
|
||||
<div key={s.key} className="flex items-center justify-between p-2 bg-bg rounded group">
|
||||
<code className="text-xs font-mono truncate">{s.key}</code>
|
||||
<button
|
||||
onClick={() => handleDeleteSecret(s.key)}
|
||||
className="p-1 text-muted hover:text-danger opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Icons.Trash className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm text-muted">
|
||||
<Icons.Key className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||||
<p>No secrets yet</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" className="w-full" onClick={() => setShowSecretModal(true)}>
|
||||
<Icons.Plus className="w-4 h-4" />
|
||||
Add Secret
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Secret Modal */}
|
||||
<Modal open={showSecretModal} onClose={() => setShowSecretModal(false)} title="Add Secret">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Key</label>
|
||||
<Input
|
||||
value={newSecretKey}
|
||||
onChange={v => setNewSecretKey(v.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
|
||||
placeholder="SLACK_WEBHOOK"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1.5">Use UPPER_SNAKE_CASE</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Value</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newSecretValue}
|
||||
onChange={setNewSecretValue}
|
||||
placeholder="https://hooks.slack.com/..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setShowSecretModal(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleAddSecret}>Add Secret</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LIST VIEW - Plugin management
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
{billing && billing.usage.plugins >= billing.tiers[billing.current_tier].max_plugins ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted">Plugin limit reached</span>
|
||||
<Button variant="primary" href="/studio/billing">Upgrade</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="primary" onClick={handleNew}>
|
||||
<Icons.Plus className="w-4 h-4" />
|
||||
New Plugin
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10">
|
||||
{/* Plugin List */}
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Your Plugins</div>
|
||||
{billing && (
|
||||
<UsageIndicator
|
||||
used={billing.usage.plugins}
|
||||
max={billing.tiers[billing.current_tier].max_plugins}
|
||||
label="used"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border">
|
||||
{!plugins?.length ? (
|
||||
<div className="px-6 lg:px-10 py-12">
|
||||
<EmptyState
|
||||
Icon={Icons.Code}
|
||||
title="No plugins yet"
|
||||
description="Create plugins to add notifications, integrations, content moderation, and more"
|
||||
/>
|
||||
<div className="flex justify-center mt-6">
|
||||
<Button variant="primary" onClick={handleNew}>
|
||||
<Icons.Plus className="w-4 h-4" />
|
||||
Create your first plugin
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
plugins.map((plugin, i) => {
|
||||
const hookInfo = hooks?.find(h => h.name === plugin.hooks[0])
|
||||
const langTab = LANGUAGE_TABS.find(l => l.value === plugin.language)
|
||||
const LangIcon = langTab?.Icon
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className={`group flex items-center justify-between px-6 lg:px-10 py-4 hover:bg-surface/50 transition-colors ${i > 0 ? 'border-t border-border' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full flex-none ${
|
||||
plugin.enabled ? 'bg-success' : 'bg-border'
|
||||
}`}
|
||||
title={plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{plugin.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted mt-0.5">
|
||||
<span>{hookInfo?.label || plugin.hooks[0]}</span>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{LangIcon && <LangIcon className="w-3.5 h-3.5" />}
|
||||
{langTab?.label || plugin.language}
|
||||
</span>
|
||||
{plugin.wasm_size ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{(plugin.wasm_size / 1024).toFixed(1)} KB</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Toggle
|
||||
checked={plugin.enabled}
|
||||
onChange={v => handleToggle(plugin.id, v)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleEdit(plugin)}
|
||||
className="p-2 text-muted hover:text-text hover:bg-bg rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Icons.Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plugin.id)}
|
||||
className="p-2 text-muted hover:text-danger hover:bg-danger/5 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Icons.Trash className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Secrets Section */}
|
||||
<div className="px-6 lg:px-10 py-5 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Secrets</div>
|
||||
<div className="text-xs text-muted mt-0.5">API keys and sensitive data available to all plugins</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setShowSecretModal(true)}>
|
||||
<Icons.Plus className="w-4 h-4" />
|
||||
Add Secret
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
{!secrets?.length ? (
|
||||
<div className="py-8 text-center border border-dashed border-border">
|
||||
<Icons.Key className="w-8 h-8 mx-auto text-muted/30 mb-2" />
|
||||
<p className="text-sm text-muted">No secrets configured</p>
|
||||
<p className="text-xs text-muted mt-1">Add API keys for services like Slack, Resend, or OpenAI</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{secrets.map(s => (
|
||||
<div key={s.key} className="flex items-center justify-between p-3 bg-surface border border-border group">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icons.Key className="w-4 h-4 text-muted flex-none" />
|
||||
<code className="text-sm font-mono truncate">{s.key}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteSecret(s.key)}
|
||||
className="p-1.5 text-muted hover:text-danger hover:bg-danger/5 rounded opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Delete"
|
||||
>
|
||||
<Icons.Trash className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Secret Modal */}
|
||||
<Modal open={showSecretModal} onClose={() => setShowSecretModal(false)} title="Add Secret">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Key</label>
|
||||
<Input
|
||||
value={newSecretKey}
|
||||
onChange={v => setNewSecretKey(v.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
|
||||
placeholder="SLACK_WEBHOOK"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1.5">Use UPPER_SNAKE_CASE for consistency</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Value</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newSecretValue}
|
||||
onChange={setNewSecretValue}
|
||||
placeholder="https://hooks.slack.com/..."
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1.5">Stored encrypted, never displayed again</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setShowSecretModal(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleAddSecret}>Add Secret</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
285
studio/src/pages/PostEditorPage.tsx
Normal file
285
studio/src/pages/PostEditorPage.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $router } from '../stores/router'
|
||||
import {
|
||||
$editorMode,
|
||||
$editorPost,
|
||||
$hasChanges,
|
||||
$isNewPost,
|
||||
$hasDraft,
|
||||
$isSaving,
|
||||
$isPublishing,
|
||||
$savePost,
|
||||
initNewPost,
|
||||
loadPost,
|
||||
updateTitle,
|
||||
triggerAutoSave,
|
||||
discardDraft,
|
||||
publishPost,
|
||||
unpublishPost,
|
||||
getPreviewUrl,
|
||||
type EditorMode
|
||||
} from '../stores/editor'
|
||||
import { addToast } from '../stores/app'
|
||||
import { SettingsPageSkeleton } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { Button, Tabs } from '../components/ui'
|
||||
import type { Tab } from '../components/ui'
|
||||
import { PostEditor } from '../components/editor/PostEditor'
|
||||
import { SourceEditor } from '../components/editor/SourceEditor'
|
||||
import { MetadataPanel } from '../components/editor/MetadataPanel'
|
||||
|
||||
const MODE_TABS: Tab<EditorMode>[] = [
|
||||
{ value: 'edit', label: 'Edit' },
|
||||
{ value: 'source', label: 'Source' },
|
||||
]
|
||||
|
||||
export default function PostEditorPage() {
|
||||
const router = useStore($router)
|
||||
const mode = useStore($editorMode)
|
||||
const post = useStore($editorPost)
|
||||
const hasChanges = useStore($hasChanges)
|
||||
const isNew = useStore($isNewPost)
|
||||
const hasDraft = useStore($hasDraft)
|
||||
const isSaving = useStore($isSaving)
|
||||
const isPublishing = useStore($isPublishing)
|
||||
const savePostStore = useStore($savePost)
|
||||
|
||||
const [showMetadata, setShowMetadata] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true)
|
||||
if (router?.route === 'postNew') {
|
||||
initNewPost()
|
||||
} else if (router?.route === 'postEdit' && router.params && 'slug' in router.params) {
|
||||
const success = await loadPost(router.params.slug as string)
|
||||
if (!success) {
|
||||
addToast('Post not found', 'error')
|
||||
$router.open('posts')
|
||||
return
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
init()
|
||||
}, [router])
|
||||
|
||||
const handleContentChange = (markdown: string) => {
|
||||
$editorPost.setKey('content', markdown)
|
||||
triggerAutoSave()
|
||||
}
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
updateTitle(title)
|
||||
triggerAutoSave()
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!post.title) {
|
||||
addToast('Title is required', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await savePostStore.mutate(post)
|
||||
addToast('Post created', 'success')
|
||||
if (post.slug) {
|
||||
window.history.replaceState(null, '', `/studio/posts/${post.slug}/edit`)
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Failed to create post', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
const success = await publishPost()
|
||||
if (success) {
|
||||
addToast('Published successfully', 'success')
|
||||
} else {
|
||||
addToast('Failed to publish', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
const success = await unpublishPost()
|
||||
if (success) {
|
||||
addToast('Unpublished', 'success')
|
||||
} else {
|
||||
addToast('Failed to unpublish', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscardDraft = async () => {
|
||||
if (!confirm('Discard all changes and revert to the published version?')) {
|
||||
return
|
||||
}
|
||||
const success = await discardDraft()
|
||||
if (success) {
|
||||
addToast('Draft discarded', 'success')
|
||||
} else {
|
||||
addToast('Failed to discard draft', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = () => {
|
||||
const url = getPreviewUrl()
|
||||
if (url) window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (hasChanges && !confirm('You have unsaved changes. Leave anyway?')) {
|
||||
return
|
||||
}
|
||||
$router.open('posts')
|
||||
}
|
||||
|
||||
if (isLoading) return <SettingsPageSkeleton />
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex flex-col bg-bg">
|
||||
{/* Header Bar */}
|
||||
<div className="flex-none border-b border-border bg-surface px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left: Back + Title */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="p-1.5 -ml-1.5 text-muted hover:text-text hover:bg-bg rounded transition-colors"
|
||||
title="Back to posts"
|
||||
>
|
||||
<Icons.ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={post.title}
|
||||
onChange={e => handleTitleChange(e.target.value)}
|
||||
placeholder="Post title..."
|
||||
className="flex-1 min-w-0 text-lg font-medium bg-transparent border-none outline-none placeholder:text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center: Mode Toggle */}
|
||||
<div className="flex-none">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onChange={(v) => $editorMode.set(v as EditorMode)}
|
||||
tabs={MODE_TABS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<button
|
||||
onClick={() => setShowMetadata(!showMetadata)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
showMetadata ? 'bg-accent/10 text-accent' : 'text-muted hover:text-text hover:bg-bg'
|
||||
}`}
|
||||
title="Toggle metadata panel"
|
||||
>
|
||||
<Icons.Settings className="w-4 h-4" />
|
||||
</button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePreview}
|
||||
disabled={!post.slug}
|
||||
>
|
||||
<Icons.Eye className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-1.5">Preview</span>
|
||||
</Button>
|
||||
|
||||
{isNew ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={savePostStore.loading}
|
||||
>
|
||||
{savePostStore.loading ? (
|
||||
<>
|
||||
<Icons.Loader className="w-4 h-4 animate-spin" />
|
||||
<span className="hidden sm:inline ml-1.5">Creating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-1.5">Create</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{isSaving && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<Icons.Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
<span className="hidden sm:inline">Saving...</span>
|
||||
</span>
|
||||
)}
|
||||
{!isSaving && hasChanges && (
|
||||
<span className="text-xs text-muted">Unsaved</span>
|
||||
)}
|
||||
{hasDraft && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDiscardDraft}
|
||||
disabled={isSaving || isPublishing}
|
||||
>
|
||||
<Icons.Undo className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-1.5">Discard</span>
|
||||
</Button>
|
||||
)}
|
||||
{!post.draft ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUnpublish}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
<Icons.EyeOff className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-1.5">Unpublish</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<Icons.Loader className="w-4 h-4 animate-spin" />
|
||||
<span className="hidden sm:inline ml-1.5">Publishing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Send className="w-4 h-4" />
|
||||
<span className="hidden sm:inline ml-1.5">Publish</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* Editor */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{mode === 'edit' ? (
|
||||
<PostEditor onChange={handleContentChange} />
|
||||
) : (
|
||||
<SourceEditor onChange={handleContentChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Panel (Slide-in) */}
|
||||
{showMetadata && (
|
||||
<MetadataPanel onClose={() => setShowMetadata(false)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
studio/src/pages/PostsPage.tsx
Normal file
302
studio/src/pages/PostsPage.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { $posts, $deletePost } from '../stores/posts'
|
||||
import { $analytics } from '../stores/analytics'
|
||||
import { addToast } from '../stores/app'
|
||||
import { Button, Modal, Badge, ActionMenu, Input, Tabs } from '../components/ui'
|
||||
import { EmptyState, PostsPageSkeleton, PageHeader } from '../components/shared'
|
||||
import { Icons } from '../components/shared/Icons'
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { Post } from '../types'
|
||||
|
||||
type FilterTab = 'all' | 'published' | 'drafts'
|
||||
|
||||
export default function PostsPage() {
|
||||
const { data, error } = useStore($posts)
|
||||
const { data: analytics } = useStore($analytics)
|
||||
const deletePostMutation = useStore($deletePost)
|
||||
const [deleteModal, setDeleteModal] = useState<Post | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState<FilterTab>('all')
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteModal) return
|
||||
try {
|
||||
await deletePostMutation.mutate(deleteModal.slug)
|
||||
addToast('Post deleted', 'success')
|
||||
setDeleteModal(null)
|
||||
} catch {
|
||||
addToast('Failed to delete post', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const copyUrl = (slug: string) => {
|
||||
const url = `${window.location.origin}/posts/${slug}`
|
||||
navigator.clipboard.writeText(url)
|
||||
addToast('URL copied', 'success')
|
||||
}
|
||||
|
||||
const getPostViews = (slug: string): number => {
|
||||
if (!analytics?.top_pages) return 0
|
||||
const page = analytics.top_pages.find(p => p.path === `/posts/${slug}`)
|
||||
return page?.views || 0
|
||||
}
|
||||
|
||||
const formatRelativeTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatViews = (views: number) => {
|
||||
if (views >= 1000) return `${(views / 1000).toFixed(1)}k`
|
||||
return views.toString()
|
||||
}
|
||||
|
||||
const filteredPosts = useMemo(() => {
|
||||
if (!data) return []
|
||||
|
||||
let posts = [...data]
|
||||
|
||||
if (filter === 'published') {
|
||||
posts = posts.filter(p => !p.draft)
|
||||
} else if (filter === 'drafts') {
|
||||
posts = posts.filter(p => p.draft)
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase()
|
||||
posts = posts.filter(p =>
|
||||
p.title.toLowerCase().includes(q) ||
|
||||
p.slug.toLowerCase().includes(q) ||
|
||||
p.tags.some(t => t.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
return posts.sort((a, b) => {
|
||||
if (a.draft && !b.draft) return -1
|
||||
if (!a.draft && b.draft) return 1
|
||||
const dateA = a.draft ? new Date(a.updated_at) : new Date(a.date)
|
||||
const dateB = b.draft ? new Date(b.updated_at) : new Date(b.date)
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
}, [data, filter, search])
|
||||
|
||||
if (!data) return <PostsPageSkeleton />
|
||||
if (error) return <EmptyState Icon={Icons.AlertCircle} title="Failed to load posts" description={error.message} />
|
||||
|
||||
const totalPosts = data.length
|
||||
const draftCount = data.filter(p => p.draft).length
|
||||
const publishedCount = data.filter(p => !p.draft).length
|
||||
const totalViews = analytics?.total_views || 0
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader />
|
||||
<EmptyState
|
||||
Icon={Icons.Posts}
|
||||
title="No posts yet"
|
||||
description="Create your first post to get started"
|
||||
action={<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">New Post</Button>}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ value: 'all' as FilterTab, label: `All (${totalPosts})` },
|
||||
{ value: 'published' as FilterTab, label: `Published (${publishedCount})` },
|
||||
{ value: 'drafts' as FilterTab, label: `Drafts (${draftCount})` },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Button variant="primary" Icon={Icons.Plus} href="/studio/posts/new">New Post</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Panel container - full-bleed borders */}
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
{/* Stats row */}
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '33.333%' }} />
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '66.666%' }} />
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="py-4 pl-6 lg:pl-10 pr-6">
|
||||
<div className="text-xs text-muted mb-0.5">Total Posts</div>
|
||||
<div className="text-xl font-semibold tracking-tight">{totalPosts}</div>
|
||||
</div>
|
||||
<div className="py-4 px-6">
|
||||
<div className="text-xs text-muted mb-0.5">Published</div>
|
||||
<div className="text-xl font-semibold tracking-tight">{publishedCount}</div>
|
||||
</div>
|
||||
<div className="py-4 pr-6 lg:pr-10 pl-6">
|
||||
<div className="text-xs text-muted mb-0.5">Total Views</div>
|
||||
<div className="text-xl font-semibold tracking-tight">{totalViews.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Filter and Search */}
|
||||
<div className="px-6 lg:px-10 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<Tabs value={filter} onChange={setFilter} tabs={tabs} />
|
||||
<div className="flex-1 sm:max-w-xs sm:ml-auto">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search posts..."
|
||||
Icon={Icons.Search}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Posts list */}
|
||||
{filteredPosts.length === 0 ? (
|
||||
<div className="px-6 lg:px-10 py-12">
|
||||
<EmptyState
|
||||
Icon={Icons.Search}
|
||||
title="No posts found"
|
||||
description={search ? `No posts matching "${search}"` : 'No posts in this category'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
filteredPosts.map((post, i) => {
|
||||
const views = getPostViews(post.slug)
|
||||
const isDraft = post.draft
|
||||
const hasWarning = !isDraft && !post.description
|
||||
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`group relative flex items-center gap-4 px-6 lg:px-10 py-4 transition-colors hover:bg-surface/50 cursor-pointer ${i > 0 ? 'border-t border-border' : ''}`}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-action-menu]')) return
|
||||
window.location.href = `/studio/posts/${post.slug}/edit`
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={`/studio/posts/${post.slug}/edit`}
|
||||
className="flex-1 min-w-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
{isDraft && <Badge variant="draft">Draft</Badge>}
|
||||
<h3 className="text-sm font-medium text-text truncate group-hover:text-accent transition-colors">
|
||||
{post.title || 'Untitled'}
|
||||
</h3>
|
||||
{post.members_only && (
|
||||
<Icons.Crown className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" title="Members only" />
|
||||
)}
|
||||
{hasWarning && (
|
||||
<span className="flex-shrink-0 text-[10px] text-warning/80 font-medium uppercase tracking-wide">
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted">
|
||||
<code className="text-[11px] opacity-60">/posts/{post.slug}</code>
|
||||
<span className="text-border">|</span>
|
||||
{isDraft ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icons.Clock className="w-3 h-3 opacity-60" />
|
||||
{formatRelativeTime(post.updated_at)}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{formatDate(post.date)}</span>
|
||||
{views > 0 && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Icons.Eye className="w-3 h-3 opacity-60" />
|
||||
{formatViews(views)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{post.tags.length > 0 && (
|
||||
<>
|
||||
<span className="text-border hidden sm:inline">|</span>
|
||||
<span className="truncate opacity-70 hidden sm:inline">
|
||||
{post.tags.slice(0, 2).join(', ')}
|
||||
{post.tags.length > 2 && ` +${post.tags.length - 2}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<ActionMenu
|
||||
items={[
|
||||
{
|
||||
label: 'Edit',
|
||||
Icon: Icons.Edit,
|
||||
href: `/studio/posts/${post.slug}/edit`,
|
||||
},
|
||||
...(!isDraft ? [{
|
||||
label: 'View',
|
||||
Icon: Icons.Eye,
|
||||
href: `/posts/${post.slug}`,
|
||||
external: true,
|
||||
}] : []),
|
||||
{
|
||||
label: 'Copy URL',
|
||||
Icon: Icons.Copy,
|
||||
onClick: () => copyUrl(post.slug),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
Icon: Icons.Trash,
|
||||
variant: 'danger' as const,
|
||||
onClick: () => setDeleteModal(post),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={!!deleteModal}
|
||||
onClose={() => setDeleteModal(null)}
|
||||
title="Delete Post"
|
||||
>
|
||||
<p className="text-sm text-muted mb-6">
|
||||
Are you sure you want to delete <span className="font-medium text-text">"{deleteModal?.title}"</span>? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => setDeleteModal(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} loading={deletePostMutation.loading}>
|
||||
Delete Post
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
studio/src/pages/index.ts
Normal file
10
studio/src/pages/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export { default as PostsPage } from './PostsPage'
|
||||
export { default as AnalyticsPage } from './AnalyticsPage'
|
||||
export { default as GeneralPage } from './GeneralPage'
|
||||
export { default as DesignPage } from './DesignPage'
|
||||
export { default as DomainPage } from './DomainPage'
|
||||
export { default as EngagementPage } from './EngagementPage'
|
||||
export { default as MonetizationPage } from './MonetizationPage'
|
||||
export { default as APIPage } from './APIPage'
|
||||
export { default as DataPage } from './DataPage'
|
||||
export { default as BillingPage } from './BillingPage'
|
||||
10
studio/src/stores/analytics.ts
Normal file
10
studio/src/stores/analytics.ts
Normal 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,
|
||||
])
|
||||
34
studio/src/stores/apiKeys.ts
Normal file
34
studio/src/stores/apiKeys.ts
Normal 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
19
studio/src/stores/app.ts
Normal 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))
|
||||
}
|
||||
33
studio/src/stores/assets.ts
Normal file
33
studio/src/stores/assets.ts
Normal 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')
|
||||
}
|
||||
)
|
||||
4
studio/src/stores/billing.ts
Normal file
4
studio/src/stores/billing.ts
Normal 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
467
studio/src/stores/editor.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
24
studio/src/stores/fetcher.ts
Normal file
24
studio/src/stores/fetcher.ts
Normal 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
|
||||
})
|
||||
}
|
||||
35
studio/src/stores/hooks.ts
Normal file
35
studio/src/stores/hooks.ts
Normal 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}`] || ''
|
||||
}
|
||||
9
studio/src/stores/index.ts
Normal file
9
studio/src/stores/index.ts
Normal 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'
|
||||
74
studio/src/stores/interactions.ts
Normal file
74
studio/src/stores/interactions.ts
Normal 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
|
||||
}
|
||||
)
|
||||
102
studio/src/stores/plugins.ts
Normal file
102
studio/src/stores/plugins.ts
Normal 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' },
|
||||
]
|
||||
41
studio/src/stores/posts.ts
Normal file
41
studio/src/stores/posts.ts
Normal 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')
|
||||
}
|
||||
)
|
||||
18
studio/src/stores/router.ts
Normal file
18
studio/src/stores/router.ts
Normal 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',
|
||||
})
|
||||
36
studio/src/stores/secrets.ts
Normal file
36
studio/src/stores/secrets.ts
Normal 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))
|
||||
}
|
||||
})
|
||||
93
studio/src/stores/settings.ts
Normal file
93
studio/src/stores/settings.ts
Normal 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
|
||||
}
|
||||
)
|
||||
69
studio/src/stores/webhooks.ts
Normal file
69
studio/src/stores/webhooks.ts
Normal 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
128
studio/src/types.ts
Normal 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
20
studio/tsconfig.json
Normal 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
259
studio/uno.config.ts
Normal 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
47
studio/vite.config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue