695 lines
20 KiB
TypeScript
695 lines
20 KiB
TypeScript
import { Hype } from '@because/hype'
|
|
import { define, stylesToCSS } from '@because/forge'
|
|
import { baseStyles, ToolScript, theme } from '@because/toes/tools'
|
|
import { readdir, stat } from 'fs/promises'
|
|
import { readFileSync } from 'fs'
|
|
import { join, extname, basename } from 'path'
|
|
import type { Child } from 'hono/jsx'
|
|
|
|
const APPS_DIR = process.env.APPS_DIR!
|
|
|
|
const app = new Hype({ prettyHTML: false })
|
|
|
|
const Container = define('Container', {
|
|
fontFamily: theme('fonts-sans'),
|
|
padding: '20px',
|
|
paddingTop: 0,
|
|
maxWidth: '1200px',
|
|
margin: '0 auto',
|
|
color: theme('colors-text'),
|
|
})
|
|
|
|
const FileList = define('FileList', {
|
|
listStyle: 'none',
|
|
padding: 0,
|
|
margin: '20px 0',
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
overflow: 'hidden',
|
|
})
|
|
|
|
const FileItem = define('FileItem', {
|
|
padding: '10px 15px',
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
states: {
|
|
':last-child': {
|
|
borderBottom: 'none',
|
|
},
|
|
':hover': {
|
|
backgroundColor: theme('colors-bgHover'),
|
|
},
|
|
}
|
|
})
|
|
|
|
const FileLink = define('FileLink', {
|
|
base: 'a',
|
|
textDecoration: 'none',
|
|
color: theme('colors-link'),
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
states: {
|
|
':hover': {
|
|
textDecoration: 'underline',
|
|
},
|
|
}
|
|
})
|
|
|
|
const FileIcon = define('FileIcon', {
|
|
base: 'svg',
|
|
width: '18px',
|
|
height: '18px',
|
|
flexShrink: 0,
|
|
fill: theme('colors-textMuted'),
|
|
})
|
|
|
|
const CodeBlock = define('CodeBlock', {
|
|
margin: '20px 0',
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
overflowX: 'auto',
|
|
selectors: {
|
|
'& pre': {
|
|
margin: 0,
|
|
padding: '15px',
|
|
whiteSpace: 'pre',
|
|
backgroundColor: theme('colors-bgSubtle'),
|
|
},
|
|
'& pre code': {
|
|
whiteSpace: 'pre',
|
|
fontFamily: theme('fonts-mono'),
|
|
},
|
|
},
|
|
})
|
|
|
|
const CodeHeader = define('CodeHeader', {
|
|
padding: '10px 15px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
fontWeight: 'bold',
|
|
fontSize: '14px',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
})
|
|
|
|
const ErrorBox = define('ErrorBox', {
|
|
color: theme('colors-error'),
|
|
padding: '20px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
borderRadius: theme('radius-md'),
|
|
margin: '20px 0',
|
|
})
|
|
|
|
const Breadcrumb = define('Breadcrumb', {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
fontSize: '14px',
|
|
marginBottom: '15px',
|
|
flexWrap: 'wrap',
|
|
})
|
|
|
|
const BreadcrumbLink = define('BreadcrumbLink', {
|
|
base: 'a',
|
|
textDecoration: 'none',
|
|
color: theme('colors-link'),
|
|
states: {
|
|
':hover': {
|
|
textDecoration: 'underline',
|
|
},
|
|
},
|
|
})
|
|
|
|
const BreadcrumbSeparator = define('BreadcrumbSeparator', {
|
|
color: theme('colors-textMuted'),
|
|
})
|
|
|
|
const BreadcrumbCurrent = define('BreadcrumbCurrent', {
|
|
color: theme('colors-text'),
|
|
fontWeight: 500,
|
|
})
|
|
|
|
const MediaContainer = define('MediaContainer', {
|
|
margin: '20px 0',
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
overflow: 'hidden',
|
|
backgroundColor: theme('colors-bgSubtle'),
|
|
})
|
|
|
|
const MediaHeader = define('MediaHeader', {
|
|
padding: '10px 15px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
|
fontWeight: 'bold',
|
|
fontSize: '14px',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
})
|
|
|
|
const MediaContent = define('MediaContent', {
|
|
padding: '20px',
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
})
|
|
|
|
const ImagePreview = define('ImagePreview', {
|
|
base: 'img',
|
|
maxWidth: '100%',
|
|
maxHeight: '600px',
|
|
objectFit: 'contain',
|
|
})
|
|
|
|
const AudioPlayer = define('AudioPlayer', {
|
|
base: 'audio',
|
|
width: '100%',
|
|
maxWidth: '500px',
|
|
})
|
|
|
|
const VideoPlayer = define('VideoPlayer', {
|
|
base: 'video',
|
|
maxWidth: '100%',
|
|
maxHeight: '600px',
|
|
})
|
|
|
|
const DownloadButton = define('DownloadButton', {
|
|
base: 'a',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
padding: '8px 16px',
|
|
backgroundColor: theme('colors-primary'),
|
|
color: 'white',
|
|
textDecoration: 'none',
|
|
borderRadius: theme('radius-md'),
|
|
fontSize: '14px',
|
|
states: {
|
|
':hover': {
|
|
opacity: 0.9,
|
|
},
|
|
},
|
|
})
|
|
|
|
const EditButton = define('EditButton', {
|
|
base: 'button',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
padding: '6px 12px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
color: theme('colors-text'),
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
fontSize: '13px',
|
|
cursor: 'pointer',
|
|
states: {
|
|
':hover': {
|
|
backgroundColor: theme('colors-bgHover'),
|
|
},
|
|
},
|
|
})
|
|
|
|
const EditLink = define('EditLink', {
|
|
base: 'a',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
padding: '6px 12px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
color: theme('colors-text'),
|
|
border: `1px solid ${theme('colors-border')}`,
|
|
borderRadius: theme('radius-md'),
|
|
fontSize: '13px',
|
|
cursor: 'pointer',
|
|
textDecoration: 'none',
|
|
states: {
|
|
':hover': {
|
|
backgroundColor: theme('colors-bgHover'),
|
|
},
|
|
},
|
|
})
|
|
|
|
|
|
const FolderIcon = () => (
|
|
<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" />
|
|
</FileIcon>
|
|
)
|
|
|
|
const FileIconSvg = () => (
|
|
<FileIcon viewBox="0 0 24 24">
|
|
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z" />
|
|
</FileIcon>
|
|
)
|
|
|
|
interface LayoutProps {
|
|
title: string
|
|
children: Child
|
|
highlight?: boolean
|
|
editable?: boolean
|
|
}
|
|
|
|
const fileMemoryScript = `
|
|
(function() {
|
|
var params = new URLSearchParams(window.location.search);
|
|
var app = params.get('app');
|
|
var file = params.get('file');
|
|
var version = params.get('version') || 'current';
|
|
if (!app) return;
|
|
var key = 'code-app:' + app + ':' + version + ':file';
|
|
if (params.has('file')) {
|
|
// Explicit file param (even empty) - save it
|
|
if (file) localStorage.setItem(key, file);
|
|
else localStorage.removeItem(key);
|
|
} else {
|
|
// No file param - restore saved location
|
|
var saved = localStorage.getItem(key);
|
|
if (saved) {
|
|
var url = '/?app=' + encodeURIComponent(app);
|
|
if (version !== 'current') url += '&version=' + encodeURIComponent(version);
|
|
url += '&file=' + encodeURIComponent(saved);
|
|
window.location.replace(url);
|
|
}
|
|
}
|
|
})();
|
|
`
|
|
|
|
function Layout({ title, children, highlight, editable }: 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 && !editable && (
|
|
<>
|
|
<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>
|
|
</>
|
|
)}
|
|
{editable && (
|
|
<>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" media="(prefers-color-scheme: light)" />
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" media="(prefers-color-scheme: dark)" />
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-tsx.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
|
|
</>
|
|
)}
|
|
</head>
|
|
<body>
|
|
<script dangerouslySetInnerHTML={{ __html: fileMemoryScript }} />
|
|
<ToolScript />
|
|
<Container>
|
|
{children}
|
|
</Container>
|
|
{highlight && !editable && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
|
|
app.get('/ok', c => c.text('ok'))
|
|
|
|
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
|
|
'Content-Type': 'text/css; charset=utf-8',
|
|
}))
|
|
|
|
app.get('/raw', async c => {
|
|
const appName = c.req.query('app')
|
|
const version = c.req.query('version') || 'current'
|
|
const filePath = c.req.query('file')
|
|
|
|
if (!appName || !filePath) {
|
|
return c.text('Missing app or file parameter', 400)
|
|
}
|
|
|
|
const fullPath = join(APPS_DIR, appName, version, filePath)
|
|
const file = Bun.file(fullPath)
|
|
|
|
if (!await file.exists()) {
|
|
return c.text(`File not found: ${fullPath}`, 404)
|
|
}
|
|
|
|
return new Response(file)
|
|
})
|
|
|
|
app.post('/save', async c => {
|
|
const appName = c.req.query('app')
|
|
const version = c.req.query('version') || 'current'
|
|
const filePath = c.req.query('file')
|
|
|
|
if (!appName || !filePath) {
|
|
return c.text('Missing app or file parameter', 400)
|
|
}
|
|
|
|
const fullPath = join(APPS_DIR, appName, version, filePath)
|
|
const content = await c.req.text()
|
|
|
|
try {
|
|
await Bun.write(fullPath, content)
|
|
return c.text('OK')
|
|
} catch (err) {
|
|
return c.text(`Failed to save: ${err}`, 500)
|
|
}
|
|
})
|
|
|
|
async function listFiles(appPath: string, subPath: string = '') {
|
|
const fullPath = join(appPath, subPath)
|
|
const entries = await readdir(fullPath, { withFileTypes: true })
|
|
|
|
const items = entries.map(entry => ({
|
|
name: entry.name,
|
|
isDirectory: entry.isDirectory(),
|
|
path: subPath ? `${subPath}/${entry.name}` : entry.name,
|
|
}))
|
|
|
|
return items.sort((a, b) => {
|
|
if (a.isDirectory !== b.isDirectory) {
|
|
return a.isDirectory ? -1 : 1
|
|
}
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
}
|
|
|
|
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}&file=`}>{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>
|
|
)
|
|
}
|
|
|
|
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'])
|
|
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac'])
|
|
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi'])
|
|
const BINARY_EXTS = new Set(['.pdf', '.zip', '.tar', '.gz', '.exe', '.dmg', '.woff', '.woff2', '.ttf', '.otf', '.eot'])
|
|
|
|
type FileType = 'text' | 'image' | 'audio' | 'video' | 'binary'
|
|
|
|
function getFileType(filename: string): FileType {
|
|
const ext = extname(filename).toLowerCase()
|
|
if (IMAGE_EXTS.has(ext)) return 'image'
|
|
if (AUDIO_EXTS.has(ext)) return 'audio'
|
|
if (VIDEO_EXTS.has(ext)) return 'video'
|
|
if (BINARY_EXTS.has(ext)) return 'binary'
|
|
return 'text'
|
|
}
|
|
|
|
function getLanguage(filename: string): string {
|
|
const ext = extname(filename).toLowerCase()
|
|
const langMap: Record<string, string> = {
|
|
'.js': 'javascript',
|
|
'.jsx': 'javascript',
|
|
'.ts': 'typescript',
|
|
'.tsx': 'typescript',
|
|
'.json': 'json',
|
|
'.css': 'css',
|
|
'.html': 'html',
|
|
'.md': 'markdown',
|
|
'.sh': 'bash',
|
|
'.yml': 'yaml',
|
|
'.yaml': 'yaml',
|
|
}
|
|
return langMap[ext] || 'plaintext'
|
|
}
|
|
|
|
function getPrismLanguage(filename: string): string {
|
|
const ext = extname(filename).toLowerCase()
|
|
const langMap: Record<string, string> = {
|
|
'.js': 'javascript',
|
|
'.jsx': 'javascript',
|
|
'.ts': 'typescript',
|
|
'.tsx': 'typescript',
|
|
'.json': 'json',
|
|
'.css': 'css',
|
|
'.html': 'html',
|
|
'.md': 'markdown',
|
|
'.sh': 'bash',
|
|
'.yml': 'yaml',
|
|
'.yaml': 'yaml',
|
|
'.py': 'python',
|
|
'.rb': 'ruby',
|
|
'.go': 'go',
|
|
'.rs': 'rust',
|
|
'.sql': 'sql',
|
|
}
|
|
return langMap[ext] || 'plaintext'
|
|
}
|
|
|
|
|
|
app.get('/', async c => {
|
|
const appName = c.req.query('app')
|
|
const version = c.req.query('version') || 'current'
|
|
const filePath = c.req.query('file') || ''
|
|
|
|
if (!appName) {
|
|
return c.html(
|
|
<Layout title="Code Browser">
|
|
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
const appPath = join(APPS_DIR, appName, version)
|
|
|
|
try {
|
|
await stat(appPath)
|
|
} catch {
|
|
return c.html(
|
|
<Layout title="Code Browser">
|
|
<ErrorBox>App "{appName}" (version: {version}) not found</ErrorBox>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
const fullPath = join(appPath, filePath)
|
|
let fileStats
|
|
|
|
try {
|
|
fileStats = await stat(fullPath)
|
|
} catch {
|
|
return c.html(
|
|
<Layout title="Code Browser">
|
|
<ErrorBox>Path "{filePath}" not found</ErrorBox>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
const versionParam = version !== 'current' ? `&version=${version}` : ''
|
|
|
|
if (fileStats.isFile()) {
|
|
const filename = basename(fullPath)
|
|
const fileType = getFileType(filename)
|
|
const rawUrl = `/raw?app=${appName}${versionParam}&file=${filePath}`
|
|
const downloadUrl = `${rawUrl}&download=1`
|
|
|
|
if (fileType === 'image') {
|
|
return c.html(
|
|
<Layout title={`${appName}/${filePath}`}>
|
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
|
<MediaContainer>
|
|
<MediaHeader>
|
|
<span>{filename}</span>
|
|
<DownloadButton href={downloadUrl}>Download</DownloadButton>
|
|
</MediaHeader>
|
|
<MediaContent>
|
|
<ImagePreview src={rawUrl} alt={filename} />
|
|
</MediaContent>
|
|
</MediaContainer>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
if (fileType === 'audio') {
|
|
return c.html(
|
|
<Layout title={`${appName}/${filePath}`}>
|
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
|
<MediaContainer>
|
|
<MediaHeader>
|
|
<span>{filename}</span>
|
|
<DownloadButton href={downloadUrl}>Download</DownloadButton>
|
|
</MediaHeader>
|
|
<MediaContent>
|
|
<AudioPlayer controls src={rawUrl} />
|
|
</MediaContent>
|
|
</MediaContainer>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
if (fileType === 'video') {
|
|
return c.html(
|
|
<Layout title={`${appName}/${filePath}`}>
|
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
|
<MediaContainer>
|
|
<MediaHeader>
|
|
<span>{filename}</span>
|
|
<DownloadButton href={downloadUrl}>Download</DownloadButton>
|
|
</MediaHeader>
|
|
<MediaContent>
|
|
<VideoPlayer controls src={rawUrl} />
|
|
</MediaContent>
|
|
</MediaContainer>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
if (fileType === 'binary') {
|
|
return c.redirect(downloadUrl)
|
|
}
|
|
|
|
// Text file - show with syntax highlighting
|
|
const content = readFileSync(fullPath, 'utf-8')
|
|
const language = getLanguage(filename)
|
|
const prismLang = getPrismLanguage(filename)
|
|
const edit = c.req.query('edit') === '1'
|
|
|
|
if (edit) {
|
|
const editorScript = `
|
|
import { CodeJar } from 'https://cdn.jsdelivr.net/npm/codejar@4.2.0/dist/codejar.js';
|
|
|
|
const editor = document.getElementById('editor');
|
|
const saveBtn = document.getElementById('save-btn');
|
|
const status = document.getElementById('save-status');
|
|
let dirty = false;
|
|
|
|
const highlight = (el) => {
|
|
Prism.highlightElement(el);
|
|
};
|
|
|
|
const jar = CodeJar(editor, highlight, { tab: ' ', addClosing: false });
|
|
|
|
// Initial highlight
|
|
highlight(editor);
|
|
|
|
jar.onUpdate(() => {
|
|
if (!dirty) {
|
|
dirty = true;
|
|
saveBtn.textContent = 'Save *';
|
|
}
|
|
});
|
|
|
|
saveBtn.onclick = async () => {
|
|
if (!dirty) return;
|
|
saveBtn.disabled = true;
|
|
status.textContent = 'Saving...';
|
|
|
|
try {
|
|
const res = await fetch('/save?app=${appName}${versionParam}&file=${filePath}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
body: jar.toString()
|
|
});
|
|
if (!res.ok) throw new Error('Save failed');
|
|
dirty = false;
|
|
saveBtn.textContent = 'Save';
|
|
saveBtn.disabled = false;
|
|
status.textContent = 'Saved!';
|
|
setTimeout(() => { status.textContent = ''; }, 2000);
|
|
} catch (err) {
|
|
saveBtn.disabled = false;
|
|
status.textContent = 'Error: ' + err.message;
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
saveBtn.click();
|
|
}
|
|
});
|
|
`
|
|
return c.html(
|
|
<Layout title={`${appName}/${filePath}`} editable>
|
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
|
<CodeBlock>
|
|
<CodeHeader>
|
|
<span>{filename}</span>
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span id="save-status" style="font-size:12px;font-weight:normal;color:var(--colors-textMuted)"></span>
|
|
<EditButton id="save-btn">Save</EditButton>
|
|
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}`}>Done</EditLink>
|
|
</div>
|
|
</CodeHeader>
|
|
<pre id="editor" class={`language-${prismLang}`} contenteditable style="margin:0;padding:15px;min-height:300px;outline:none">{content}</pre>
|
|
</CodeBlock>
|
|
<script type="module" dangerouslySetInnerHTML={{ __html: editorScript }} />
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
return c.html(
|
|
<Layout title={`${appName}/${filePath}`} highlight>
|
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
|
<CodeBlock>
|
|
<CodeHeader>
|
|
<span>{filename}</span>
|
|
<EditLink href={`/?app=${appName}${versionParam}&file=${filePath}&edit=1`}>Edit</EditLink>
|
|
</CodeHeader>
|
|
<pre><code class={`language-${language}`}>{content}</code></pre>
|
|
</CodeBlock>
|
|
</Layout>
|
|
)
|
|
}
|
|
|
|
const files = await listFiles(appPath, filePath)
|
|
|
|
return c.html(
|
|
<Layout title={`${appName}${filePath ? `/${filePath}` : ''}`}>
|
|
<PathBreadcrumb appName={appName} filePath={filePath} versionParam={versionParam} />
|
|
<FileList>
|
|
{files.map(file => (
|
|
<FileItem>
|
|
<FileLink href={`/?app=${appName}${versionParam}&file=${file.path}`}>
|
|
{file.isDirectory ? <FolderIcon /> : <FileIconSvg />}
|
|
<span>{file.name}</span>
|
|
</FileLink>
|
|
</FileItem>
|
|
))}
|
|
</FileList>
|
|
</Layout>
|
|
)
|
|
})
|
|
|
|
export default app.defaults
|