tools!
This commit is contained in:
parent
28948b13b2
commit
bde7a2c287
|
|
@ -9,7 +9,8 @@
|
|||
"dev": "bun run --hot index.tsx"
|
||||
},
|
||||
"toes": {
|
||||
"icon": "🖥️"
|
||||
"tool": true,
|
||||
"icon": "💻"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
|
|
|||
1
apps/todo/.npmrc
Normal file
1
apps/todo/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
registry=https://npm.nose.space
|
||||
43
apps/todo/bun.lock
Normal file
43
apps/todo/bun.lock
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "todo",
|
||||
"dependencies": {
|
||||
"@because/forge": "*",
|
||||
"@because/howl": "*",
|
||||
"@because/hype": "*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||
|
||||
"@because/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
|
||||
|
||||
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lucide-static": ["lucide-static@0.555.0", "https://npm.nose.space/lucide-static/-/lucide-static-0.555.0.tgz", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
1
apps/todo/index.tsx
Normal file
1
apps/todo/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './src/server'
|
||||
26
apps/todo/package.json
Normal file
26
apps/todo/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "todo",
|
||||
"module": "index.tsx",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"toes": "bun run --watch index.tsx",
|
||||
"start": "bun toes",
|
||||
"dev": "bun run --hot index.tsx"
|
||||
},
|
||||
"toes": {
|
||||
"tool": true,
|
||||
"icon": "🖥️"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@because/hype": "*",
|
||||
"@because/forge": "*",
|
||||
"@because/howl": "*"
|
||||
}
|
||||
}
|
||||
BIN
apps/todo/pub/img/bite1.png
Normal file
BIN
apps/todo/pub/img/bite1.png
Normal file
Binary file not shown.
BIN
apps/todo/pub/img/bite2.png
Normal file
BIN
apps/todo/pub/img/bite2.png
Normal file
Binary file not shown.
BIN
apps/todo/pub/img/burger.png
Normal file
BIN
apps/todo/pub/img/burger.png
Normal file
Binary file not shown.
36
apps/todo/src/client/App.tsx
Normal file
36
apps/todo/src/client/App.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { render, useState } from 'hono/jsx/dom'
|
||||
import { define } from '@because/forge'
|
||||
|
||||
const Wrapper = define({
|
||||
margin: '0 auto',
|
||||
marginTop: 50,
|
||||
width: '50vw',
|
||||
border: '1px solid black',
|
||||
padding: 24,
|
||||
textAlign: 'center'
|
||||
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
try {
|
||||
return (
|
||||
<Wrapper>
|
||||
<h1>It works!</h1>
|
||||
<h2>Count: {count}</h2>
|
||||
<div>
|
||||
<button onClick={() => setCount(c => c + 1)}>+</button>
|
||||
|
||||
<button onClick={() => setCount(c => c && c - 1)}>-</button>
|
||||
</div>
|
||||
</Wrapper>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Render error:', error)
|
||||
return <><h1>Error</h1><pre>{error instanceof Error ? error : new Error(String(error))}</pre></>
|
||||
}
|
||||
}
|
||||
|
||||
const root = document.getElementById('root')!
|
||||
render(<App />, root)
|
||||
40
apps/todo/src/css/main.css
Normal file
40
apps/todo/src/css/main.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
section {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hype {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.8rem;
|
||||
background: linear-gradient(45deg,
|
||||
#ff00ff 0%,
|
||||
#00ffff 33%,
|
||||
#ffff00 66%,
|
||||
#ff00ff 100%);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
color: black;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
31
apps/todo/src/pages/index.tsx
Normal file
31
apps/todo/src/pages/index.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { $ } from 'bun'
|
||||
|
||||
const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7)
|
||||
|| await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown')
|
||||
|
||||
export default () => <>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>hype</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
|
||||
<script dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.GIT_HASH = '${GIT_HASH}';
|
||||
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
|
||||
`
|
||||
}} />
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewport">
|
||||
<main>
|
||||
<div id="root" />
|
||||
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</>
|
||||
8
apps/todo/src/server/index.tsx
Normal file
8
apps/todo/src/server/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Hype } from '@because/hype'
|
||||
|
||||
const app = new Hype({ layout: false })
|
||||
|
||||
// custom routes go here
|
||||
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
||||
|
||||
export default app.defaults
|
||||
0
apps/todo/src/shared/types.ts
Normal file
0
apps/todo/src/shared/types.ts
Normal file
29
apps/todo/tsconfig.json
Normal file
29
apps/todo/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"$*": ["src/server/*"],
|
||||
"#*": ["src/client/*"],
|
||||
"@*": ["src/shared/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { define } from '@because/forge'
|
|||
import type { App } from '../../shared/types'
|
||||
import { restartApp, startApp, stopApp } from '../api'
|
||||
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||
import { selectedTab } from '../state'
|
||||
import { apps, selectedTab } from '../state'
|
||||
import {
|
||||
ActionBar,
|
||||
Button,
|
||||
|
|
@ -44,6 +44,9 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
|
|||
})
|
||||
|
||||
export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||
// Find all tools
|
||||
const tools = apps.filter(a => a.tool)
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<MainHeader>
|
||||
|
|
@ -57,7 +60,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
|||
</HeaderActions>
|
||||
</MainHeader>
|
||||
<MainContent>
|
||||
<Nav render={render} />
|
||||
<Nav app={app} render={render} />
|
||||
|
||||
<TabContent active={selectedTab === 'overview'}>
|
||||
<Section>
|
||||
|
|
@ -142,9 +145,29 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
|||
</ActionBar>
|
||||
</TabContent>
|
||||
|
||||
<TabContent active={selectedTab === 'todo'}>
|
||||
<h1>hardy har har</h1>
|
||||
</TabContent>
|
||||
{tools.map(tool => {
|
||||
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
|
||||
const isSelected = selectedTab === tool.name
|
||||
return (
|
||||
<TabContent key={tool.name} active={isSelected}>
|
||||
<Section>
|
||||
<SectionTitle>{toolName}</SectionTitle>
|
||||
{tool.state !== 'running' && (
|
||||
<p style={{ color: theme('colors-textFaint') }}>
|
||||
Tool is {stateLabels[tool.state].toLowerCase()}
|
||||
</p>
|
||||
)}
|
||||
{/* Target for iframe overlay positioning */}
|
||||
{tool.state === 'running' && (
|
||||
<div
|
||||
data-tool-target={isSelected ? tool.name : undefined}
|
||||
style={{ width: '100%', height: '600px' }}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</TabContent>
|
||||
)
|
||||
})}
|
||||
</MainContent>
|
||||
</Main>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
import { selectedTab, setSelectedTab } from '../state'
|
||||
import type { App } from '../../shared/types'
|
||||
import { apps, selectedTab, setSelectedTab } from '../state'
|
||||
import { Tab, TabBar } from '../styles'
|
||||
|
||||
export function Nav({ render }: { render: () => void }) {
|
||||
const handleTabClick = (tab: 'overview' | 'todo') => {
|
||||
export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||
const handleTabClick = (tab: string) => {
|
||||
setSelectedTab(tab)
|
||||
render()
|
||||
}
|
||||
|
||||
// Find all tools
|
||||
const tools = apps.filter(a => a.tool)
|
||||
|
||||
return (
|
||||
<TabBar>
|
||||
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => handleTabClick('overview')}>Overview</Tab>
|
||||
<Tab active={selectedTab === 'todo' ? true : undefined} onClick={() => handleTabClick('todo')}>TODO</Tab>
|
||||
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => handleTabClick('overview')}>
|
||||
Overview
|
||||
</Tab>
|
||||
{tools.map(tool => {
|
||||
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
|
||||
return (
|
||||
<Tab
|
||||
key={tool.name}
|
||||
active={selectedTab === tool.name ? true : undefined}
|
||||
onClick={() => handleTabClick(tool.name)}
|
||||
>
|
||||
{toolName}
|
||||
</Tab>
|
||||
)
|
||||
})}
|
||||
</TabBar>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { openNewAppModal } from '../modals'
|
||||
import { apps, selectedApp, setSelectedApp, setSidebarCollapsed, sidebarCollapsed } from '../state'
|
||||
import {
|
||||
apps,
|
||||
selectedApp,
|
||||
setSelectedApp,
|
||||
setSidebarCollapsed,
|
||||
setSidebarSection,
|
||||
sidebarCollapsed,
|
||||
sidebarSection,
|
||||
} from '../state'
|
||||
import {
|
||||
AppItem,
|
||||
AppList,
|
||||
|
|
@ -7,7 +15,8 @@ import {
|
|||
HamburgerLine,
|
||||
Logo,
|
||||
NewAppButton,
|
||||
SectionLabel,
|
||||
SectionSwitcher,
|
||||
SectionTab,
|
||||
Sidebar as SidebarContainer,
|
||||
SidebarFooter,
|
||||
StatusDot,
|
||||
|
|
@ -24,6 +33,15 @@ export function Sidebar({ render }: { render: () => void }) {
|
|||
render()
|
||||
}
|
||||
|
||||
const switchSection = (section: 'apps' | 'tools') => {
|
||||
setSidebarSection(section)
|
||||
render()
|
||||
}
|
||||
|
||||
const regularApps = apps.filter(app => !app.tool)
|
||||
const toolApps = apps.filter(app => app.tool)
|
||||
const activeApps = sidebarSection === 'apps' ? regularApps : toolApps
|
||||
|
||||
return (
|
||||
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
||||
<Logo>
|
||||
|
|
@ -34,9 +52,18 @@ export function Sidebar({ render }: { render: () => void }) {
|
|||
<HamburgerLine />
|
||||
</HamburgerButton>
|
||||
</Logo>
|
||||
{!sidebarCollapsed && <SectionLabel>Apps</SectionLabel>}
|
||||
{!sidebarCollapsed && toolApps.length > 0 && (
|
||||
<SectionSwitcher>
|
||||
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
|
||||
Apps
|
||||
</SectionTab>
|
||||
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
|
||||
Tools
|
||||
</SectionTab>
|
||||
</SectionSwitcher>
|
||||
)}
|
||||
<AppList>
|
||||
{apps.map(app => (
|
||||
{activeApps.map(app => (
|
||||
<AppItem
|
||||
key={app.name}
|
||||
onClick={() => selectApp(app.name)}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
import { render as renderApp } from 'hono/jsx/dom'
|
||||
import { Dashboard } from './components'
|
||||
import { apps, selectedApp, setApps, setSelectedApp } from './state'
|
||||
import { apps, selectedApp, selectedTab, setApps, setSelectedApp } from './state'
|
||||
import { initModal } from './components/modal'
|
||||
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
||||
import { initUpdate } from './update'
|
||||
|
||||
const render = () => {
|
||||
renderApp(<Dashboard render={render} />, document.getElementById('app')!)
|
||||
// Update tool iframes after DOM settles
|
||||
requestAnimationFrame(() => {
|
||||
const tools = apps.filter(a => a.tool)
|
||||
updateToolIframes(selectedTab, tools)
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize render functions
|
||||
initModal(render)
|
||||
initUpdate(render)
|
||||
initToolIframes()
|
||||
|
||||
// Set theme based on system preference
|
||||
const setTheme = () => {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import type { App } from '../shared/types'
|
|||
// UI state (survives re-renders)
|
||||
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
||||
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
||||
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
|
||||
|
||||
// Server state (from SSE)
|
||||
export let apps: App[] = []
|
||||
|
||||
// Tab state
|
||||
export let selectedTab: 'overview' | 'todo' = 'overview'
|
||||
export let selectedTab: string = 'overview'
|
||||
|
||||
// State setters
|
||||
export function setSelectedApp(name: string | null) {
|
||||
|
|
@ -25,10 +26,15 @@ export function setSidebarCollapsed(collapsed: boolean) {
|
|||
localStorage.setItem('sidebarCollapsed', String(collapsed))
|
||||
}
|
||||
|
||||
export function setSidebarSection(section: 'apps' | 'tools') {
|
||||
sidebarSection = section
|
||||
localStorage.setItem('sidebarSection', section)
|
||||
}
|
||||
|
||||
export function setApps(newApps: App[]) {
|
||||
apps = newApps
|
||||
}
|
||||
|
||||
export function setSelectedTab(tab: 'overview' | 'todo') {
|
||||
export function setSelectedTab(tab: string) {
|
||||
selectedTab = tab
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export {
|
|||
MainHeader,
|
||||
MainTitle,
|
||||
SectionLabel,
|
||||
SectionSwitcher,
|
||||
SectionTab,
|
||||
Sidebar,
|
||||
SidebarFooter,
|
||||
} from './layout'
|
||||
|
|
|
|||
|
|
@ -60,6 +60,40 @@ export const SectionLabel = define('SectionLabel', {
|
|||
letterSpacing: '0.05em',
|
||||
})
|
||||
|
||||
export const SectionSwitcher = define('SectionSwitcher', {
|
||||
display: 'flex',
|
||||
gap: 0,
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||
})
|
||||
|
||||
export const SectionTab = define('SectionTab', {
|
||||
base: 'button',
|
||||
flex: 1,
|
||||
padding: '6px 12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: theme('colors-textMuted'),
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderRadius: theme('radius-sm'),
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: theme('colors-bgHover'),
|
||||
color: theme('colors-text'),
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
active: {
|
||||
background: theme('colors-bgSelected'),
|
||||
color: theme('colors-text'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const AppList = define('AppList', {
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
|
|
|
|||
130
src/client/tool-iframes.ts
Normal file
130
src/client/tool-iframes.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { theme } from './themes'
|
||||
|
||||
// Iframe cache - these never get recreated once loaded
|
||||
// Use a global to survive hot reloads
|
||||
const iframes: Map<string, { iframe: HTMLIFrameElement; port: number }> =
|
||||
(window as any).__toolIframes ??= new Map()
|
||||
|
||||
// Track current state to avoid unnecessary DOM updates
|
||||
// Also stored on window to survive hot reloads
|
||||
let currentTool: string | null = (window as any).__currentTool ?? null
|
||||
|
||||
// Get the stable container (outside Hono-managed DOM)
|
||||
const getContainer = () => document.getElementById('tool-iframes')
|
||||
|
||||
// Initialize the container styles
|
||||
export function initToolIframes() {
|
||||
const container = getContainer()
|
||||
if (!container) return
|
||||
|
||||
// Restore iframe cache from DOM if module was hot-reloaded
|
||||
if (iframes.size === 0) {
|
||||
const existingIframes = container.querySelectorAll('iframe')
|
||||
existingIframes.forEach(iframe => {
|
||||
const match = iframe.src.match(/localhost:(\d+)/)
|
||||
if (match && match[1]) {
|
||||
const port = parseInt(match[1], 10)
|
||||
const name = iframe.dataset.toolName
|
||||
if (name) {
|
||||
iframes.set(name, { iframe, port })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
container.style.cssText = `
|
||||
display: none;
|
||||
position: fixed;
|
||||
background: ${theme('colors-bg')};
|
||||
`
|
||||
}
|
||||
|
||||
// Update which iframe is visible based on selected tab and tool state
|
||||
export function updateToolIframes(
|
||||
selectedTab: string,
|
||||
tools: Array<{ name: string; port?: number; state: string }>
|
||||
) {
|
||||
const container = getContainer()
|
||||
if (!container) return
|
||||
|
||||
// Find the selected tool
|
||||
const selectedTool = tools.find(t => t.name === selectedTab)
|
||||
const showIframe = selectedTool?.state === 'running' && selectedTool?.port
|
||||
|
||||
if (!showIframe) {
|
||||
// Only update if state changed
|
||||
if (currentTool !== null) {
|
||||
container.style.display = 'none'
|
||||
currentTool = null
|
||||
;(window as any).__currentTool = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const tool = selectedTool!
|
||||
|
||||
// Skip if nothing changed
|
||||
if (currentTool === tool.name) {
|
||||
// Just update position in case of scroll/resize
|
||||
const tabContent = document.querySelector('[data-tool-target]')
|
||||
if (tabContent) {
|
||||
const rect = tabContent.getBoundingClientRect()
|
||||
container.style.top = `${rect.top}px`
|
||||
container.style.left = `${rect.left}px`
|
||||
container.style.width = `${rect.width}px`
|
||||
container.style.height = `${rect.height}px`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Position container over the tab content area
|
||||
const tabContent = document.querySelector('[data-tool-target]')
|
||||
if (!tabContent) {
|
||||
container.style.display = 'none'
|
||||
currentTool = null
|
||||
return
|
||||
}
|
||||
|
||||
const rect = tabContent.getBoundingClientRect()
|
||||
container.style.cssText = `
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: ${rect.top}px;
|
||||
left: ${rect.left}px;
|
||||
width: ${rect.width}px;
|
||||
height: ${rect.height}px;
|
||||
background: ${theme('colors-bg')};
|
||||
z-index: 100;
|
||||
`
|
||||
|
||||
// Get or create the iframe for this tool
|
||||
let cached = iframes.get(tool.name)
|
||||
|
||||
if (!cached || cached.port !== tool.port) {
|
||||
// Create new iframe (first time or port changed)
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = `http://localhost:${tool.port}`
|
||||
iframe.dataset.toolName = tool.name // For hot reload recovery
|
||||
iframe.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
`
|
||||
cached = { iframe, port: tool.port! }
|
||||
iframes.set(tool.name, cached)
|
||||
// Add to container
|
||||
container.appendChild(iframe)
|
||||
}
|
||||
|
||||
// Show only the selected iframe, hide others
|
||||
for (const [name, { iframe }] of iframes) {
|
||||
const shouldShow = name === tool.name
|
||||
if (shouldShow && iframe.parentElement !== container) {
|
||||
container.appendChild(iframe)
|
||||
}
|
||||
iframe.style.display = shouldShow ? 'block' : 'none'
|
||||
}
|
||||
|
||||
currentTool = tool.name
|
||||
;(window as any).__currentTool = tool.name
|
||||
}
|
||||
|
|
@ -19,14 +19,10 @@ function convert(app: BackendApp): SharedApp {
|
|||
// SSE endpoint for real-time app state updates
|
||||
router.sse('/stream', (send) => {
|
||||
const broadcast = () => {
|
||||
const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({
|
||||
name,
|
||||
state,
|
||||
icon,
|
||||
error,
|
||||
port,
|
||||
started,
|
||||
logs,
|
||||
const apps: SharedApp[] = allApps().map(({
|
||||
name, state, icon, error, port, started, logs, tool
|
||||
}) => ({
|
||||
name, state, icon, error, port, started, logs, tool,
|
||||
}))
|
||||
send(apps)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,7 +243,8 @@ function discoverApps() {
|
|||
const { pkg, error } = loadApp(dir)
|
||||
const state: AppState = error ? 'invalid' : 'stopped'
|
||||
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
|
||||
_apps.set(dir, { name: dir, state, icon, error })
|
||||
const tool = pkg.toes?.tool
|
||||
_apps.set(dir, { name: dir, state, icon, error, tool })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -613,7 +614,8 @@ function watchAppsDir() {
|
|||
const { pkg, error } = loadApp(dir)
|
||||
const state: AppState = error ? 'invalid' : 'stopped'
|
||||
const icon = pkg.toes?.icon
|
||||
_apps.set(dir, { name: dir, state, icon, error })
|
||||
const tool = pkg.toes?.tool
|
||||
_apps.set(dir, { name: dir, state, icon, error, tool })
|
||||
update()
|
||||
if (!error) {
|
||||
runApp(dir, getPort(dir))
|
||||
|
|
@ -637,8 +639,9 @@ function watchAppsDir() {
|
|||
|
||||
const { pkg, error } = loadApp(dir)
|
||||
|
||||
// Update icon and error from package.json
|
||||
// Update icon, tool, and error from package.json
|
||||
app.icon = pkg.toes?.icon
|
||||
app.tool = pkg.toes?.tool
|
||||
app.error = error
|
||||
|
||||
// App became valid - start it if stopped
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const Shell = () => (
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="tool-iframes"></div>
|
||||
<script type="module" src="/client/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -26,4 +26,5 @@ export type App = {
|
|||
port?: number
|
||||
started?: number
|
||||
logs?: LogLine[]
|
||||
tool?: boolean | string
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user