Compare commits
No commits in common. "4cbe5c2566179d1aa98498f6b76fd3061ec45b38" and "f1b78197c7af5106f17df3bc04dc508106937cc4" have entirely different histories.
4cbe5c2566
...
f1b78197c7
194
ISSUES.md
Normal file
194
ISSUES.md
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# Issues - Versioned Deployment Implementation
|
||||||
|
|
||||||
|
## Critical Issues
|
||||||
|
|
||||||
|
### 1. Watch Filter Breaks File Change Detection
|
||||||
|
|
||||||
|
**Location**: `src/server/apps.ts:589-593`
|
||||||
|
|
||||||
|
**Problem**: The watch logic ignores all changes deeper than 2 levels:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Ignore changes deeper than 2 levels (inside timestamp dirs)
|
||||||
|
if (parts.length > 2) return
|
||||||
|
|
||||||
|
// For versioned apps, only care about changes to "current" directory
|
||||||
|
if (parts.length === 2 && parts[1] !== 'current' && parts[1] !== 'package.json') return
|
||||||
|
```
|
||||||
|
|
||||||
|
Files inside `current/` are 3 levels deep: `appname/current/somefile.ts`
|
||||||
|
|
||||||
|
This **ignores all file changes** inside the current directory, breaking hot reload and auto-restart.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```ts
|
||||||
|
// Ignore changes inside old timestamp dirs (but allow current/)
|
||||||
|
if (parts.length > 2 && parts[1] !== 'current') return
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. App Restart Race Condition
|
||||||
|
|
||||||
|
**Location**: `src/server/api/sync.ts:145-148`
|
||||||
|
|
||||||
|
**Problem**: Activation uses arbitrary 1 second delay without confirming stop completed:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Restart app to use new version
|
||||||
|
const app = allApps().find(a => a.name === appName)
|
||||||
|
if (app?.state === 'running') {
|
||||||
|
stopApp(appName)
|
||||||
|
setTimeout(() => startApp(appName), 1000) // ⚠️ Arbitrary 1s delay
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issues**:
|
||||||
|
- 1 second may not be enough for app to fully stop
|
||||||
|
- No confirmation that stop completed before start
|
||||||
|
- If app crashes during startup, activation still returns success
|
||||||
|
|
||||||
|
**Fix**: Make activation async and wait for stop to complete, or move restart logic to after symlink succeeds and poll for stop completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Major Issues
|
||||||
|
|
||||||
|
### 3. No Version Cleanup
|
||||||
|
|
||||||
|
**Problem**: Old timestamp directories accumulate forever with no cleanup mechanism.
|
||||||
|
|
||||||
|
**Impact**: Disk space grows indefinitely as deployments pile up.
|
||||||
|
|
||||||
|
**Recommendation**: Add cleanup logic:
|
||||||
|
- Keep last N versions (e.g., 5-10)
|
||||||
|
- Delete versions older than X days
|
||||||
|
- Expose as `toes clean <app>` command or automatic post-activation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. safePath Behavior Change
|
||||||
|
|
||||||
|
**Location**: `src/server/api/sync.ts:14-21`
|
||||||
|
|
||||||
|
**Problem**: Security model changed by resolving symlinks in base path:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const canonicalBase = existsSync(base) ? realpathSync(base) : base
|
||||||
|
```
|
||||||
|
|
||||||
|
Previously paths were checked against literal base, now against resolved base. This changes behavior if someone creates a symlink attack.
|
||||||
|
|
||||||
|
**Recommendation**: Document this intentional change, or keep original check and add separate symlink resolution only where needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Non-Atomic Deploy Copy
|
||||||
|
|
||||||
|
**Location**: `src/server/api/sync.ts:133-141`
|
||||||
|
|
||||||
|
**Problem**: Race condition possible during deploy:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (existsSync(currentLink)) {
|
||||||
|
const currentReal = realpathSync(currentLink)
|
||||||
|
cpSync(currentReal, newVersion, { recursive: true })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `current` changes between `existsSync` and `cpSync`, stale code might be copied.
|
||||||
|
|
||||||
|
**Impact**: Low probability for single-user system, but possible during concurrent deploys.
|
||||||
|
|
||||||
|
**Fix**: Read symlink once and reuse: `const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Upload Error Handling Inconsistency
|
||||||
|
|
||||||
|
**Location**: `src/cli/commands/sync.ts:113-115`
|
||||||
|
|
||||||
|
**Problem**: Upload continues if a file fails but doesn't track failures:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (success) {
|
||||||
|
console.log(` ${color.green('↑')} ${file}`)
|
||||||
|
} else {
|
||||||
|
console.log(` ${color.red('✗')} ${file}`)
|
||||||
|
// Continue even if one file fails
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If any file fails to upload, deployment still activates with incomplete files.
|
||||||
|
|
||||||
|
**Fix**: Either:
|
||||||
|
- Abort entire deployment on first failure
|
||||||
|
- Track failures and warn/abort before activating
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minor Issues
|
||||||
|
|
||||||
|
### 7. POST Body Check Too Loose
|
||||||
|
|
||||||
|
**Location**: `src/cli/http.ts:42-44`
|
||||||
|
|
||||||
|
**Problem**: Falsy values like `0`, `false`, or `""` would incorrectly skip body:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```ts
|
||||||
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Unconventional Timestamp Format
|
||||||
|
|
||||||
|
**Location**: `src/server/api/sync.ts:115-123`
|
||||||
|
|
||||||
|
**Problem**: Unusual array construction with separator as element:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const timestamp = [
|
||||||
|
now.getFullYear(),
|
||||||
|
String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(now.getDate()).padStart(2, '0'),
|
||||||
|
'-', // Unusual to put separator as array element
|
||||||
|
String(now.getHours()).padStart(2, '0'),
|
||||||
|
String(now.getMinutes()).padStart(2, '0'),
|
||||||
|
String(now.getSeconds()).padStart(2, '0'),
|
||||||
|
].join('')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: Use template literal:
|
||||||
|
```ts
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const timestamp = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||||
|
```
|
||||||
|
|
||||||
|
Or ISO format: `now.toISOString().replace(/[:.]/g, '-').slice(0, -5)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positive Points
|
||||||
|
|
||||||
|
✓ Atomic symlink swapping with temp file pattern is correct
|
||||||
|
✓ Clear 3-step deployment protocol (deploy, upload, activate)
|
||||||
|
✓ Proper use of `realpathSync` to resolve canonical paths for running apps
|
||||||
|
✓ Good separation of concerns in API routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
1. **BLOCKER**: Fix watch filter (#1) - breaks hot reload
|
||||||
|
2. **HIGH**: Fix restart race condition (#2) - affects deployment reliability
|
||||||
|
3. **HIGH**: Add version cleanup (#3) - disk space concern
|
||||||
|
4. **MEDIUM**: Fix upload error handling (#6) - data integrity
|
||||||
|
5. **LOW**: Everything else
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "code",
|
"name": "code",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
"@because/howl": "*",
|
"@because/howl": "*",
|
||||||
"@because/hype": "*",
|
"@because/hype": "*",
|
||||||
"@because/toes": "*",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|
@ -24,18 +24,12 @@
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
|
|
||||||
|
|
||||||
"@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/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=="],
|
"@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=="],
|
"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=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"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=="],
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,8 @@
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/howl": "*",
|
|
||||||
"@because/hype": "*",
|
"@because/hype": "*",
|
||||||
"@because/toes": "*"
|
"@because/forge": "*",
|
||||||
|
"@because/howl": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { createThemes, define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
|
||||||
import { readdir, stat } from 'fs/promises'
|
import { readdir, stat } from 'fs/promises'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { join, extname, basename } from 'path'
|
import { join, extname, basename } from 'path'
|
||||||
|
|
@ -10,33 +9,85 @@ const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
const theme = createThemes({
|
||||||
|
light: {
|
||||||
|
bg: '#ffffff',
|
||||||
|
text: '#1a1a1a',
|
||||||
|
textMuted: '#666666',
|
||||||
|
border: '#dddddd',
|
||||||
|
borderSubtle: '#eeeeee',
|
||||||
|
borderStrong: '#333333',
|
||||||
|
hover: '#f5f5f5',
|
||||||
|
surface: '#f5f5f5',
|
||||||
|
link: '#0066cc',
|
||||||
|
icon: '#666666',
|
||||||
|
codeBg: '#fafafa',
|
||||||
|
error: '#d32f2f',
|
||||||
|
errorBg: '#ffebee',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: '#1a1a1a',
|
||||||
|
text: '#e5e5e5',
|
||||||
|
textMuted: '#999999',
|
||||||
|
border: '#404040',
|
||||||
|
borderSubtle: '#333333',
|
||||||
|
borderStrong: '#555555',
|
||||||
|
hover: '#2a2a2a',
|
||||||
|
surface: '#252525',
|
||||||
|
link: '#5c9eff',
|
||||||
|
icon: '#888888',
|
||||||
|
codeBg: '#1e1e1e',
|
||||||
|
error: '#ff6b6b',
|
||||||
|
errorBg: '#3d1f1f',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Styles
|
||||||
const Container = define('Container', {
|
const Container = define('Container', {
|
||||||
fontFamily: theme('fonts-sans'),
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
paddingTop: 0,
|
|
||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
color: theme('colors-text'),
|
color: theme('text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Header = define('Header', {
|
||||||
|
marginBottom: '20px',
|
||||||
|
paddingBottom: '10px',
|
||||||
|
borderBottom: `2px solid ${theme('borderStrong')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = define('Title', {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Subtitle = define('Subtitle', {
|
||||||
|
color: theme('textMuted'),
|
||||||
|
fontSize: '18px',
|
||||||
|
marginTop: '5px',
|
||||||
})
|
})
|
||||||
|
|
||||||
const FileList = define('FileList', {
|
const FileList = define('FileList', {
|
||||||
listStyle: 'none',
|
listStyle: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
border: `1px solid ${theme('border')}`,
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
})
|
})
|
||||||
|
|
||||||
const FileItem = define('FileItem', {
|
const FileItem = define('FileItem', {
|
||||||
padding: '10px 15px',
|
padding: '10px 15px',
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
borderBottom: `1px solid ${theme('borderSubtle')}`,
|
||||||
states: {
|
states: {
|
||||||
':last-child': {
|
':last-child': {
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
},
|
},
|
||||||
':hover': {
|
':hover': {
|
||||||
backgroundColor: theme('colors-bgHover'),
|
backgroundColor: theme('hover'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -44,7 +95,7 @@ const FileItem = define('FileItem', {
|
||||||
const FileLink = define('FileLink', {
|
const FileLink = define('FileLink', {
|
||||||
base: 'a',
|
base: 'a',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: theme('colors-link'),
|
color: theme('link'),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
|
|
@ -60,57 +111,52 @@ const FileIcon = define('FileIcon', {
|
||||||
width: '18px',
|
width: '18px',
|
||||||
height: '18px',
|
height: '18px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fill: theme('colors-textMuted'),
|
fill: theme('icon'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const CodeBlock = define('CodeBlock', {
|
const CodeBlock = define('CodeBlock', {
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
border: `1px solid ${theme('border')}`,
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
selectors: {
|
selectors: {
|
||||||
'& pre': {
|
'& pre': {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '15px',
|
padding: '15px',
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
backgroundColor: theme('colors-bgSubtle'),
|
backgroundColor: theme('codeBg'),
|
||||||
},
|
},
|
||||||
'& pre code': {
|
'& pre code': {
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
fontFamily: theme('fonts-mono'),
|
fontFamily: 'monospace',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const CodeHeader = define('CodeHeader', {
|
const CodeHeader = define('CodeHeader', {
|
||||||
padding: '10px 15px',
|
padding: '10px 15px',
|
||||||
backgroundColor: theme('colors-bgElement'),
|
backgroundColor: theme('surface'),
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
borderBottom: `1px solid ${theme('border')}`,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
})
|
})
|
||||||
|
|
||||||
const ErrorBox = define('ErrorBox', {
|
const ErrorBox = define('ErrorBox', {
|
||||||
color: theme('colors-error'),
|
color: theme('error'),
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
backgroundColor: theme('colors-bgElement'),
|
backgroundColor: theme('errorBg'),
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
})
|
})
|
||||||
|
|
||||||
const Breadcrumb = define('Breadcrumb', {
|
const BackLink = define('BackLink', {
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
fontSize: '14px',
|
|
||||||
marginBottom: '15px',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
})
|
|
||||||
|
|
||||||
const BreadcrumbLink = define('BreadcrumbLink', {
|
|
||||||
base: 'a',
|
base: 'a',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: theme('colors-link'),
|
color: theme('link'),
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
marginBottom: '15px',
|
||||||
states: {
|
states: {
|
||||||
':hover': {
|
':hover': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
|
|
@ -118,15 +164,6 @@ const BreadcrumbLink = define('BreadcrumbLink', {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const BreadcrumbSeparator = define('BreadcrumbSeparator', {
|
|
||||||
color: theme('colors-textMuted'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const BreadcrumbCurrent = define('BreadcrumbCurrent', {
|
|
||||||
color: theme('colors-text'),
|
|
||||||
fontWeight: 500,
|
|
||||||
})
|
|
||||||
|
|
||||||
const FolderIcon = () => (
|
const FolderIcon = () => (
|
||||||
<FileIcon viewBox="0 0 24 24">
|
<FileIcon viewBox="0 0 24 24">
|
||||||
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
|
@ -139,13 +176,45 @@ const FileIconSvg = () => (
|
||||||
</FileIcon>
|
</FileIcon>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const initScript = `
|
||||||
|
(function() {
|
||||||
|
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
document.body.setAttribute('data-theme', theme);
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
|
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
function sendHeight() {
|
||||||
|
// Measure the actual content container (skip script tags)
|
||||||
|
var container = document.querySelector('.Container');
|
||||||
|
if (!container) return;
|
||||||
|
var rect = container.getBoundingClientRect();
|
||||||
|
var height = rect.bottom + 20; // Add some padding
|
||||||
|
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
|
||||||
|
}
|
||||||
|
sendHeight();
|
||||||
|
setTimeout(sendHeight, 50);
|
||||||
|
setTimeout(sendHeight, 200);
|
||||||
|
setTimeout(sendHeight, 500);
|
||||||
|
new ResizeObserver(sendHeight).observe(document.body);
|
||||||
|
window.addEventListener('load', sendHeight);
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
const baseStyles = `
|
||||||
|
body {
|
||||||
|
background: ${theme('bg')};
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
title: string
|
title: string
|
||||||
|
subtitle?: string
|
||||||
children: Child
|
children: Child
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function Layout({ title, children, highlight }: LayoutProps) {
|
function Layout({ title, subtitle, children, highlight }: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -164,6 +233,10 @@ function Layout({ title, children, highlight }: LayoutProps) {
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>Code Browser</Title>
|
||||||
|
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||||
|
</Header>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
{highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
|
{highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
|
||||||
|
|
@ -194,43 +267,6 @@ async function listFiles(appPath: string, subPath: string = '') {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BreadcrumbProps {
|
|
||||||
appName: string
|
|
||||||
filePath: string
|
|
||||||
versionParam: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function PathBreadcrumb({ appName, filePath, versionParam }: BreadcrumbProps) {
|
|
||||||
const parts = filePath ? filePath.split('/').filter(Boolean) : []
|
|
||||||
const crumbs: { name: string; path: string }[] = []
|
|
||||||
|
|
||||||
let currentPath = ''
|
|
||||||
for (const part of parts) {
|
|
||||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
|
||||||
crumbs.push({ name: part, path: currentPath })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Breadcrumb>
|
|
||||||
{crumbs.length > 0 ? (
|
|
||||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}`}>{appName}</BreadcrumbLink>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbCurrent>{appName}</BreadcrumbCurrent>
|
|
||||||
)}
|
|
||||||
{crumbs.map((crumb, i) => (
|
|
||||||
<>
|
|
||||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
|
||||||
{i === crumbs.length - 1 ? (
|
|
||||||
<BreadcrumbCurrent>{crumb.name}</BreadcrumbCurrent>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink href={`/?app=${appName}${versionParam}&file=${crumb.path}`}>{crumb.name}</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</Breadcrumb>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLanguage(filename: string): string {
|
function getLanguage(filename: string): string {
|
||||||
const ext = extname(filename).toLowerCase()
|
const ext = extname(filename).toLowerCase()
|
||||||
const langMap: Record<string, string> = {
|
const langMap: Record<string, string> = {
|
||||||
|
|
@ -263,6 +299,7 @@ app.get('/', async c => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName, version)
|
const appPath = join(APPS_DIR, appName, version)
|
||||||
|
const versionSuffix = version !== 'current' ? ` @ ${version}` : ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stat(appPath)
|
await stat(appPath)
|
||||||
|
|
@ -281,21 +318,23 @@ app.get('/', async c => {
|
||||||
fileStats = await stat(fullPath)
|
fileStats = await stat(fullPath)
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Code Browser">
|
<Layout title="Code Browser" subtitle={appName + versionSuffix}>
|
||||||
<ErrorBox>Path "{filePath}" not found</ErrorBox>
|
<ErrorBox>Path "{filePath}" not found</ErrorBox>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
||||||
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
||||||
|
const backUrl = `/?app=${appName}${versionParam}${parentPath ? `&file=${parentPath}` : ''}`
|
||||||
|
|
||||||
if (fileStats.isFile()) {
|
if (fileStats.isFile()) {
|
||||||
const content = readFileSync(fullPath, 'utf-8')
|
const content = readFileSync(fullPath, 'utf-8')
|
||||||
const language = getLanguage(basename(fullPath))
|
const language = getLanguage(basename(fullPath))
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}/${filePath}`} highlight>
|
<Layout title={`${appName}/${filePath}`} subtitle={`${appName}${versionSuffix}/${filePath}`} highlight>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
<BackLink href={backUrl}>← Back</BackLink>
|
||||||
<CodeBlock>
|
<CodeBlock>
|
||||||
<CodeHeader>{basename(fullPath)}</CodeHeader>
|
<CodeHeader>{basename(fullPath)}</CodeHeader>
|
||||||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
<pre><code class={`language-${language}`}>{content}</code></pre>
|
||||||
|
|
@ -307,8 +346,8 @@ app.get('/', async c => {
|
||||||
const files = await listFiles(appPath, filePath)
|
const files = await listFiles(appPath, filePath)
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`} subtitle={`${appName}${versionSuffix}${filePath ? `/${filePath}` : ''}`}>
|
||||||
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
{filePath && <BackLink href={backUrl}>← Back</BackLink>}
|
||||||
<FileList>
|
<FileList>
|
||||||
{files.map(file => (
|
{files.map(file => (
|
||||||
<FileItem>
|
<FileItem>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "todo",
|
"name": "todo",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/howl": "*",
|
||||||
"@because/hype": "*",
|
"@because/hype": "*",
|
||||||
"@because/toes": "*",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|
@ -19,9 +20,9 @@
|
||||||
"packages": {
|
"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/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/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=="],
|
"@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/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
|
"@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/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=="],
|
||||||
|
|
||||||
|
|
@ -29,14 +30,12 @@
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/hype": "*",
|
"@because/hype": "*",
|
||||||
"@because/toes": "*"
|
"@because/forge": "*",
|
||||||
|
"@because/howl": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { createThemes, define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
|
|
@ -8,12 +7,45 @@ const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
const theme = createThemes({
|
||||||
|
light: {
|
||||||
|
bg: '#ffffff',
|
||||||
|
text: '#1a1a1a',
|
||||||
|
textMuted: '#666666',
|
||||||
|
border: '#dddddd',
|
||||||
|
hover: '#f5f5f5',
|
||||||
|
surface: '#f8f8f8',
|
||||||
|
accent: '#0066cc',
|
||||||
|
done: '#888888',
|
||||||
|
error: '#d32f2f',
|
||||||
|
errorBg: '#ffebee',
|
||||||
|
success: '#2e7d32',
|
||||||
|
successBg: '#e8f5e9',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: '#1a1a1a',
|
||||||
|
text: '#e5e5e5',
|
||||||
|
textMuted: '#999999',
|
||||||
|
border: '#404040',
|
||||||
|
hover: '#2a2a2a',
|
||||||
|
surface: '#252525',
|
||||||
|
accent: '#5c9eff',
|
||||||
|
done: '#666666',
|
||||||
|
error: '#ff6b6b',
|
||||||
|
errorBg: '#3d1f1f',
|
||||||
|
success: '#81c784',
|
||||||
|
successBg: '#1b3d1e',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Styles
|
||||||
const Container = define('TodoContainer', {
|
const Container = define('TodoContainer', {
|
||||||
fontFamily: theme('fonts-sans'),
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
color: theme('colors-text'),
|
color: theme('text'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Header = define('Header', {
|
const Header = define('Header', {
|
||||||
|
|
@ -30,7 +62,7 @@ const Title = define('Title', {
|
||||||
})
|
})
|
||||||
|
|
||||||
const AppName = define('AppName', {
|
const AppName = define('AppName', {
|
||||||
color: theme('colors-textMuted'),
|
color: theme('textMuted'),
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -47,10 +79,10 @@ const TodoSection = define('TodoSection', {
|
||||||
const SectionTitle = define('SectionTitle', {
|
const SectionTitle = define('SectionTitle', {
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: theme('colors-textMuted'),
|
color: theme('textMuted'),
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
paddingBottom: '8px',
|
paddingBottom: '8px',
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
borderBottom: `1px solid ${theme('border')}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const TodoItemStyle = define('TodoItem', {
|
const TodoItemStyle = define('TodoItem', {
|
||||||
|
|
@ -76,10 +108,10 @@ const TodoItemStyle = define('TodoItem', {
|
||||||
const doneClass = 'todo-done'
|
const doneClass = 'todo-done'
|
||||||
|
|
||||||
const Error = define('Error', {
|
const Error = define('Error', {
|
||||||
color: theme('colors-error'),
|
color: theme('error'),
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
backgroundColor: theme('colors-bgElement'),
|
backgroundColor: theme('errorBg'),
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -88,11 +120,11 @@ const errorClass = 'msg-error'
|
||||||
|
|
||||||
const SaveButton = define('SaveButton', {
|
const SaveButton = define('SaveButton', {
|
||||||
base: 'button',
|
base: 'button',
|
||||||
backgroundColor: theme('colors-primary'),
|
backgroundColor: theme('accent'),
|
||||||
color: theme('colors-primaryText'),
|
color: '#ffffff',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|
@ -117,37 +149,63 @@ const AddInput = define('AddInput', {
|
||||||
base: 'input',
|
base: 'input',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
border: `1px solid ${theme('border')}`,
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
backgroundColor: theme('colors-bg'),
|
backgroundColor: theme('bg'),
|
||||||
color: theme('colors-text'),
|
color: theme('text'),
|
||||||
states: {
|
states: {
|
||||||
':focus': {
|
':focus': {
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
borderColor: theme('colors-primary'),
|
borderColor: theme('accent'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const todoStyles = `
|
const themeScript = `
|
||||||
|
(function() {
|
||||||
|
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
document.body.setAttribute('data-theme', theme);
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
|
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
const resizeScript = `
|
||||||
|
(function() {
|
||||||
|
function sendHeight() {
|
||||||
|
var height = document.documentElement.scrollHeight;
|
||||||
|
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
|
||||||
|
}
|
||||||
|
sendHeight();
|
||||||
|
new ResizeObserver(sendHeight).observe(document.body);
|
||||||
|
window.addEventListener('load', sendHeight);
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
const baseStyles = `
|
||||||
|
body {
|
||||||
|
background: ${theme('bg')};
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.${doneClass} {
|
.${doneClass} {
|
||||||
color: ${theme('colors-done')};
|
color: ${theme('done')};
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
.${successClass} {
|
.${successClass} {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: ${theme('radius-md')};
|
border-radius: 4px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background-color: ${theme('colors-successBg')};
|
background-color: ${theme('successBg')};
|
||||||
color: ${theme('colors-success')};
|
color: ${theme('success')};
|
||||||
}
|
}
|
||||||
.${errorClass} {
|
.${errorClass} {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: ${theme('radius-md')};
|
border-radius: 4px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background-color: ${theme('colors-bgElement')};
|
background-color: ${theme('errorBg')};
|
||||||
color: ${theme('colors-error')};
|
color: ${theme('error')};
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -190,7 +248,7 @@ function serializeTodo(todo: ParsedTodo): string {
|
||||||
return lines.join('\n') + '\n'
|
return lines.join('\n') + '\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
'Content-Type': 'text/css; charset=utf-8',
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -209,7 +267,7 @@ app.get('/', async c => {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeScript + resizeScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<Title>TODO</Title>
|
<Title>TODO</Title>
|
||||||
|
|
@ -232,6 +290,7 @@ app.get('/', async c => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingItems = todo.items.filter(i => !i.done)
|
const pendingItems = todo.items.filter(i => !i.done)
|
||||||
|
const doneItems = todo.items.filter(i => i.done)
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -242,7 +301,7 @@ app.get('/', async c => {
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeScript + resizeScript }} />
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -295,7 +354,7 @@ app.get('/', async c => {
|
||||||
{todo.items.length === 0 && (
|
{todo.items.length === 0 && (
|
||||||
<TodoSection>
|
<TodoSection>
|
||||||
<SectionTitle>No todos yet</SectionTitle>
|
<SectionTitle>No todos yet</SectionTitle>
|
||||||
<p style={{ color: theme('colors-textMuted') }}>Add your first todo above!</p>
|
<p style={{ color: theme('textMuted') }}>Add your first todo above!</p>
|
||||||
</TodoSection>
|
</TodoSection>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "versions",
|
"name": "versions",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
"@because/hype": "*",
|
"@because/hype": "*",
|
||||||
"@because/toes": "*",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|
@ -21,18 +21,12 @@
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
|
|
||||||
|
|
||||||
"@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/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=="],
|
"@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=="],
|
"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=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
|
||||||
|
|
||||||
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"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=="],
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { createThemes, define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, initScript, theme } from '@because/toes/tools'
|
|
||||||
import { readdir, readlink, stat } from 'fs/promises'
|
import { readdir, readlink, stat } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import type { Child } from 'hono/jsx'
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
@ -9,19 +8,49 @@ const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false })
|
const app = new Hype({ prettyHTML: false })
|
||||||
|
|
||||||
|
const theme = createThemes({
|
||||||
|
light: {
|
||||||
|
bg: '#ffffff',
|
||||||
|
text: '#1a1a1a',
|
||||||
|
textMuted: '#666666',
|
||||||
|
border: '#dddddd',
|
||||||
|
borderSubtle: '#eeeeee',
|
||||||
|
borderStrong: '#333333',
|
||||||
|
hover: '#f5f5f5',
|
||||||
|
link: '#0066cc',
|
||||||
|
error: '#d32f2f',
|
||||||
|
errorBg: '#ffebee',
|
||||||
|
accent: '#2e7d32',
|
||||||
|
accentBg: '#e8f5e9',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: '#1a1a1a',
|
||||||
|
text: '#e5e5e5',
|
||||||
|
textMuted: '#999999',
|
||||||
|
border: '#404040',
|
||||||
|
borderSubtle: '#333333',
|
||||||
|
borderStrong: '#555555',
|
||||||
|
hover: '#2a2a2a',
|
||||||
|
link: '#5c9eff',
|
||||||
|
error: '#ff6b6b',
|
||||||
|
errorBg: '#3d1f1f',
|
||||||
|
accent: '#81c784',
|
||||||
|
accentBg: '#1b3d1f',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const Container = define('Container', {
|
const Container = define('Container', {
|
||||||
fontFamily: theme('fonts-sans'),
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
paddingTop: 0,
|
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
color: theme('colors-text'),
|
color: theme('text'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Header = define('Header', {
|
const Header = define('Header', {
|
||||||
marginBottom: '20px',
|
marginBottom: '20px',
|
||||||
paddingBottom: '10px',
|
paddingBottom: '10px',
|
||||||
borderBottom: `2px solid ${theme('colors-border')}`,
|
borderBottom: `2px solid ${theme('borderStrong')}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const Title = define('Title', {
|
const Title = define('Title', {
|
||||||
|
|
@ -31,7 +60,7 @@ const Title = define('Title', {
|
||||||
})
|
})
|
||||||
|
|
||||||
const Subtitle = define('Subtitle', {
|
const Subtitle = define('Subtitle', {
|
||||||
color: theme('colors-textMuted'),
|
color: theme('textMuted'),
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
marginTop: '5px',
|
marginTop: '5px',
|
||||||
})
|
})
|
||||||
|
|
@ -40,14 +69,14 @@ const VersionList = define('VersionList', {
|
||||||
listStyle: 'none',
|
listStyle: 'none',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
border: `1px solid ${theme('colors-border')}`,
|
border: `1px solid ${theme('border')}`,
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
})
|
})
|
||||||
|
|
||||||
const VersionItem = define('VersionItem', {
|
const VersionItem = define('VersionItem', {
|
||||||
padding: '12px 15px',
|
padding: '12px 15px',
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
borderBottom: `1px solid ${theme('borderSubtle')}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|
@ -56,7 +85,7 @@ const VersionItem = define('VersionItem', {
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
},
|
},
|
||||||
':hover': {
|
':hover': {
|
||||||
backgroundColor: theme('colors-bgHover'),
|
backgroundColor: theme('hover'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -64,8 +93,8 @@ const VersionItem = define('VersionItem', {
|
||||||
const VersionLink = define('VersionLink', {
|
const VersionLink = define('VersionLink', {
|
||||||
base: 'a',
|
base: 'a',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: theme('colors-link'),
|
color: theme('link'),
|
||||||
fontFamily: theme('fonts-mono'),
|
fontFamily: 'monospace',
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
states: {
|
states: {
|
||||||
|
|
@ -78,20 +107,46 @@ const VersionLink = define('VersionLink', {
|
||||||
const Badge = define('Badge', {
|
const Badge = define('Badge', {
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
backgroundColor: theme('colors-bgElement'),
|
backgroundColor: theme('accentBg'),
|
||||||
color: theme('colors-statusRunning'),
|
color: theme('accent'),
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
})
|
})
|
||||||
|
|
||||||
const ErrorBox = define('ErrorBox', {
|
const ErrorBox = define('ErrorBox', {
|
||||||
color: theme('colors-error'),
|
color: theme('error'),
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
backgroundColor: theme('colors-bgElement'),
|
backgroundColor: theme('errorBg'),
|
||||||
borderRadius: theme('radius-md'),
|
borderRadius: '4px',
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initScript = `
|
||||||
|
(function() {
|
||||||
|
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
document.body.setAttribute('data-theme', theme);
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
|
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
function sendHeight() {
|
||||||
|
var container = document.querySelector('.Container');
|
||||||
|
if (!container) return;
|
||||||
|
var rect = container.getBoundingClientRect();
|
||||||
|
window.parent.postMessage({ type: 'resize-iframe', height: rect.bottom + 20 }, '*');
|
||||||
|
}
|
||||||
|
sendHeight();
|
||||||
|
setTimeout(sendHeight, 50);
|
||||||
|
new ResizeObserver(sendHeight).observe(document.body);
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
const baseStyles = `
|
||||||
|
body {
|
||||||
|
background: ${theme('bg')};
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
|
@ -112,6 +167,7 @@ function Layout({ title, subtitle, children }: LayoutProps) {
|
||||||
<Container>
|
<Container>
|
||||||
<Header>
|
<Header>
|
||||||
<Title>Versions</Title>
|
<Title>Versions</Title>
|
||||||
|
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||||
</Header>
|
</Header>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "*",
|
|
||||||
"@because/hype": "*",
|
"@because/hype": "*",
|
||||||
"@because/toes": "*"
|
"@because/forge": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
docs/APPS.md
85
docs/APPS.md
|
|
@ -1,85 +0,0 @@
|
||||||
# Apps
|
|
||||||
|
|
||||||
An app is an HTTP server that runs on its assigned port.
|
|
||||||
|
|
||||||
## minimum requirements
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/<name>/
|
|
||||||
<timestamp>/ # YYYYMMDD-HHMMSS
|
|
||||||
package.json
|
|
||||||
index.tsx
|
|
||||||
current -> <timestamp> # symlink to active version
|
|
||||||
```
|
|
||||||
|
|
||||||
**package.json** must have `scripts.toes`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "my-app",
|
|
||||||
"module": "index.tsx",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"toes": "bun run --watch index.tsx"
|
|
||||||
},
|
|
||||||
"toes": {
|
|
||||||
"icon": "🎨"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@because/hype": "*",
|
|
||||||
"@because/forge": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**index.tsx** must export `app.defaults`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Hype } from '@because/hype'
|
|
||||||
|
|
||||||
const app = new Hype()
|
|
||||||
app.get('/', c => c.html(<h1>Hello</h1>))
|
|
||||||
|
|
||||||
export default app.defaults
|
|
||||||
```
|
|
||||||
|
|
||||||
## environment
|
|
||||||
|
|
||||||
- `PORT` - your assigned port (3001-3100)
|
|
||||||
- `APPS_DIR` - path to `/apps` directory
|
|
||||||
|
|
||||||
## health checks
|
|
||||||
|
|
||||||
Toes hits `GET /` every 30 seconds. Return 2xx or get restarted.
|
|
||||||
|
|
||||||
3 failures = restart with exponential backoff (1s, 2s, 4s, 8s, 16s, 32s).
|
|
||||||
|
|
||||||
## lifecycle
|
|
||||||
|
|
||||||
`invalid` -> `stopped` -> `starting` -> `running` -> `stopping`
|
|
||||||
|
|
||||||
Apps auto-restart on crash. `bun install` runs before every start.
|
|
||||||
|
|
||||||
## cli
|
|
||||||
|
|
||||||
```bash
|
|
||||||
toes new my-app # create from template
|
|
||||||
toes list # show apps
|
|
||||||
toes start my-app # start
|
|
||||||
toes stop my-app # stop
|
|
||||||
toes restart my-app # restart
|
|
||||||
toes logs -f my-app # tail logs
|
|
||||||
toes open my-app # open in browser
|
|
||||||
```
|
|
||||||
|
|
||||||
## making a new app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
toes new my-app --template=spa
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ssr` - server-side rendered (default)
|
|
||||||
- `spa` - single page app (w/ hono/jsx)
|
|
||||||
- `bare` - minimal
|
|
||||||
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
# Tools
|
|
||||||
|
|
||||||
A tool is an app that appears as a tab in the dashboard instead of in the sidebar.
|
|
||||||
|
|
||||||
Tools know which app is selected and render in an iframe over the content area.
|
|
||||||
|
|
||||||
## making an app a tool
|
|
||||||
|
|
||||||
Add `toes.tool` to package.json:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"toes": {
|
|
||||||
"tool": true,
|
|
||||||
"icon": "🔧"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"toes": "bun run --watch index.tsx"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## getting the selected app
|
|
||||||
|
|
||||||
Query param `?app=<name>` tells you which app the user selected:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
app.get('/', c => {
|
|
||||||
const appName = c.req.query('app')
|
|
||||||
if (!appName) {
|
|
||||||
return c.html(<p>No app selected</p>)
|
|
||||||
}
|
|
||||||
// do something with appName
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## accessing app files
|
|
||||||
|
|
||||||
Always go through the `current` symlink:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const APPS_DIR = process.env.APPS_DIR ?? '.'
|
|
||||||
const appPath = join(APPS_DIR, appName, 'current')
|
|
||||||
```
|
|
||||||
|
|
||||||
Not `APPS_DIR/appName` directly.
|
|
||||||
|
|
||||||
## talking to the parent
|
|
||||||
|
|
||||||
**Navigate to another tool:**
|
|
||||||
|
|
||||||
```js
|
|
||||||
window.parent.postMessage({
|
|
||||||
type: 'navigate-tool',
|
|
||||||
tool: 'code',
|
|
||||||
params: { app: 'my-app', version: '20260130-000000' }
|
|
||||||
}, '*')
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resize your iframe:**
|
|
||||||
|
|
||||||
```js
|
|
||||||
window.parent.postMessage({
|
|
||||||
type: 'resize-iframe',
|
|
||||||
height: 500
|
|
||||||
}, '*')
|
|
||||||
```
|
|
||||||
|
|
||||||
## iframe behavior
|
|
||||||
|
|
||||||
- iframes are cached per tool+app combination
|
|
||||||
- Never recreated once loaded
|
|
||||||
- State persists across tab switches
|
|
||||||
|
|
||||||
## cli
|
|
||||||
|
|
||||||
```bash
|
|
||||||
toes list --tools # list tools only
|
|
||||||
toes list --all # list apps and tools
|
|
||||||
```
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "@because/toes",
|
"name": "@because/toes",
|
||||||
"version": "0.0.4",
|
"version": "0.0.3",
|
||||||
"description": "personal web appliance - turn it on and forget about the cloud",
|
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": ["src"],
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./tools": "./src/tools/index.ts"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"toes": "src/cli/index.ts"
|
"toes": "src/cli/index.ts"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
|
|
||||||
program
|
program
|
||||||
.name('toes')
|
.name('toes')
|
||||||
.version('v0.0.4', '-v, --version')
|
.version('v0.0.3', '-v, --version')
|
||||||
.addHelpText('beforeAll', (ctx) => {
|
.addHelpText('beforeAll', (ctx) => {
|
||||||
if (ctx.command === program) {
|
if (ctx.command === program) {
|
||||||
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')
|
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<TabContent key={tool.name} active={isSelected}>
|
<TabContent key={tool.name} active={isSelected}>
|
||||||
<Section>
|
<Section>
|
||||||
|
<SectionTitle>{toolName}</SectionTitle>
|
||||||
{tool.state !== 'running' && (
|
{tool.state !== 'running' && (
|
||||||
<p style={{ color: theme('colors-textFaint') }}>
|
<p style={{ color: theme('colors-textFaint') }}>
|
||||||
Tool is {stateLabels[tool.state].toLowerCase()}
|
Tool is {stateLabels[tool.state].toLowerCase()}
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ export default {
|
||||||
'colors-statusStarting': '#eab308',
|
'colors-statusStarting': '#eab308',
|
||||||
'colors-statusInvalid': '#ef4444',
|
'colors-statusInvalid': '#ef4444',
|
||||||
|
|
||||||
'colors-success': '#81c784',
|
|
||||||
'colors-successBg': '#1b3d1e',
|
|
||||||
'colors-done': '#666',
|
|
||||||
|
|
||||||
'fonts-sans': 'system-ui, -apple-system, sans-serif',
|
'fonts-sans': 'system-ui, -apple-system, sans-serif',
|
||||||
'fonts-mono': 'ui-monospace, monospace',
|
'fonts-mono': 'ui-monospace, monospace',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ export default {
|
||||||
'colors-statusStarting': '#ca8a04',
|
'colors-statusStarting': '#ca8a04',
|
||||||
'colors-statusInvalid': '#dc2626',
|
'colors-statusInvalid': '#dc2626',
|
||||||
|
|
||||||
'colors-success': '#2e7d32',
|
|
||||||
'colors-successBg': '#e8f5e9',
|
|
||||||
'colors-done': '#888',
|
|
||||||
|
|
||||||
'fonts-sans': 'system-ui, -apple-system, sans-serif',
|
'fonts-sans': 'system-ui, -apple-system, sans-serif',
|
||||||
'fonts-mono': 'ui-monospace, monospace',
|
'fonts-mono': 'ui-monospace, monospace',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './server'
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { theme } from '../client/themes'
|
|
||||||
export { baseStyles, initScript } from './scripts'
|
|
||||||
export { define, stylesToCSS } from '@because/forge'
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { theme } from '../client/themes'
|
|
||||||
|
|
||||||
export const initScript = `
|
|
||||||
(function() {
|
|
||||||
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
document.body.setAttribute('data-theme', theme);
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
|
||||||
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
|
||||||
});
|
|
||||||
function sendHeight() {
|
|
||||||
var container = document.querySelector('.Container');
|
|
||||||
if (!container) return;
|
|
||||||
var rect = container.getBoundingClientRect();
|
|
||||||
var height = rect.bottom + 20;
|
|
||||||
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
|
|
||||||
}
|
|
||||||
sendHeight();
|
|
||||||
setTimeout(sendHeight, 50);
|
|
||||||
setTimeout(sendHeight, 200);
|
|
||||||
setTimeout(sendHeight, 500);
|
|
||||||
new ResizeObserver(sendHeight).observe(document.body);
|
|
||||||
window.addEventListener('load', sendHeight);
|
|
||||||
})();
|
|
||||||
`
|
|
||||||
|
|
||||||
export const baseStyles = `
|
|
||||||
body {
|
|
||||||
background: ${theme('colors-bg')};
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
Loading…
Reference in New Issue
Block a user