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:
parent
c662e41b97
commit
bef5dd4437
108 changed files with 8650 additions and 441 deletions
198
frontends/studio/src/components/editor/MetadataPanel.tsx
Normal file
198
frontends/studio/src/components/editor/MetadataPanel.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $editorPost, $versions, $isNewPost, restoreVersion } from '../../stores/editor'
|
||||
import { addToast } from '../../stores/app'
|
||||
import { Icons } from '../shared/Icons'
|
||||
import { Input, Textarea, Toggle } from '../ui'
|
||||
|
||||
interface MetadataPanelProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MetadataPanel({ onClose }: MetadataPanelProps) {
|
||||
const post = useStore($editorPost)
|
||||
const versions = useStore($versions)
|
||||
const isNew = useStore($isNewPost)
|
||||
const [restoringId, setRestoringId] = useState<number | null>(null)
|
||||
|
||||
const handleTagsChange = (value: string) => {
|
||||
const tags = value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
$editorPost.setKey('tags', tags)
|
||||
}
|
||||
|
||||
const handleRestore = async (versionId: number) => {
|
||||
if (!confirm('Restore this version? Your current draft will be replaced.')) return
|
||||
setRestoringId(versionId)
|
||||
const success = await restoreVersion(versionId)
|
||||
setRestoringId(null)
|
||||
if (success) {
|
||||
addToast('Version restored', 'success')
|
||||
} else {
|
||||
addToast('Failed to restore version', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-80 flex-none border-l border-border bg-surface overflow-y-auto">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">Post Settings</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-muted hover:text-text rounded"
|
||||
>
|
||||
<Icons.Close className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5">
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="label">URL Slug</label>
|
||||
<Input
|
||||
value={post.slug}
|
||||
onChange={v => $editorPost.setKey('slug', v.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
|
||||
placeholder="my-post-title"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1">/posts/{post.slug || 'my-post-title'}</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="label">Description</label>
|
||||
<Textarea
|
||||
value={post.description}
|
||||
onChange={v => $editorPost.setKey('description', v)}
|
||||
placeholder="Brief summary for SEO and social shares..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cover Image */}
|
||||
<div>
|
||||
<label className="label">Cover Image</label>
|
||||
{post.cover_image ? (
|
||||
<div className="space-y-2">
|
||||
<div className="relative aspect-video bg-border/50 overflow-hidden rounded">
|
||||
<img
|
||||
src={post.cover_image}
|
||||
alt="Cover"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => $editorPost.setKey('cover_image', '')}
|
||||
className="absolute top-2 right-2 p-1 bg-bg/80 hover:bg-bg rounded text-muted hover:text-text transition-colors"
|
||||
title="Remove cover"
|
||||
>
|
||||
<Icons.Close className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={post.cover_image || ''}
|
||||
onChange={v => $editorPost.setKey('cover_image', v)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted mt-1">Used for social sharing and post headers</p>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="label">Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={post.date}
|
||||
onChange={v => $editorPost.setKey('date', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="label">Tags</label>
|
||||
<Input
|
||||
value={post.tags.join(', ')}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="react, typescript, tutorial"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1">Comma-separated</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-3">Publishing</h4>
|
||||
|
||||
{/* Draft Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Draft</div>
|
||||
<div className="text-xs text-muted">Not visible to readers</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={post.draft}
|
||||
onChange={v => $editorPost.setKey('draft', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Members Only Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Members only</div>
|
||||
<div className="text-xs text-muted">Requires login to view</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={post.members_only}
|
||||
onChange={v => $editorPost.setKey('members_only', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version History */}
|
||||
{!isNew && versions.length > 0 && (
|
||||
<div className="border-t border-border pt-4">
|
||||
<h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-3">
|
||||
<Icons.History className="w-3.5 h-3.5 inline-block mr-1.5 -mt-0.5" />
|
||||
Version History
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="flex items-center justify-between py-2 px-2 -mx-2 rounded hover:bg-bg"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm truncate">{version.title}</div>
|
||||
<div className="text-xs text-muted">{formatDate(version.created_at)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-2 py-1 text-xs text-muted hover:text-text hover:bg-bg/50 rounded transition-colors disabled:opacity-50"
|
||||
onClick={() => handleRestore(version.id)}
|
||||
disabled={restoringId !== null}
|
||||
>
|
||||
{restoringId === version.id ? (
|
||||
<Icons.Loader className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
'Restore'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
722
frontends/studio/src/components/editor/PluginEditor.tsx
Normal file
722
frontends/studio/src/components/editor/PluginEditor.tsx
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import Editor, { type Monaco } from '@monaco-editor/react'
|
||||
import type { editor, languages, IDisposable, Position } from 'monaco-editor'
|
||||
|
||||
interface PluginEditorProps {
|
||||
language: 'typescript' | 'go'
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
height?: string
|
||||
secretKeys?: string[]
|
||||
hook?: string
|
||||
}
|
||||
|
||||
interface SDKFunction {
|
||||
name: string
|
||||
signature: string
|
||||
insertText: string
|
||||
documentation: string
|
||||
}
|
||||
|
||||
interface SDKNamespace {
|
||||
functions?: SDKFunction[]
|
||||
}
|
||||
|
||||
interface SDKField {
|
||||
name: string
|
||||
type: string
|
||||
doc?: string
|
||||
}
|
||||
|
||||
interface SDKEventType {
|
||||
fields: SDKField[]
|
||||
}
|
||||
|
||||
interface SDKSchema {
|
||||
Runner: SDKNamespace
|
||||
events?: Record<string, SDKEventType>
|
||||
nestedTypes?: Record<string, SDKField[]>
|
||||
}
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
typescript: 'typescript',
|
||||
go: 'go',
|
||||
}
|
||||
|
||||
// TypeScript SDK type definitions - well-formatted for hover display
|
||||
const getTypeScriptSDK = (secretKeys: string[]) => {
|
||||
const secretsType = secretKeys.length > 0
|
||||
? `{\n${secretKeys.map(k => ` /** Secret: ${k} */\n ${k}: string;`).join('\n')}\n }`
|
||||
: 'Record<string, string>'
|
||||
|
||||
return `
|
||||
/**
|
||||
* Runner provides all plugin capabilities
|
||||
*/
|
||||
declare namespace Runner {
|
||||
/** Log a message (visible in plugin logs) */
|
||||
function log(message: string): void;
|
||||
|
||||
/** Make an HTTP request to external services */
|
||||
function httpRequest(options: HttpRequestOptions): HttpResponse;
|
||||
|
||||
/** Access your configured secrets */
|
||||
const secrets: ${secretsType};
|
||||
}
|
||||
|
||||
/** Options for making HTTP requests */
|
||||
interface HttpRequestOptions {
|
||||
/** The URL to request */
|
||||
url: string;
|
||||
/** HTTP method (default: GET) */
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
/** HTTP headers to send */
|
||||
headers?: Record<string, string>;
|
||||
/** Request body (for POST/PUT/PATCH) */
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** Response from an HTTP request */
|
||||
interface HttpResponse {
|
||||
/** HTTP status code */
|
||||
status: number;
|
||||
/** Response headers */
|
||||
headers: Record<string, string>;
|
||||
/** Response body as string */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Result for validation hooks (comment.validate, etc.) */
|
||||
interface ValidationResult {
|
||||
/** Whether the action is allowed */
|
||||
allowed: boolean;
|
||||
/** Reason for rejection (shown to user if allowed=false) */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Blog post information */
|
||||
interface Post {
|
||||
/** URL-safe identifier */
|
||||
slug: string;
|
||||
/** Post title */
|
||||
title: string;
|
||||
/** Full URL to the post */
|
||||
url: string;
|
||||
/** Short excerpt of the content */
|
||||
excerpt: string;
|
||||
/** ISO date when published */
|
||||
publishedAt: string;
|
||||
/** ISO date when last updated */
|
||||
updatedAt?: string;
|
||||
/** Post tags */
|
||||
tags: string[];
|
||||
/** Estimated reading time in minutes */
|
||||
readingTime: number;
|
||||
}
|
||||
|
||||
/** Author information */
|
||||
interface Author {
|
||||
/** Author's display name */
|
||||
name: string;
|
||||
/** Author's email */
|
||||
email: string;
|
||||
/** URL to author's avatar image */
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
/** Blog information */
|
||||
interface Blog {
|
||||
/** Blog name */
|
||||
name: string;
|
||||
/** Blog URL */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a post is published
|
||||
* @example
|
||||
* export function onPostPublished(event: PostPublishedEvent): void {
|
||||
* Runner.log(\`Published: \${event.post.title}\`);
|
||||
* }
|
||||
*/
|
||||
interface PostPublishedEvent {
|
||||
/** The published post */
|
||||
post: Post;
|
||||
/** The post author */
|
||||
author: Author;
|
||||
/** The blog where it was published */
|
||||
blog: Blog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a post is updated
|
||||
*/
|
||||
interface PostUpdatedEvent {
|
||||
/** The updated post */
|
||||
post: Post;
|
||||
/** The author who made the update */
|
||||
author: Author;
|
||||
/** What changed in this update */
|
||||
changes: {
|
||||
/** Title change (old and new values) */
|
||||
title?: { old: string; new: string };
|
||||
/** Whether content was modified */
|
||||
content?: boolean;
|
||||
/** Tags that were added or removed */
|
||||
tags?: { added: string[]; removed: string[] };
|
||||
};
|
||||
}
|
||||
|
||||
/** Comment information */
|
||||
interface Comment {
|
||||
/** Unique comment ID */
|
||||
id: string;
|
||||
/** Comment content (may contain markdown) */
|
||||
content: string;
|
||||
/** Commenter's display name */
|
||||
authorName: string;
|
||||
/** Commenter's email */
|
||||
authorEmail: string;
|
||||
/** Slug of the post being commented on */
|
||||
postSlug: string;
|
||||
/** Parent comment ID for replies */
|
||||
parentId?: string;
|
||||
/** ISO date when created */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a comment is created
|
||||
*/
|
||||
interface CommentCreatedEvent {
|
||||
/** The new comment */
|
||||
comment: Comment;
|
||||
/** The post being commented on */
|
||||
post: {
|
||||
slug: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Member/subscriber information */
|
||||
interface Member {
|
||||
/** Member's email */
|
||||
email: string;
|
||||
/** Member's name (if provided) */
|
||||
name?: string;
|
||||
/** ISO date when subscribed */
|
||||
subscribedAt: string;
|
||||
}
|
||||
|
||||
/** Subscription tier information */
|
||||
interface Tier {
|
||||
/** Tier name (e.g., "Free", "Premium") */
|
||||
name: string;
|
||||
/** Monthly price in cents (0 for free tier) */
|
||||
price: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a member subscribes
|
||||
*/
|
||||
interface MemberSubscribedEvent {
|
||||
/** The new member */
|
||||
member: Member;
|
||||
/** The subscription tier they signed up for */
|
||||
tier: Tier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when an asset (image, file) is uploaded
|
||||
*/
|
||||
interface AssetUploadedEvent {
|
||||
/** Unique asset ID */
|
||||
id: string;
|
||||
/** Public URL to access the asset */
|
||||
url: string;
|
||||
/** MIME type (e.g., "image/png") */
|
||||
contentType: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Image width in pixels (for images only) */
|
||||
width?: number;
|
||||
/** Image height in pixels (for images only) */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when analytics data is synced
|
||||
*/
|
||||
interface AnalyticsSyncEvent {
|
||||
/** Time period for this analytics data */
|
||||
period: {
|
||||
/** ISO date for period start */
|
||||
start: string;
|
||||
/** ISO date for period end */
|
||||
end: string;
|
||||
};
|
||||
/** Total pageviews in this period */
|
||||
pageviews: number;
|
||||
/** Unique visitors in this period */
|
||||
visitors: number;
|
||||
/** Top pages by view count */
|
||||
topPages: Array<{
|
||||
/** Page path (e.g., "/posts/my-post") */
|
||||
path: string;
|
||||
/** Number of views */
|
||||
views: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for comment validation hook
|
||||
* Return ValidationResult to allow or reject the comment
|
||||
*/
|
||||
interface CommentInput {
|
||||
/** Comment content to validate */
|
||||
content: string;
|
||||
/** Commenter's name */
|
||||
authorName: string;
|
||||
/** Commenter's email */
|
||||
authorEmail: string;
|
||||
/** Post slug being commented on */
|
||||
postSlug: string;
|
||||
/** Parent comment ID (for replies) */
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for content rendering hook
|
||||
*/
|
||||
interface ContentRenderInput {
|
||||
/** HTML content to transform */
|
||||
html: string;
|
||||
/** Post metadata */
|
||||
post: {
|
||||
slug: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Output for content rendering hook
|
||||
*/
|
||||
interface ContentRenderOutput {
|
||||
/** Transformed HTML content */
|
||||
html: string;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
const defineWriteKitTheme = (monaco: Monaco) => {
|
||||
monaco.editor.defineTheme('writekit-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: false,
|
||||
rules: [
|
||||
// Base text - warm gray for readability
|
||||
{ token: '', foreground: 'c4c4c4' },
|
||||
|
||||
// Comments - muted, italic
|
||||
{ token: 'comment', foreground: '525252', fontStyle: 'italic' },
|
||||
{ token: 'comment.doc', foreground: '5c5c5c', fontStyle: 'italic' },
|
||||
|
||||
// Keywords - subtle off-white, not too bright
|
||||
{ token: 'keyword', foreground: 'd4d4d4' },
|
||||
{ token: 'keyword.control', foreground: 'd4d4d4' },
|
||||
{ token: 'keyword.operator', foreground: '9ca3af' },
|
||||
|
||||
// Strings - emerald accent (WriteKit brand)
|
||||
{ token: 'string', foreground: '34d399' },
|
||||
{ token: 'string.key', foreground: 'a3a3a3' },
|
||||
{ token: 'string.escape', foreground: '6ee7b7' },
|
||||
|
||||
// Numbers - muted amber for contrast
|
||||
{ token: 'number', foreground: 'fbbf24' },
|
||||
{ token: 'number.hex', foreground: 'f59e0b' },
|
||||
|
||||
// Types/Interfaces - cyan accent
|
||||
{ token: 'type', foreground: '22d3ee' },
|
||||
{ token: 'type.identifier', foreground: '22d3ee' },
|
||||
{ token: 'class', foreground: '22d3ee' },
|
||||
{ token: 'interface', foreground: '67e8f9' },
|
||||
{ token: 'namespace', foreground: '22d3ee' },
|
||||
|
||||
// Functions - clean white for emphasis
|
||||
{ token: 'function', foreground: 'f5f5f5' },
|
||||
{ token: 'function.declaration', foreground: 'ffffff' },
|
||||
{ token: 'method', foreground: 'f5f5f5' },
|
||||
|
||||
// Variables and parameters
|
||||
{ token: 'variable', foreground: 'c4c4c4' },
|
||||
{ token: 'variable.predefined', foreground: '67e8f9' },
|
||||
{ token: 'parameter', foreground: 'e5e5e5' },
|
||||
{ token: 'property', foreground: 'a3a3a3' },
|
||||
|
||||
// Constants - emerald like strings
|
||||
{ token: 'constant', foreground: '34d399' },
|
||||
{ token: 'constant.language', foreground: 'fb923c' },
|
||||
|
||||
// Operators and delimiters - subdued
|
||||
{ token: 'operator', foreground: '737373' },
|
||||
{ token: 'delimiter', foreground: '525252' },
|
||||
{ token: 'delimiter.bracket', foreground: '737373' },
|
||||
|
||||
// HTML/JSX
|
||||
{ token: 'tag', foreground: '22d3ee' },
|
||||
{ token: 'attribute.name', foreground: 'a3a3a3' },
|
||||
{ token: 'attribute.value', foreground: '34d399' },
|
||||
|
||||
// Regex
|
||||
{ token: 'regexp', foreground: 'f472b6' },
|
||||
],
|
||||
colors: {
|
||||
// Editor chrome - slightly lighter than pure dark
|
||||
'editor.background': '#1c1c1e',
|
||||
'editor.foreground': '#c4c4c4',
|
||||
|
||||
// Line highlighting - subtle
|
||||
'editor.lineHighlightBackground': '#232326',
|
||||
'editor.lineHighlightBorder': '#00000000',
|
||||
|
||||
// Selection - emerald tint
|
||||
'editor.selectionBackground': '#10b98135',
|
||||
'editor.inactiveSelectionBackground': '#10b98118',
|
||||
'editor.selectionHighlightBackground': '#10b98115',
|
||||
|
||||
// Cursor - emerald brand color
|
||||
'editorCursor.foreground': '#10b981',
|
||||
|
||||
// Line numbers - muted
|
||||
'editorLineNumber.foreground': '#3f3f46',
|
||||
'editorLineNumber.activeForeground': '#71717a',
|
||||
|
||||
// Indent guides
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#3f3f46',
|
||||
|
||||
// Bracket matching
|
||||
'editorBracketMatch.background': '#10b98125',
|
||||
'editorBracketMatch.border': '#10b98170',
|
||||
|
||||
// Whitespace
|
||||
'editorWhitespace.foreground': '#2e2e33',
|
||||
|
||||
// Scrollbar - subtle
|
||||
'scrollbarSlider.background': '#3f3f4660',
|
||||
'scrollbarSlider.hoverBackground': '#52525b80',
|
||||
'scrollbarSlider.activeBackground': '#71717a80',
|
||||
|
||||
// Widgets (autocomplete, hover)
|
||||
'editorWidget.background': '#1e1e21',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.background': '#1e1e21',
|
||||
'editorSuggestWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.selectedBackground': '#2a2a2e',
|
||||
'editorSuggestWidget.highlightForeground': '#34d399',
|
||||
'editorHoverWidget.background': '#1e1e21',
|
||||
'editorHoverWidget.border': '#3f3f46',
|
||||
|
||||
// Input fields
|
||||
'input.background': '#1e1e21',
|
||||
'input.border': '#3f3f46',
|
||||
'input.foreground': '#c4c4c4',
|
||||
|
||||
// Focus
|
||||
'focusBorder': '#10b981',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function PluginEditor({ language, value, onChange, height = '500px', secretKeys = [], hook = 'post.published' }: PluginEditorProps) {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
|
||||
const monacoRef = useRef<Monaco | null>(null)
|
||||
const disposablesRef = useRef<IDisposable[]>([])
|
||||
const [sdkSchema, setSdkSchema] = useState<SDKSchema | null>(null)
|
||||
|
||||
// Fetch SDK schema when language changes
|
||||
useEffect(() => {
|
||||
fetch(`/api/studio/sdk?language=${language}`)
|
||||
.then(r => r.json())
|
||||
.then(setSdkSchema)
|
||||
.catch(() => {})
|
||||
}, [language])
|
||||
|
||||
// Helper to get fields for a type from nested types or event fields
|
||||
const getFieldsForType = useCallback((typeName: string): SDKField[] => {
|
||||
if (!sdkSchema) return []
|
||||
// Check nested types first
|
||||
if (sdkSchema.nestedTypes?.[typeName]) {
|
||||
return sdkSchema.nestedTypes[typeName]
|
||||
}
|
||||
return []
|
||||
}, [sdkSchema])
|
||||
|
||||
// Track TypeScript lib disposable separately
|
||||
const tsLibDisposableRef = useRef<IDisposable | null>(null)
|
||||
|
||||
// Update TypeScript SDK when secrets change
|
||||
useEffect(() => {
|
||||
const monaco = monacoRef.current
|
||||
if (!monaco || language !== 'typescript') return
|
||||
|
||||
// Dispose previous SDK lib if it exists
|
||||
if (tsLibDisposableRef.current) {
|
||||
tsLibDisposableRef.current.dispose()
|
||||
}
|
||||
|
||||
// Add updated SDK with new secrets
|
||||
const sdk = getTypeScriptSDK(secretKeys)
|
||||
tsLibDisposableRef.current = monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
sdk,
|
||||
'file:///node_modules/@writekit/sdk/index.d.ts'
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (tsLibDisposableRef.current) {
|
||||
tsLibDisposableRef.current.dispose()
|
||||
}
|
||||
}
|
||||
}, [language, secretKeys])
|
||||
|
||||
// Register completion providers when SDK schema or language changes
|
||||
useEffect(() => {
|
||||
const monaco = monacoRef.current
|
||||
if (!monaco || !sdkSchema) return
|
||||
|
||||
// Dispose previous providers
|
||||
disposablesRef.current.forEach(d => d.dispose())
|
||||
disposablesRef.current = []
|
||||
|
||||
// Register Go completion providers
|
||||
if (language === 'go') {
|
||||
const langId = LANGUAGE_MAP[language]
|
||||
|
||||
// Completion provider for Runner. and event.
|
||||
const completionProvider = monaco.languages.registerCompletionItemProvider(langId, {
|
||||
triggerCharacters: ['.'],
|
||||
provideCompletionItems: (model: editor.ITextModel, position: Position) => {
|
||||
const textBefore = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column
|
||||
})
|
||||
|
||||
const suggestions: languages.CompletionItem[] = []
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: position.column,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column
|
||||
}
|
||||
|
||||
// Runner. completions
|
||||
if (textBefore.endsWith('Runner.')) {
|
||||
// Add functions
|
||||
sdkSchema.Runner.functions?.forEach(fn => {
|
||||
suggestions.push({
|
||||
label: fn.name,
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: fn.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: { value: `**${fn.signature}**\n\n${fn.documentation}` },
|
||||
detail: fn.signature,
|
||||
range
|
||||
})
|
||||
})
|
||||
// Add secrets namespace
|
||||
suggestions.push({
|
||||
label: 'Secrets',
|
||||
kind: monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'Secrets',
|
||||
documentation: { value: 'Access your configured secrets' },
|
||||
detail: 'namespace',
|
||||
range
|
||||
})
|
||||
}
|
||||
|
||||
// Runner.Secrets. completions (dynamic based on user's secrets)
|
||||
if (textBefore.endsWith('Runner.Secrets.')) {
|
||||
secretKeys.forEach(key => {
|
||||
// Convert to PascalCase for Go/C#
|
||||
const secretName = key.split('_').map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join('')
|
||||
suggestions.push({
|
||||
label: secretName,
|
||||
kind: monaco.languages.CompletionItemKind.Constant,
|
||||
insertText: secretName,
|
||||
documentation: { value: `Secret: \`${key}\`` },
|
||||
detail: 'string',
|
||||
range
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// event. completions (based on current hook)
|
||||
if (textBefore.endsWith('event.')) {
|
||||
const eventType = sdkSchema.events?.[hook]
|
||||
if (eventType) {
|
||||
eventType.fields.forEach(field => {
|
||||
suggestions.push({
|
||||
label: field.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: field.name,
|
||||
documentation: { value: field.doc || `${field.type}` },
|
||||
detail: field.type,
|
||||
range
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Nested type completions (e.g., event.Post.)
|
||||
// Match patterns like: event.Post. or event.Author.
|
||||
const nestedMatch = textBefore.match(/event\.(\w+)\.$/)
|
||||
if (nestedMatch) {
|
||||
const parentField = nestedMatch[1]
|
||||
// Find the type of this field from the event
|
||||
const eventType = sdkSchema.events?.[hook]
|
||||
const field = eventType?.fields.find(f => f.name === parentField)
|
||||
if (field) {
|
||||
// Get nested type fields
|
||||
const nestedFields = getFieldsForType(field.type)
|
||||
nestedFields.forEach(nestedField => {
|
||||
suggestions.push({
|
||||
label: nestedField.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: nestedField.name,
|
||||
documentation: { value: nestedField.doc || `${nestedField.type}` },
|
||||
detail: nestedField.type,
|
||||
range
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions }
|
||||
}
|
||||
})
|
||||
|
||||
disposablesRef.current.push(completionProvider)
|
||||
|
||||
// Hover provider
|
||||
const hoverProvider = monaco.languages.registerHoverProvider(langId, {
|
||||
provideHover: (model: editor.ITextModel, position: Position) => {
|
||||
const word = model.getWordAtPosition(position)
|
||||
if (!word) return null
|
||||
|
||||
// Check Runner functions
|
||||
const runnerFn = sdkSchema.Runner.functions?.find(f => f.name === word.word)
|
||||
if (runnerFn) {
|
||||
return {
|
||||
contents: [
|
||||
{ value: `**Runner.${runnerFn.name}**` },
|
||||
{ value: `\`\`\`\n${runnerFn.signature}\n\`\`\`` },
|
||||
{ value: runnerFn.documentation }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
disposablesRef.current.push(hoverProvider)
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposablesRef.current.forEach(d => d.dispose())
|
||||
disposablesRef.current = []
|
||||
}
|
||||
}, [language, sdkSchema, secretKeys, hook, getFieldsForType])
|
||||
|
||||
// Configure Monaco before editor mounts
|
||||
const handleBeforeMount = useCallback((monaco: Monaco) => {
|
||||
// Define theme for all languages
|
||||
defineWriteKitTheme(monaco)
|
||||
|
||||
// TypeScript-specific configuration
|
||||
if (language === 'typescript') {
|
||||
// Configure TypeScript compiler options
|
||||
// Don't set 'lib' explicitly - let Monaco use defaults which include all ES libs
|
||||
// This ensures JSON, Array, Object, Math, etc. are available
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||
module: monaco.languages.typescript.ModuleKind.ESNext,
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
allowNonTsExtensions: true,
|
||||
})
|
||||
|
||||
// Enable full diagnostics for hover info and error checking
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
})
|
||||
|
||||
// Eager model sync ensures types are available immediately
|
||||
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
|
||||
|
||||
// Add WriteKit SDK types
|
||||
const sdk = getTypeScriptSDK(secretKeys)
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
sdk,
|
||||
'file:///node_modules/@writekit/sdk/index.d.ts'
|
||||
)
|
||||
}
|
||||
}, [language, secretKeys])
|
||||
|
||||
const handleMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
editorRef.current = editor
|
||||
monacoRef.current = monaco
|
||||
// Theme is already defined in beforeMount, just apply it
|
||||
monaco.editor.setTheme('writekit-dark')
|
||||
}, [])
|
||||
|
||||
const isFullHeight = height === '100%'
|
||||
|
||||
return (
|
||||
<div className={`overflow-hidden ${isFullHeight ? 'h-full' : 'border border-border'}`}>
|
||||
<Editor
|
||||
height={height}
|
||||
language={LANGUAGE_MAP[language]}
|
||||
value={value}
|
||||
onChange={v => onChange(v || '')}
|
||||
beforeMount={handleBeforeMount}
|
||||
onMount={handleMount}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", Consolas, monospace',
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: language === 'go' ? 4 : 2,
|
||||
padding: { top: 16, bottom: 16 },
|
||||
renderLineHighlight: 'line',
|
||||
renderLineHighlightOnlyWhenFocus: true,
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
cursorBlinking: 'blink',
|
||||
cursorSmoothCaretAnimation: 'off',
|
||||
smoothScrolling: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
scrollbar: {
|
||||
vertical: 'auto',
|
||||
horizontal: 'auto',
|
||||
verticalScrollbarSize: 10,
|
||||
horizontalScrollbarSize: 10,
|
||||
useShadows: false,
|
||||
},
|
||||
bracketPairColorization: {
|
||||
enabled: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
343
frontends/studio/src/components/editor/PostEditor.tsx
Normal file
343
frontends/studio/src/components/editor/PostEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
270
frontends/studio/src/components/editor/SlashCommands.tsx
Normal file
270
frontends/studio/src/components/editor/SlashCommands.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { Extension } from '@tiptap/core'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion'
|
||||
import tippy, { type Instance as TippyInstance } from 'tippy.js'
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { Icons } from '../shared/Icons'
|
||||
|
||||
interface CommandItem {
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
command: (props: { editor: any; range: any }) => void
|
||||
}
|
||||
|
||||
const commands: CommandItem[] = [
|
||||
{
|
||||
title: 'Heading 1',
|
||||
description: 'Large section heading',
|
||||
icon: <Icons.Heading1 className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 2',
|
||||
description: 'Medium section heading',
|
||||
icon: <Icons.Heading2 className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 3',
|
||||
description: 'Small section heading',
|
||||
icon: <Icons.Heading3 className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bullet List',
|
||||
description: 'Create a bullet list',
|
||||
icon: <Icons.List className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Numbered List',
|
||||
description: 'Create a numbered list',
|
||||
icon: <Icons.ListOrdered className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Task List',
|
||||
description: 'Create a task list with checkboxes',
|
||||
icon: <Icons.Check className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleTaskList().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Code Block',
|
||||
description: 'Insert a code block',
|
||||
icon: <Icons.Code className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Blockquote',
|
||||
description: 'Insert a quote',
|
||||
icon: <Icons.Quote className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Horizontal Rule',
|
||||
description: 'Insert a divider',
|
||||
icon: <span className="w-4 h-4 flex items-center justify-center">—</span>,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Image',
|
||||
description: 'Insert an image from URL',
|
||||
icon: <Icons.Image className="w-4 h-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
const url = window.prompt('Enter image URL:')
|
||||
if (url) {
|
||||
editor.chain().focus().deleteRange(range).setImage({ src: url }).run()
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
interface CommandListProps {
|
||||
items: CommandItem[]
|
||||
command: (item: CommandItem) => void
|
||||
}
|
||||
|
||||
interface CommandListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean
|
||||
}
|
||||
|
||||
const CommandList = forwardRef<CommandListRef, CommandListProps>(
|
||||
({ items, command }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index]
|
||||
if (item) {
|
||||
command(item)
|
||||
}
|
||||
},
|
||||
[items, command]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="bg-surface border border-border shadow-lg rounded p-2 text-sm text-muted">
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border shadow-lg rounded py-1 min-w-[200px] max-h-[300px] overflow-y-auto">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
onClick={() => selectItem(index)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-accent/10 text-text'
|
||||
: 'text-muted hover:bg-bg hover:text-text'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-muted">{item.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text">{item.title}</div>
|
||||
<div className="text-xs text-muted truncate">{item.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
CommandList.displayName = 'CommandList'
|
||||
|
||||
const suggestion: Omit<SuggestionOptions<CommandItem>, 'editor'> = {
|
||||
items: ({ query }) => {
|
||||
return commands.filter((item) =>
|
||||
item.title.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<CommandListRef> | null = null
|
||||
let popup: TippyInstance[] | null = null
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
component?.updateProps(props)
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown: (props) => {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0]?.hide()
|
||||
return true
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
popup?.[0]?.destroy()
|
||||
component?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const SlashCommands = Extension.create({
|
||||
name: 'slashCommands',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: '/',
|
||||
command: ({ editor, range, props }: { editor: any; range: any; props: CommandItem }) => {
|
||||
props.command({ editor, range })
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...suggestion,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
105
frontends/studio/src/components/editor/SourceEditor.tsx
Normal file
105
frontends/studio/src/components/editor/SourceEditor.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useCallback, useRef } from 'react'
|
||||
import Editor, { type Monaco } from '@monaco-editor/react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $editorPost } from '../../stores/editor'
|
||||
|
||||
interface SourceEditorProps {
|
||||
onChange?: (markdown: string) => void
|
||||
}
|
||||
|
||||
const defineWriteKitTheme = (monaco: Monaco) => {
|
||||
monaco.editor.defineTheme('writekit-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: '', foreground: 'c4c4c4' },
|
||||
{ token: 'comment', foreground: '525252', fontStyle: 'italic' },
|
||||
{ token: 'keyword', foreground: 'd4d4d4' },
|
||||
{ token: 'string', foreground: '34d399' },
|
||||
{ token: 'number', foreground: 'fbbf24' },
|
||||
{ token: 'type', foreground: '22d3ee' },
|
||||
{ token: 'function', foreground: 'f5f5f5' },
|
||||
{ token: 'variable', foreground: 'c4c4c4' },
|
||||
{ token: 'operator', foreground: '737373' },
|
||||
{ token: 'delimiter', foreground: '525252' },
|
||||
{ token: 'tag', foreground: '22d3ee' },
|
||||
{ token: 'attribute.name', foreground: 'a3a3a3' },
|
||||
{ token: 'attribute.value', foreground: '34d399' },
|
||||
{ token: 'string.link', foreground: '34d399', fontStyle: 'underline' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#1c1c1e',
|
||||
'editor.foreground': '#c4c4c4',
|
||||
'editor.lineHighlightBackground': '#232326',
|
||||
'editor.lineHighlightBorder': '#00000000',
|
||||
'editor.selectionBackground': '#10b98135',
|
||||
'editor.inactiveSelectionBackground': '#10b98118',
|
||||
'editorCursor.foreground': '#10b981',
|
||||
'editorLineNumber.foreground': '#3f3f46',
|
||||
'editorLineNumber.activeForeground': '#71717a',
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#3f3f46',
|
||||
'scrollbarSlider.background': '#3f3f4660',
|
||||
'scrollbarSlider.hoverBackground': '#52525b80',
|
||||
'editorWidget.background': '#1e1e21',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function SourceEditor({ onChange }: SourceEditorProps) {
|
||||
const post = useStore($editorPost)
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
|
||||
|
||||
const handleBeforeMount = useCallback((monaco: Monaco) => {
|
||||
defineWriteKitTheme(monaco)
|
||||
}, [])
|
||||
|
||||
const handleMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
editorRef.current = editor
|
||||
monaco.editor.setTheme('writekit-dark')
|
||||
}, [])
|
||||
|
||||
const handleChange = useCallback((value: string | undefined) => {
|
||||
onChange?.(value || '')
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
value={post.content}
|
||||
onChange={handleChange}
|
||||
beforeMount={handleBeforeMount}
|
||||
onMount={handleMount}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", Consolas, monospace',
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
padding: { top: 16, bottom: 16 },
|
||||
renderLineHighlight: 'line',
|
||||
renderLineHighlightOnlyWhenFocus: true,
|
||||
wordWrap: 'on',
|
||||
lineHeight: 1.6,
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
cursorBlinking: 'blink',
|
||||
smoothScrolling: true,
|
||||
scrollbar: {
|
||||
vertical: 'auto',
|
||||
horizontal: 'hidden',
|
||||
verticalScrollbarSize: 10,
|
||||
useShadows: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { CodeBlockEnhanced } from './CodeBlockExtension'
|
||||
export { CodeBlockView } from './CodeBlockView'
|
||||
export { getLanguageIconUrl, getLanguageDisplayName, SUPPORTED_LANGUAGES, LANGUAGE_ICONS } from './icons'
|
||||
4
frontends/studio/src/components/editor/index.ts
Normal file
4
frontends/studio/src/components/editor/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { PluginEditor } from './PluginEditor'
|
||||
export { PostEditor } from './PostEditor'
|
||||
export { SourceEditor } from './SourceEditor'
|
||||
export { MetadataPanel } from './MetadataPanel'
|
||||
76
frontends/studio/src/components/layout/Header.tsx
Normal file
76
frontends/studio/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $router } from '../../stores/router'
|
||||
import { Icons, type IconComponent } from '../shared/Icons'
|
||||
|
||||
interface NavItem {
|
||||
route: string
|
||||
label: string
|
||||
Icon: IconComponent
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ route: 'posts', label: 'Posts', Icon: Icons.Posts },
|
||||
{ route: 'analytics', label: 'Analytics', Icon: Icons.Analytics },
|
||||
{ route: 'general', label: 'General', Icon: Icons.Settings },
|
||||
{ route: 'design', label: 'Design', Icon: Icons.Design },
|
||||
{ route: 'domain', label: 'Domain', Icon: Icons.Domain },
|
||||
{ route: 'engagement', label: 'Engagement', Icon: Icons.Engagement },
|
||||
{ route: 'monetization', label: 'Monetization', Icon: Icons.Monetization },
|
||||
{ route: 'api', label: 'API Keys', Icon: Icons.ApiKeys },
|
||||
{ route: 'data', label: 'Data', Icon: Icons.Data },
|
||||
{ route: 'billing', label: 'Billing', Icon: Icons.Billing },
|
||||
]
|
||||
|
||||
interface HeaderProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Header({ className = '' }: HeaderProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const page = useStore($router)
|
||||
const currentRoute = page?.route ?? 'posts'
|
||||
|
||||
return (
|
||||
<header className={`lg:hidden bg-surface border-b border-border sticky top-0 z-50 ${className}`}>
|
||||
<div className="h-14 flex items-center justify-between px-4">
|
||||
<a href="/" className="block">
|
||||
<div className="text-[15px] font-bold tracking-tight text-text">WriteKit</div>
|
||||
<div className="text-[11px] font-medium text-muted tracking-wide">Studio</div>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="w-9 h-9 flex items-center justify-center hover:bg-border transition-colors"
|
||||
>
|
||||
{menuOpen ? <Icons.Close className="text-lg" /> : <Icons.Menu className="text-lg" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{menuOpen && (
|
||||
<nav className="p-2 border-t border-border bg-surface">
|
||||
{navItems.map(item => (
|
||||
<a
|
||||
key={item.route}
|
||||
href={`/studio/${item.route}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={currentRoute === item.route ? 'nav-item-active' : 'nav-item'}
|
||||
>
|
||||
<item.Icon className={`text-sm ${currentRoute === item.route ? 'text-accent opacity-100' : 'opacity-50'}`} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
<div className="border-t border-border mt-2 pt-2">
|
||||
<a
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="nav-item"
|
||||
>
|
||||
<Icons.ExternalLink className="text-sm opacity-50" />
|
||||
<span>View Site</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
102
frontends/studio/src/components/layout/Sidebar.tsx
Normal file
102
frontends/studio/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { $router } from '../../stores/router'
|
||||
import { Icons, type IconComponent } from '../shared/Icons'
|
||||
|
||||
interface NavItem {
|
||||
route: string
|
||||
label: string
|
||||
Icon: IconComponent
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navigation: NavSection[] = [
|
||||
{
|
||||
title: '',
|
||||
items: [
|
||||
{ route: 'home', label: 'Home', Icon: Icons.Home },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
items: [
|
||||
{ route: 'posts', label: 'Posts', Icon: Icons.Posts },
|
||||
{ route: 'analytics', label: 'Analytics', Icon: Icons.Analytics },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Site',
|
||||
items: [
|
||||
{ route: 'general', label: 'General', Icon: Icons.Settings },
|
||||
{ route: 'design', label: 'Design', Icon: Icons.Design },
|
||||
{ route: 'domain', label: 'Domain', Icon: Icons.Domain },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Readers',
|
||||
items: [
|
||||
{ route: 'engagement', label: 'Engagement', Icon: Icons.Engagement },
|
||||
{ route: 'monetization', label: 'Monetization', Icon: Icons.Monetization },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Developer',
|
||||
items: [
|
||||
{ route: 'plugins', label: 'Plugins', Icon: Icons.Code },
|
||||
{ route: 'api', label: 'API Keys', Icon: Icons.ApiKeys },
|
||||
{ route: 'data', label: 'Data', Icon: Icons.Data },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{ route: 'billing', label: 'Billing', Icon: Icons.Billing },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const page = useStore($router)
|
||||
const currentRoute = page?.route ?? 'posts'
|
||||
|
||||
return (
|
||||
<aside className="w-56 h-screen bg-bg border-r border-border flex flex-col">
|
||||
<div className="px-4 py-6">
|
||||
<a href="/" className="block group">
|
||||
<div className="text-[15px] font-bold tracking-tight text-text">
|
||||
WriteKit
|
||||
</div>
|
||||
<div className="text-[11px] font-medium text-muted tracking-wide">
|
||||
Studio
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3">
|
||||
{navigation.map((section, idx) => (
|
||||
<div key={section.title || idx} className="mb-1">
|
||||
{section.title && <div className="nav-section">{section.title}</div>}
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map(item => {
|
||||
const href = item.route === 'home' ? '/studio' : `/studio/${item.route}`
|
||||
return (
|
||||
<a
|
||||
key={item.route}
|
||||
href={href}
|
||||
className={currentRoute === item.route ? 'nav-item-active' : 'nav-item'}
|
||||
>
|
||||
<item.Icon className={`text-sm ${currentRoute === item.route ? 'text-accent opacity-100' : 'opacity-50'}`} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
2
frontends/studio/src/components/layout/index.ts
Normal file
2
frontends/studio/src/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Sidebar } from './Sidebar'
|
||||
export { Header } from './Header'
|
||||
45
frontends/studio/src/components/shared/Breadcrumb.tsx
Normal file
45
frontends/studio/src/components/shared/Breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { $router } from '../../stores/router'
|
||||
import { Icons } from './Icons'
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
home: 'Home',
|
||||
posts: 'Posts',
|
||||
postNew: 'New Post',
|
||||
postEdit: 'Edit Post',
|
||||
analytics: 'Analytics',
|
||||
general: 'General',
|
||||
design: 'Design',
|
||||
domain: 'Domain',
|
||||
engagement: 'Engagement',
|
||||
monetization: 'Monetization',
|
||||
plugins: 'Plugins',
|
||||
api: 'API Keys',
|
||||
data: 'Data',
|
||||
billing: 'Billing',
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Breadcrumb({ title }: BreadcrumbProps) {
|
||||
const page = useStore($router)
|
||||
const routeKey = page?.route ?? 'home'
|
||||
const displayTitle = title || routeLabels[routeKey] || ''
|
||||
const isHome = routeKey === 'home'
|
||||
|
||||
if (isHome) {
|
||||
return <span className="font-medium text-text">{displayTitle}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<a href="/studio" className="text-muted hover:text-text transition-colors">
|
||||
Studio
|
||||
</a>
|
||||
<Icons.ChevronRight className="text-muted opacity-50" />
|
||||
<span className="font-medium text-text">{displayTitle}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
frontends/studio/src/components/shared/BreakdownList.tsx
Normal file
51
frontends/studio/src/components/shared/BreakdownList.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { IconComponent } from './Icons'
|
||||
|
||||
interface BreakdownItem {
|
||||
label: string
|
||||
value: number
|
||||
percentage: number
|
||||
Icon?: IconComponent
|
||||
flagUrl?: string
|
||||
}
|
||||
|
||||
interface BreakdownListProps {
|
||||
items: BreakdownItem[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function BreakdownList({ items, limit = 6 }: BreakdownListProps) {
|
||||
const displayItems = items.slice(0, limit)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{displayItems.map(item => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
{item.flagUrl ? (
|
||||
<div className="w-5 h-5 flex-shrink-0 rounded-sm overflow-hidden bg-border">
|
||||
<img
|
||||
src={item.flagUrl}
|
||||
alt={item.label}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : item.Icon ? (
|
||||
<item.Icon className="text-sm flex-shrink-0 opacity-50" />
|
||||
) : null}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text truncate">{item.label}</span>
|
||||
<span className="text-muted ml-2 flex-shrink-0">{item.percentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-border overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
style={{ width: `${Math.min(item.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
frontends/studio/src/components/shared/EmptyState.tsx
Normal file
24
frontends/studio/src/components/shared/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import type { IconComponent } from './Icons'
|
||||
|
||||
interface EmptyStateProps {
|
||||
Icon: IconComponent
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function EmptyState({ Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="card text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-border flex items-center justify-center">
|
||||
<Icon className="text-muted text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-text mb-1">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-xs text-muted mb-4">{description}</p>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
frontends/studio/src/components/shared/Field.tsx
Normal file
27
frontends/studio/src/components/shared/Field.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Input, Textarea } from '../ui'
|
||||
|
||||
interface FieldProps {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
multiline?: boolean
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export function Field({ label, value, onChange, placeholder, multiline, hint }: FieldProps) {
|
||||
const InputComponent = multiline ? Textarea : Input
|
||||
return (
|
||||
<div>
|
||||
<label className="label">{label}</label>
|
||||
<InputComponent
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{hint && (
|
||||
<p className="text-xs text-muted mt-1">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
316
frontends/studio/src/components/shared/Icons.tsx
Normal file
316
frontends/studio/src/components/shared/Icons.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import type { ComponentProps, ReactElement } from 'react'
|
||||
|
||||
export type IconProps = ComponentProps<'span'>
|
||||
export type IconComponent = (props: IconProps) => ReactElement
|
||||
|
||||
function createIcon(className: string): IconComponent {
|
||||
return function Icon(props: IconProps) {
|
||||
return <span {...props} className={`${className}${props.className ? ' ' + props.className : ''}`} />
|
||||
}
|
||||
}
|
||||
|
||||
export const Icons = {
|
||||
// Navigation
|
||||
Home: createIcon('i-lucide-home'),
|
||||
Posts: createIcon('i-lucide-file-text'),
|
||||
Analytics: createIcon('i-lucide-bar-chart-2'),
|
||||
Settings: createIcon('i-lucide-settings'),
|
||||
Design: createIcon('i-lucide-palette'),
|
||||
Domain: createIcon('i-lucide-globe'),
|
||||
Engagement: createIcon('i-lucide-message-circle'),
|
||||
Monetization: createIcon('i-lucide-credit-card'),
|
||||
ApiKeys: createIcon('i-lucide-key'),
|
||||
Data: createIcon('i-lucide-database'),
|
||||
Billing: createIcon('i-lucide-receipt'),
|
||||
ExternalLink: createIcon('i-lucide-external-link'),
|
||||
|
||||
// Actions
|
||||
Plus: createIcon('i-lucide-plus'),
|
||||
Edit: createIcon('i-lucide-pencil'),
|
||||
Trash: createIcon('i-lucide-trash-2'),
|
||||
Copy: createIcon('i-lucide-copy'),
|
||||
Download: createIcon('i-lucide-download'),
|
||||
Upload: createIcon('i-lucide-upload'),
|
||||
Refresh: createIcon('i-lucide-refresh-cw'),
|
||||
Search: createIcon('i-lucide-search'),
|
||||
Close: createIcon('i-lucide-x'),
|
||||
Menu: createIcon('i-lucide-menu'),
|
||||
Check: createIcon('i-lucide-check'),
|
||||
CheckCircle: createIcon('i-lucide-check-circle'),
|
||||
AlertCircle: createIcon('i-lucide-alert-circle'),
|
||||
Info: createIcon('i-lucide-info'),
|
||||
Eye: createIcon('i-lucide-eye'),
|
||||
Loader: createIcon('i-lucide-loader-2'),
|
||||
ArrowLeft: createIcon('i-lucide-arrow-left'),
|
||||
Key: createIcon('i-lucide-key'),
|
||||
Play: createIcon('i-lucide-play'),
|
||||
Save: createIcon('i-lucide-save'),
|
||||
|
||||
// Content
|
||||
PenTool: createIcon('i-lucide-pen-tool'),
|
||||
Image: createIcon('i-lucide-image'),
|
||||
Link: createIcon('i-lucide-link'),
|
||||
Crown: createIcon('i-lucide-crown'),
|
||||
Ghost: createIcon('i-lucide-ghost'),
|
||||
|
||||
// Languages
|
||||
TypeScript: createIcon('i-logos-typescript-icon'),
|
||||
Go: createIcon('i-logos-go'),
|
||||
CSharp: createIcon('i-logos-c-sharp'),
|
||||
JavaScript: createIcon('i-logos-javascript'),
|
||||
Rust: createIcon('i-logos-rust'),
|
||||
Python: createIcon('i-logos-python'),
|
||||
|
||||
// Text formatting
|
||||
Bold: createIcon('i-lucide-bold'),
|
||||
Italic: createIcon('i-lucide-italic'),
|
||||
Strikethrough: createIcon('i-lucide-strikethrough'),
|
||||
CodeInline: createIcon('i-lucide-code'),
|
||||
Heading1: createIcon('i-lucide-heading-1'),
|
||||
Heading2: createIcon('i-lucide-heading-2'),
|
||||
Heading3: createIcon('i-lucide-heading-3'),
|
||||
List: createIcon('i-lucide-list'),
|
||||
ListOrdered: createIcon('i-lucide-list-ordered'),
|
||||
Quote: createIcon('i-lucide-quote'),
|
||||
LinkOff: createIcon('i-lucide-link-2-off'),
|
||||
|
||||
// UI
|
||||
ChevronDown: createIcon('i-lucide-chevron-down'),
|
||||
ChevronUp: createIcon('i-lucide-chevron-up'),
|
||||
ChevronRight: createIcon('i-lucide-chevron-right'),
|
||||
Send: createIcon('i-lucide-send'),
|
||||
Undo: createIcon('i-lucide-undo'),
|
||||
History: createIcon('i-lucide-history'),
|
||||
EyeOff: createIcon('i-lucide-eye-off'),
|
||||
MoreHorizontal: createIcon('i-lucide-more-horizontal'),
|
||||
TrendingUp: createIcon('i-lucide-trending-up'),
|
||||
TrendingDown: createIcon('i-lucide-trending-down'),
|
||||
AlertTriangle: createIcon('i-lucide-alert-triangle'),
|
||||
Clock: createIcon('i-lucide-clock'),
|
||||
ArrowRight: createIcon('i-lucide-arrow-right'),
|
||||
|
||||
// Code / Developer
|
||||
Code: createIcon('i-lucide-code-2'),
|
||||
WordPress: createIcon('i-lucide-pen-tool'),
|
||||
|
||||
// Devices
|
||||
Desktop: createIcon('i-lucide-monitor'),
|
||||
Mobile: createIcon('i-lucide-smartphone'),
|
||||
Tablet: createIcon('i-lucide-tablet'),
|
||||
|
||||
// Browsers
|
||||
Chrome: createIcon('i-simple-icons-googlechrome'),
|
||||
Firefox: createIcon('i-simple-icons-firefox'),
|
||||
Safari: createIcon('i-simple-icons-safari'),
|
||||
Edge: createIcon('i-simple-icons-microsoftedge'),
|
||||
Opera: createIcon('i-simple-icons-opera'),
|
||||
Brave: createIcon('i-simple-icons-brave'),
|
||||
Vivaldi: createIcon('i-simple-icons-vivaldi'),
|
||||
Arc: createIcon('i-simple-icons-arc'),
|
||||
Samsung: createIcon('i-simple-icons-samsung'),
|
||||
|
||||
// Operating Systems
|
||||
Windows: createIcon('i-simple-icons-windows'),
|
||||
MacOS: createIcon('i-simple-icons-apple'),
|
||||
Linux: createIcon('i-simple-icons-linux'),
|
||||
Android: createIcon('i-simple-icons-android'),
|
||||
iOS: createIcon('i-simple-icons-apple'),
|
||||
ChromeOS: createIcon('i-simple-icons-googlechrome'),
|
||||
Ubuntu: createIcon('i-simple-icons-ubuntu'),
|
||||
|
||||
// Referrer brand icons
|
||||
Google: createIcon('i-simple-icons-google'),
|
||||
Bing: createIcon('i-simple-icons-bing'),
|
||||
DuckDuckGo: createIcon('i-simple-icons-duckduckgo'),
|
||||
Twitter: createIcon('i-simple-icons-x'),
|
||||
Facebook: createIcon('i-simple-icons-facebook'),
|
||||
LinkedIn: createIcon('i-simple-icons-linkedin'),
|
||||
Instagram: createIcon('i-simple-icons-instagram'),
|
||||
Pinterest: createIcon('i-simple-icons-pinterest'),
|
||||
Reddit: createIcon('i-simple-icons-reddit'),
|
||||
GitHub: createIcon('i-simple-icons-github'),
|
||||
DevTo: createIcon('i-simple-icons-devdotto'),
|
||||
HackerNews: createIcon('i-simple-icons-ycombinator'),
|
||||
YouTube: createIcon('i-simple-icons-youtube'),
|
||||
Medium: createIcon('i-simple-icons-medium'),
|
||||
Substack: createIcon('i-simple-icons-substack'),
|
||||
Mastodon: createIcon('i-simple-icons-mastodon'),
|
||||
Bluesky: createIcon('i-simple-icons-bluesky'),
|
||||
Discord: createIcon('i-simple-icons-discord'),
|
||||
Slack: createIcon('i-simple-icons-slack'),
|
||||
ProductHunt: createIcon('i-simple-icons-producthunt'),
|
||||
StackOverflow: createIcon('i-simple-icons-stackoverflow'),
|
||||
Direct: createIcon('i-lucide-globe'),
|
||||
Mail: createIcon('i-lucide-mail'),
|
||||
RSS: createIcon('i-lucide-rss'),
|
||||
} as const
|
||||
|
||||
const referrerIconMap: Record<string, IconComponent> = {
|
||||
'direct': Icons.Direct,
|
||||
'google': Icons.Google,
|
||||
'bing': Icons.Bing,
|
||||
'duckduckgo': Icons.DuckDuckGo,
|
||||
'twitter': Icons.Twitter,
|
||||
'x.com': Icons.Twitter,
|
||||
't.co': Icons.Twitter,
|
||||
'facebook': Icons.Facebook,
|
||||
'linkedin': Icons.LinkedIn,
|
||||
'instagram': Icons.Instagram,
|
||||
'pinterest': Icons.Pinterest,
|
||||
'reddit': Icons.Reddit,
|
||||
'github': Icons.GitHub,
|
||||
'dev.to': Icons.DevTo,
|
||||
'hacker news': Icons.HackerNews,
|
||||
'ycombinator': Icons.HackerNews,
|
||||
'youtube': Icons.YouTube,
|
||||
'youtu.be': Icons.YouTube,
|
||||
'medium': Icons.Medium,
|
||||
'substack': Icons.Substack,
|
||||
'mastodon': Icons.Mastodon,
|
||||
'bluesky': Icons.Bluesky,
|
||||
'bsky': Icons.Bluesky,
|
||||
'discord': Icons.Discord,
|
||||
'slack': Icons.Slack,
|
||||
'producthunt': Icons.ProductHunt,
|
||||
'product hunt': Icons.ProductHunt,
|
||||
'stackoverflow': Icons.StackOverflow,
|
||||
'stack overflow': Icons.StackOverflow,
|
||||
}
|
||||
|
||||
export function getReferrerIcon(name: string): IconComponent {
|
||||
if (!name || name === 'Direct') return Icons.Direct
|
||||
const lower = name.toLowerCase()
|
||||
for (const [key, icon] of Object.entries(referrerIconMap)) {
|
||||
if (lower.includes(key)) return icon
|
||||
}
|
||||
return Icons.Link
|
||||
}
|
||||
|
||||
const countryNameToCode: Record<string, string> = {
|
||||
'united states': 'us',
|
||||
'united kingdom': 'gb',
|
||||
'canada': 'ca',
|
||||
'australia': 'au',
|
||||
'germany': 'de',
|
||||
'france': 'fr',
|
||||
'japan': 'jp',
|
||||
'china': 'cn',
|
||||
'india': 'in',
|
||||
'brazil': 'br',
|
||||
'mexico': 'mx',
|
||||
'spain': 'es',
|
||||
'italy': 'it',
|
||||
'netherlands': 'nl',
|
||||
'sweden': 'se',
|
||||
'norway': 'no',
|
||||
'denmark': 'dk',
|
||||
'finland': 'fi',
|
||||
'poland': 'pl',
|
||||
'russia': 'ru',
|
||||
'ukraine': 'ua',
|
||||
'south korea': 'kr',
|
||||
'taiwan': 'tw',
|
||||
'singapore': 'sg',
|
||||
'hong kong': 'hk',
|
||||
'indonesia': 'id',
|
||||
'thailand': 'th',
|
||||
'vietnam': 'vn',
|
||||
'philippines': 'ph',
|
||||
'malaysia': 'my',
|
||||
'new zealand': 'nz',
|
||||
'ireland': 'ie',
|
||||
'switzerland': 'ch',
|
||||
'austria': 'at',
|
||||
'belgium': 'be',
|
||||
'portugal': 'pt',
|
||||
'czech republic': 'cz',
|
||||
'czechia': 'cz',
|
||||
'romania': 'ro',
|
||||
'hungary': 'hu',
|
||||
'greece': 'gr',
|
||||
'turkey': 'tr',
|
||||
'israel': 'il',
|
||||
'united arab emirates': 'ae',
|
||||
'saudi arabia': 'sa',
|
||||
'south africa': 'za',
|
||||
'argentina': 'ar',
|
||||
'chile': 'cl',
|
||||
'colombia': 'co',
|
||||
'peru': 'pe',
|
||||
'egypt': 'eg',
|
||||
'nigeria': 'ng',
|
||||
'kenya': 'ke',
|
||||
'pakistan': 'pk',
|
||||
'bangladesh': 'bd',
|
||||
'sri lanka': 'lk',
|
||||
}
|
||||
|
||||
export function getCountryCode(name: string): string | null {
|
||||
if (!name) return null
|
||||
const lower = name.toLowerCase()
|
||||
return countryNameToCode[lower] || null
|
||||
}
|
||||
|
||||
export function getCountryFlagUrl(name: string, size: number = 24): string | null {
|
||||
const code = getCountryCode(name)
|
||||
if (!code) return null
|
||||
return `https://flagcdn.com/w${size}/${code}.png`
|
||||
}
|
||||
|
||||
const browserIconMap: Record<string, IconComponent> = {
|
||||
'chrome': Icons.Chrome,
|
||||
'firefox': Icons.Firefox,
|
||||
'safari': Icons.Safari,
|
||||
'edge': Icons.Edge,
|
||||
'opera': Icons.Opera,
|
||||
'brave': Icons.Brave,
|
||||
'vivaldi': Icons.Vivaldi,
|
||||
'arc': Icons.Arc,
|
||||
'samsung': Icons.Samsung,
|
||||
}
|
||||
|
||||
export function getBrowserIcon(name: string): IconComponent | null {
|
||||
if (!name) return null
|
||||
const lower = name.toLowerCase()
|
||||
for (const [key, icon] of Object.entries(browserIconMap)) {
|
||||
if (lower.includes(key)) return icon
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const deviceIconMap: Record<string, IconComponent> = {
|
||||
'desktop': Icons.Desktop,
|
||||
'mobile': Icons.Mobile,
|
||||
'tablet': Icons.Tablet,
|
||||
'phone': Icons.Mobile,
|
||||
}
|
||||
|
||||
export function getDeviceIcon(name: string): IconComponent | null {
|
||||
if (!name) return null
|
||||
const lower = name.toLowerCase()
|
||||
for (const [key, icon] of Object.entries(deviceIconMap)) {
|
||||
if (lower.includes(key)) return icon
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const osIconMap: Record<string, IconComponent> = {
|
||||
'windows': Icons.Windows,
|
||||
'macos': Icons.MacOS,
|
||||
'mac os': Icons.MacOS,
|
||||
'ios': Icons.iOS,
|
||||
'android': Icons.Android,
|
||||
'linux': Icons.Linux,
|
||||
'ubuntu': Icons.Ubuntu,
|
||||
'chrome os': Icons.ChromeOS,
|
||||
'chromeos': Icons.ChromeOS,
|
||||
}
|
||||
|
||||
export function getOSIcon(name: string): IconComponent | null {
|
||||
if (!name) return null
|
||||
const lower = name.toLowerCase()
|
||||
for (const [key, icon] of Object.entries(osIconMap)) {
|
||||
if (lower.includes(key)) return icon
|
||||
}
|
||||
return null
|
||||
}
|
||||
9
frontends/studio/src/components/shared/LoadingState.tsx
Normal file
9
frontends/studio/src/components/shared/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Icons } from './Icons'
|
||||
|
||||
export function LoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Icons.Loader className="animate-spin text-muted text-2xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontends/studio/src/components/shared/PageHeader.tsx
Normal file
16
frontends/studio/src/components/shared/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Breadcrumb title={title} />
|
||||
{children && <div className="flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
frontends/studio/src/components/shared/SaveBar.tsx
Normal file
39
frontends/studio/src/components/shared/SaveBar.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Button } from '../ui'
|
||||
|
||||
interface SaveBarProps {
|
||||
onSave: () => void
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
changes?: string[]
|
||||
}
|
||||
|
||||
export function SaveBar({ onSave, loading, disabled, changes = [] }: SaveBarProps) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-bg/95 backdrop-blur-sm">
|
||||
<div className="max-w-4xl mx-auto px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-sm">
|
||||
{changes.length > 0 ? (
|
||||
<>
|
||||
<span className="text-muted">Changed: </span>
|
||||
<span className="font-medium">{changes.slice(0, 3).join(', ')}</span>
|
||||
{changes.length > 3 && <span className="text-muted"> +{changes.length - 3} more</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted">Unsaved changes</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
frontends/studio/src/components/shared/Section.tsx
Normal file
21
frontends/studio/src/components/shared/Section.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Section({ title, description, children }: SectionProps) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-medium text-text">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-xs text-muted mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
596
frontends/studio/src/components/shared/Skeleton.tsx
Normal file
596
frontends/studio/src/components/shared/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
import { PageHeader } from './PageHeader'
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Skeleton({ className = '' }: SkeletonProps) {
|
||||
return <div className={`bg-border/60 animate-shimmer ${className}`} />
|
||||
}
|
||||
|
||||
export function SkeletonText({ className = '' }: SkeletonProps) {
|
||||
return <Skeleton className={`h-4 rounded ${className}`} />
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className = '' }: SkeletonProps) {
|
||||
return <Skeleton className={`h-24 ${className}`} />
|
||||
}
|
||||
|
||||
// Page header skeleton with optional action button placeholder
|
||||
interface PageHeaderSkeletonProps {
|
||||
showAction?: boolean
|
||||
}
|
||||
|
||||
export function PageHeaderSkeleton({ showAction = true }: PageHeaderSkeletonProps) {
|
||||
return (
|
||||
<PageHeader>
|
||||
{showAction && <Skeleton className="w-28 h-9" />}
|
||||
</PageHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostsPageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Skeleton className="w-24 h-9" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '33.333%' }} />
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '66.666%' }} />
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="py-4 pl-6 lg:pl-10 pr-6">
|
||||
<div className="text-xs text-muted mb-0.5">Total Posts</div>
|
||||
<Skeleton className="w-10 h-6" />
|
||||
</div>
|
||||
<div className="py-4 px-6">
|
||||
<div className="text-xs text-muted mb-0.5">Published</div>
|
||||
<Skeleton className="w-10 h-6" />
|
||||
</div>
|
||||
<div className="py-4 pr-6 lg:pr-10 pl-6">
|
||||
<div className="text-xs text-muted mb-0.5">Total Views</div>
|
||||
<Skeleton className="w-16 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="px-6 lg:px-10 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex bg-border/50 border border-border">
|
||||
{['All', 'Published', 'Drafts'].map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`px-3 py-1.5 text-xs font-medium tracking-wide text-muted ${
|
||||
i === 0 ? 'bg-surface border-x border-border/50' : ''
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 sm:max-w-xs sm:ml-auto">
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className={`flex items-center gap-4 px-6 lg:px-10 py-4 ${i > 1 ? 'border-t border-border' : ''}`}>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{i <= 2 && <Skeleton className="w-12 h-5" />}
|
||||
<SkeletonText className={i <= 2 ? 'w-40' : 'w-56'} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonText className="w-24 h-3" />
|
||||
<SkeletonText className="w-16 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-8 h-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsPageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<div className="flex bg-border/50 border border-border">
|
||||
{['7d', '30d', '90d'].map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`px-3 py-1.5 text-xs font-medium tracking-wide text-muted ${
|
||||
i === 0 ? 'bg-surface border-x border-border/50' : ''
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border lg:hidden" />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '25%' }} />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '50%' }} />
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '75%' }} />
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4">
|
||||
<div className="py-5 pl-6 lg:pl-10 pr-6">
|
||||
<div className="text-xs text-muted mb-1">Total Views</div>
|
||||
<Skeleton className="w-20 h-8" />
|
||||
<div className="text-xs mt-1 text-muted">
|
||||
<Skeleton className="w-24 h-3 inline-block" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-5 px-6 lg:pr-6">
|
||||
<div className="text-xs text-muted mb-1">Page Views</div>
|
||||
<Skeleton className="w-16 h-8" />
|
||||
</div>
|
||||
<div className="py-5 px-6 lg:pl-6 border-t border-border lg:border-t-0">
|
||||
<div className="text-xs text-muted mb-1">Unique Visitors</div>
|
||||
<Skeleton className="w-14 h-8" />
|
||||
</div>
|
||||
<div className="py-5 pr-6 lg:pr-10 pl-6 border-t border-border lg:border-t-0">
|
||||
<div className="text-xs text-muted mb-1">Bandwidth</div>
|
||||
<Skeleton className="w-16 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="px-6 lg:px-10 py-6">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Views Over Time</div>
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
<div className="grid lg:grid-cols-2">
|
||||
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Pages</div>
|
||||
{[1, 2, 3].map(j => (
|
||||
<div key={j} className="space-y-2 mb-3">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="w-32" />
|
||||
<SkeletonText className="w-8" />
|
||||
</div>
|
||||
<Skeleton className="h-1.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="py-6 pr-6 lg:pr-10 pl-6">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Referrers</div>
|
||||
{[1, 2, 3].map(j => (
|
||||
<div key={j} className="space-y-2 mb-3">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="w-24" />
|
||||
<SkeletonText className="w-8" />
|
||||
</div>
|
||||
<Skeleton className="h-1.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Generic settings page skeleton
|
||||
export function SettingsPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeaderSkeleton showAction={false} />
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="card p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<SkeletonText className="w-24" />
|
||||
<SkeletonText className="w-48 h-3" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(j => (
|
||||
<div key={j} className="space-y-1">
|
||||
<SkeletonText className="w-16 h-3" />
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// General page skeleton - 3 sections with fields
|
||||
export function GeneralPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
<PageHeaderSkeleton showAction={false} />
|
||||
{/* Site Information */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-text">Site Information</h3>
|
||||
<p className="text-xs text-muted">Basic details about your blog</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted">Site Title</span>
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted">Description</span>
|
||||
<Skeleton className="h-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Author */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-text">Author</h3>
|
||||
<p className="text-xs text-muted">Information displayed on your posts</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{['Name', 'Email', 'Avatar URL', 'Bio'].map((label, i) => (
|
||||
<div key={label} className="space-y-1">
|
||||
<span className="text-xs text-muted">{label}</span>
|
||||
<Skeleton className={i === 3 ? 'h-20' : 'h-10'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Social Links */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-text">Social Links</h3>
|
||||
<p className="text-xs text-muted">Links to your social profiles</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{['Twitter', 'GitHub', 'LinkedIn', 'Website'].map(label => (
|
||||
<div key={label} className="space-y-1">
|
||||
<span className="text-xs text-muted">{label}</span>
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Design page skeleton - preview, colors, grids
|
||||
export function DesignPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8 pb-20">
|
||||
<PageHeaderSkeleton showAction={false} />
|
||||
{/* Live Preview */}
|
||||
<div>
|
||||
<span className="text-xs text-muted block mb-3">Live Preview</span>
|
||||
<Skeleton className="h-64 border border-border" />
|
||||
</div>
|
||||
{/* Accent Color */}
|
||||
<div>
|
||||
<span className="text-xs text-muted block mb-3">Accent Color</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="w-12 h-12" />
|
||||
<Skeleton className="w-32 h-10" />
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<Skeleton key={i} className="w-7 h-7" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Typography */}
|
||||
<div>
|
||||
<span className="text-xs text-muted block mb-3">Typography</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<Skeleton key={i} className="h-20 border border-border" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Code Theme */}
|
||||
<div>
|
||||
<span className="text-xs text-muted block mb-3">Code Theme</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} className="h-24 border border-border" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<span className="text-xs text-muted block mb-3">Layout</span>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Skeleton key={i} className="h-28 border border-border" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Engagement page skeleton - toggles and options
|
||||
export function EngagementPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
<PageHeaderSkeleton showAction={false} />
|
||||
{/* Comments section */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-text">Comments</h3>
|
||||
<p className="text-xs text-muted">Let readers comment on your posts</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-text">Enable Comments</span>
|
||||
<SkeletonText className="w-48 h-3" />
|
||||
</div>
|
||||
<Skeleton className="w-12 h-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Reactions section */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-text">Reactions</h3>
|
||||
<p className="text-xs text-muted">Let readers react to posts</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-text">Enable Reactions</span>
|
||||
<SkeletonText className="w-44 h-3" />
|
||||
</div>
|
||||
<Skeleton className="w-12 h-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function APIPageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Skeleton className="w-28 h-9" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Your API Keys</div>
|
||||
<div className="text-xs text-muted mt-0.5">Use these keys to authenticate API requests</div>
|
||||
</div>
|
||||
<div className="border-t border-border">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className={`flex flex-col sm:flex-row sm:items-center gap-4 px-6 lg:px-10 py-4 ${i > 1 ? 'border-t border-border' : ''}`}>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<SkeletonText className="w-24" />
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonText className="w-20 h-3" />
|
||||
<SkeletonText className="w-28 h-3" />
|
||||
<SkeletonText className="w-24 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-16 h-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Webhooks</div>
|
||||
<div className="text-xs text-muted mt-0.5">Get notified when posts are published, updated, or deleted</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-3">
|
||||
<div className="border border-border p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-2 h-2 rounded-full" />
|
||||
<SkeletonText className="w-24" />
|
||||
</div>
|
||||
<SkeletonText className="w-48 h-3" />
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="w-20 h-5" />
|
||||
<Skeleton className="w-16 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-28 h-9" />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">API Reference</div>
|
||||
<div className="text-xs text-muted mt-0.5">Base URL: <Skeleton className="w-32 h-3 inline-block" /></div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 py-6 space-y-2">
|
||||
{['GET', 'POST', 'GET', 'PUT', 'DELETE'].map((method, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border border-border">
|
||||
<Skeleton className={`w-12 h-5 ${method === 'GET' ? 'bg-success/15' : method === 'POST' ? 'bg-blue-500/15' : method === 'PUT' ? 'bg-warning/15' : 'bg-danger/15'}`} />
|
||||
<SkeletonText className="w-32" />
|
||||
<SkeletonText className="flex-1 hidden sm:block" />
|
||||
<Skeleton className="w-4 h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomePageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader>
|
||||
<Skeleton className="w-24 h-9" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '33.333%' }} />
|
||||
<div className="absolute top-0 bottom-0 border-l border-border" style={{ left: '66.666%' }} />
|
||||
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="py-5 pl-6 lg:pl-10 pr-6">
|
||||
<div className="text-xs text-muted mb-1">Views</div>
|
||||
<Skeleton className="w-16 h-8" />
|
||||
<div className="text-xs mt-1 text-muted">
|
||||
<Skeleton className="w-24 h-3 inline-block" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-5 px-6">
|
||||
<div className="text-xs text-muted mb-1">Visitors</div>
|
||||
<Skeleton className="w-12 h-8" />
|
||||
</div>
|
||||
<div className="py-5 pr-6 lg:pr-10 pl-6">
|
||||
<div className="text-xs text-muted mb-1">Posts</div>
|
||||
<Skeleton className="w-8 h-8" />
|
||||
<SkeletonText className="w-16 h-3 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border" />
|
||||
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="pl-6 lg:pl-10 pr-6 py-6 space-y-6">
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Continue Writing</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="w-3.5 h-3.5" />
|
||||
<SkeletonText className="w-32" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonText className="w-10 h-3" />
|
||||
<Skeleton className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Recent Posts</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="w-3.5 h-3.5" />
|
||||
<SkeletonText className="w-40" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted">
|
||||
<SkeletonText className="w-6 h-3" />
|
||||
<SkeletonText className="w-12 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pr-6 lg:pr-10 pl-6 py-6">
|
||||
<div className="mb-3">
|
||||
<span className="text-xs font-medium text-muted uppercase tracking-wide">Top Referrers</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="w-24" />
|
||||
<SkeletonText className="w-8" />
|
||||
</div>
|
||||
<Skeleton className="h-1.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BillingPageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
|
||||
<div className="-mx-6 lg:-mx-10 mt-6">
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Current Plan</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 pb-6">
|
||||
<div className="p-4 border border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="w-24 h-6" />
|
||||
<SkeletonText className="w-32 h-3" />
|
||||
</div>
|
||||
<Skeleton className="w-32 h-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Upgrade to Pro</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 pb-6">
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<Skeleton className="w-20 h-8" />
|
||||
<Skeleton className="w-24 h-8" />
|
||||
</div>
|
||||
<div className="max-w-md mx-auto p-6 border border-border">
|
||||
<div className="text-center mb-6 space-y-2">
|
||||
<SkeletonText className="w-12 mx-auto" />
|
||||
<Skeleton className="w-24 h-10 mx-auto" />
|
||||
</div>
|
||||
<div className="space-y-3 mb-6">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="w-4 h-4" />
|
||||
<SkeletonText className="w-40" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="w-full h-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="px-6 lg:px-10 py-5">
|
||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Feature Comparison</div>
|
||||
</div>
|
||||
<div className="px-6 lg:px-10 pb-6">
|
||||
<Skeleton className="w-full h-64" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
frontends/studio/src/components/shared/StatCard.tsx
Normal file
26
frontends/studio/src/components/shared/StatCard.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
interface StatCardProps {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
change?: string
|
||||
positive?: boolean
|
||||
}
|
||||
|
||||
export function StatCard({ label, value, icon, change, positive }: StatCardProps) {
|
||||
return (
|
||||
<div className="card">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 bg-accent/10 flex items-center justify-center mb-3">
|
||||
<span className={`${icon} text-accent text-lg`} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted uppercase tracking-wide mb-1">{label}</div>
|
||||
<div className="text-2xl font-semibold text-text">{value}</div>
|
||||
{change && (
|
||||
<div className={`text-xs mt-1 ${positive ? 'text-success' : 'text-danger'}`}>
|
||||
{change}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontends/studio/src/components/shared/index.ts
Normal file
23
frontends/studio/src/components/shared/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export { Section } from './Section'
|
||||
export { Field } from './Field'
|
||||
export { StatCard } from './StatCard'
|
||||
export { BreakdownList } from './BreakdownList'
|
||||
export { EmptyState } from './EmptyState'
|
||||
export { LoadingState } from './LoadingState'
|
||||
export { SaveBar } from './SaveBar'
|
||||
export { Breadcrumb } from './Breadcrumb'
|
||||
export { PageHeader } from './PageHeader'
|
||||
export {
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
SkeletonCard,
|
||||
PostsPageSkeleton,
|
||||
AnalyticsPageSkeleton,
|
||||
SettingsPageSkeleton,
|
||||
GeneralPageSkeleton,
|
||||
DesignPageSkeleton,
|
||||
EngagementPageSkeleton,
|
||||
APIPageSkeleton,
|
||||
BillingPageSkeleton,
|
||||
HomePageSkeleton,
|
||||
} from './Skeleton'
|
||||
92
frontends/studio/src/components/ui/ActionMenu.tsx
Normal file
92
frontends/studio/src/components/ui/ActionMenu.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { IconComponent } from '../shared/Icons'
|
||||
import { Icons } from '../shared/Icons'
|
||||
|
||||
export interface ActionMenuItem {
|
||||
label: string
|
||||
Icon?: IconComponent
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
external?: boolean
|
||||
variant?: 'default' | 'danger'
|
||||
}
|
||||
|
||||
interface ActionMenuProps {
|
||||
items: ActionMenuItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ActionMenu({ items, className = '' }: ActionMenuProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`} data-action-menu>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpen(!open)
|
||||
}}
|
||||
className="p-2 text-muted/60 hover:text-text hover:bg-bg rounded transition-all duration-150"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<Icons.MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-36 bg-surface border border-border shadow-lg z-[100] py-1">
|
||||
{items.map((item, i) => {
|
||||
const itemClass = `w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors ${
|
||||
item.variant === 'danger'
|
||||
? 'text-danger hover:bg-danger/5'
|
||||
: 'text-text hover:bg-bg'
|
||||
}`
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={item.href}
|
||||
target={item.external ? '_blank' : undefined}
|
||||
rel={item.external ? 'noopener noreferrer' : undefined}
|
||||
className={itemClass}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{item.Icon && <item.Icon className="w-3.5 h-3.5 opacity-60" />}
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
item.onClick?.()
|
||||
}}
|
||||
className={itemClass}
|
||||
>
|
||||
{item.Icon && <item.Icon className="w-3.5 h-3.5 opacity-70" />}
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
frontends/studio/src/components/ui/Badge.tsx
Normal file
20
frontends/studio/src/components/ui/Badge.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
type BadgeVariant = 'draft' | 'published' | 'default'
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant
|
||||
children: string
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
draft: 'badge-draft',
|
||||
published: 'badge-published',
|
||||
default: 'badge text-muted border-border',
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'default', children }: BadgeProps) {
|
||||
return (
|
||||
<span className={variantClasses[variant]}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
63
frontends/studio/src/components/ui/Button.tsx
Normal file
63
frontends/studio/src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react'
|
||||
import { Icons, type IconComponent } from '../shared/Icons'
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
|
||||
type ButtonBaseProps = {
|
||||
variant?: ButtonVariant
|
||||
loading?: boolean
|
||||
Icon?: IconComponent
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type ButtonAsButton = ButtonBaseProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: never }
|
||||
type ButtonAsLink = ButtonBaseProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }
|
||||
|
||||
type ButtonProps = ButtonAsButton | ButtonAsLink
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
danger: 'btn-danger',
|
||||
ghost: 'btn-ghost',
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
loading = false,
|
||||
Icon,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const content = (
|
||||
<>
|
||||
{loading ? (
|
||||
<Icons.Loader className="animate-spin text-xs" />
|
||||
) : Icon ? (
|
||||
<Icon className="text-xs opacity-70" />
|
||||
) : null}
|
||||
<span>{children}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if ('href' in props && props.href) {
|
||||
const { href, ...linkProps } = props
|
||||
return (
|
||||
<a href={href} className={`${variantClasses[variant]} ${className}`} {...linkProps}>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const { disabled, ...buttonProps } = props as ButtonAsButton
|
||||
return (
|
||||
<button
|
||||
className={`${variantClasses[variant]} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...buttonProps}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
86
frontends/studio/src/components/ui/Dropdown.tsx
Normal file
86
frontends/studio/src/components/ui/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { IconComponent } from '../shared/Icons'
|
||||
import { Icons } from '../shared/Icons'
|
||||
|
||||
export interface DropdownOption<T extends string = string> {
|
||||
value: T
|
||||
label: string
|
||||
Icon?: IconComponent
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface DropdownProps<T extends string = string> {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: DropdownOption<T>[]
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Dropdown<T extends string = string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select...',
|
||||
className = '',
|
||||
}: DropdownProps<T>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selected = options.find(opt => opt.value === value)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm bg-bg border border-border transition-colors hover:border-muted focus:outline-none focus:border-muted"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{selected?.Icon && <selected.Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
<span className="truncate">{selected?.label || placeholder}</span>
|
||||
</span>
|
||||
<Icons.ChevronDown className={`w-4 h-4 flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-surface border border-border shadow-lg max-h-60 overflow-auto">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-start gap-2 px-3 py-2 text-sm text-left transition-colors ${
|
||||
opt.value === value ? 'bg-accent/5 border-l-2 border-l-accent' : 'hover:bg-bg'
|
||||
}`}
|
||||
>
|
||||
{opt.Icon && <opt.Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{opt.label}</div>
|
||||
{opt.description && (
|
||||
<div className="text-xs text-muted truncate">{opt.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{opt.value === value && (
|
||||
<Icons.Check className="w-4 h-4 flex-shrink-0 text-accent" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
frontends/studio/src/components/ui/Input.tsx
Normal file
50
frontends/studio/src/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
||||
import type { IconComponent } from '../shared/Icons'
|
||||
|
||||
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
Icon?: IconComponent
|
||||
}
|
||||
|
||||
interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export function Input({ value, onChange, Icon, className = '', ...props }: InputProps) {
|
||||
if (Icon) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Icon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted text-sm" />
|
||||
<input
|
||||
className={`input pl-9 ${className}`}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<input
|
||||
className={`input ${className}`}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Textarea({ value, onChange, rows = 4, className = '', ...props }: TextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={`input resize-none ${className}`}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
frontends/studio/src/components/ui/Modal.tsx
Normal file
51
frontends/studio/src/components/ui/Modal.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { Icons } from '../shared/Icons'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative bg-surface border border-border shadow-lg w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-sm font-medium text-text">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-ghost p-1 -m-1"
|
||||
>
|
||||
<Icons.Close />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontends/studio/src/components/ui/Select.tsx
Normal file
33
frontends/studio/src/components/ui/Select.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Select({ value, onChange, options, placeholder, className = '' }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={`input ${className}`}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
74
frontends/studio/src/components/ui/Tabs.tsx
Normal file
74
frontends/studio/src/components/ui/Tabs.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useRef, useState, useLayoutEffect } from 'react'
|
||||
import type { IconComponent } from '../shared/Icons'
|
||||
|
||||
export interface Tab<T extends string = string> {
|
||||
value: T
|
||||
label: string
|
||||
Icon?: IconComponent
|
||||
}
|
||||
|
||||
interface TabsProps<T extends string = string> {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
tabs: Tab<T>[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Tabs<T extends string = string>({
|
||||
value,
|
||||
onChange,
|
||||
tabs,
|
||||
className = '',
|
||||
}: TabsProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const activeIndex = tabs.findIndex(tab => tab.value === value)
|
||||
const buttons = container.querySelectorAll('button')
|
||||
const activeButton = buttons[activeIndex]
|
||||
|
||||
if (activeButton) {
|
||||
setIndicatorStyle({
|
||||
left: activeButton.offsetLeft,
|
||||
width: activeButton.offsetWidth,
|
||||
})
|
||||
}
|
||||
}, [value, tabs])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative flex bg-border/50 border border-border ${className}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 bg-surface border-x border-border/50 transition-all duration-200 ease-out"
|
||||
style={{
|
||||
left: indicatorStyle.left,
|
||||
width: indicatorStyle.width,
|
||||
}}
|
||||
/>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.value === value
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={`relative z-10 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium tracking-wide transition-colors duration-150 ${
|
||||
isActive
|
||||
? 'text-text'
|
||||
: 'text-muted hover:text-text/70'
|
||||
}`}
|
||||
>
|
||||
{tab.Icon && <tab.Icon className="w-3.5 h-3.5" />}
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontends/studio/src/components/ui/Toast.tsx
Normal file
33
frontends/studio/src/components/ui/Toast.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { $toasts, removeToast } from '../../stores/app'
|
||||
import { Icons } from '@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>
|
||||
)
|
||||
}
|
||||
29
frontends/studio/src/components/ui/Toggle.tsx
Normal file
29
frontends/studio/src/components/ui/Toggle.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, label, description }: ToggleProps) {
|
||||
return (
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<div className="relative mt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-6 bg-border rounded-full peer-checked:bg-accent transition-colors" />
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full shadow transition-transform peer-checked:translate-x-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-text">{label || ""}</div>
|
||||
{description && (
|
||||
<div className="text-xs text-muted mt-0.5">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
42
frontends/studio/src/components/ui/UsageIndicator.tsx
Normal file
42
frontends/studio/src/components/ui/UsageIndicator.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
interface UsageIndicatorProps {
|
||||
used: number
|
||||
max: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export function UsageIndicator({ used, max, label }: UsageIndicatorProps) {
|
||||
const atLimit = used >= max
|
||||
const segments = Array.from({ length: max }, (_, i) => i < used)
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 px-3 py-2 rounded-md border ${atLimit ? 'bg-warning/5 border-warning/30' : 'bg-accent/5 border-accent/20'}`}>
|
||||
{/* Segmented dots */}
|
||||
<div className="flex gap-1.5">
|
||||
{segments.map((filled, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`
|
||||
w-2.5 h-2.5 rounded-full transition-all duration-200
|
||||
${filled
|
||||
? atLimit
|
||||
? 'bg-warning shadow-[0_0_6px_rgba(245,158,11,0.5)]'
|
||||
: 'bg-accent shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-border'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-sm font-bold tabular-nums ${atLimit ? 'text-warning' : 'text-accent'}`}>
|
||||
{used}
|
||||
</span>
|
||||
<span className="text-muted text-xs font-medium">/</span>
|
||||
<span className="text-muted text-xs font-medium tabular-nums">{max}</span>
|
||||
<span className="text-muted text-xs ml-0.5">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
frontends/studio/src/components/ui/index.ts
Normal file
18
frontends/studio/src/components/ui/index.ts
Normal 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'
|
||||
Loading…
Add table
Add a link
Reference in a new issue