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
341
frontends/studio/src/pages/DesignPage.tsx
Normal file
341
frontends/studio/src/pages/DesignPage.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
|
||||
import { addToast } from '../stores/app'
|
||||
import { SaveBar, DesignPageSkeleton } from '../components/shared'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 = Object.keys(themePreviewColors)
|
||||
|
||||
// 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">
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue