refactor: move studio to frontends workspace

- Move studio from root to frontends/studio/
- Add owner-tools frontend for live blog admin UI
- Add shared ui component library
- Set up npm workspaces for frontends
- Add enhanced code block extension for editor

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Josh 2026-01-12 01:59:56 +02:00
parent c662e41b97
commit bef5dd4437
108 changed files with 8650 additions and 441 deletions

View file

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

View file

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

View file

@ -0,0 +1,343 @@
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 { Markdown } from '@tiptap/markdown'
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'
import { CodeBlockEnhanced } from './extensions/code-block'
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 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,
}),
CodeBlockEnhanced,
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
const currentContent = editor.getMarkdown()
if (currentContent !== post.content) {
skipNextUpdate.current = true
editor.commands.setContent(post.content || '', { contentType: 'markdown' })
}
}, [editor, post.content])
useEffect(() => {
const codeTheme = settings.code_theme || 'github'
const id = 'code-theme-css'
let link = document.getElementById(id) as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.id = id
link.rel = 'stylesheet'
document.head.appendChild(link)
}
link.href = `/api/studio/code-theme.css?theme=${codeTheme}`
}, [settings.code_theme])
if (!editor) return null
return (
<div
ref={wrapperRef}
className={`editor-wrapper h-full overflow-auto cursor-text relative ${isDragging ? 'bg-accent/5' : ''}`}
onClick={handleWrapperClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="px-4 py-2 bg-accent text-white text-sm font-medium rounded">
Drop image to upload
</div>
</div>
)}
<BubbleMenu
editor={editor}
className="flex items-center gap-0.5 p-1 bg-surface border border-border shadow-lg rounded"
>
{showLinkInput ? (
<div className="flex items-center gap-1 px-1">
<input
type="text"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
setLink()
}
if (e.key === 'Escape') {
setShowLinkInput(false)
setLinkUrl('')
}
}}
placeholder="Enter URL..."
className="w-48 px-2 py-1 text-xs bg-bg border border-border rounded focus:outline-none focus:border-accent"
autoFocus
/>
<button
onClick={setLink}
className="p-1.5 text-accent hover:bg-accent/10 rounded"
>
<Icons.Check className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
setShowLinkInput(false)
setLinkUrl('')
}}
className="p-1.5 text-muted hover:bg-bg rounded"
>
<Icons.Close className="w-3.5 h-3.5" />
</button>
</div>
) : (
<>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<Icons.Bold className="w-3.5 h-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<Icons.Italic className="w-3.5 h-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Strikethrough"
>
<Icons.Strikethrough className="w-3.5 h-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title="Inline Code (Ctrl+E)"
>
<Icons.CodeInline className="w-3.5 h-3.5" />
</ToolbarButton>
<div className="w-px h-4 bg-border mx-1" />
<ToolbarButton
onClick={openLinkInput}
isActive={editor.isActive('link')}
title="Add Link (Ctrl+K)"
>
<Icons.Link className="w-3.5 h-3.5" />
</ToolbarButton>
{editor.isActive('link') && (
<ToolbarButton
onClick={() => editor.chain().focus().unsetLink().run()}
title="Remove Link"
>
<Icons.LinkOff className="w-3.5 h-3.5" />
</ToolbarButton>
)}
</>
)}
</BubbleMenu>
<EditorContent editor={editor} />
</div>
)
}
function ToolbarButton({
onClick,
isActive,
title,
children,
}: {
onClick: () => void
isActive?: boolean
title: string
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
title={title}
className={`p-1.5 rounded transition-colors ${
isActive
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-text hover:bg-bg'
}`}
>
{children}
</button>
)
}

View file

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

View file

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

View file

