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
65
frontends/owner-tools/index.html
Normal file
65
frontends/owner-tools/index.html
Normal 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
2553
frontends/owner-tools/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
frontends/owner-tools/package.json
Normal file
28
frontends/owner-tools/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
69
frontends/owner-tools/src/App.tsx
Normal file
69
frontends/owner-tools/src/App.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
18
frontends/owner-tools/src/RegeneratingOverlay.tsx
Normal file
18
frontends/owner-tools/src/RegeneratingOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
frontends/owner-tools/src/SettingsPanel.tsx
Normal file
97
frontends/owner-tools/src/SettingsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
frontends/owner-tools/src/api.ts
Normal file
39
frontends/owner-tools/src/api.ts
Normal 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')
|
||||
}
|
||||
39
frontends/owner-tools/src/main.tsx
Normal file
39
frontends/owner-tools/src/main.tsx
Normal 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()
|
||||
}
|
||||
64
frontends/owner-tools/src/stores/app.ts
Normal file
64
frontends/owner-tools/src/stores/app.ts
Normal 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(() => {})
|
||||
})
|
||||
15
frontends/owner-tools/tsconfig.json
Normal file
15
frontends/owner-tools/tsconfig.json
Normal 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"]
|
||||
}
|
||||
31
frontends/owner-tools/uno.config.ts
Normal file
31
frontends/owner-tools/uno.config.ts
Normal 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); }
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
})
|
||||
42
frontends/owner-tools/vite.config.ts
Normal file
42
frontends/owner-tools/vite.config.ts
Normal 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
|
||||
}
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue