#!/usr/bin/env node
/**
* Mini web example for hyper-issues
*
* SSR issue tracker dashboard. Pages:
* GET / → list issues + create form
* GET /:id → show issue + comments + comment form
* POST /create → create an issue
* POST /:id/close → close an issue
* POST /:id/reopen → reopen an issue
* POST /:id/comment → add a comment
*
* Usage: node examples/web.js [--port 9096] [--storage <path>]
*/
import http from 'http'
import Corestore from 'corestore'
import b4a from 'b4a'
import { HyperIssues } from '../index.js'
const args = process.argv.slice(2)
function getFlag (name) {
const i = args.indexOf('--' + name)
return i !== -1 && args[i + 1] ? args[i + 1] : null
}
const port = parseInt(getFlag('port')) || 9096
const storagePath = getFlag('storage') || './.hyper-issues-web'
const store = new Corestore(storagePath)
await store.ready()
const tracker = new HyperIssues(store, { namespace: 'issues' })
await tracker.ready()
await tracker.initializeDefaultLabels()
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
function collectBody (req) {
return new Promise((resolve, reject) => {
const parts = []
req.on('data', chunk => parts.push(chunk))
req.on('end', () => resolve(b4a.concat(parts).toString('utf8')))
req.on('error', reject)
})
}
const httpServer = http.createServer(async (req, res) => {
const url = req.url.split('?')[0]
try {
if (req.method === 'GET' && url === '/') {
const filter = (req.url.split('?')[1] || '').match(/status=([^&]+)/)
const status = filter ? filter[1] : 'open'
const issues = await tracker.list({ status, sort: 'newest' })
const counts = await tracker.counts()
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(renderIndex(issues, counts, status))
return
}
const showMatch = url.match(/^\/(\d+)$/)
if (req.method === 'GET' && showMatch) {
const id = parseInt(showMatch[1])
const issue = await tracker.get(id)
if (!issue) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not found')
return
}
const comments = await tracker.getComments(id)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(renderIssue(issue, comments))
return
}
if (req.method === 'POST' && url === '/create') {
const params = new URLSearchParams(await collectBody(req))
const title = params.get('title')
const body = params.get('body') || ''
const author = params.get('author') || 'anon'
if (title) {
const id = await tracker.create({ title, body, author })
res.writeHead(303, { Location: '/' + id })
res.end()
return
}
res.writeHead(400)
res.end('title required')
return
}
const closeMatch = url.match(/^\/(\d+)\/(close|reopen)$/)
if (req.method === 'POST' && closeMatch) {
const id = parseInt(closeMatch[1])
const status = closeMatch[2] === 'close' ? 'closed' : 'open'
await tracker.update(id, { status }, 'web')
res.writeHead(303, { Location: '/' + id })
res.end()
return
}
const commentMatch = url.match(/^\/(\d+)\/comment$/)
if (req.method === 'POST' && commentMatch) {
const id = parseInt(commentMatch[1])
const params = new URLSearchParams(await collectBody(req))
const body = params.get('body')
const author = params.get('author') || 'anon'
if (body) await tracker.comment(id, { body, author })
res.writeHead(303, { Location: '/' + id })
res.end()
return
}
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not found')
} catch (err) {
console.error(err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end(err.message)
}
})
function styles () {
return `
body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 800px; margin: 0 auto; }
h1 { color: #58a6ff; }
h2 { font-size: 1rem; color: #c9d1d9; margin-top: 0; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
.card h2 { margin: 0 0 0.5rem; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; }
.row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid #21262d; }
.row:last-child { border-bottom: none; }
.label { color: #8b949e; }
.filter { display: inline-block; padding: 0.3rem 0.6rem; margin-right: 0.3rem; border-radius: 4px; background: #21262d; color: #c9d1d9; }
.filter.active { background: #1f6feb; color: white; }
.issue { padding: 0.6rem 0.5rem; border-bottom: 1px solid #21262d; }
.issue:last-child { border-bottom: none; }
.issue .id { color: #8b949e; font-size: 0.8rem; }
.issue .title { color: #c9d1d9; font-weight: bold; }
.issue.closed .title { color: #8b949e; text-decoration: line-through; }
.issue .meta { color: #6e7681; font-size: 0.75rem; margin-top: 0.2rem; }
.tag { display: inline-block; padding: 0.05rem 0.4rem; margin-right: 0.3rem; border-radius: 999px; font-size: 0.7rem; background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; }
.badge-open { background: #2ea04322; color: #3fb950; border: 1px solid #2ea04344; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
.badge-closed { background: #8957e522; color: #a371f7; border: 1px solid #8957e544; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.75rem; }
input, textarea, button { background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.6rem; border-radius: 4px; font-family: monospace; font-size: 0.85rem; }
textarea { width: 100%; min-height: 4em; }
input { width: 100%; box-sizing: border-box; }
button { background: #21262d; cursor: pointer; }
button:hover { background: #30363d; }
button.btn-primary { background: #1f6feb; border-color: #1f6feb; color: white; }
button.btn-primary:hover { background: #1158c7; }
.field { margin-bottom: 0.5rem; }
.field label { display: block; color: #8b949e; font-size: 0.75rem; margin-bottom: 0.2rem; }
.comment { padding: 0.6rem 0; border-bottom: 1px solid #21262d; }
.comment:last-child { border-bottom: none; }
.comment .meta { color: #8b949e; font-size: 0.75rem; }
.empty { color: #484f58; font-style: italic; padding: 1rem 0; }
form { margin: 0; }
`
}
function renderIndex (issues, counts, currentStatus) {
return `<!DOCTYPE html>
<html><head><title>hyper-issues</title><style>${styles()}</style></head><body>
<h1><a href="/">hyper-issues</a></h1>
<div class="card">
<h2>Filter</h2>
<a class="filter ${currentStatus === 'open' ? 'active' : ''}" href="/?status=open">${counts.open} open</a>
<a class="filter ${currentStatus === 'closed' ? 'active' : ''}" href="/?status=closed">${counts.closed} closed</a>
<a class="filter ${currentStatus === 'all' ? 'active' : ''}" href="/?status=all">${counts.total} all</a>
</div>
<div class="card">
<h2>${issues.length} ${currentStatus} issue${issues.length === 1 ? '' : 's'}</h2>
${issues.length === 0 ? '<div class="empty">(no issues)</div>' : issues.map(i => `
<div class="issue ${i.status}">
<div>
<span class="id">#${i.id}</span>
<a class="title" href="/${i.id}">${escapeHtml(i.title)}</a>
${(i.labels || []).map(l => '<span class="tag">' + escapeHtml(l) + '</span>').join(' ')}
</div>
<div class="meta">by ${escapeHtml(i.author)} · ${new Date(i.createdAt).toLocaleString()}</div>
</div>`).join('')}
</div>
<div class="card">
<h2>Create issue</h2>
<form action="/create" method="POST">
<div class="field"><label>Title</label><input name="title" required></div>
<div class="field"><label>Body</label><textarea name="body"></textarea></div>
<div class="field"><label>Author</label><input name="author" value="anon"></div>
<button class="btn-primary" type="submit">Create</button>
</form>
</div>
</body></html>`
}
function renderIssue (issue, comments) {
const badge = issue.status === 'closed'
? '<span class="badge-closed">closed</span>'
: '<span class="badge-open">open</span>'
return `<!DOCTYPE html>
<html><head><title>hyper-issues #${issue.id}</title><style>${styles()}</style></head><body>
<h1><a href="/">hyper-issues</a> / #${issue.id}</h1>
<div class="card">
<h2>Issue</h2>
<div style="display: flex; justify-content: space-between; align-items: center">
<div>
<strong>${escapeHtml(issue.title)}</strong>
${badge}
</div>
<form action="/${issue.id}/${issue.status === 'open' ? 'close' : 'reopen'}" method="POST">
<button type="submit">${issue.status === 'open' ? 'Close' : 'Reopen'}</button>
</form>
</div>
<div class="meta" style="color:#8b949e;font-size:0.75rem;margin-top:0.5rem">
by ${escapeHtml(issue.author)} · ${new Date(issue.createdAt).toLocaleString()}
</div>
${(issue.labels || []).length > 0 ? '<div style="margin-top:0.5rem">' + issue.labels.map(l => '<span class="tag">' + escapeHtml(l) + '</span>').join(' ') + '</div>' : ''}
${issue.body ? '<div style="margin-top:0.8rem;white-space:pre-wrap;color:#c9d1d9">' + escapeHtml(issue.body) + '</div>' : ''}
</div>
<div class="card">
<h2>${comments.length} Comment${comments.length === 1 ? '' : 's'}</h2>
${comments.length === 0 ? '<div class="empty">(no comments yet)</div>' : comments.map(c => `
<div class="comment">
<div class="meta">${escapeHtml(c.author)} · ${new Date(c.createdAt).toLocaleString()}${c.editedAt ? ' (edited)' : ''}</div>
<div style="white-space:pre-wrap;margin-top:0.3rem">${escapeHtml(c.body)}</div>
</div>`).join('')}
</div>
<div class="card">
<h2>Add comment</h2>
<form action="/${issue.id}/comment" method="POST">
<div class="field"><label>Body</label><textarea name="body" required></textarea></div>
<div class="field"><label>Author</label><input name="author" value="anon"></div>
<button class="btn-primary" type="submit">Comment</button>
</form>
</div>
</body></html>`
}
httpServer.listen(port, () => {
console.log('hyper-issues dashboard:')
console.log(' http://localhost:%d', port)
console.log(' storage: %s', storagePath)
console.log(' tracker key: %s', tracker.keyHex)
})