OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / dashboard / catalog.html

422 lines · html ref:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiveRelay Catalog</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%2316213e'/%3E%3Cpath d='M50 10 C 54 38 60 44 88 50 C 60 56 54 62 50 90 C 46 62 40 56 12 50 C 40 44 46 38 50 10 Z' fill='%23f5c842'/%3E%3C/svg%3E">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#0e1118;--card:#151a26;--card-border:#222b3e;
  --green:#3fb950;--orange:#f0a64a;--cyan:#58a6ff;--red:#f47067;--purple:#b266ff;--pink:#ff6699;
  --text:#d6dde8;--text-dim:#8493a8;--text-bright:#fff;
  --mono:'SF Mono','Cascadia Code','Fira Code','Consolas',monospace;
  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
}
html{font-size:14px}
body{background:var(--bg) radial-gradient(1100px 500px at 50% -150px,#131a2b,transparent);color:var(--text);font-family:var(--sans);min-height:100vh;padding:1rem}

.topbar{
  background:var(--card);border-bottom:1px solid var(--card-border);
  padding:1rem 2rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
  position:sticky;top:0;z-index:100;margin:-1rem -1rem 1.5rem -1rem;
}
.topbar-logo{display:flex;align-items:center;gap:.75rem;text-decoration:none}
.hive-logo{width:32px;height:32px;flex-shrink:0}
.topbar h1{font-size:1.2rem;color:var(--text-bright);font-weight:600}
.topbar-brand{display:flex;flex-direction:column;line-height:1.15}
.topbar-sub{font-size:.58rem;color:var(--text-dim);letter-spacing:.6px;text-transform:uppercase}
.topbar-nav{display:flex;gap:.25rem;margin-left:1.5rem;flex-wrap:wrap}
.topbar-nav a{
  color:var(--text);text-decoration:none;padding:.4rem .85rem;border-radius:4px;
  font-size:.9rem;transition:all .15s;
}
.topbar-nav a:hover{background:rgba(255,255,255,.08);color:var(--text-bright)}
.topbar-nav a.active{color:#10131b;background:#f5c842;font-weight:600}

.container{max-width:1400px;margin:0 auto}
.page-title{color:var(--text-bright);font-size:1.6rem;margin-bottom:.25rem}
.page-subtitle{color:var(--text-dim);margin-bottom:1.5rem}

.toolbar{
  background:var(--card);border:1px solid var(--card-border);border-radius:8px;
  padding:1rem;margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;
}
.search-input{
  flex:1;min-width:200px;background:#0d1526;color:var(--text);
  border:1px solid var(--card-border);border-radius:4px;padding:.55rem .8rem;
  font-family:var(--mono);font-size:.9rem;
}
.search-input:focus{outline:none;border-color:var(--cyan)}
.filter-chip{
  background:#0d1526;border:1px solid var(--card-border);color:var(--text);
  padding:.4rem .85rem;border-radius:20px;cursor:pointer;font-size:.85rem;
  transition:all .15s;
}
.filter-chip:hover{border-color:var(--cyan)}
.filter-chip.active{background:var(--cyan);color:#000;border-color:var(--cyan);font-weight:600}

.stats-row{
  display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.75rem;
  margin-bottom:1.5rem;
}
.stat-card{
  background:var(--card);border:1px solid var(--card-border);border-radius:6px;
  padding:.9rem 1.1rem;
}
.stat-label{color:var(--text-dim);font-size:.75rem;text-transform:uppercase;letter-spacing:.5px}
.stat-value{color:var(--text-bright);font-size:1.6rem;font-weight:600;margin-top:.25rem;font-family:var(--mono)}

.catalog-grid{
  display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;
}
.catalog-card{
  background:var(--card);border:1px solid var(--card-border);border-radius:8px;
  padding:1.1rem;transition:all .15s;display:flex;flex-direction:column;gap:.5rem;
}
.catalog-card:hover{border-color:var(--cyan);transform:translateY(-2px)}
.card-header{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem}
.card-title{color:var(--text-bright);font-weight:600;font-size:1rem;line-height:1.3;flex:1;word-break:break-word}
.card-type-badge{
  padding:.15rem .55rem;border-radius:10px;font-size:.7rem;font-weight:600;
  text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;
}
.type-app{background:rgba(88,166,255,.2);color:var(--cyan)}
.type-drive{background:rgba(63,185,80,.2);color:var(--green)}
.type-dataset{background:rgba(178,102,255,.2);color:var(--purple)}
.type-media{background:rgba(255,102,153,.2);color:var(--pink)}
.type-resource{background:rgba(240,166,74,.2);color:var(--orange)}

.card-description{color:var(--text-dim);font-size:.85rem;line-height:1.5;min-height:2.5rem}
.card-meta{display:flex;flex-wrap:wrap;gap:.5rem;font-size:.75rem;color:var(--text-dim)}
.card-meta span{background:rgba(255,255,255,.05);padding:.15rem .5rem;border-radius:3px}
.card-key{
  font-family:var(--mono);font-size:.7rem;color:var(--text-dim);
  background:#0d1526;padding:.4rem .55rem;border-radius:4px;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;
}
.card-key:hover{color:var(--cyan)}
.card-actions{display:flex;gap:.5rem;margin-top:.5rem}
.btn{
  flex:1;background:rgba(88,166,255,.12);color:var(--cyan);border:1px solid rgba(88,166,255,.3);
  padding:.45rem .75rem;border-radius:4px;cursor:pointer;font-size:.8rem;
  text-align:center;text-decoration:none;transition:all .15s;
}
.btn:hover{background:var(--cyan);color:#000}
.btn-secondary{background:rgba(255,255,255,.05);color:var(--text);border-color:var(--card-border)}
.btn-secondary:hover{background:rgba(255,255,255,.1);color:var(--text-bright)}

.empty-state{
  background:var(--card);border:1px dashed var(--card-border);border-radius:8px;
  padding:3rem;text-align:center;color:var(--text-dim);
}
.empty-state h3{color:var(--text);margin-bottom:.5rem}

.pagination{
  display:flex;justify-content:center;gap:.5rem;margin-top:1.5rem;align-items:center;
}
.page-info{color:var(--text-dim);font-size:.85rem;padding:0 1rem}

.toast{
  position:fixed;bottom:1.5rem;right:1.5rem;background:var(--card);
  border:1px solid var(--green);color:var(--green);padding:.75rem 1.25rem;
  border-radius:4px;font-size:.9rem;opacity:0;transition:opacity .2s;pointer-events:none;
}
.toast.show{opacity:1}
</style>
</head>
<body>
<div class="topbar">
  <a href="/dashboard" class="topbar-logo">
    <svg class="hive-logo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
      <defs><linearGradient id="sparkg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#ffe066"/><stop offset="100%" stop-color="#f5a623"/></linearGradient></defs>
      <path d="M50 6 C 54.5 36, 61 42.5, 92 50 C 61 57.5, 54.5 64, 50 94 C 45.5 64, 39 57.5, 8 50 C 39 42.5, 45.5 36, 50 6 Z" fill="url(#sparkg)"/>
      <path d="M79 10 C 80.5 19, 82.5 21, 91 23 C 82.5 25, 80.5 27, 79 36 C 77.5 27, 75.5 25, 67 23 C 75.5 21, 77.5 19, 79 10 Z" fill="#ffe066" opacity=".9"/>
    </svg>
    <div class="topbar-brand"><h1>HiveRelay Catalog</h1><div class="topbar-sub">browser-ready seeded content</div></div>
  </a>
  <nav class="topbar-nav">
    <a href="/dashboard">Dashboard</a>
    <a href="/catalog" class="active">Catalog</a>
    <a href="/network">Network</a>
    <a href="/leaderboard">Leaderboard</a>
    <a href="/payments">Payments</a>
    <a href="/calculator">Calculator</a>
    <a href="/docs">Docs</a>
  </nav>
</div>

<div class="container">
  <h2 class="page-title">Content Catalog</h2>
  <p class="page-subtitle">Browse apps, drives, datasets, and media seeded across the HiveRelay network.</p>

  <div class="stats-row" id="stats-row">
    <div class="stat-card"><div class="stat-label">Total</div><div class="stat-value" id="stat-total">—</div></div>
    <div class="stat-card"><div class="stat-label">Apps</div><div class="stat-value" id="stat-apps">—</div></div>
    <div class="stat-card"><div class="stat-label">Drives</div><div class="stat-value" id="stat-drives">—</div></div>
    <div class="stat-card"><div class="stat-label">Datasets</div><div class="stat-value" id="stat-datasets">—</div></div>
    <div class="stat-card"><div class="stat-label">Media</div><div class="stat-value" id="stat-media">—</div></div>
  </div>

  <div class="toolbar">
    <input type="text" class="search-input" id="search" placeholder="Search by name, description, key, or category...">
    <button type="button" class="filter-chip active" data-filter="all">All</button>
    <button type="button" class="filter-chip" data-filter="app">Apps</button>
    <button type="button" class="filter-chip" data-filter="drive">Drives</button>
    <button type="button" class="filter-chip" data-filter="dataset">Datasets</button>
    <button type="button" class="filter-chip" data-filter="media">Media</button>
    <button type="button" class="filter-chip" data-filter="resource">Resources</button>
  </div>

  <div class="catalog-grid" id="catalog-grid"></div>

  <div class="pagination" id="pagination" style="display:none">
    <button type="button" class="btn btn-secondary" id="prev-page">← Previous</button>
    <span class="page-info" id="page-info">Page 1</span>
    <button type="button" class="btn btn-secondary" id="next-page">Next →</button>
  </div>
</div>

<div class="toast" id="toast">Copied to clipboard!</div>

<script>
const state = {
  items: [],
  filtered: [],
  filter: 'all',
  search: '',
  page: 1,
  pageSize: 24,
  stats: { app: 0, drive: 0, dataset: 0, media: 0, resource: 0, total: 0 }
}
const REQUEST_TIMEOUT_MS = 10000
let catalogRefreshBusy = false

function fetchWithTimeout (path, opts = {}) {
  const options = { ...opts }
  if (typeof AbortController !== 'function') return fetch(path, options)
  const controller = new AbortController()
  const timeout = setTimeout(() => { controller.abort() }, REQUEST_TIMEOUT_MS)
  options.signal = controller.signal
  return fetch(path, options).finally(() => {
    clearTimeout(timeout)
  })
}

async function loadCatalog () {
  try {
    const res = await fetchWithTimeout('/catalog.json?pageSize=200', { cache: 'no-store' })
    const data = await res.json()
    // Support both v2 ({apps:[],drives:[]...}) and paginated ({items:[]}) shapes
    let items = []
    if (Array.isArray(data.items)) {
      items = data.items
    } else {
      items = [
        ...(data.apps || []),
        ...(data.drives || []),
        ...(data.datasets || []),
        ...(data.media || []),
        ...(data.resources || [])
      ]
    }
    state.items = items
    countStats()
    applyFilter()
  } catch (err) {
    renderEmptyState(document.getElementById('catalog-grid'), 'Could not load catalog', err && err.message ? err.message : 'unknown')
  }
}

function refreshCatalog (force) {
  if (catalogRefreshBusy) return
  if (force !== true && document.hidden === true) return
  catalogRefreshBusy = true
  loadCatalog().finally(() => {
    catalogRefreshBusy = false
  })
}

function countStats () {
  const s = { app: 0, drive: 0, dataset: 0, media: 0, resource: 0, total: state.items.length }
  for (const item of state.items) {
    const t = item.type || 'app'
    if (s[t] !== undefined) s[t]++
  }
  state.stats = s
  document.getElementById('stat-total').textContent = s.total
  document.getElementById('stat-apps').textContent = s.app
  document.getElementById('stat-drives').textContent = s.drive
  document.getElementById('stat-datasets').textContent = s.dataset
  document.getElementById('stat-media').textContent = s.media
}

function applyFilter () {
  const search = state.search.toLowerCase().trim()
  state.filtered = state.items.filter(item => {
    const type = item.type || 'app'
    if (state.filter !== 'all' && type !== state.filter) return false
    if (!search) return true
    const haystack = [
      item.name, item.description, item.appKey, item.key,
      ...catalogCategories(item.categories), item.author
    ].filter(Boolean).join(' ').toLowerCase()
    return haystack.includes(search)
  })
  state.page = 1
  renderGrid()
}

function renderGrid () {
  const grid = document.getElementById('catalog-grid')
  clearNode(grid)
  if (state.filtered.length === 0) {
    renderEmptyState(grid, 'No items found', 'Try a different filter or search.')
    document.getElementById('pagination').style.display = 'none'
    return
  }

  const start = (state.page - 1) * state.pageSize
  const pageItems = state.filtered.slice(start, start + state.pageSize)

  for (const item of pageItems) grid.appendChild(renderCard(item))

  // Pagination
  const totalPages = Math.ceil(state.filtered.length / state.pageSize)
  const pag = document.getElementById('pagination')
  if (totalPages > 1) {
    pag.style.display = 'flex'
    document.getElementById('page-info').textContent = `Page ${state.page} / ${totalPages}`
    document.getElementById('prev-page').disabled = state.page <= 1
    document.getElementById('next-page').disabled = state.page >= totalPages
  } else {
    pag.style.display = 'none'
  }
}

function renderCard (item) {
  const type = normalizeCatalogType(item.type)
  const key = String(item.appKey || item.key || '')
  const gatewayUrl = key ? `/v1/hyper/${encodeURIComponent(key)}/` : '#'

  const card = makeEl('div', 'catalog-card')
  const header = appendEl(card, 'div', 'card-header')
  appendEl(header, 'div', 'card-title', item.name || 'Unnamed')
  appendEl(header, 'span', `card-type-badge type-${type}`, type)
  appendEl(card, 'div', 'card-description', item.description || 'No description available.')

  const categories = catalogCategories(item.categories).slice(0, 3)
  if (item.version || item.author || categories.length) {
    const meta = appendEl(card, 'div', 'card-meta')
    if (item.version) appendEl(meta, 'span', '', 'v' + String(item.version))
    if (item.author) appendEl(meta, 'span', '', 'by ' + String(item.author))
    categories.forEach(c => appendEl(meta, 'span', '', c))
  }

  const keyEl = appendEl(card, 'div', 'card-key', key || '(no key)')
  keyEl.dataset.key = key
  keyEl.title = 'Click to copy'
  keyEl.addEventListener('click', () => copyToClipboard(key))

  const actions = appendEl(card, 'div', 'card-actions')
  const browse = appendEl(actions, 'a', 'btn', 'Browse Files')
  browse.href = gatewayUrl
  browse.target = '_blank'
  browse.rel = 'noopener noreferrer'
  const copy = appendEl(actions, 'button', 'btn btn-secondary copy-key-btn', 'Copy Key')
  copy.type = 'button'
  copy.dataset.key = key
  copy.addEventListener('click', () => copyToClipboard(key))

  return card
}

function normalizeCatalogType (type) {
  const t = String(type || 'app').toLowerCase()
  return ['app', 'drive', 'dataset', 'media', 'resource'].includes(t) ? t : 'resource'
}

function catalogCategories (categories) {
  if (!Array.isArray(categories)) return []
  return categories.filter(c => c !== null && c !== undefined).map(c => String(c))
}

function clearNode (node) {
  while (node.firstChild) node.removeChild(node.firstChild)
  node.textContent = ''
}

function makeEl (tag, className, text) {
  const el = document.createElement(tag)
  if (className) el.className = className
  if (text !== undefined) el.textContent = String(text)
  return el
}

function appendEl (parent, tag, className, text) {
  const el = makeEl(tag, className, text)
  parent.appendChild(el)
  return el
}

function renderEmptyState (parent, title, message) {
  clearNode(parent)
  const empty = appendEl(parent, 'div', 'empty-state')
  appendEl(empty, 'h3', '', title)
  appendEl(empty, 'p', '', message)
  return empty
}

function showCopyToast (message) {
  const toast = document.getElementById('toast')
  toast.textContent = message || 'Copied to clipboard!'
  toast.classList.add('show')
  setTimeout(() => toast.classList.remove('show'), 1500)
}

function copyToClipboard (text) {
  if (!text) return
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) {
    showCopyToast('Clipboard unavailable')
    return
  }
  navigator.clipboard.writeText(text)
    .then(() => showCopyToast('Copied to clipboard!'))
    .catch(() => showCopyToast('Copy failed'))
}

// Event listeners
document.getElementById('search').addEventListener('input', (e) => {
  state.search = e.target.value
  applyFilter()
})

document.querySelectorAll('.filter-chip').forEach(chip => {
  chip.addEventListener('click', () => {
    document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'))
    chip.classList.add('active')
    state.filter = chip.dataset.filter
    applyFilter()
  })
})

document.getElementById('prev-page').addEventListener('click', () => {
  if (state.page > 1) { state.page--; renderGrid() }
})
document.getElementById('next-page').addEventListener('click', () => {
  const totalPages = Math.ceil(state.filtered.length / state.pageSize)
  if (state.page < totalPages) { state.page++; renderGrid() }
})

// Auto-refresh every 30s
refreshCatalog(true)
setInterval(() => { refreshCatalog(false) }, 30_000)
document.addEventListener('visibilitychange', () => {
  if (document.hidden === true) return
  refreshCatalog(true)
})
</script>
</body>
</html>