Feature(custom): rewrite the webserver ui

This commit is contained in:
Kuingsmile
2025-09-26 17:55:26 +08:00
parent caec9bff07
commit 1f745c3d01

View File

@@ -11,34 +11,640 @@ import { configPaths } from '~/utils/configPaths'
const defaultPath = process.platform === 'win32' ? 'C:\\Users' : '/'
function generateDirectoryListingHtml(files: any[], requestPath: any) {
let html = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><h1>Directory Listing</h1><ul>'
files.forEach((file: string) => {
const filePath = path.join(requestPath, file)
html += `<li><a href="${encodeFilePath(filePath)}">${file}</a></li>`
})
html += '</ul></body></html>'
function getFileIcon(fileName: string, isDirectory: boolean): string {
if (isDirectory)
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 4v8a1 1 0 001 1h10a1 1 0 001-1V5a1 1 0 00-1-1H7L6 3H3a1 1 0 00-1 1z" fill="#FFCB47"/></svg>`
const ext = path.extname(fileName).toLowerCase()
if (['.txt', '.md', '.readme'].includes(ext)) {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 2v12h8V6l-4-4H4z" fill="#6B73FF" stroke="#5A67D8"/><path d="M8 2v4h4" stroke="#5A67D8" fill="none"/></svg>`
}
if (['.js', '.jsx', '.ts', '.tsx', '.html', '.css', '.json'].includes(ext)) {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 2v12h8V6l-4-4H4z" fill="#FF6B6B" stroke="#E53E3E"/><path d="M8 2v4h4" stroke="#E53E3E" fill="none"/><text x="8" y="10" font-family="monospace" font-size="4" text-anchor="middle" fill="#fff">&lt;/&gt;</text></svg>`
}
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp'].includes(ext)) {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 3v10h12V3H2z" fill="#4ECDC4" stroke="#319795"/><circle cx="5" cy="6" r="1" fill="#fff"/><path d="m2 10 3-3 2 2 4-4 3 3v2H2v-1z" fill="#fff" opacity="0.8"/></svg>`
}
if (['.mp4', '.avi', '.mov', '.wmv', '.mkv'].includes(ext)) {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 3v10h12V3H2z" fill="#FF6B6B" stroke="#E53E3E"/><path d="m6 5 4 3-4 3V5z" fill="#fff"/></svg>`
}
if (['.mp3', '.wav', '.flac', '.aac'].includes(ext)) {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 2v12h8V6l-4-4H4z" fill="#9F7AEA" stroke="#805AD5"/><path d="M8 2v4h4" stroke="#805AD5" fill="none"/><circle cx="8" cy="10" r="2" fill="#fff"/></svg>`
}
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 2v12h8V6l-4-4H4z" fill="#F6AD55" stroke="#DD6B20"/><path d="M8 2v4h4" stroke="#DD6B20" fill="none"/><rect x="6" y="8" width="4" height="2" fill="#fff" rx="0.5"/></svg>`
}
if (ext === '.pdf') {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 2v12h8V6l-4-4H4z" fill="#E53E3E" stroke="#C53030"/><path d="M8 2v4h4" stroke="#C53030" fill="none"/><text x="8" y="10" font-family="Arial" font-size="3" text-anchor="middle" fill="#fff">PDF</text></svg>`
}
if (['.exe', '.msi', '.app', '.deb', '.rpm'].includes(ext)) {
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 2v12h8V6l-4-4H4z" fill="#38A169" stroke="#2F855A"/><path d="M8 2v4h4" stroke="#2F855A" fill="none"/><rect x="6" y="8" width="4" height="4" fill="#fff" rx="1"/></svg>`
}
return `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 2v12h8V6l-4-4H4z" fill="#A0ADB8" stroke="#718096"/><path d="M8 2v4h4" stroke="#718096" fill="none"/></svg>`
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function generateDirectoryListingHtml(files: any[], requestPath: string, fullPath: string) {
const sortedFiles = sortFiles(files, fullPath)
let html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${requestPath || 'Home'} - File Browser</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-size: 13px;
background: #ffffff;
color: #212121;
line-height: 1.4;
}
.window {
height: 100vh;
display: flex;
flex-direction: column;
border: 1px solid #dadce0;
}
.titlebar {
height: 32px;
background: #f8f9fa;
border-bottom: 1px solid #dadce0;
display: flex;
align-items: center;
padding: 0 12px;
color: #5f6368;
font-size: 13px;
font-weight: 500;
}
.toolbar {
height: 40px;
background: #ffffff;
border-bottom: 1px solid #e8eaed;
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
}
.nav-button {
width: 24px;
height: 24px;
border: 1px solid #dadce0;
background: #ffffff;
cursor: pointer;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #5f6368;
}
.nav-button:hover {
background: #f8f9fa;
border-color: #c4c7c5;
}
.nav-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.address-bar {
flex: 1;
height: 24px;
border: 1px solid #dadce0;
background: #ffffff;
padding: 0 8px;
margin: 0 8px;
border-radius: 2px;
font-size: 13px;
color: #202124;
}
.address-bar:focus {
outline: none;
border-color: #1a73e8;
}
.search-box {
width: 200px;
height: 24px;
border: 1px solid #dadce0;
background: #ffffff;
padding: 0 8px;
border-radius: 2px;
font-size: 13px;
}
.search-box:focus {
outline: none;
border-color: #1a73e8;
}
.main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.file-list-container {
flex: 1;
overflow: auto;
background: #ffffff;
}
.list-header {
height: 24px;
background: #f8f9fa;
border-bottom: 1px solid #e8eaed;
display: flex;
align-items: center;
padding: 0 12px;
font-size: 11px;
font-weight: 600;
color: #5f6368;
text-transform: uppercase;
letter-spacing: 0.8px;
}
.header-name {
flex: 1;
min-width: 200px;
}
.header-modified {
width: 140px;
padding: 0 8px;
}
.header-size {
width: 80px;
text-align: right;
padding: 0 8px;
}
.file-list {
user-select: none;
}
.file-item {
height: 20px;
display: flex;
align-items: center;
padding: 0 12px;
text-decoration: none;
color: #202124;
border-bottom: 1px solid transparent;
cursor: pointer;
}
.file-item:hover {
background: #f8f9fa;
}
.file-item:active {
background: #e8f0fe;
}
.file-icon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.file-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
}
.file-modified {
width: 140px;
padding: 0 8px;
font-size: 12px;
color: #5f6368;
white-space: nowrap;
}
.file-size {
width: 80px;
text-align: right;
padding: 0 8px;
font-size: 12px;
color: #5f6368;
white-space: nowrap;
}
.directory .file-name {
font-weight: 500;
}
.parent-dir {
border-bottom: 1px solid #e8eaed;
}
.parent-dir .file-name {
color: #1a73e8;
font-weight: 500;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #5f6368;
}
.empty-state-icon {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.status-bar {
height: 22px;
background: #f8f9fa;
border-top: 1px solid #e8eaed;
display: flex;
align-items: center;
padding: 0 12px;
font-size: 12px;
color: #5f6368;
}
@media (max-width: 768px) {
.header-modified, .file-modified {
display: none;
}
.header-size, .file-size {
width: 60px;
}
.search-box {
width: 150px;
}
}
</style>
</head>
<body>
<div class="window">
<div class="titlebar">
File Browser - ${requestPath || 'Home'}
</div>
<div class="toolbar">
<button class="nav-button" onclick="history.back()" title="Back">←</button>
<button class="nav-button" onclick="history.forward()" title="Forward">→</button>
<button class="nav-button" onclick="location.reload()" title="Refresh">↻</button>
<input type="text" class="address-bar" value="${requestPath || '/'}" readonly>
<input type="text" class="search-box" placeholder="Search..." id="searchBox">
</div>
<div class="main-content">
<div class="file-list-container">
<div class="list-header">
<div class="header-name">Name</div>
<div class="header-modified">Date modified</div>
<div class="header-size">Size</div>
</div>
<div class="file-list" id="fileList">
`
if (requestPath !== '/' && requestPath !== '') {
const parentPath = path.dirname(requestPath)
html += `
<a href="${encodeFilePath(parentPath === '.' ? '/' : parentPath)}" class="file-item parent-dir">
<div class="file-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 6h12l-1.5-1.5H3.5L2 6z" fill="#1a73e8"/>
<path d="M2 6v6a1 1 0 001 1h10a1 1 0 001-1V6H2z" fill="#4285f4"/>
</svg>
</div>
<div class="file-name">.. (Up to parent directory)</div>
<div class="file-modified"></div>
<div class="file-size"></div>
</a>
`
}
if (sortedFiles.length === 0) {
html += `
<div class="empty-state">
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z"/>
</svg>
<div>This folder is empty</div>
</div>
`
} else {
sortedFiles.forEach((fileInfo: any) => {
const filePath = path.join(requestPath, fileInfo.name)
const icon = getFileIcon(fileInfo.name, fileInfo.isDirectory)
const sizeDisplay = fileInfo.isDirectory ? '' : formatFileSize(fileInfo.size || 0)
const modifiedDate = fileInfo.mtime
? new Date(fileInfo.mtime).toLocaleDateString('en-US', {
year: '2-digit',
month: 'numeric',
day: 'numeric'
}) +
' ' +
new Date(fileInfo.mtime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
: ''
html += `
<a href="${encodeFilePath(filePath)}" class="file-item ${fileInfo.isDirectory ? 'directory' : ''}">
<div class="file-icon">${icon}</div>
<div class="file-name">${fileInfo.name}</div>
<div class="file-modified">${modifiedDate}</div>
<div class="file-size">${sizeDisplay}</div>
</a>
`
})
}
const itemCount = sortedFiles.length + (requestPath !== '/' && requestPath !== '' ? 1 : 0)
html += `
</div>
</div>
</div>
<div class="status-bar">
${itemCount} items
</div>
</div>
<script>
// Search functionality
document.getElementById('searchBox').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const fileItems = document.querySelectorAll('.file-item:not(.parent-dir)');
fileItems.forEach(item => {
const fileName = item.querySelector('.file-name').textContent.toLowerCase();
if (fileName.includes(searchTerm)) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
});
// Handle keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.key === 'F5') {
e.preventDefault();
location.reload();
}
});
</script>
</body>
</html>
`
return html
}
function serveDirectory(res: http.ServerResponse, filePath: fs.PathLike, requestPath: any) {
function sortFiles(files: string[], fullPath: string): any[] {
return files
.map(file => {
const filePath = path.join(fullPath, file)
let stats
try {
stats = fs.statSync(filePath)
} catch (err) {
stats = null
}
return {
name: file,
isDirectory: stats ? stats.isDirectory() : false,
size: stats ? stats.size : 0,
mtime: stats ? stats.mtime : null
}
})
.sort((a, b) => {
// Directories first, then files
if (a.isDirectory && !b.isDirectory) return -1
if (!a.isDirectory && b.isDirectory) return 1
// Then alphabetical by name
return a.name.localeCompare(b.name)
})
}
function generateErrorPage(errorCode: number, errorMessage: string): string {
const errorDescriptions: { [key: number]: { title: string; description: string } } = {
404: {
title: 'Page Not Found',
description: 'The file or directory you are looking for could not be found.'
},
500: {
title: 'Server Error',
description: 'An internal server error occurred while processing your request.'
},
403: {
title: 'Access Denied',
description: 'You do not have permission to access this resource.'
}
}
const error = errorDescriptions[errorCode] || {
title: 'Unknown Error',
description: 'An unexpected error occurred.'
}
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error ${errorCode}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.error-container {
background: rgba(255, 255, 255, 0.95);
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
padding: 60px 40px;
text-align: center;
max-width: 500px;
width: 100%;
}
.error-icon {
font-size: 80px;
margin-bottom: 20px;
opacity: 0.8;
}
.error-code {
font-size: 48px;
font-weight: 700;
color: #e74c3c;
margin-bottom: 10px;
}
.error-title {
font-size: 24px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 15px;
}
.error-description {
font-size: 16px;
color: #7f8c8d;
line-height: 1.6;
margin-bottom: 30px;
}
.back-button {
display: inline-block;
padding: 12px 24px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
text-decoration: none;
border-radius: 25px;
font-weight: 500;
transition: transform 0.3s, box-shadow 0.3s;
}
.back-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(79, 172, 254, 0.4);
}
@media (max-width: 480px) {
.error-container {
padding: 40px 30px;
}
.error-icon {
font-size: 60px;
}
.error-code {
font-size: 36px;
}
.error-title {
font-size: 20px;
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">${errorCode === 404 ? '🔍' : errorCode === 500 ? '⚠️' : errorCode === 403 ? '🚫' : '❌'}</div>
<div class="error-code">${errorCode}</div>
<div class="error-title">${error.title}</div>
<div class="error-description">${error.description}</div>
<a href="/" class="back-button">🏠 Go Home</a>
</div>
</body>
</html>
`
}
function serveDirectory(res: http.ServerResponse, filePath: fs.PathLike, requestPath: string) {
fs.readdir(filePath, (err, files) => {
if (err) {
res.writeHead(500)
res.end('Error listing directory contents')
logger.error(`Error reading directory ${filePath}:`, err)
res.writeHead(500, { 'Content-Type': 'text/html' })
res.end(generateErrorPage(500, 'Error listing directory contents'))
} else {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(generateDirectoryListingHtml(files, requestPath))
res.end(generateDirectoryListingHtml(files, requestPath, filePath.toString()))
}
})
}
function serveFile(res: http.ServerResponse, filePath: fs.PathLike) {
const readStream = fs.createReadStream(filePath)
const ext = path.extname(filePath.toString()).toLowerCase()
const contentTypes: { [key: string]: string } = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.md': 'text/markdown'
}
const contentType = contentTypes[ext] || 'application/octet-stream'
res.setHeader('Content-Type', contentType)
readStream.pipe(res)
readStream.on('error', () => {
res.writeHead(500)
res.end('Error reading file')
readStream.on('error', err => {
logger.error(`Error reading file ${filePath}:`, err)
res.writeHead(500, { 'Content-Type': 'text/html' })
res.end(generateErrorPage(500, 'Error reading file'))
})
}
@@ -62,8 +668,8 @@ class WebServer {
initServer(): void {
this.#server = http.createServer((req, res) => {
const requestPath = req.url?.split('?')[0]
const filePath = path.join(this.#config.webServerPath, decodeURIComponent(requestPath || ''))
const requestPath = req.url?.split('?')[0] || '/'
const filePath = path.join(this.#config.webServerPath, decodeURIComponent(requestPath))
try {
const stats = fs.statSync(filePath)
@@ -73,8 +679,9 @@ class WebServer {
serveFile(res, filePath)
}
} catch (err) {
res.writeHead(404)
res.end('404 Not Found')
logger.error(`File not found: ${filePath}`)
res.writeHead(404, { 'Content-Type': 'text/html' })
res.end(generateErrorPage(404, 'The requested file or directory was not found'))
}
})
}