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
|
||||||
|
}
|
||||||
|
}))
|
||||||
4427
frontends/package-lock.json
generated
Normal file
4427
frontends/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
14
frontends/package.json
Normal file
14
frontends/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "writekit-frontends",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"ui",
|
||||||
|
"studio",
|
||||||
|
"owner-tools"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:studio": "npm -w studio run dev",
|
||||||
|
"dev:owner-tools": "npm -w owner-tools run dev",
|
||||||
|
"build": "npm -w ui run build && npm -w studio run build && npm -w owner-tools run build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@writekit/ui": "*",
|
||||||
"@iconify-json/logos": "^1.2.10",
|
"@iconify-json/logos": "^1.2.10",
|
||||||
"@iconify-json/lucide": "^1.2.82",
|
"@iconify-json/lucide": "^1.2.82",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
|
@ -7,17 +7,14 @@ import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
|
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
|
||||||
import TaskList from '@tiptap/extension-task-list'
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
import TaskItem from '@tiptap/extension-task-item'
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
|
||||||
import { Markdown } from '@tiptap/markdown'
|
import { Markdown } from '@tiptap/markdown'
|
||||||
import { common, createLowlight } from 'lowlight'
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $editorPost, broadcastPreview } from '../../stores/editor'
|
import { $editorPost, broadcastPreview } from '../../stores/editor'
|
||||||
import { $settings } from '../../stores/settings'
|
import { $settings } from '../../stores/settings'
|
||||||
import { Icons } from '../shared/Icons'
|
import { Icons } from '../shared/Icons'
|
||||||
import { SlashCommands } from './SlashCommands'
|
import { SlashCommands } from './SlashCommands'
|
||||||
|
import { CodeBlockEnhanced } from './extensions/code-block'
|
||||||
const lowlight = createLowlight(common)
|
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface PostEditorProps {
|
||||||
onChange?: (markdown: string) => void
|
onChange?: (markdown: string) => void
|
||||||
|
|
@ -45,7 +42,6 @@ async function uploadImage(file: File): Promise<string | null> {
|
||||||
export function PostEditor({ onChange }: PostEditorProps) {
|
export function PostEditor({ onChange }: PostEditorProps) {
|
||||||
const post = useStore($editorPost)
|
const post = useStore($editorPost)
|
||||||
const settings = useStore($settings)
|
const settings = useStore($settings)
|
||||||
const isInitialMount = useRef(true)
|
|
||||||
const skipNextUpdate = useRef(false)
|
const skipNextUpdate = useRef(false)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
|
|
@ -79,9 +75,7 @@ export function PostEditor({ onChange }: PostEditorProps) {
|
||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
CodeBlockLowlight.configure({
|
CodeBlockEnhanced,
|
||||||
lowlight,
|
|
||||||
}),
|
|
||||||
Markdown,
|
Markdown,
|
||||||
SlashCommands,
|
SlashCommands,
|
||||||
],
|
],
|
||||||
|
|
@ -184,11 +178,6 @@ export function PostEditor({ onChange }: PostEditorProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
if (isInitialMount.current) {
|
|
||||||
isInitialMount.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentContent = editor.getMarkdown()
|
const currentContent = editor.getMarkdown()
|
||||||
if (currentContent !== post.content) {
|
if (currentContent !== post.content) {
|
||||||
skipNextUpdate.current = true
|
skipNextUpdate.current = true
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { $toasts, removeToast } from '../../stores/app'
|
import { $toasts, removeToast } from '../../stores/app'
|
||||||
import { Icons } from '../shared/Icons'
|
import { Icons } from '@writekit/ui'
|
||||||
|
|
||||||
export function Toasts() {
|
export function Toasts() {
|
||||||
const toasts = useStore($toasts)
|
const toasts = useStore($toasts)
|
||||||
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'
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
|
import { $settings, $settingsData, $hasChanges, $saveSettings, $changedFields } from '../stores/settings'
|
||||||
import { addToast } from '../stores/app'
|
import { addToast } from '../stores/app'
|
||||||
import { SaveBar, DesignPageSkeleton } from '../components/shared'
|
import { SaveBar, DesignPageSkeleton } from '../components/shared'
|
||||||
import './DesignPage.preview.css'
|
|
||||||
|
|
||||||
const fontConfigs = {
|
const fontConfigs = {
|
||||||
system: { family: 'system-ui, -apple-system, sans-serif', url: '' },
|
system: { family: 'system-ui, -apple-system, sans-serif', url: '' },
|
||||||
|
|
@ -139,104 +138,13 @@ function LayoutPreview({ layout, selected }: { layout: string; selected: boolean
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
|
||||||
© 2024 Your Blog
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DesignPage() {
|
export default function DesignPage() {
|
||||||
const settings = useStore($settings)
|
const settings = useStore($settings)
|
||||||
const { data } = useStore($settingsData)
|
const { data } = useStore($settingsData)
|
||||||
const hasChanges = useStore($hasChanges)
|
const hasChanges = useStore($hasChanges)
|
||||||
const changedFields = useStore($changedFields)
|
const changedFields = useStore($changedFields)
|
||||||
const saveSettings = useStore($saveSettings)
|
const saveSettings = useStore($saveSettings)
|
||||||
const [availableThemes, setAvailableThemes] = useState<string[]>(Object.keys(themePreviewColors))
|
const availableThemes = Object.keys(themePreviewColors)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/studio/code-themes')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then((themes: string[]) => setAvailableThemes(themes))
|
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Load all fonts for previews
|
// Load all fonts for previews
|
||||||
Object.keys(fontConfigs).forEach(useFontLoader)
|
Object.keys(fontConfigs).forEach(useFontLoader)
|
||||||
|
|
@ -261,47 +169,6 @@ export default function DesignPage() {
|
||||||
|
|
||||||
{/* Panel container - full-bleed borders */}
|
{/* Panel container - full-bleed borders */}
|
||||||
<div className="-mx-6 lg:-mx-10">
|
<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 */}
|
{/* Accent Color */}
|
||||||
<div className="px-6 lg:px-10 py-5">
|
<div className="px-6 lg:px-10 py-5">
|
||||||
<div className="text-xs font-medium text-muted uppercase tracking-wide">Accent Color</div>
|
<div className="text-xs font-medium text-muted uppercase tracking-wide">Accent Color</div>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { defineConfig, presetWind4, presetIcons, presetTypography } from 'unocss'
|
import { defineConfig, presetWind4, presetIcons, presetTypography } from 'unocss'
|
||||||
|
import { theme, shortcuts } from '@writekit/ui/uno.config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
presets: [
|
presets: [
|
||||||
|
|
@ -206,54 +207,6 @@ export default defineConfig({
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
theme: {
|
theme,
|
||||||
colors: {
|
shortcuts,
|
||||||
bg: '#fafafa',
|
|
||||||
surface: '#ffffff',
|
|
||||||
text: '#0a0a0a',
|
|
||||||
muted: '#737373',
|
|
||||||
border: '#e5e5e5',
|
|
||||||
accent: '#10b981',
|
|
||||||
success: '#10b981',
|
|
||||||
warning: '#f59e0b',
|
|
||||||
danger: '#ef4444',
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
||||||
mono: '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shortcuts: {
|
|
||||||
// Buttons - dark bg with light text for primary
|
|
||||||
'btn': 'inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-medium border border-border transition-all duration-150 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
'btn-primary': 'btn bg-text text-bg border-text hover:bg-[#1a1a1a]',
|
|
||||||
'btn-secondary': 'btn bg-bg text-text hover:border-muted',
|
|
||||||
'btn-danger': 'btn text-danger border-danger hover:bg-danger hover:text-white hover:border-danger',
|
|
||||||
'btn-ghost': 'btn border-transparent hover:bg-border',
|
|
||||||
|
|
||||||
// Inputs - clean borders, minimal styling
|
|
||||||
'input': 'w-full px-3 py-2 text-sm bg-bg border border-border font-sans focus:outline-none focus:border-muted transition-colors placeholder:text-muted/60',
|
|
||||||
'textarea': 'input resize-none',
|
|
||||||
|
|
||||||
// Labels - small, muted
|
|
||||||
'label': 'block text-xs text-muted font-medium mb-1',
|
|
||||||
|
|
||||||
// Cards - minimal borders, no shadows
|
|
||||||
'card': 'bg-surface border border-border p-6',
|
|
||||||
'section': 'card',
|
|
||||||
|
|
||||||
// Navigation - matching old dashboard
|
|
||||||
'nav-item': 'relative flex items-center gap-2.5 px-2.5 py-2 text-[13px] font-[450] text-muted transition-all duration-150 hover:text-text hover:bg-black/4',
|
|
||||||
'nav-item-active': 'relative flex items-center gap-2.5 px-2.5 py-2 text-[13px] font-[450] text-text bg-black/4 before:content-[""] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-[3px] before:h-4 before:bg-accent before:rounded-r-sm',
|
|
||||||
'nav-section': 'text-[10px] uppercase tracking-[0.06em] text-muted font-semibold px-2.5 pt-3 pb-1.5',
|
|
||||||
|
|
||||||
// Badges
|
|
||||||
'badge': 'inline-flex items-center text-xs px-2 py-0.5 border',
|
|
||||||
'badge-draft': 'badge text-warning border-warning/40 bg-warning/10',
|
|
||||||
'badge-published': 'badge text-success border-success/40 bg-success/10',
|
|
||||||
|
|
||||||
// Page structure
|
|
||||||
'page-header': 'flex items-center justify-between mb-6',
|
|
||||||
'page-title': 'text-base font-medium text-text',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
27
frontends/ui/package.json
Normal file
27
frontends/ui/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "@writekit/ui",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./uno.config": "./uno.config.ts",
|
||||||
|
"./styles": "./src/styles.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "echo 'no build needed - consumed as source'",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"unocss": "^66.5.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.82",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
frontends/ui/src/ActionMenu.tsx
Normal file
92
frontends/ui/src/ActionMenu.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import type { IconComponent } from './Icons'
|
||||||
|
import { Icons } from './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/ui/src/Badge.tsx
Normal file
20
frontends/ui/src/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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
frontends/ui/src/Button.tsx
Normal file
64
frontends/ui/src/Button.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { ButtonHTMLAttributes, AnchorHTMLAttributes, ReactNode } from 'react'
|
||||||
|
import { Icons, type IconComponent } from './Icons'
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'accent'
|
||||||
|
|
||||||
|
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',
|
||||||
|
accent: 'btn-accent',
|
||||||
|
}
|
||||||
|
|
||||||
|
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/ui/src/Dropdown.tsx
Normal file
86
frontends/ui/src/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import type { IconComponent } from './Icons'
|
||||||
|
import { Icons } from './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/ui/src/Icons.tsx
Normal file
50
frontends/ui/src/Icons.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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'),
|
||||||
|
Settings: createIcon('i-lucide-settings'),
|
||||||
|
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'),
|
||||||
|
Search: createIcon('i-lucide-search'),
|
||||||
|
Close: createIcon('i-lucide-x'),
|
||||||
|
Check: createIcon('i-lucide-check'),
|
||||||
|
Loader: createIcon('i-lucide-loader-2'),
|
||||||
|
Save: createIcon('i-lucide-save'),
|
||||||
|
|
||||||
|
// UI
|
||||||
|
ChevronDown: createIcon('i-lucide-chevron-down'),
|
||||||
|
ChevronUp: createIcon('i-lucide-chevron-up'),
|
||||||
|
ChevronRight: createIcon('i-lucide-chevron-right'),
|
||||||
|
ArrowLeft: createIcon('i-lucide-arrow-left'),
|
||||||
|
ArrowRight: createIcon('i-lucide-arrow-right'),
|
||||||
|
MoreHorizontal: createIcon('i-lucide-more-horizontal'),
|
||||||
|
|
||||||
|
// Status
|
||||||
|
CheckCircle: createIcon('i-lucide-check-circle'),
|
||||||
|
AlertCircle: createIcon('i-lucide-alert-circle'),
|
||||||
|
AlertTriangle: createIcon('i-lucide-alert-triangle'),
|
||||||
|
Info: createIcon('i-lucide-info'),
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
Eye: createIcon('i-lucide-eye'),
|
||||||
|
EyeOff: createIcon('i-lucide-eye-off'),
|
||||||
|
Link: createIcon('i-lucide-link'),
|
||||||
|
Globe: createIcon('i-lucide-globe'),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export { createIcon }
|
||||||
50
frontends/ui/src/Input.tsx
Normal file
50
frontends/ui/src/Input.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
||||||
|
import type { IconComponent } from './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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue