<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyper-overlay</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #e6edf3; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; cursor: pointer; }
.layout { display: flex; height: 100vh; }
/* Sidebar */
.sidebar { width: 280px; min-width: 280px; background: #161b22; border-right: 1px solid #30363d; display: flex; flex-direction: column; overflow: hidden; }
.sidebar-header { padding: 16px; border-bottom: 1px solid #30363d; }
.sidebar-header h1 { font-size: 16px; font-weight: 600; }
.sidebar-header .key { font-size: 11px; font-family: monospace; color: #8b949e; cursor: pointer; display: block; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
.sidebar-header .key:hover { color: #58a6ff; }
.stats-row { display: flex; padding: 8px 16px; gap: 16px; border-bottom: 1px solid #30363d; font-size: 12px; }
.stats-row .s { color: #8b949e; }
.stats-row .v { font-weight: 600; margin-left: 4px; }
.stats-row .v.online { color: #3fb950; }
.stats-row .v.warn { color: #d29922; }
.sidebar-section { padding: 8px 0; border-bottom: 1px solid #21262d; }
.sidebar-section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #8b949e; padding: 4px 16px; }
.sync-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 16px; font-size: 13px; }
.sync-item:hover { background: #1c2128; }
.sync-item .path { font-family: monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.sync-item .badge { font-size: 10px; color: #8b949e; background: #21262d; padding: 1px 6px; border-radius: 8px; margin-left: 8px; }
.sync-item .rm { color: #f85149; cursor: pointer; margin-left: 4px; font-size: 14px; display: none; }
.sync-item:hover .rm { display: inline; }
.add-row { display: flex; gap: 4px; padding: 8px 16px; }
.add-row input { flex: 1; background: #0d1117; border: 1px solid #30363d; color: #e6edf3; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-family: monospace; }
.add-row button { background: #238636; border: none; color: #fff; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
/* Conflicts */
.conflict-item { padding: 6px 16px; font-size: 12px; border-left: 3px solid #d29922; margin: 4px 16px; background: #1c1d21; border-radius: 0 4px 4px 0; }
.conflict-item .cpath { font-family: monospace; }
.conflict-item .cactions { margin-top: 4px; display: flex; gap: 4px; }
.conflict-item .cactions button { padding: 2px 8px; border-radius: 4px; border: 1px solid #30363d; background: #21262d; color: #e6edf3; cursor: pointer; font-size: 11px; }
.conflict-item .cactions .accept { border-color: #1f6feb; background: #1f6feb; }
.conflict-item .cactions .keep { border-color: #238636; background: #238636; }
/* Main content area */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Breadcrumb */
.breadcrumb { padding: 8px 16px; border-bottom: 1px solid #30363d; background: #161b22; font-size: 13px; display: flex; align-items: center; gap: 4px; }
.breadcrumb span { color: #8b949e; }
.breadcrumb a:hover { text-decoration: underline; }
/* Toolbar */
.toolbar { padding: 8px 16px; border-bottom: 1px solid #21262d; display: flex; gap: 8px; align-items: center; }
.toolbar button { background: #21262d; border: 1px solid #30363d; color: #e6edf3; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.toolbar button:hover { background: #30363d; }
.toolbar .upload-btn { background: #238636; border-color: #238636; }
.toolbar .upload-btn:hover { background: #2ea043; }
/* File list */
.file-list { flex: 1; overflow-y: auto; }
.file-entry { display: flex; align-items: center; padding: 6px 16px; font-size: 13px; cursor: pointer; border-bottom: 1px solid #21262d; }
.file-entry:hover { background: #161b22; }
.file-entry.selected { background: #1c2128; border-left: 2px solid #58a6ff; }
.file-entry .icon { width: 20px; margin-right: 8px; text-align: center; font-size: 14px; }
.file-entry .name { flex: 1; font-family: monospace; font-size: 13px; }
.file-entry .meta { font-size: 11px; color: #8b949e; }
.file-entry .del { color: #f85149; cursor: pointer; margin-left: 8px; display: none; font-size: 14px; }
.file-entry:hover .del { display: inline; }
/* Viewer */
.viewer { border-top: 1px solid #30363d; max-height: 50vh; overflow: auto; background: #0d1117; }
.viewer-header { padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; font-size: 12px; }
.viewer-header .close-btn { cursor: pointer; color: #8b949e; font-size: 16px; }
.viewer-header .close-btn:hover { color: #e6edf3; }
.viewer-content { padding: 16px; }
.viewer-content pre { font-family: monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-wrap: break-word; }
.viewer-content img { max-width: 100%; max-height: 400px; }
.viewer-content .binary { color: #8b949e; font-style: italic; }
.empty { color: #8b949e; font-style: italic; padding: 24px 16px; text-align: center; }
.hidden { display: none !important; }
/* Drag drop overlay */
.drop-zone { position: absolute; inset: 0; background: rgba(35, 134, 54, 0.15); border: 2px dashed #238636; display: none; align-items: center; justify-content: center; font-size: 18px; color: #3fb950; z-index: 10; }
.drop-zone.active { display: flex; }
</style>
</head>
<body>
<div class="layout">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h1>hyper-overlay</h1>
<span class="key" id="key" onclick="copyKey()">loading...</span>
</div>
<div class="stats-row">
<div><span class="s">Peers</span><span class="v" id="peers">-</span></div>
<div><span class="s">Files</span><span class="v" id="file-count">-</span></div>
<div><span class="s">Conflicts</span><span class="v" id="conflict-count">-</span></div>
</div>
<div class="sidebar-section">
<h3>Sync Folders</h3>
<div id="sync-list"></div>
<div class="add-row">
<input type="text" id="add-path" placeholder="/path/to/folder">
<button onclick="addSync()">+</button>
</div>
</div>
<div class="sidebar-section">
<h3>Conflicts</h3>
<div id="conflict-list"><div class="empty" style="padding:8px 16px">None</div></div>
</div>
</div>
<!-- Main -->
<div class="main" style="position:relative">
<div class="drop-zone" id="drop-zone">Drop files here to upload</div>
<div class="breadcrumb" id="breadcrumb">
<a onclick="browse('')">root</a>
</div>
<div class="toolbar">
<button class="upload-btn" onclick="document.getElementById('file-input').click()">Upload File</button>
<button onclick="scanFolder()">Scan & Sync</button>
<button onclick="refreshBrowse()">Refresh</button>
<input type="file" id="file-input" style="display:none" multiple onchange="uploadFiles(this.files)">
</div>
<div class="file-list" id="file-list">
<div class="empty">Select a sync folder to browse</div>
</div>
<div class="viewer hidden" id="viewer">
<div class="viewer-header">
<span id="viewer-name"></span>
<span class="close-btn" onclick="closeViewer()">×</span>
</div>
<div class="viewer-content" id="viewer-content"></div>
</div>
</div>
</div>
<script>
const BASE = window.location.origin
let currentDir = ''
function formatSize (b) {
if (b < 1024) return b + ' B'
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB'
return (b / 1073741824).toFixed(2) + ' GB'
}
async function api (path, opts) {
const res = await fetch(BASE + path, opts)
return res.json()
}
function copyKey () {
const key = document.getElementById('key').title
if (key && navigator.clipboard) navigator.clipboard.writeText(key)
}
// ── Status refresh ────────────────────────────────────────
async function refreshStatus () {
try {
const s = await api('/api/status')
document.getElementById('key').textContent = s.key ? s.key.slice(0, 20) + '...' : '-'
document.getElementById('key').title = s.key || ''
const peersEl = document.getElementById('peers')
peersEl.textContent = s.peers
peersEl.className = 'v' + (s.peers > 0 ? ' online' : '')
document.getElementById('file-count').textContent = s.files
const cEl = document.getElementById('conflict-count')
cEl.textContent = s.conflicts
cEl.className = 'v' + (s.conflicts > 0 ? ' warn' : '')
// Syncs
const syncs = await api('/api/syncs')
const sl = document.getElementById('sync-list')
if (syncs.length === 0) {
sl.innerHTML = '<div class="empty" style="padding:4px 16px;font-size:12px">No folders</div>'
} else {
sl.innerHTML = syncs.map(s =>
'<div class="sync-item" onclick="browse(\'' + esc(s.path) + '\')">' +
'<span class="path">' + esc(s.path) + '</span>' +
'<span class="badge">' + s.strategy + '</span>' +
'<span class="rm" onclick="event.stopPropagation();removeSync(\'' + esc(s.path) + '\')">×</span>' +
'</div>'
).join('')
// Auto-browse first folder if not browsing
if (!currentDir && syncs.length > 0) browse(syncs[0].path)
}
// Conflicts
const conflicts = await api('/api/conflicts')
const cl = document.getElementById('conflict-list')
if (conflicts.length === 0) {
cl.innerHTML = '<div class="empty" style="padding:8px 16px">None</div>'
} else {
cl.innerHTML = conflicts.map(c =>
'<div class="conflict-item">' +
'<div class="cpath">' + esc(c.path) + '</div>' +
'<div class="cactions">' +
'<button class="accept" onclick="resolve(\'' + esc(c.path) + '\',\'overwrite\')">Accept Remote</button>' +
'<button class="keep" onclick="resolve(\'' + esc(c.path) + '\',\'keep-both\')">Keep Both</button>' +
'<button onclick="resolve(\'' + esc(c.path) + '\',\'skip\')">Skip</button>' +
'</div></div>'
).join('')
}
} catch (e) { console.error('status error:', e) }
}
// ── File browser ──────────────────────────────────────────
async function browse (dir) {
currentDir = dir
updateBreadcrumb(dir)
try {
const data = await api('/api/browse?dir=' + encodeURIComponent(dir))
const fl = document.getElementById('file-list')
if (!data.entries || data.entries.length === 0) {
fl.innerHTML = '<div class="empty">Empty folder</div>'
return
}
fl.innerHTML = data.entries.map(e => {
const icon = e.type === 'dir' ? '\uD83D\uDCC1' : '\uD83D\uDCC4'
const click = e.type === 'dir'
? 'onclick="browse(\'' + esc(e.path) + '\')"'
: 'onclick="viewFile(\'' + esc(e.path) + '\')"'
return '<div class="file-entry" ' + click + '>' +
'<span class="icon">' + icon + '</span>' +
'<span class="name">' + esc(e.name) + '</span>' +
'<span class="meta">' + formatSize(e.size) + '</span>' +
(e.type === 'file' ? '<span class="del" onclick="event.stopPropagation();deleteFile(\'' + esc(e.path) + '\')">×</span>' : '') +
'</div>'
}).join('')
} catch (e) {
document.getElementById('file-list').innerHTML = '<div class="empty">Error: ' + e.message + '</div>'
}
}
function updateBreadcrumb (dir) {
const bc = document.getElementById('breadcrumb')
if (!dir) {
bc.innerHTML = '<a onclick="browse(\'\')">root</a>'
return
}
const parts = dir.split('/').filter(Boolean)
let html = ''
let accum = ''
for (let i = 0; i < parts.length; i++) {
accum += '/' + parts[i]
const p = accum
html += '<span>/</span><a onclick="browse(\'' + esc(p) + '\')">' + esc(parts[i]) + '</a>'
}
bc.innerHTML = html
}
function refreshBrowse () {
if (currentDir) browse(currentDir)
}
async function scanFolder () {
if (!currentDir) return alert('Browse to a folder first')
const btn = event.target
btn.textContent = 'Scanning...'
btn.disabled = true
try {
const res = await api('/api/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dir: currentDir })
})
btn.textContent = 'Scan & Sync'
btn.disabled = false
if (res.error) return alert('Scan failed: ' + res.error)
alert('Scanned ' + res.scanned + ' files, indexed ' + res.indexed + ' new')
refreshStatus()
refreshBrowse()
} catch (e) {
btn.textContent = 'Scan & Sync'
btn.disabled = false
alert('Scan failed: ' + e.message)
}
}
// ── File viewer ───────────────────────────────────────────
async function viewFile (filePath) {
const viewer = document.getElementById('viewer')
const content = document.getElementById('viewer-content')
const nameEl = document.getElementById('viewer-name')
nameEl.textContent = filePath.split('/').pop()
content.innerHTML = '<div class="empty">Loading...</div>'
viewer.classList.remove('hidden')
try {
const ext = filePath.split('.').pop().toLowerCase()
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']
if (imageExts.includes(ext)) {
content.innerHTML = '<img src="/api/read?file=' + encodeURIComponent(filePath) + '">'
return
}
const data = await api('/api/read?file=' + encodeURIComponent(filePath))
if (data.error) {
content.innerHTML = '<div class="empty">' + esc(data.error) + '</div>'
} else if (data.type === 'text') {
content.innerHTML = '<pre>' + esc(data.content) + '</pre>'
} else {
content.innerHTML = '<div class="binary">Binary file (' + formatSize(data.size) + ')</div>'
}
} catch (e) {
content.innerHTML = '<div class="empty">Error: ' + e.message + '</div>'
}
}
function closeViewer () {
document.getElementById('viewer').classList.add('hidden')
}
// ── File upload ───────────────────────────────────────────
async function uploadFiles (fileList) {
if (!currentDir) return alert('Browse to a folder first')
for (const file of fileList) {
try {
// Pass the File directly — fetch streams the body without
// materializing it as a single ArrayBuffer/Uint8Array. This
// dodges the ~2 GB renderer-side cap that broke uploads of
// multi-GB files (#15). The server consumes it streamingly
// via overlay.putStream; memory peak ≈ one FastCDC chunk.
const res = await fetch(BASE + '/api/upload?dir=' + encodeURIComponent(currentDir) + '&name=' + encodeURIComponent(file.name), {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file,
// Required by Chrome/Firefox/Safari for streamed request bodies.
// Harmless on other browsers (option is ignored).
duplex: 'half'
})
const data = await res.json()
if (data.error) alert('Upload failed: ' + data.error)
} catch (e) {
alert('Upload error: ' + e.message)
}
}
document.getElementById('file-input').value = ''
refreshBrowse()
}
// Drag and drop
const mainEl = document.querySelector('.main')
const dropZone = document.getElementById('drop-zone')
mainEl.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('active') })
mainEl.addEventListener('dragleave', (e) => { if (e.target === dropZone) dropZone.classList.remove('active') })
mainEl.addEventListener('drop', async (e) => {
e.preventDefault()
dropZone.classList.remove('active')
if (e.dataTransfer.files.length > 0) await uploadFiles(e.dataTransfer.files)
})
// ── File delete ───────────────────────────────────────────
async function deleteFile (filePath) {
if (!confirm('Delete ' + filePath.split('/').pop() + '?')) return
await fetch(BASE + '/api/file', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath })
})
refreshBrowse()
}
// ── Sync management ───────────────────────────────────────
async function addSync () {
const input = document.getElementById('add-path')
const p = input.value.trim()
if (!p) return
await api('/api/syncs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: p, strategy: 'prompt' })
})
input.value = ''
refreshStatus()
}
async function removeSync (p) {
await api('/api/syncs', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: p })
})
refreshStatus()
}
async function resolve (p, choice) {
await api('/api/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: p, choice })
})
refreshStatus()
}
function esc (s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
}
// Init
refreshStatus()
setInterval(refreshStatus, 3000)
</script>
</body>
</html>