refactor: unify into single muyue binary with embedded desktop mode
All checks were successful
Beta Release / beta (push) Successful in 37s
All checks were successful
Beta Release / beta (push) Successful in 37s
- Merge muyue + muyue-desktop into one binary (13MB) - `muyue` starts TUI, `muyue desktop` launches web UI in browser - Move frontend from cmd/muyue-desktop/frontend/ to web/ (standard Go layout) - Add web/embed.go with //go:embed all:dist for frontend assets - Add internal/desktop/ package (server, browser open, SPA routing, signals) - Split internal/api/api.go into server.go + handlers.go - Add internal/desktop/desktop.go with SPA fallback and --port/--no-open flags - Clean package.json: remove unused @xterm/xterm, switch to ESM - Fix vite.config.js proxy to use port 8095 for dev mode - Add Makefile targets: frontend, desktop, dev-desktop - Update all CI workflows: single binary build, web/ paths - Remove cmd/muyue-desktop/ entirely 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
31
web/src/api/client.js
Normal file
31
web/src/api/client.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const api = {
|
||||
getInfo: () => request('/info'),
|
||||
getSystem: () => request('/system'),
|
||||
getTools: () => request('/tools'),
|
||||
getConfig: () => request('/config'),
|
||||
getProviders: () => request('/providers'),
|
||||
getSkills: () => request('/skills'),
|
||||
getLSP: () => request('/lsp'),
|
||||
getMCP: () => request('/mcp'),
|
||||
getUpdates: () => request('/updates'),
|
||||
runScan: () => request('/scan', { method: 'POST' }),
|
||||
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
|
||||
configureMCP: () => request('/mcp/configure', { method: 'POST' }),
|
||||
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
|
||||
}
|
||||
|
||||
export default api
|
||||
105
web/src/components/App.jsx
Normal file
105
web/src/components/App.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import api from '../api/client'
|
||||
import { getTheme, getThemeNames, applyTheme } from '../themes'
|
||||
import Dashboard from './Dashboard'
|
||||
import Studio from './Studio'
|
||||
import Shell from './Shell'
|
||||
import Config from './Config'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'dash', label: 'DASH', icon: '[■]' },
|
||||
{ id: 'studio', label: 'STUDIO', icon: '[<>]' },
|
||||
{ id: 'shell', label: 'SHELL', icon: '[$]' },
|
||||
{ id: 'config', label: 'CONFIG', icon: '[//]' },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('dash')
|
||||
const [info, setInfo] = useState({})
|
||||
const [clock, setClock] = useState(new Date())
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [tools, setTools] = useState([])
|
||||
const [transition, setTransition] = useState(false)
|
||||
const [currentTheme, setCurrentTheme] = useState('cyberpunk-red')
|
||||
// api is imported directly
|
||||
|
||||
useEffect(() => {
|
||||
api.getInfo().then(setInfo).catch(() => {})
|
||||
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
|
||||
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
|
||||
|
||||
const theme = getTheme(currentTheme)
|
||||
applyTheme(theme)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setClock(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const switchTab = useCallback((tabId) => {
|
||||
if (tabId === activeTab) return
|
||||
setTransition(true)
|
||||
setTimeout(() => {
|
||||
setActiveTab(tabId)
|
||||
setTimeout(() => setTransition(false), 150)
|
||||
}, 100)
|
||||
}, [activeTab])
|
||||
|
||||
const hasUpdates = updates.some(u => u.needsUpdate)
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={(t) => setTools(t)} />
|
||||
case 'studio': return <Studio api={api} />
|
||||
case 'shell': return <Shell api={api} />
|
||||
case 'config': return <Config api={api} theme={currentTheme} onThemeChange={setCurrentTheme} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<header className="header">
|
||||
<span className="header-logo">MUYUE</span>
|
||||
<span className="header-version">v{info.version || '...'}</span>
|
||||
|
||||
<div className="header-tabs">
|
||||
{TABS.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`header-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => switchTab(tab.id)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="header-spacer" />
|
||||
|
||||
<div className="header-status">
|
||||
<span className={`status-dot ${tools.length > 0 ? 'ok' : 'off'}`} title="System" />
|
||||
<span className={`status-dot ${hasUpdates ? 'warn' : 'ok'}`} title="Updates" />
|
||||
</div>
|
||||
|
||||
<span className="header-date">{clock.toLocaleDateString('fr-FR')}</span>
|
||||
<span className="header-clock">{clock.toLocaleTimeString('fr-FR')}</span>
|
||||
</header>
|
||||
|
||||
<div className={`content ${transition ? 'glitch-text' : 'fade-in tab-transition'}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
<footer className="footer">
|
||||
<span className="footer-shortcuts">
|
||||
<kbd>1-4</kbd> tabs · <kbd>Ctrl+T</kbd> switcher · <kbd>Ctrl+C</kbd> quit
|
||||
</span>
|
||||
<span className={`footer-update ${hasUpdates ? 'available' : 'uptodate'}`}>
|
||||
{hasUpdates ? '[UPD] Updates available' : '[OK] Up to date'}
|
||||
</span>
|
||||
<span className="footer-version">v{info.version || '...'}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
web/src/components/Config.jsx
Normal file
98
web/src/components/Config.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getThemeNames, applyTheme, getTheme } from '../themes'
|
||||
|
||||
export default function Config({ api, theme, onThemeChange }) {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [providers, setProviders] = useState([])
|
||||
const [skillList, setSkillList] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(d => setConfig(d)).catch(() => {})
|
||||
api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {})
|
||||
api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const themes = getThemeNames()
|
||||
|
||||
const handleThemeChange = (themeId) => {
|
||||
const t = getTheme(themeId)
|
||||
applyTheme(t)
|
||||
onThemeChange(themeId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-container">
|
||||
<div className="config-section">
|
||||
<div className="section-header">Profile</div>
|
||||
{config?.profile && (
|
||||
<div>
|
||||
<Field label="Name" value={config.profile.name} />
|
||||
<Field label="Pseudo" value={config.profile.pseudo} />
|
||||
<Field label="Email" value={config.profile.email} />
|
||||
<Field label="Editor" value={config.profile.preferences?.editor} />
|
||||
<Field label="Shell" value={config.profile.preferences?.shell} />
|
||||
<Field label="Default AI" value={config.profile.preferences?.defaultAI} />
|
||||
<Field label="Languages" value={config.profile.languages?.join(', ')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="section-header">AI Providers</div>
|
||||
{providers.map((p, i) => (
|
||||
<div key={i} className="config-field" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: 'var(--text-bright)', fontWeight: 700 }}>{p.name}</span>
|
||||
{p.active && <span style={{ color: 'var(--cyber-red)', fontSize: 11, fontWeight: 700 }}>{'>>'}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--dim-red)' }}>model={p.model}</span>
|
||||
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
|
||||
key={p.apiKey ? 'configured' : 'no key'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="section-header">Theme</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{themes.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={theme === t.id ? 'primary' : ''}
|
||||
onClick={() => handleThemeChange(t.id)}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<div className="section-header">Skills ({skillList.length})</div>
|
||||
{skillList.length === 0 ? (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>No skills. Run `muyue skills init`.</span>
|
||||
) : (
|
||||
skillList.map((s, i) => (
|
||||
<div key={i} className="tool-item">
|
||||
<span className="tool-name">{s.name}</span>
|
||||
<span style={{ color: 'var(--cyber-red)', fontSize: 11 }}>[{s.target || 'both'}]</span>
|
||||
<span style={{ color: 'var(--dim-red)', fontSize: 11 }}>{s.description}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }) {
|
||||
return (
|
||||
<div className="config-field">
|
||||
<span className="config-label">{label}:</span>
|
||||
<span className="config-value">{value || '-'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
web/src/components/Dashboard.jsx
Normal file
121
web/src/components/Dashboard.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function Dashboard({ tools, updates, api, onRescan }) {
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [installLog, setInstallLog] = useState([])
|
||||
|
||||
const installed = tools.filter(t => t.installed).length
|
||||
const total = tools.length
|
||||
const pct = total > 0 ? (installed / total) * 100 : 0
|
||||
const missing = tools.filter(t => !t.installed).map(t => t.Name || t.name)
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (missing.length === 0) return
|
||||
setInstalling(true)
|
||||
setInstallLog(prev => [...prev, { text: `Installing ${missing.length} tools...`, type: 'info' }])
|
||||
try {
|
||||
await api.installTools(missing)
|
||||
setInstallLog(prev => [...prev, { text: 'Install started. Rescan to see changes.', type: 'ok' }])
|
||||
const data = await api.runScan()
|
||||
const toolData = await api.getTools()
|
||||
onRescan(toolData.tools || [])
|
||||
} catch (err) {
|
||||
setInstallLog(prev => [...prev, { text: err.message, type: 'error' }])
|
||||
}
|
||||
setInstalling(false)
|
||||
}
|
||||
|
||||
const handleScan = async () => {
|
||||
await api.runScan()
|
||||
const data = await api.getTools()
|
||||
onRescan(data.tools || [])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid-2">
|
||||
<div style={{ overflow: 'auto', padding: '4px' }}>
|
||||
<div className="section-header">System</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ color: 'var(--text-main)' }}>{installed}/{total} tools installed</span>
|
||||
</div>
|
||||
|
||||
<div className="section-header">Installed Tools</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{tools.map((t, i) => (
|
||||
<div key={i} className="tool-item">
|
||||
<span className={`tool-status ${t.installed ? 'ok' : 'missing'}`}>
|
||||
{t.installed ? '[OK]' : '[--]'}
|
||||
</span>
|
||||
<span className="tool-name">{t.Name || t.name}</span>
|
||||
{(t.Version || t.version) && (
|
||||
<span className="tool-version">{extractVersion(t.Version || t.version)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar" style={{ marginBottom: 16 }}>
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
|
||||
{installing && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span className="loading-spinner"> Installing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installLog.length > 0 && (
|
||||
<div>
|
||||
<div className="section-header">Install Log</div>
|
||||
{installLog.map((log, i) => (
|
||||
<div key={i} style={{
|
||||
color: log.type === 'error' ? 'var(--error)' :
|
||||
log.type === 'ok' ? 'var(--success)' : 'var(--text-dim)',
|
||||
fontSize: 12, padding: '2px 0'
|
||||
}}>
|
||||
{log.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflow: 'auto', padding: '4px' }}>
|
||||
<div className="section-header">Quick Actions</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 }}>
|
||||
<button onClick={handleInstall} disabled={installing || missing.length === 0}>
|
||||
[i] Install missing ({missing.length})
|
||||
</button>
|
||||
<button onClick={() => api.getUpdates().then(d => {})}> [u] Check updates</button>
|
||||
<button onClick={handleScan}>[s] Rescan system</button>
|
||||
<button onClick={() => api.configureMCP()}>[m] Configure MCP</button>
|
||||
</div>
|
||||
|
||||
<div className="section-header">Updates</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{updates.length === 0 ? (
|
||||
<span style={{ color: 'var(--text-muted)' }}>No update data yet</span>
|
||||
) : updates.map((u, i) => (
|
||||
<div key={i} className="tool-item">
|
||||
<span className={`tool-status ${u.needsUpdate ? 'missing' : 'ok'}`}>
|
||||
{u.needsUpdate ? '[!!]' : '[OK]'}
|
||||
</span>
|
||||
<span className="tool-name">{u.tool}</span>
|
||||
{u.needsUpdate && (
|
||||
<span style={{ color: 'var(--warning)', fontSize: 11 }}>
|
||||
{u.current} → {u.latest}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractVersion(s) {
|
||||
if (!s) return ''
|
||||
const m = s.match(/\d+\.\d+\.\d+/)
|
||||
return m ? m[0] : s.slice(0, 12)
|
||||
}
|
||||
150
web/src/components/Shell.jsx
Normal file
150
web/src/components/Shell.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
export default function Shell({ api }) {
|
||||
const [history, setHistory] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [cwd, setCwd] = useState('~')
|
||||
const [aiPanel, setAiPanel] = useState(true)
|
||||
const [aiMessages, setAiMessages] = useState([
|
||||
{ role: 'ai', content: '>> I know your system inside out. Ask me anything.' }
|
||||
])
|
||||
const [aiInput, setAiInput] = useState('')
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const outputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current?.scrollTo(0, outputRef.current.scrollHeight)
|
||||
}, [history])
|
||||
|
||||
const handleCommand = async (cmd) => {
|
||||
if (!cmd.trim()) return
|
||||
if (cmd === 'clear') {
|
||||
setHistory([])
|
||||
return
|
||||
}
|
||||
if (cmd === 'exit' || cmd === 'quit') return
|
||||
|
||||
setHistory(prev => [...prev, { type: 'input', text: `${cwd} $ ${cmd}` }])
|
||||
|
||||
try {
|
||||
const res = await api.runCommand(cmd, cwd === '~' ? '' : cwd)
|
||||
if (res.output) {
|
||||
setHistory(prev => [...prev, { type: 'output', text: res.output }])
|
||||
}
|
||||
if (res.error) {
|
||||
setHistory(prev => [...prev, { type: 'error', text: res.error }])
|
||||
}
|
||||
if (cmd.startsWith('cd ')) {
|
||||
const dir = cmd.slice(3).trim()
|
||||
setCwd(dir === '~' ? '~' : dir)
|
||||
}
|
||||
} catch (err) {
|
||||
setHistory(prev => [...prev, { type: 'error', text: err.message }])
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleCommand(input)
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading) return
|
||||
const text = aiInput.trim()
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: '>> ' + text }])
|
||||
setAiInput('')
|
||||
setAiLoading(true)
|
||||
|
||||
try {
|
||||
const res = await api.runCommand(`echo "AI: ${text}"`, '')
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: '>> ' + (res.output || 'No response') }])
|
||||
} catch (err) {
|
||||
setAiMessages(prev => [...prev, { role: 'ai', content: '[ERROR] ' + err.message }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal" style={{ height: '100%' }}>
|
||||
<div className="terminal-container" style={{ flex: 1 }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-dim)' }}>
|
||||
<div className="section-header" style={{ margin: 0 }}>
|
||||
Terminal
|
||||
<span style={{ color: 'var(--dim-red)', fontWeight: 400, marginLeft: 12, fontSize: 11 }}>{cwd}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="terminal-output" ref={outputRef}>
|
||||
{history.map((line, i) => (
|
||||
<div key={i} style={{
|
||||
color: line.type === 'input' ? 'var(--dim-red)' :
|
||||
line.type === 'error' ? 'var(--error)' : 'var(--text-main)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-input-row">
|
||||
<span className="terminal-prompt">{'>'}</span>
|
||||
<input
|
||||
className="terminal-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiPanel && (
|
||||
<div style={{
|
||||
width: 320,
|
||||
borderLeft: '1px solid var(--border-dim)',
|
||||
background: 'var(--bg-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-dim)' }}>
|
||||
<div className="section-header" style={{ margin: 0 }}>AI Assistant</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<div key={i} style={{
|
||||
marginBottom: 8,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: msg.role === 'ai' ? 'var(--bg-card)' : 'var(--muted-red)',
|
||||
borderLeft: `3px solid ${msg.role === 'ai' ? 'var(--cyber-red)' : 'var(--cyber-rose)'}`,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{aiLoading && <span className="loading-spinner"> thinking...</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px 12px', borderTop: '1px solid var(--border-dim)', display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
style={{ flex: 1, padding: '4px 8px', fontSize: 12 }}
|
||||
value={aiInput}
|
||||
onChange={e => setAiInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
||||
placeholder="Ask AI..."
|
||||
/>
|
||||
<button style={{ padding: '4px 8px' }} onClick={handleAiSend}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
web/src/components/Studio.jsx
Normal file
126
web/src/components/Studio.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
export default function Studio({ api }) {
|
||||
const [messages, setMessages] = useState([
|
||||
{ role: 'ai', content: '>> Welcome to Studio! Chat with your AI assistant here.' },
|
||||
{ role: 'ai', content: '>> Configure agents and workflows from the sidebar.' },
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [sidebarPanel, setSidebarPanel] = useState('chat')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const messagesEnd = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || loading) return
|
||||
const text = input.trim()
|
||||
setMessages(prev => [...prev, { role: 'user', content: '>> ' + text }])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
api.runCommand(`echo "AI response simulation for: ${text}"`, '')
|
||||
.then(res => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: '>> ' + (res.output || res.error || 'No response') }])
|
||||
})
|
||||
.catch(err => {
|
||||
setMessages(prev => [...prev, { role: 'ai', content: '[ERROR] ' + err.message }])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="split-horizontal">
|
||||
<div className="chat-container" style={{ flex: 1, borderRight: '1px solid var(--border-dim)' }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-dim)' }}>
|
||||
<div className="section-header" style={{ margin: 0 }}>
|
||||
Chat
|
||||
{loading && <span className="loading-spinner" style={{ marginLeft: 8 }}> thinking...</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`chat-message ${msg.role}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-container">
|
||||
<input
|
||||
className="chat-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (/plan <goal> for workflows)"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="split-right">
|
||||
<div className="sidebar-section">
|
||||
<div className="section-header">Studio</div>
|
||||
{['chat', 'agents', 'workflows'].map(panel => (
|
||||
<div
|
||||
key={panel}
|
||||
className={`sidebar-item ${sidebarPanel === panel ? 'active' : ''}`}
|
||||
onClick={() => setSidebarPanel(panel)}
|
||||
>
|
||||
[{panel === 'chat' ? '#' : panel === 'agents' ? '*' : '~'}] {panel.charAt(0).toUpperCase() + panel.slice(1)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border-dim)', paddingTop: 12 }}>
|
||||
{sidebarPanel === 'chat' && (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginBottom: 8 }}>Commands</div>
|
||||
<div style={{ color: 'var(--dim-red)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
/plan {'<goal>'}<br/>
|
||||
/help
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'agents' && (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginBottom: 8 }}>Active Agents</div>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>Crush</span>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: 11 }}>[|| stopped]</span>
|
||||
</div>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>Claude Code</span>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: 11 }}>[|| stopped]</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarPanel === 'workflows' && (
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No active workflow.</div>
|
||||
<div style={{ color: 'var(--dim-red)', fontSize: 12, marginTop: 8 }}>
|
||||
Use /plan {'<goal>'} in chat to start.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
web/src/main.jsx
Normal file
9
web/src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './components/App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
576
web/src/styles/global.css
Normal file
576
web/src/styles/global.css
Normal file
@@ -0,0 +1,576 @@
|
||||
:root {
|
||||
--bg-void: #0A0A0C;
|
||||
--bg-base: #0F0D10;
|
||||
--bg-surface: #161218;
|
||||
--bg-panel: #1C1719;
|
||||
--bg-card: #221B1E;
|
||||
--bg-input: #2A2225;
|
||||
--bg-hover: #332528;
|
||||
|
||||
--cyber-red: #FF0033;
|
||||
--cyber-red-dark: #8B0020;
|
||||
--cyber-red-deep: #5C0015;
|
||||
--cyber-pink: #FF1A5E;
|
||||
--cyber-rose: #FF4D6D;
|
||||
--neon-red: #FF1744;
|
||||
--bright-red: #FF5252;
|
||||
--dim-red: #6B2033;
|
||||
--muted-red: #4A1525;
|
||||
|
||||
--text-bright: #EAE0E2;
|
||||
--text-main: #D4C4C8;
|
||||
--text-dim: #8A7A7E;
|
||||
--text-muted: #5A4F52;
|
||||
|
||||
--success: #00E676;
|
||||
--warning: #FFD740;
|
||||
--error: #FF1744;
|
||||
|
||||
--border-dim: #2A1F22;
|
||||
--border-red: #FF003344;
|
||||
--border-red-full: #FF0033;
|
||||
|
||||
--radius: 8px;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace;
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
|
||||
--header-h: 48px;
|
||||
--footer-h: 32px;
|
||||
--tab-h: 40px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-void);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--dim-red);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--cyber-red-dark);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--cyber-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--cyber-red);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--cyber-pink);
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
border-color: var(--cyber-red);
|
||||
box-shadow: 0 0 0 2px var(--border-red);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-main);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--cyber-red-dark);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--cyber-red);
|
||||
color: #fff;
|
||||
border-color: var(--cyber-red);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--neon-red);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: var(--header-h);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 900;
|
||||
font-size: 16px;
|
||||
color: var(--cyber-red);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.header-version {
|
||||
font-size: 11px;
|
||||
color: var(--dim-red);
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.header-tab {
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
text-transform: uppercase;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header-tab:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.header-tab.active {
|
||||
color: #fff;
|
||||
background: var(--cyber-red);
|
||||
border-color: var(--cyber-red);
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.ok { background: var(--success); }
|
||||
.status-dot.warn { background: var(--warning); }
|
||||
.status-dot.error { background: var(--error); }
|
||||
.status-dot.off { background: var(--text-muted); }
|
||||
|
||||
.header-clock {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--cyber-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: var(--footer-h);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-shortcuts {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footer-shortcuts kbd {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.footer-version {
|
||||
font-size: 11px;
|
||||
color: var(--dim-red);
|
||||
}
|
||||
|
||||
.footer-update {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer-update.available {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.footer-update.uptodate {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--dim-red);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--cyber-red);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--cyber-red);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-header::before {
|
||||
content: '■';
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-status {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.tool-status.ok { color: var(--success); }
|
||||
.tool-status.missing { color: var(--error); }
|
||||
|
||||
.tool-name {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.tool-version {
|
||||
color: var(--dim-red);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--cyber-red), var(--cyber-pink));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.split-horizontal {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.split-left {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.split-right {
|
||||
width: 320px;
|
||||
border-left: 1px solid var(--border-dim);
|
||||
background: var(--bg-surface);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-message.ai {
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--cyber-red);
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
background: var(--muted-red);
|
||||
border-left: 3px solid var(--cyber-rose);
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: var(--cyber-red);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.terminal-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-input);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
color: var(--success);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.terminal-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.config-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.config-label {
|
||||
width: 140px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@keyframes glitch {
|
||||
0% { transform: translate(0); }
|
||||
20% { transform: translate(-2px, 2px); }
|
||||
40% { transform: translate(-2px, -2px); }
|
||||
60% { transform: translate(2px, 2px); }
|
||||
80% { transform: translate(2px, -2px); }
|
||||
100% { transform: translate(0); }
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% { top: -10%; }
|
||||
100% { top: 110%; }
|
||||
}
|
||||
|
||||
@keyframes typewriter {
|
||||
from { width: 0; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.tab-transition {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.glitch-text {
|
||||
animation: glitch 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
animation: pulse 1s infinite;
|
||||
color: var(--cyber-red);
|
||||
}
|
||||
138
web/src/themes/index.js
Normal file
138
web/src/themes/index.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const defaultTheme = {
|
||||
name: 'Cyberpunk Red',
|
||||
colors: {
|
||||
bgVoid: '#0A0A0C',
|
||||
bgBase: '#0F0D10',
|
||||
bgSurface: '#161218',
|
||||
bgPanel: '#1C1719',
|
||||
bgCard: '#221B1E',
|
||||
bgInput: '#2A2225',
|
||||
bgHover: '#332528',
|
||||
cyberRed: '#FF0033',
|
||||
cyberRedDark: '#8B0020',
|
||||
cyberRedDeep: '#5C0015',
|
||||
cyberPink: '#FF1A5E',
|
||||
cyberRose: '#FF4D6D',
|
||||
neonRed: '#FF1744',
|
||||
brightRed: '#FF5252',
|
||||
dimRed: '#6B2033',
|
||||
mutedRed: '#4A1525',
|
||||
textBright: '#EAE0E2',
|
||||
textMain: '#D4C4C8',
|
||||
textDim: '#8A7A7E',
|
||||
textMuted: '#5A4F52',
|
||||
success: '#00E676',
|
||||
warning: '#FFD740',
|
||||
error: '#FF1744',
|
||||
borderDim: '#2A1F22',
|
||||
borderRed: '#FF003344',
|
||||
borderRedFull: '#FF0033',
|
||||
},
|
||||
fonts: {
|
||||
mono: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
||||
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
|
||||
},
|
||||
borderRadius: '8px',
|
||||
animations: {
|
||||
glitch: true,
|
||||
scanline: true,
|
||||
typewriter: true,
|
||||
pulse: true,
|
||||
},
|
||||
}
|
||||
|
||||
const themes = {
|
||||
'cyberpunk-red': defaultTheme,
|
||||
'cyberpunk-pink': {
|
||||
...defaultTheme,
|
||||
name: 'Cyberpunk Pink',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
cyberRed: '#FF1A8C',
|
||||
cyberRedDark: '#8B1050',
|
||||
cyberRedDeep: '#5C0A35',
|
||||
cyberPink: '#FF4DAE',
|
||||
cyberRose: '#FF6DC2',
|
||||
neonRed: '#FF1A8C',
|
||||
brightRed: '#FF6DC2',
|
||||
dimRed: '#6B2050',
|
||||
mutedRed: '#4A1535',
|
||||
},
|
||||
},
|
||||
'midnight-blue': {
|
||||
...defaultTheme,
|
||||
name: 'Midnight Blue',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
cyberRed: '#0088FF',
|
||||
cyberRedDark: '#004488',
|
||||
cyberRedDeep: '#002255',
|
||||
cyberPink: '#00AAFF',
|
||||
cyberRose: '#44CCFF',
|
||||
neonRed: '#0088FF',
|
||||
brightRed: '#44CCFF',
|
||||
dimRed: '#203366',
|
||||
mutedRed: '#152244',
|
||||
},
|
||||
},
|
||||
'matrix-green': {
|
||||
...defaultTheme,
|
||||
name: 'Matrix Green',
|
||||
colors: {
|
||||
...defaultTheme.colors,
|
||||
cyberRed: '#00FF41',
|
||||
cyberRedDark: '#008822',
|
||||
cyberRedDeep: '#005515',
|
||||
cyberPink: '#33FF66',
|
||||
cyberRose: '#66FF99',
|
||||
neonRed: '#00FF41',
|
||||
brightRed: '#66FF99',
|
||||
dimRed: '#206630',
|
||||
mutedRed: '#154420',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getTheme(name) {
|
||||
return themes[name] || defaultTheme
|
||||
}
|
||||
|
||||
export function getThemeNames() {
|
||||
return Object.keys(themes).map(k => ({ id: k, name: themes[k].name }))
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
const root = document.documentElement
|
||||
const c = theme.colors
|
||||
const map = {
|
||||
'--bg-void': c.bgVoid,
|
||||
'--bg-base': c.bgBase,
|
||||
'--bg-surface': c.bgSurface,
|
||||
'--bg-panel': c.bgPanel,
|
||||
'--bg-card': c.bgCard,
|
||||
'--bg-input': c.bgInput,
|
||||
'--bg-hover': c.bgHover,
|
||||
'--cyber-red': c.cyberRed,
|
||||
'--cyber-red-dark': c.cyberRedDark,
|
||||
'--cyber-red-deep': c.cyberRedDeep,
|
||||
'--cyber-pink': c.cyberPink,
|
||||
'--cyber-rose': c.cyberRose,
|
||||
'--neon-red': c.neonRed,
|
||||
'--bright-red': c.brightRed,
|
||||
'--dim-red': c.dimRed,
|
||||
'--muted-red': c.mutedRed,
|
||||
'--text-bright': c.textBright,
|
||||
'--text-main': c.textMain,
|
||||
'--text-dim': c.textDim,
|
||||
'--text-muted': c.textMuted,
|
||||
'--success': c.success,
|
||||
'--warning': c.warning,
|
||||
'--error': c.error,
|
||||
'--border-dim': c.borderDim,
|
||||
'--border-red': c.borderRed,
|
||||
'--border-red-full': c.borderRedFull,
|
||||
}
|
||||
Object.entries(map).forEach(([k, v]) => root.style.setProperty(k, v))
|
||||
}
|
||||
|
||||
export default defaultTheme
|
||||
Reference in New Issue
Block a user