OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues / examples / web.js

255 lines · javascript ref:
#!/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

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)
})