Compare commits
5 Commits
1c51427034
...
f1b78197c7
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b78197c7 | |||
| ae38084440 | |||
| d45144478d | |||
| 7574a8702e | |||
| 4c1701a06d |
3
apps/clock/20260130-000000/TODO.txt
Normal file
3
apps/clock/20260130-000000/TODO.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# clock TODO
|
||||||
|
[x] do something
|
||||||
|
[ ] do more things
|
||||||
|
|
@ -3,6 +3,7 @@ import { createThemes, define, stylesToCSS } from '@because/forge'
|
||||||
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'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
const APPS_DIR = process.env.APPS_DIR!
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ const theme = createThemes({
|
||||||
})
|
})
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
const Container = define('CodeBrowserContainer', {
|
const Container = define('Container', {
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
|
|
@ -63,7 +64,7 @@ const Title = define('Title', {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
})
|
})
|
||||||
|
|
||||||
const AppName = define('AppName', {
|
const Subtitle = define('Subtitle', {
|
||||||
color: theme('textMuted'),
|
color: theme('textMuted'),
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
marginTop: '5px',
|
marginTop: '5px',
|
||||||
|
|
@ -117,12 +118,11 @@ const CodeBlock = define('CodeBlock', {
|
||||||
margin: '20px 0',
|
margin: '20px 0',
|
||||||
border: `1px solid ${theme('border')}`,
|
border: `1px solid ${theme('border')}`,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'auto',
|
overflowX: 'auto',
|
||||||
selectors: {
|
selectors: {
|
||||||
'& pre': {
|
'& pre': {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
padding: '15px',
|
padding: '15px',
|
||||||
overflow: 'auto',
|
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
backgroundColor: theme('codeBg'),
|
backgroundColor: theme('codeBg'),
|
||||||
},
|
},
|
||||||
|
|
@ -141,7 +141,7 @@ const CodeHeader = define('CodeHeader', {
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
})
|
})
|
||||||
|
|
||||||
const Error = define('Error', {
|
const ErrorBox = define('ErrorBox', {
|
||||||
color: theme('error'),
|
color: theme('error'),
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
backgroundColor: theme('errorBg'),
|
backgroundColor: theme('errorBg'),
|
||||||
|
|
@ -176,14 +176,28 @@ const FileIconSvg = () => (
|
||||||
</FileIcon>
|
</FileIcon>
|
||||||
)
|
)
|
||||||
|
|
||||||
const themeScript = `
|
const initScript = `
|
||||||
(function() {
|
(function() {
|
||||||
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
document.body.setAttribute('data-theme', theme);
|
document.body.setAttribute('data-theme', theme);
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
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 = `
|
const baseStyles = `
|
||||||
|
|
@ -193,6 +207,44 @@ body {
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
children: Child
|
||||||
|
highlight?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Layout({ title, subtitle, children, highlight }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
{highlight && (
|
||||||
|
<>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>Code Browser</Title>
|
||||||
|
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||||
|
</Header>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
{highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/styles.css', c => c.text(baseStyles + 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',
|
||||||
}))
|
}))
|
||||||
|
|
@ -201,17 +253,11 @@ async function listFiles(appPath: string, subPath: string = '') {
|
||||||
const fullPath = join(appPath, subPath)
|
const fullPath = join(appPath, subPath)
|
||||||
const entries = await readdir(fullPath, { withFileTypes: true })
|
const entries = await readdir(fullPath, { withFileTypes: true })
|
||||||
|
|
||||||
const items = await Promise.all(
|
const items = entries.map(entry => ({
|
||||||
entries.map(async entry => {
|
name: entry.name,
|
||||||
const itemPath = join(fullPath, entry.name)
|
isDirectory: entry.isDirectory(),
|
||||||
const stats = await stat(itemPath)
|
path: subPath ? `${subPath}/${entry.name}` : entry.name,
|
||||||
return {
|
}))
|
||||||
name: entry.name,
|
|
||||||
isDirectory: entry.isDirectory(),
|
|
||||||
path: subPath ? `${subPath}/${entry.name}` : entry.name,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
if (a.isDirectory !== b.isDirectory) {
|
if (a.isDirectory !== b.isDirectory) {
|
||||||
|
|
@ -221,15 +267,6 @@ async function listFiles(appPath: string, subPath: string = '') {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
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> = {
|
||||||
|
|
@ -250,156 +287,78 @@ function getLanguage(filename: string): string {
|
||||||
|
|
||||||
app.get('/', async c => {
|
app.get('/', async c => {
|
||||||
const appName = c.req.query('app')
|
const appName = c.req.query('app')
|
||||||
|
const version = c.req.query('version') || 'current'
|
||||||
const filePath = c.req.query('file') || ''
|
const filePath = c.req.query('file') || ''
|
||||||
|
|
||||||
if (!appName) {
|
if (!appName) {
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title="Code Browser">
|
||||||
<head>
|
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||||
<meta charset="UTF-8" />
|
</Layout>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>Please specify an app name with ?app=<name></Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const appPath = join(APPS_DIR, appName, 'current')
|
const appPath = join(APPS_DIR, appName, version)
|
||||||
|
const versionSuffix = version !== 'current' ? ` @ ${version}` : ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stat(appPath)
|
await stat(appPath)
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title="Code Browser">
|
||||||
<head>
|
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
|
||||||
<meta charset="UTF-8" />
|
</Layout>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>App "{appName}" not found</Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(appPath, filePath)
|
const fullPath = join(appPath, filePath)
|
||||||
let stats
|
let fileStats
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stats = await stat(fullPath)
|
fileStats = await stat(fullPath)
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title="Code Browser" subtitle={appName + versionSuffix}>
|
||||||
<head>
|
<ErrorBox>Path "{filePath}" not found</ErrorBox>
|
||||||
<meta charset="UTF-8" />
|
</Layout>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Code Browser</title>
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
</Header>
|
|
||||||
<Error>Path "{filePath}" not found in app "{appName}"</Error>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.isFile()) {
|
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
||||||
|
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
||||||
|
const backUrl = `/?app=${appName}${versionParam}${parentPath ? `&file=${parentPath}` : ''}`
|
||||||
|
|
||||||
|
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))
|
||||||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title={`${appName}/${filePath}`} subtitle={`${appName}${versionSuffix}/${filePath}`} highlight>
|
||||||
<head>
|
<BackLink href={backUrl}>← Back</BackLink>
|
||||||
<meta charset="UTF-8" />
|
<CodeBlock>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<CodeHeader>{basename(fullPath)}</CodeHeader>
|
||||||
<title>{`${appName}/${filePath}`}</title>
|
<pre><code class={`language-${language}`}>{content}</code></pre>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
</CodeBlock>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)" />
|
</Layout>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>Code Browser</Title>
|
|
||||||
<AppName>{appName}/{filePath}</AppName>
|
|
||||||
</Header>
|
|
||||||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
|
||||||
← Back
|
|
||||||
</BackLink>
|
|
||||||
<CodeBlock>
|
|
||||||
<CodeHeader>{basename(fullPath)}</CodeHeader>
|
|
||||||
<pre><code class={`language-${language}`}>{content}</code></pre>
|
|
||||||
</CodeBlock>
|
|
||||||
</Container>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await listFiles(appPath, filePath)
|
const files = await listFiles(appPath, filePath)
|
||||||
const parentPath = filePath.split('/').slice(0, -1).join('/')
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<html>
|
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`} subtitle={`${appName}${versionSuffix}${filePath ? `/${filePath}` : ''}`}>
|
||||||
<head>
|
{filePath && <BackLink href={backUrl}>← Back</BackLink>}
|
||||||
<meta charset="UTF-8" />
|
<FileList>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
{files.map(file => (
|
||||||
<title>{`${appName}${filePath ? `/${filePath}` : ''}`}</title>
|
<FileItem>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
|
||||||
</head>
|
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
||||||
<body>
|
<span>{file.name}</span>
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
</FileLink>
|
||||||
<Container>
|
</FileItem>
|
||||||
<Header>
|
))}
|
||||||
<Title>Code Browser</Title>
|
</FileList>
|
||||||
<AppName>{appName}{filePath ? `/${filePath}` : ''}</AppName>
|
</Layout>
|
||||||
</Header>
|
|
||||||
{filePath && (
|
|
||||||
<BackLink href={`/?app=${appName}${parentPath ? `&file=${parentPath}` : ''}`}>
|
|
||||||
← Back
|
|
||||||
</BackLink>
|
|
||||||
)}
|
|
||||||
<FileList>
|
|
||||||
{files.map(file => (
|
|
||||||
<FileItem>
|
|
||||||
<FileLink href={`/?app=${appName}&file=${file.path}`}>
|
|
||||||
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
|
||||||
<span>{file.name}</span>
|
|
||||||
</FileLink>
|
|
||||||
</FileItem>
|
|
||||||
))}
|
|
||||||
</FileList>
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
1
apps/todo/20260130-181927/.npmrc
Normal file
1
apps/todo/20260130-181927/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
43
apps/todo/20260130-181927/bun.lock
Normal file
43
apps/todo/20260130-181927/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/20260130-181927/index.tsx
Normal file
1
apps/todo/20260130-181927/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './src/server'
|
||||||
26
apps/todo/20260130-181927/package.json
Normal file
26
apps/todo/20260130-181927/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": "TODO",
|
||||||
|
"icon": "✅"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/howl": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/todo/20260130-181927/pub/img/bite1.png
Normal file
BIN
apps/todo/20260130-181927/pub/img/bite1.png
Normal file
Binary file not shown.
BIN
apps/todo/20260130-181927/pub/img/bite2.png
Normal file
BIN
apps/todo/20260130-181927/pub/img/bite2.png
Normal file
Binary file not shown.
BIN
apps/todo/20260130-181927/pub/img/burger.png
Normal file
BIN
apps/todo/20260130-181927/pub/img/burger.png
Normal file
Binary file not shown.
36
apps/todo/20260130-181927/src/client/App.tsx
Normal file
36
apps/todo/20260130-181927/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/20260130-181927/src/css/main.css
Normal file
40
apps/todo/20260130-181927/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/20260130-181927/src/pages/index.tsx
Normal file
31
apps/todo/20260130-181927/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>
|
||||||
|
</>
|
||||||
422
apps/todo/20260130-181927/src/server/index.tsx
Normal file
422
apps/todo/20260130-181927/src/server/index.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { createThemes, define, stylesToCSS } from '@because/forge'
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
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', {
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
color: theme('text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const Header = define('Header', {
|
||||||
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = define('Title', {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
})
|
||||||
|
|
||||||
|
const AppName = define('AppName', {
|
||||||
|
color: theme('textMuted'),
|
||||||
|
fontSize: '14px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const TodoList = define('TodoList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const TodoSection = define('TodoSection', {
|
||||||
|
marginBottom: '24px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const SectionTitle = define('SectionTitle', {
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme('textMuted'),
|
||||||
|
marginBottom: '12px',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
borderBottom: `1px solid ${theme('border')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const TodoItemStyle = define('TodoItem', {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '8px 0',
|
||||||
|
gap: '10px',
|
||||||
|
selectors: {
|
||||||
|
'& input[type="checkbox"]': {
|
||||||
|
marginTop: '3px',
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'& label': {
|
||||||
|
flex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const doneClass = 'todo-done'
|
||||||
|
|
||||||
|
const Error = define('Error', {
|
||||||
|
color: theme('error'),
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: theme('errorBg'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
margin: '20px 0',
|
||||||
|
})
|
||||||
|
|
||||||
|
const successClass = 'msg-success'
|
||||||
|
const errorClass = 'msg-error'
|
||||||
|
|
||||||
|
const SaveButton = define('SaveButton', {
|
||||||
|
base: 'button',
|
||||||
|
backgroundColor: theme('accent'),
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
':disabled': {
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddForm = define('AddForm', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddInput = define('AddInput', {
|
||||||
|
base: 'input',
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: `1px solid ${theme('border')}`,
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
backgroundColor: theme('bg'),
|
||||||
|
color: theme('text'),
|
||||||
|
states: {
|
||||||
|
':focus': {
|
||||||
|
outline: 'none',
|
||||||
|
borderColor: theme('accent'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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} {
|
||||||
|
color: ${theme('done')};
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.${successClass} {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: ${theme('successBg')};
|
||||||
|
color: ${theme('success')};
|
||||||
|
}
|
||||||
|
.${errorClass} {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: ${theme('errorBg')};
|
||||||
|
color: ${theme('error')};
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface TodoEntry {
|
||||||
|
text: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedTodo {
|
||||||
|
title: string
|
||||||
|
items: TodoEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTodoFile(content: string): ParsedTodo {
|
||||||
|
const lines = content.split('\n')
|
||||||
|
let title = 'TODO'
|
||||||
|
const items: TodoEntry[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
if (trimmed.startsWith('# ')) {
|
||||||
|
title = trimmed.slice(2)
|
||||||
|
} else if (trimmed.startsWith('[x] ') || trimmed.startsWith('[X] ')) {
|
||||||
|
items.push({ text: trimmed.slice(4), done: true })
|
||||||
|
} else if (trimmed.startsWith('[ ] ')) {
|
||||||
|
items.push({ text: trimmed.slice(4), done: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTodo(todo: ParsedTodo): string {
|
||||||
|
const lines = [`# ${todo.title}`]
|
||||||
|
for (const item of todo.items) {
|
||||||
|
lines.push(item.done ? `[x] ${item.text}` : `[ ] ${item.text}`)
|
||||||
|
}
|
||||||
|
return lines.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
const message = c.req.query('message')
|
||||||
|
const messageType = c.req.query('type') as 'success' | 'error' | undefined
|
||||||
|
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TODO</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeScript + resizeScript }} />
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>TODO</Title>
|
||||||
|
</Header>
|
||||||
|
<Error>Select an app to view its TODO list</Error>
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
||||||
|
|
||||||
|
let todo: ParsedTodo
|
||||||
|
if (existsSync(todoPath)) {
|
||||||
|
const content = readFileSync(todoPath, 'utf-8')
|
||||||
|
todo = parseTodoFile(content)
|
||||||
|
} else {
|
||||||
|
todo = { title: `${appName} TODO`, items: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingItems = todo.items.filter(i => !i.done)
|
||||||
|
const doneItems = todo.items.filter(i => i.done)
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{todo.title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeScript + resizeScript }} />
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<div>
|
||||||
|
<Title>{todo.title}</Title>
|
||||||
|
<AppName>{appName}/TODO.txt</AppName>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div class={messageType === 'success' ? successClass : errorClass}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action="/add" method="post">
|
||||||
|
<input type="hidden" name="app" value={appName} />
|
||||||
|
<AddForm>
|
||||||
|
<AddInput type="text" name="text" placeholder="Add a new todo..." required />
|
||||||
|
<SaveButton type="submit">Add</SaveButton>
|
||||||
|
</AddForm>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{todo.items.length > 0 && (
|
||||||
|
<TodoSection>
|
||||||
|
<SectionTitle>
|
||||||
|
{pendingItems.length === 0 ? 'All done!' : `Pending (${pendingItems.length})`}
|
||||||
|
</SectionTitle>
|
||||||
|
<TodoList>
|
||||||
|
{todo.items.map((item, i) => (
|
||||||
|
<TodoItemStyle key={i}>
|
||||||
|
<form action="/toggle" method="post" style={{ display: 'contents' }}>
|
||||||
|
<input type="hidden" name="app" value={appName} />
|
||||||
|
<input type="hidden" name="index" value={i.toString()} />
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`item-${i}`}
|
||||||
|
checked={item.done}
|
||||||
|
onchange="this.form.submit()"
|
||||||
|
/>
|
||||||
|
<label for={`item-${i}`} class={item.done ? doneClass : ''}>
|
||||||
|
{item.text}
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</TodoItemStyle>
|
||||||
|
))}
|
||||||
|
</TodoList>
|
||||||
|
</TodoSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{todo.items.length === 0 && (
|
||||||
|
<TodoSection>
|
||||||
|
<SectionTitle>No todos yet</SectionTitle>
|
||||||
|
<p style={{ color: theme('textMuted') }}>Add your first todo above!</p>
|
||||||
|
</TodoSection>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/toggle', async c => {
|
||||||
|
const form = await c.req.formData()
|
||||||
|
const appName = form.get('app') as string
|
||||||
|
const index = parseInt(form.get('index') as string, 10)
|
||||||
|
|
||||||
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
||||||
|
|
||||||
|
let todo: ParsedTodo
|
||||||
|
if (existsSync(todoPath)) {
|
||||||
|
const content = readFileSync(todoPath, 'utf-8')
|
||||||
|
todo = parseTodoFile(content)
|
||||||
|
} else {
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 0 && index < todo.items.length) {
|
||||||
|
todo.items[index].done = !todo.items[index].done
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(todoPath, serializeTodo(todo))
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
} catch {
|
||||||
|
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to save')}&type=error`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/add', async c => {
|
||||||
|
const form = await c.req.formData()
|
||||||
|
const appName = form.get('app') as string
|
||||||
|
const text = (form.get('text') as string).trim()
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoPath = join(APPS_DIR, appName, 'current', 'TODO.txt')
|
||||||
|
|
||||||
|
let todo: ParsedTodo
|
||||||
|
if (existsSync(todoPath)) {
|
||||||
|
const content = readFileSync(todoPath, 'utf-8')
|
||||||
|
todo = parseTodoFile(content)
|
||||||
|
} else {
|
||||||
|
todo = { title: `${appName} TODO`, items: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
todo.items.push({ text, done: false })
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(todoPath, serializeTodo(todo))
|
||||||
|
return c.redirect(`/?app=${appName}`)
|
||||||
|
} catch {
|
||||||
|
return c.redirect(`/?app=${appName}&message=${encodeURIComponent('Failed to add')}&type=error`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
0
apps/todo/20260130-181927/src/shared/types.ts
Normal file
0
apps/todo/20260130-181927/src/shared/types.ts
Normal file
29
apps/todo/20260130-181927/tsconfig.json
Normal file
29
apps/todo/20260130-181927/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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/todo/current
Symbolic link
1
apps/todo/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-181927
|
||||||
1
apps/versions/20260130-000000/.npmrc
Normal file
1
apps/versions/20260130-000000/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
38
apps/versions/20260130-000000/bun.lock
Normal file
38
apps/versions/20260130-000000/bun.lock
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "versions",
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@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/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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
253
apps/versions/20260130-000000/index.tsx
Normal file
253
apps/versions/20260130-000000/index.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { createThemes, define, stylesToCSS } from '@because/forge'
|
||||||
|
import { readdir, readlink, stat } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { Child } from 'hono/jsx'
|
||||||
|
|
||||||
|
const APPS_DIR = process.env.APPS_DIR!
|
||||||
|
|
||||||
|
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', {
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
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 VersionList = define('VersionList', {
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: '20px 0',
|
||||||
|
border: `1px solid ${theme('border')}`,
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const VersionItem = define('VersionItem', {
|
||||||
|
padding: '12px 15px',
|
||||||
|
borderBottom: `1px solid ${theme('borderSubtle')}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
states: {
|
||||||
|
':last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: theme('hover'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const VersionLink = define('VersionLink', {
|
||||||
|
base: 'a',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: theme('link'),
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '15px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
states: {
|
||||||
|
':hover': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Badge = define('Badge', {
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: theme('accentBg'),
|
||||||
|
color: theme('accent'),
|
||||||
|
fontWeight: 'bold',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ErrorBox = define('ErrorBox', {
|
||||||
|
color: theme('error'),
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: theme('errorBg'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
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 {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
children: Child
|
||||||
|
}
|
||||||
|
|
||||||
|
function Layout({ title, subtitle, children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>Versions</Title>
|
||||||
|
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||||
|
</Header>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function getVersions(appPath: string): Promise<{ name: string; isCurrent: boolean }[]> {
|
||||||
|
const entries = await readdir(appPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
let currentTarget = ''
|
||||||
|
try {
|
||||||
|
currentTarget = await readlink(join(appPath, 'current'))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name))
|
||||||
|
.map(e => ({ name: e.name, isCurrent: e.name === currentTarget }))
|
||||||
|
.sort((a, b) => b.name.localeCompare(a.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: string): string {
|
||||||
|
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}:${ts.slice(13, 15)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/', async c => {
|
||||||
|
const appName = c.req.query('app')
|
||||||
|
|
||||||
|
if (!appName) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Versions">
|
||||||
|
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appPath = join(APPS_DIR, appName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(appPath)
|
||||||
|
} catch {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Versions">
|
||||||
|
<ErrorBox>App "{appName}" not found</ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await getVersions(appPath)
|
||||||
|
|
||||||
|
if (versions.length === 0) {
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Versions" subtitle={appName}>
|
||||||
|
<ErrorBox>No versions found</ErrorBox>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Versions" subtitle={appName}>
|
||||||
|
<VersionList>
|
||||||
|
{versions.map(v => (
|
||||||
|
<VersionItem>
|
||||||
|
<VersionLink
|
||||||
|
href="#"
|
||||||
|
onclick={`window.parent.postMessage({type:'navigate-tool',tool:'code',params:{app:'${appName}',version:'${v.name}'}}, '*'); return false;`}
|
||||||
|
>
|
||||||
|
{formatTimestamp(v.name)}
|
||||||
|
</VersionLink>
|
||||||
|
{v.isCurrent && <Badge>current</Badge>}
|
||||||
|
</VersionItem>
|
||||||
|
))}
|
||||||
|
</VersionList>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
25
apps/versions/20260130-000000/package.json
Normal file
25
apps/versions/20260130-000000/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "versions",
|
||||||
|
"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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/versions/20260130-000000/tsconfig.json
Normal file
30
apps/versions/20260130-000000/tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/versions/current
Symbolic link
1
apps/versions/current
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
20260130-000000
|
||||||
|
|
@ -10,6 +10,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
|
|
||||||
// Find all tools
|
// Find all tools
|
||||||
const tools = apps.filter(a => a.tool)
|
const tools = apps.filter(a => a.tool)
|
||||||
|
const titlecase = (s: string) => s.split(' ').map(part => part[0]?.toUpperCase() + part.slice(1))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabBar>
|
<TabBar>
|
||||||
|
|
@ -24,7 +25,7 @@ export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
active={selectedTab === tool.name ? true : undefined}
|
active={selectedTab === tool.name ? true : undefined}
|
||||||
onClick={() => handleTabClick(tool.name)}
|
onClick={() => handleTabClick(tool.name)}
|
||||||
>
|
>
|
||||||
{toolName}
|
{titlecase(toolName)}
|
||||||
</Tab>
|
</Tab>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { render as renderApp } from 'hono/jsx/dom'
|
import { render as renderApp } from 'hono/jsx/dom'
|
||||||
import { Dashboard } from './components'
|
import { Dashboard } from './components'
|
||||||
import { apps, selectedApp, selectedTab, setApps, setSelectedApp } from './state'
|
import { apps, selectedApp, selectedTab, setApps, setSelectedApp, setSelectedTab } from './state'
|
||||||
import { initModal } from './components/modal'
|
import { initModal } from './components/modal'
|
||||||
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
import { initToolIframes, updateToolIframes, setNavigateToolHandler, navigateToTool } from './tool-iframes'
|
||||||
import { initUpdate } from './update'
|
import { initUpdate } from './update'
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
|
|
@ -19,6 +19,15 @@ initModal(render)
|
||||||
initUpdate(render)
|
initUpdate(render)
|
||||||
initToolIframes()
|
initToolIframes()
|
||||||
|
|
||||||
|
// Handle tool-to-tool navigation via postMessage
|
||||||
|
setNavigateToolHandler((tool, params) => {
|
||||||
|
const tools = apps.filter(a => a.tool)
|
||||||
|
navigateToTool(tool, params, tools, (tab) => {
|
||||||
|
setSelectedTab(tab)
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Set theme based on system preference
|
// Set theme based on system preference
|
||||||
const setTheme = () => {
|
const setTheme = () => {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { theme } from './themes'
|
||||||
|
|
||||||
// Iframe cache - these never get recreated once loaded
|
// Iframe cache - these never get recreated once loaded
|
||||||
// Use a global to survive hot reloads
|
// Use a global to survive hot reloads
|
||||||
const iframes: Map<string, { iframe: HTMLIFrameElement; port: number }> =
|
const iframes: Map<string, { iframe: HTMLIFrameElement; port: number; contentHeight?: number }> =
|
||||||
(window as any).__toolIframes ??= new Map()
|
(window as any).__toolIframes ??= new Map()
|
||||||
|
|
||||||
// Track current state to avoid unnecessary DOM updates
|
// Track current state to avoid unnecessary DOM updates
|
||||||
|
|
@ -12,11 +12,51 @@ let currentTool: string | null = (window as any).__currentTool ?? null
|
||||||
// Get the stable container (outside Hono-managed DOM)
|
// Get the stable container (outside Hono-managed DOM)
|
||||||
const getContainer = () => document.getElementById('tool-iframes')
|
const getContainer = () => document.getElementById('tool-iframes')
|
||||||
|
|
||||||
|
// Callback for tool navigation requests
|
||||||
|
let onNavigateTool: ((tool: string, params: Record<string, string>) => void) | null = null
|
||||||
|
|
||||||
|
export function setNavigateToolHandler(handler: (tool: string, params: Record<string, string>) => void) {
|
||||||
|
onNavigateTool = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from iframes
|
||||||
|
function setupMessageListener() {
|
||||||
|
if ((window as any).__toolIframeMessageListener) return
|
||||||
|
; (window as any).__toolIframeMessageListener = true
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const { type } = event.data || {}
|
||||||
|
|
||||||
|
if (type === 'resize-iframe') {
|
||||||
|
const height = event.data.height
|
||||||
|
if (!height || typeof height !== 'number') return
|
||||||
|
|
||||||
|
// Find which iframe sent this message
|
||||||
|
for (const [, cached] of iframes) {
|
||||||
|
if (cached.iframe.contentWindow === event.source) {
|
||||||
|
cached.contentHeight = height
|
||||||
|
cached.iframe.style.height = `100vh`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'navigate-tool') {
|
||||||
|
const { tool, params } = event.data
|
||||||
|
if (tool && onNavigateTool) {
|
||||||
|
onNavigateTool(tool, params || {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the container styles
|
// Initialize the container styles
|
||||||
export function initToolIframes() {
|
export function initToolIframes() {
|
||||||
const container = getContainer()
|
const container = getContainer()
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
|
setupMessageListener()
|
||||||
|
|
||||||
// Restore iframe cache from DOM if module was hot-reloaded
|
// Restore iframe cache from DOM if module was hot-reloaded
|
||||||
if (iframes.size === 0) {
|
if (iframes.size === 0) {
|
||||||
const existingIframes = container.querySelectorAll('iframe')
|
const existingIframes = container.querySelectorAll('iframe')
|
||||||
|
|
@ -38,9 +78,66 @@ export function initToolIframes() {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: ${theme('colors-bg')};
|
background: ${theme('colors-bg')};
|
||||||
|
overflow: auto;
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build URL with params
|
||||||
|
function buildToolUrl(port: number, params: Record<string, string>): string {
|
||||||
|
const searchParams = new URLSearchParams(params)
|
||||||
|
const query = searchParams.toString()
|
||||||
|
return query ? `http://localhost:${port}?${query}` : `http://localhost:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cache key from tool name and params
|
||||||
|
function buildCacheKey(toolName: string, params: Record<string, string>): string {
|
||||||
|
const parts = [toolName]
|
||||||
|
const sortedKeys = Object.keys(params).sort()
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
parts.push(`${key}=${params[key]}`)
|
||||||
|
}
|
||||||
|
return parts.join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to a tool with specific params (called from postMessage handler)
|
||||||
|
export function navigateToTool(
|
||||||
|
toolName: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
tools: Array<{ name: string; port?: number; state: string }>,
|
||||||
|
onSelectTab: (tab: string) => void
|
||||||
|
) {
|
||||||
|
const tool = tools.find(t => t.name === toolName)
|
||||||
|
if (!tool || tool.state !== 'running' || !tool.port) return
|
||||||
|
|
||||||
|
const container = getContainer()
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const cacheKey = buildCacheKey(toolName, params)
|
||||||
|
|
||||||
|
// Create iframe if needed
|
||||||
|
let cached = iframes.get(cacheKey)
|
||||||
|
if (!cached || cached.port !== tool.port) {
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
iframe.src = buildToolUrl(tool.port, params)
|
||||||
|
iframe.dataset.toolName = toolName
|
||||||
|
iframe.dataset.appName = params.app ?? ''
|
||||||
|
iframe.style.cssText = `width: 100%; border: none;`
|
||||||
|
cached = { iframe, port: tool.port }
|
||||||
|
iframes.set(cacheKey, cached)
|
||||||
|
container.appendChild(iframe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to that tab
|
||||||
|
onSelectTab(toolName)
|
||||||
|
|
||||||
|
// Force show this specific iframe
|
||||||
|
currentTool = cacheKey
|
||||||
|
;(window as any).__currentTool = cacheKey
|
||||||
|
for (const [key, { iframe }] of iframes) {
|
||||||
|
iframe.style.display = key === cacheKey ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update which iframe is visible based on selected tab and tool state
|
// Update which iframe is visible based on selected tab and tool state
|
||||||
export function updateToolIframes(
|
export function updateToolIframes(
|
||||||
selectedTab: string,
|
selectedTab: string,
|
||||||
|
|
@ -55,7 +152,6 @@ export function updateToolIframes(
|
||||||
const showIframe = selectedTool?.state === 'running' && selectedTool?.port
|
const showIframe = selectedTool?.state === 'running' && selectedTool?.port
|
||||||
|
|
||||||
if (!showIframe) {
|
if (!showIframe) {
|
||||||
// Only update if state changed
|
|
||||||
if (currentTool !== null) {
|
if (currentTool !== null) {
|
||||||
container.style.display = 'none'
|
container.style.display = 'none'
|
||||||
currentTool = null
|
currentTool = null
|
||||||
|
|
@ -66,19 +162,20 @@ export function updateToolIframes(
|
||||||
|
|
||||||
const tool = selectedTool!
|
const tool = selectedTool!
|
||||||
|
|
||||||
// Build cache key for tool + app combination
|
// Build params and cache key
|
||||||
const cacheKey = selectedApp ? `${tool.name}:${selectedApp}` : tool.name
|
const params: Record<string, string> = {}
|
||||||
|
if (selectedApp) params.app = selectedApp
|
||||||
|
const cacheKey = buildCacheKey(tool.name, params)
|
||||||
|
|
||||||
// Skip if nothing changed
|
// Skip if nothing changed
|
||||||
if (currentTool === cacheKey) {
|
if (currentTool === cacheKey) {
|
||||||
// Just update position in case of scroll/resize
|
|
||||||
const tabContent = document.querySelector('[data-tool-target]')
|
const tabContent = document.querySelector('[data-tool-target]')
|
||||||
if (tabContent) {
|
if (tabContent) {
|
||||||
const rect = tabContent.getBoundingClientRect()
|
const rect = tabContent.getBoundingClientRect()
|
||||||
container.style.top = `${rect.top}px`
|
container.style.top = `${rect.top}px`
|
||||||
container.style.left = `${rect.left}px`
|
container.style.left = `${rect.left}px`
|
||||||
container.style.width = `${rect.width}px`
|
container.style.width = `${rect.width}px`
|
||||||
container.style.height = `${rect.height}px`
|
container.style.height = '100vh'
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -98,35 +195,27 @@ export function updateToolIframes(
|
||||||
top: ${rect.top}px;
|
top: ${rect.top}px;
|
||||||
left: ${rect.left}px;
|
left: ${rect.left}px;
|
||||||
width: ${rect.width}px;
|
width: ${rect.width}px;
|
||||||
height: ${rect.height}px;
|
height: calc(100vh - ${rect.top}px);
|
||||||
background: ${theme('colors-bg')};
|
background: ${theme('colors-bg')};
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
overflow: auto;
|
||||||
`
|
`
|
||||||
|
|
||||||
// Get or create the iframe for this tool + app combination
|
// Get or create the iframe
|
||||||
let cached = iframes.get(cacheKey)
|
let cached = iframes.get(cacheKey)
|
||||||
|
|
||||||
if (!cached || cached.port !== tool.port) {
|
if (!cached || cached.port !== tool.port) {
|
||||||
// Create new iframe (first time or port changed)
|
|
||||||
const iframe = document.createElement('iframe')
|
const iframe = document.createElement('iframe')
|
||||||
const url = selectedApp
|
iframe.src = buildToolUrl(tool.port!, params)
|
||||||
? `http://localhost:${tool.port}?app=${encodeURIComponent(selectedApp)}`
|
iframe.dataset.toolName = tool.name
|
||||||
: `http://localhost:${tool.port}`
|
iframe.dataset.appName = selectedApp ?? ''
|
||||||
iframe.src = url
|
iframe.style.cssText = `width: 100%; border: none;`
|
||||||
iframe.dataset.toolName = tool.name // For hot reload recovery
|
|
||||||
iframe.dataset.appName = selectedApp ?? '' // For hot reload recovery
|
|
||||||
iframe.style.cssText = `
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
`
|
|
||||||
cached = { iframe, port: tool.port! }
|
cached = { iframe, port: tool.port! }
|
||||||
iframes.set(cacheKey, cached)
|
iframes.set(cacheKey, cached)
|
||||||
// Add to container
|
|
||||||
container.appendChild(iframe)
|
container.appendChild(iframe)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show only the selected iframe, hide others
|
// Show only the selected iframe
|
||||||
for (const [key, { iframe }] of iframes) {
|
for (const [key, { iframe }] of iframes) {
|
||||||
const shouldShow = key === cacheKey
|
const shouldShow = key === cacheKey
|
||||||
if (shouldShow && iframe.parentElement !== container) {
|
if (shouldShow && iframe.parentElement !== container) {
|
||||||
|
|
|
||||||
|
|
@ -640,10 +640,15 @@ function watchAppsDir() {
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
|
|
||||||
// Update icon, tool, and error from package.json
|
// Update icon, tool, and error from package.json
|
||||||
|
const iconChanged = app.icon !== pkg.toes?.icon
|
||||||
|
const toolChanged = app.tool !== pkg.toes?.tool
|
||||||
app.icon = pkg.toes?.icon
|
app.icon = pkg.toes?.icon
|
||||||
app.tool = pkg.toes?.tool
|
app.tool = pkg.toes?.tool
|
||||||
app.error = error
|
app.error = error
|
||||||
|
|
||||||
|
// Broadcast if icon or tool changed
|
||||||
|
if (iconChanged || toolChanged) update()
|
||||||
|
|
||||||
// App became valid - start it if stopped
|
// App became valid - start it if stopped
|
||||||
if (!error && app.state === 'invalid') {
|
if (!error && app.state === 'invalid') {
|
||||||
app.state = 'stopped'
|
app.state = 'stopped'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user