refactor: move studio to frontends workspace

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

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

View file

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