refactor: move studio to frontends workspace

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

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

View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Owner Tools Dev</title>
<style>
:root {
--accent: #10b981;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #fafafa;
min-height: 100vh;
}
.mock-blog {
max-width: 680px;
margin: 0 auto;
padding: 60px 20px;
}
.mock-blog h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #18181b;
}
.mock-blog p {
font-size: 1.125rem;
line-height: 1.75;
color: #3f3f46;
margin-bottom: 1.5rem;
}
.mock-blog pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="mock-blog">
<h1>Sample Blog Post</h1>
<p>
This is a mock blog page for developing the owner tools panel.
The settings panel should appear when you click the cog icon in the bottom right.
</p>
<p>
Changes to accent color should update the <code>--accent</code> CSS variable
and you should see it reflected immediately.
</p>
<pre><code>function hello() {
console.log("Hello, world!");
}</code></pre>
<p style="color: var(--accent); font-weight: 600;">
This text uses the accent color.
</p>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2553
frontends/owner-tools/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
{
"name": "owner-tools",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"watch": "vite build --watch"
},
"dependencies": {
"@writekit/ui": "*",
"@nanostores/react": "^1.0.0",
"@unocss/reset": "^66.5.12",
"nanostores": "^1.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"unocss": "^66.5.12"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.82",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

View file

@ -0,0 +1,69 @@
import { useStore } from '@nanostores/react'
import { Icons } from '@writekit/ui'
import { SettingsPanel } from './SettingsPanel'
import { RegeneratingOverlay } from './RegeneratingOverlay'
import {
$panelOpen,
$regenerating,
$error,
$settings,
openPanel,
closePanel,
} from './stores/app'
export function App() {
const open = useStore($panelOpen)
const regenerating = useStore($regenerating)
const error = useStore($error)
const settings = useStore($settings)
const hasSettings = Object.keys(settings).length > 0
return (
<>
<RegeneratingOverlay visible={regenerating} />
<button
className="pointer-events-auto fixed bottom-5 left-5 w-12 h-12 rounded-full bg-text text-white border-none cursor-pointer flex items-center justify-center shadow-lg hover:scale-105 hover:bg-zinc-700 transition-all z-99999"
onClick={openPanel}
aria-label="Site settings"
>
<Icons.Settings className="w-5 h-5" />
</button>
{open && (
<div
className="pointer-events-auto fixed inset-0 bg-black/40 z-100000 flex justify-start"
onClick={closePanel}
>
<div
className="w-full max-w-100 h-full bg-white shadow-xl overflow-y-auto animate-[wk-slide-in_0.2s_ease-out]"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border sticky top-0 bg-white">
<h2 className="m-0 text-lg font-semibold text-text">Site Settings</h2>
<button
className="bg-transparent border-none cursor-pointer p-1 text-muted hover:text-text"
onClick={closePanel}
aria-label="Close"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
{error && (
<div className="mx-5 mt-4 p-3 bg-red-50 text-danger rounded-lg text-sm">
{error}
</div>
)}
{hasSettings ? (
<SettingsPanel />
) : (
<div className="py-10 px-5 text-center text-muted">Loading...</div>
)}
</div>
</div>
)}
</>
)
}

View file

@ -0,0 +1,18 @@
import { Icons } from '@writekit/ui'
interface Props {
visible: boolean
}
export function RegeneratingOverlay({ visible }: Props) {
if (!visible) return null
return (
<div className="pointer-events-auto fixed inset-0 z-[100001] bg-black/60 flex items-center justify-center">
<div className="bg-white px-6 py-4 rounded-lg shadow-xl flex items-center gap-3">
<Icons.Loader className="w-5 h-5 animate-spin text-accent" />
<span className="text-sm font-medium text-text">Regenerating site...</span>
</div>
</div>
)
}

View file

@ -0,0 +1,97 @@
import { useStore } from '@nanostores/react'
import { Button, Input, Textarea, Select } from '@writekit/ui'
import { $settings, $schema, $dirty, updateSetting, saveSettings } from './stores/app'
import type { SettingDefinition } from './api'
export function SettingsPanel() {
const settings = useStore($settings)
const schema = useStore($schema)
const dirty = useStore($dirty)
function renderField(def: SettingDefinition) {
const value = settings[def.key] ?? def.default ?? ''
if (def.type === 'color') {
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<div className="flex gap-2">
<input
type="color"
value={value}
onChange={e => updateSetting(def.key, e.target.value)}
className="w-11 h-11 p-0.5 border border-border rounded-lg cursor-pointer"
/>
<Input
value={value}
onChange={v => updateSetting(def.key, v)}
placeholder={def.default}
className="flex-1"
/>
</div>
</label>
)
}
if (def.type === 'select' && def.options) {
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<Select
value={value}
onChange={v => updateSetting(def.key, v)}
options={def.options}
/>
</label>
)
}
if (def.type === 'textarea') {
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<Textarea
value={value}
onChange={v => updateSetting(def.key, v)}
placeholder={def.default}
rows={4}
className="resize-y min-h-20"
/>
</label>
)
}
return (
<label key={def.key} className="block mb-4">
<span className="label-text">{def.label}</span>
<Input
value={value}
onChange={v => updateSetting(def.key, v)}
placeholder={def.default}
/>
</label>
)
}
return (
<div className="p-5">
<section className="mb-6">
<h3 className="text-xs font-semibold text-muted uppercase tracking-wide mb-4">
Appearance
</h3>
{schema.map(renderField)}
</section>
<div className="sticky bottom-0 py-4 bg-white border-t border-border -mx-5 px-5">
<Button
variant="accent"
onClick={saveSettings}
disabled={!dirty}
className="w-full"
>
Save Changes
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,39 @@
const BASE = '/api/studio'
export interface Settings {
[key: string]: string
}
export interface SettingOption {
value: string
label: string
}
export interface SettingDefinition {
key: string
type: 'color' | 'select' | 'text' | 'textarea'
label: string
options?: SettingOption[]
default?: string
}
export async function getSettingsSchema(): Promise<SettingDefinition[]> {
const res = await fetch(`${BASE}/settings/schema`)
if (!res.ok) throw new Error('Failed to fetch settings schema')
return res.json()
}
export async function getSettings(): Promise<Settings> {
const res = await fetch(`${BASE}/settings`)
if (!res.ok) throw new Error('Failed to fetch settings')
return res.json()
}
export async function saveSettings(settings: Partial<Settings>): Promise<void> {
const res = await fetch(`${BASE}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
})
if (!res.ok) throw new Error('Failed to save settings')
}

View file

@ -0,0 +1,39 @@
import { createRoot } from 'react-dom/client'
import { App } from './App'
import reset from '@unocss/reset/tailwind.css?inline'
import css from 'virtual:uno.css?inline'
function mount() {
const host = document.createElement('div')
host.id = 'writekit-owner-tools'
document.body.appendChild(host)
const shadow = host.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = reset + css + `
:host {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`
shadow.appendChild(style)
const container = document.createElement('div')
container.style.cssText = `
position:fixed;inset:0;z-index:99999;pointer-events:none;
--un-bg-opacity:100%;--un-text-opacity:100%;--un-border-opacity:100%;
--un-ring-opacity:100%;--un-shadow-opacity:100%;
`.replace(/\s+/g, '')
shadow.appendChild(container)
createRoot(container).render(<App />)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount)
} else {
mount()
}

View file

@ -0,0 +1,64 @@
import { atom, map, onMount } from 'nanostores'
import { getSettings, getSettingsSchema, saveSettings as apiSaveSettings } from '../api'
import type { Settings, SettingDefinition } from '../api'
export const $panelOpen = atom(false)
export const $regenerating = atom(false)
export const $error = atom<string | null>(null)
export const $settings = map<Settings>({})
export const $schema = atom<SettingDefinition[]>([])
export const $dirty = atom(false)
export function openPanel() {
$panelOpen.set(true)
}
export function closePanel() {
$panelOpen.set(false)
}
export function updateSetting(key: string, value: string) {
$settings.setKey(key, value)
$dirty.set(true)
}
async function softReload() {
const res = await fetch(window.location.href)
if (!res.ok) throw new Error('Failed to fetch page')
const html = await res.text()
const parser = new DOMParser()
const newDoc = parser.parseFromString(html, 'text/html')
const currentPage = document.getElementById('page')
const newPage = newDoc.getElementById('page')
if (currentPage && newPage) {
currentPage.outerHTML = newPage.outerHTML
}
}
export async function saveSettings() {
$regenerating.set(true)
$error.set(null)
try {
await apiSaveSettings($settings.get())
await softReload()
$regenerating.set(false)
$dirty.set(false)
} catch {
$regenerating.set(false)
$error.set('Failed to save')
}
}
onMount($settings, () => {
getSettings()
.then(data => $settings.set(data))
.catch(() => $error.set('Failed to load settings'))
getSettingsSchema()
.then(data => $schema.set(data))
.catch(() => {})
})

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

View file

@ -0,0 +1,31 @@
import { defineConfig, presetWind4, presetIcons } from 'unocss'
import { theme, shortcuts } from '@writekit/ui/uno.config'
export default defineConfig({
presets: [
presetWind4(),
presetIcons({
scale: 1.2,
cdn: 'https://esm.sh/',
extraProperties: {
'display': 'inline-block',
'vertical-align': 'middle',
},
}),
],
theme,
shortcuts: {
...shortcuts,
'label-text': 'block text-sm font-medium text-text mb-1.5',
},
preflights: [
{
getCSS: () => `
@keyframes wk-slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
`,
},
],
})

View file

@ -0,0 +1,42 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'
import { resolve } from 'path'
export default defineConfig(({ command }) => ({
plugins: [UnoCSS(), ...(command === 'build' ? [react()] : [])],
base: command === 'serve' ? '/@owner-tools' : '/',
esbuild: {
jsxInject: `import React from 'react'`
},
...(command === 'serve' && {
server: {
host: true,
port: 5174,
strictPort: true,
allowedHosts: true,
hmr: {
clientPort: 80,
path: '/@owner-tools'
},
proxy: {
'/api/studio': {
target: 'http://app:8080',
changeOrigin: true
}
}
}
}),
build: {
lib: {
entry: resolve(__dirname, 'src/main.tsx'),
name: 'OwnerTools',
formats: ['iife'],
fileName: () => 'owner-tools.js'
},
outDir: '../../internal/build/assets/js',
emptyOutDir: false
}
}))