@ -0,0 +1,104 @@
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { common, createLowlight } from 'lowlight'
import { CodeBlockView } from './CodeBlockView'
import type { MarkdownToken, JSONContent, MarkdownRendererHelpers, RenderContext, MarkdownParseHelpers } from '@tiptap/core'
export const lowlight = createLowlight(common)
function parseInfoString(info: string): { language: string | null; title: string | null } {
const trimmed = info.trim()
const titleMatch = trimmed.match(/title=["']([^"']+)["']/)
const title = titleMatch ? titleMatch[1] : null
const languagePart = trimmed.replace(/title=["'][^"']+["']/, '').trim()
const language = languagePart || null
return { language, title }
}
function buildInfoString(language: string | null, title: string | null): string {
const parts: string[] = []
if (language) parts.push(language)
if (title) parts.push(`title="${title}"`)
return parts.join(' ')
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
codeBlockEnhanced: {
setCodeBlock: (attributes?: { language?: string; title?: string }) => ReturnType
}
}
}
export const CodeBlockEnhanced = CodeBlockLowlight.extend({
name: 'codeBlock',
addAttributes() {
return {
...this.parent?.(),
language: {
default: null,
parseHTML: (element) => {
const classAttr = element.querySelector('code')?.getAttribute('class')
if (classAttr) {
const match = classAttr.match(/language-(\w+)/)
return match ? match[1] : null
}
return null
},
},
title: {
default: null,
parseHTML: (element) => element.getAttribute('data-title'),
renderHTML: (attributes) => {
if (!attributes.title) return {}
return { 'data-title': attributes.title }
},
},
}
},
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView)
},
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: ({ editor }) => {
if (!editor.isActive('codeBlock')) return false
editor.commands.insertContent(' ')
return true
},
'Shift-Tab': () => {
return true
},
}
},
markdownTokenName: 'code',
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
if (token.raw?.startsWith('```') === false && token.codeBlockStyle !== 'indented') {
return []
}
const info = (token.lang as string) || ''
const { language, title } = parseInfoString(info)
const text = (token.text as string) || ''
return helpers.createNode(
'codeBlock',
{ language, title },
text ? [helpers.createTextNode(text)] : []
)
},
renderMarkdown(node: JSONContent, helpers: MarkdownRendererHelpers, _ctx: RenderContext) {
const language = node.attrs?.language || ''
const title = node.attrs?.title || null
const info = buildInfoString(language, title)
const content = node.content ? helpers.renderChildren(node.content) : ''
return '```' + info + '\n' + content + '\n```'
},
}).configure({
lowlight,
})

View file

@ -0,0 +1,168 @@
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { useState, useCallback, useRef, useEffect } from 'react'
import { getLanguageIconUrl, getLanguageDisplayName, SUPPORTED_LANGUAGES } from './icons'
import { Icons } from '../../../shared/Icons'
export function CodeBlockView({ node, updateAttributes, extension }: NodeViewProps) {
const { language, title } = node.attrs
const [showLanguageMenu, setShowLanguageMenu] = useState(false)
const [copied, setCopied] = useState(false)
const [isEditingTitle, setIsEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState(title || '')
const titleInputRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const iconUrl = language ? getLanguageIconUrl(language) : null
const displayName = language ? getLanguageDisplayName(language) : 'Plain text'
const handleCopy = useCallback(async () => {
const code = node.textContent
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [node.textContent])
const handleLanguageSelect = useCallback((lang: string) => {
updateAttributes({ language: lang })
setShowLanguageMenu(false)
}, [updateAttributes])
const handleTitleSubmit = useCallback(() => {
updateAttributes({ title: titleValue || null })
setIsEditingTitle(false)
}, [titleValue, updateAttributes])
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleTitleSubmit()
}
if (e.key === 'Escape') {
setTitleValue(title || '')
setIsEditingTitle(false)
}
}, [handleTitleSubmit, title])
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.select()
}
}, [isEditingTitle])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowLanguageMenu(false)
}
}
if (showLanguageMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showLanguageMenu])
return (
<NodeViewWrapper className="code-block-wrapper relative my-6">
{/* Header bar */}
<div className="flex items-center gap-2 px-3 py-2 bg-zinc-50 border border-zinc-200 border-b-0 rounded-t-md">
{/* Language selector */}
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setShowLanguageMenu(!showLanguageMenu)}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded transition-colors"
contentEditable={false}
>
{iconUrl && (
<img src={iconUrl} alt="" className="w-3.5 h-3.5" />
)}
<span>{displayName}</span>
<Icons.ChevronDown className="w-3 h-3 opacity-50" />
</button>
{showLanguageMenu && (
<div className="absolute top-full left-0 mt-1 w-48 max-h-64 overflow-y-auto bg-white border border-zinc-200 rounded-md shadow-lg z-50">
{SUPPORTED_LANGUAGES.map((lang) => {
const langIcon = getLanguageIconUrl(lang)
return (
<button
key={lang}
type="button"
onClick={() => handleLanguageSelect(lang)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-zinc-50 transition-colors ${
language === lang ? 'bg-zinc-100 text-zinc-900' : 'text-zinc-600'
}`}
>
{langIcon && <img src={langIcon} alt="" className="w-3.5 h-3.5" />}
<span>{getLanguageDisplayName(lang)}</span>
</button>
)
})}
</div>
)}
</div>
{/* Divider */}
<div className="w-px h-4 bg-zinc-200" />
{/* Title (editable) */}
<div className="flex-1 min-w-0">
{isEditingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={handleTitleSubmit}
onKeyDown={handleTitleKeyDown}
placeholder="filename.ext"
className="w-full px-1 py-0.5 text-xs bg-white border border-zinc-300 rounded focus:outline-none focus:border-zinc-400"
contentEditable={false}
/>
) : (
<button
type="button"
onClick={() => {
setTitleValue(title || '')
setIsEditingTitle(true)
}}
className="text-xs text-zinc-500 hover:text-zinc-700 truncate max-w-full"
contentEditable={false}
>
{title || 'Add title...'}
</button>
)}
</div>
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 rounded transition-colors"
contentEditable={false}
>
{copied ? (
<>
<Icons.Check className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600">Copied</span>
</>
) : (
<>
<Icons.Copy className="w-3 h-3" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Code content */}
<pre className="!mt-0 !rounded-t-none">
<NodeViewContent as="code" className={language ? `language-${language}` : ''} />
</pre>
</NodeViewWrapper>
)
}

View file

@ -0,0 +1,174 @@
/**
* Language to Iconify icon mapping
* Uses simple-icons set via Iconify API
* Format: https://api.iconify.design/simple-icons/{slug}.svg
*/
export const LANGUAGE_ICONS: Record<string, string> = {
// Web
javascript: 'javascript',
js: 'javascript',
typescript: 'typescript',
ts: 'typescript',
html: 'html5',
css: 'css3',
scss: 'sass',
sass: 'sass',
less: 'less',
// Frontend frameworks
react: 'react',
jsx: 'react',
tsx: 'react',
vue: 'vuedotjs',
svelte: 'svelte',
angular: 'angular',
astro: 'astro',
// Backend
python: 'python',
py: 'python',
go: 'go',
golang: 'go',
rust: 'rust',
java: 'openjdk',
kotlin: 'kotlin',
scala: 'scala',
ruby: 'ruby',
rb: 'ruby',
php: 'php',
csharp: 'csharp',
cs: 'csharp',
cpp: 'cplusplus',
c: 'c',
swift: 'swift',
// Data & config
json: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
xml: 'xml',
// Shell & scripting
bash: 'gnubash',
sh: 'gnubash',
shell: 'gnubash',
zsh: 'gnubash',
powershell: 'powershell',
ps1: 'powershell',
// Database
sql: 'postgresql',
mysql: 'mysql',
postgres: 'postgresql',
postgresql: 'postgresql',
mongodb: 'mongodb',
redis: 'redis',
// Other
graphql: 'graphql',
gql: 'graphql',
docker: 'docker',
dockerfile: 'docker',
markdown: 'markdown',
md: 'markdown',
lua: 'lua',
elixir: 'elixir',
erlang: 'erlang',
haskell: 'haskell',
clojure: 'clojure',
zig: 'zig',
nim: 'nim',
r: 'r',
julia: 'julia',
dart: 'dart',
flutter: 'flutter',
solidity: 'solidity',
terraform: 'terraform',
nginx: 'nginx',
apache: 'apache',
}
/**
* Get Iconify API URL for a language
*/
export function getLanguageIconUrl(language: string): string | null {
const slug = LANGUAGE_ICONS[language.toLowerCase()]
if (!slug) return null
return `https://api.iconify.design/simple-icons/${slug}.svg?color=%2371717a`
}
/**
* Get display name for a language
*/
export function getLanguageDisplayName(language: string): string {
const displayNames: Record<string, string> = {
javascript: 'JavaScript',
typescript: 'TypeScript',
python: 'Python',
go: 'Go',
rust: 'Rust',
java: 'Java',
kotlin: 'Kotlin',
ruby: 'Ruby',
php: 'PHP',
csharp: 'C#',
cpp: 'C++',
c: 'C',
swift: 'Swift',
html: 'HTML',
css: 'CSS',
scss: 'SCSS',
json: 'JSON',
yaml: 'YAML',
bash: 'Bash',
sql: 'SQL',
graphql: 'GraphQL',
markdown: 'Markdown',
docker: 'Docker',
jsx: 'JSX',
tsx: 'TSX',
vue: 'Vue',
svelte: 'Svelte',
}
return displayNames[language.toLowerCase()] || language
}
/**
* All supported languages for the language selector
*/
export const SUPPORTED_LANGUAGES = [
'javascript',
'typescript',
'python',
'go',
'rust',
'java',
'kotlin',
'ruby',
'php',
'csharp',
'cpp',
'c',
'swift',
'html',
'css',
'scss',
'json',
'yaml',
'bash',
'sql',
'graphql',
'markdown',
'docker',
'jsx',
'tsx',
'vue',
'svelte',
'lua',
'elixir',
'haskell',
'dart',
'terraform',
] as const

View file

@ -0,0 +1,3 @@
export { CodeBlockEnhanced } from './CodeBlockExtension'
export { CodeBlockView } from './CodeBlockView'
export { getLanguageIconUrl, getLanguageDisplayName, SUPPORTED_LANGUAGES, LANGUAGE_ICONS } from './icons'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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