OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / web / js / app.js

3376 lines · javascript ref:
/**
 * Hypernote — Web Application
 *
 * Hash-based SPA router + view management
 * Routes: #/ | #/:notebook | #/:notebook/:noteId | #/:notebook/:noteId/edit | #/search?q=... | #/profile
 */

;(function () {
  // ==================== THEME ====================

  function applyTheme (theme) {
    var valid = ['light', 'dark', 'dark-blue']
    if (valid.indexOf(theme) === -1) theme = 'dark-blue'
    document.documentElement.setAttribute('data-theme', theme)
    localStorage.setItem('hn-theme', theme)
  }

  // Apply saved theme immediately (before profile loads)
  applyTheme(localStorage.getItem('hn-theme') || 'dark')

  // ==================== LIGHTBOX ====================

  var lightboxState = { images: [], index: 0, overlay: null }

  function openLightbox (galleryId, index) {
    // Collect all images in this gallery
    var items = document.querySelectorAll('.gallery-item[data-gallery="' + galleryId + '"]')
    lightboxState.images = []
    for (var i = 0; i < items.length; i++) {
      var img = items[i].querySelector('img')
      var cap = items[i].querySelector('.gallery-caption')
      lightboxState.images.push({
        src: img.src,
        alt: img.alt || '',
        caption: cap ? cap.textContent : ''
      })
    }
    lightboxState.index = index || 0
    renderLightbox()
  }

  function renderLightbox () {
    var img = lightboxState.images[lightboxState.index]
    if (!img) return closeLightbox()

    if (!lightboxState.overlay) {
      lightboxState.overlay = document.createElement('div')
      lightboxState.overlay.className = 'lightbox-overlay'
      lightboxState.overlay.addEventListener('click', function (e) {
        if (e.target === lightboxState.overlay) closeLightbox()
      })
      document.body.appendChild(lightboxState.overlay)
      document.addEventListener('keydown', lightboxKeyHandler)
    }

    var total = lightboxState.images.length
    var idx = lightboxState.index
    var html = '<button class="lightbox-close" title="Close">&times;</button>'
    if (total > 1) {
      html += '<button class="lightbox-nav lightbox-prev" title="Previous">&lsaquo;</button>'
      html += '<button class="lightbox-nav lightbox-next" title="Next">&rsaquo;</button>'
      html += '<div class="lightbox-counter">' + (idx + 1) + ' / ' + total + '</div>'
    }
    html += '<img class="lightbox-img" src="' + img.src + '" alt="' + img.alt.replace(/"/g, '&quot;') + '">'
    if (img.caption) {
      html += '<div class="lightbox-caption">' + img.caption.replace(/</g, '&lt;') + '</div>'
    }
    lightboxState.overlay.innerHTML = html

    lightboxState.overlay.querySelector('.lightbox-close').addEventListener('click', closeLightbox)
    var prev = lightboxState.overlay.querySelector('.lightbox-prev')
    var next = lightboxState.overlay.querySelector('.lightbox-next')
    if (prev) prev.addEventListener('click', function (e) { e.stopPropagation(); lightboxNav(-1) })
    if (next) next.addEventListener('click', function (e) { e.stopPropagation(); lightboxNav(1) })
  }

  function lightboxNav (dir) {
    var len = lightboxState.images.length
    lightboxState.index = (lightboxState.index + dir + len) % len
    renderLightbox()
  }

  function closeLightbox () {
    if (lightboxState.overlay) {
      lightboxState.overlay.remove()
      lightboxState.overlay = null
      document.removeEventListener('keydown', lightboxKeyHandler)
    }
  }

  function lightboxKeyHandler (e) {
    if (e.key === 'Escape') closeLightbox()
    else if (e.key === 'ArrowLeft') lightboxNav(-1)
    else if (e.key === 'ArrowRight') lightboxNav(1)
  }

  // Delegated click handler for gallery items
  document.addEventListener('click', function (e) {
    var item = e.target.closest('.gallery-item')
    if (!item) return
    var galleryId = item.getAttribute('data-gallery')
    var index = parseInt(item.getAttribute('data-index'), 10)
    openLightbox(galleryId, index)
  })

  // ==================== HELPERS ====================

  function escapeHtml (s) {
    if (!s) return ''
    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
  }

  // Rewrite /api/ media URLs with basePath for reverse proxy or hyper:// mode
  function rewriteMediaUrls (content) {
    if (!content) return ''
    var bp = HnApi.getApiBase ? HnApi.getApiBase() : (HnApi.basePath || '')
    if (!bp) return content
    return content
      .replace(/src="(\/api\/)/g, 'src="' + bp + '/api/')
      .replace(/\]\((\/api\/)/g, '](' + bp + '/api/')
  }

  // ==================== HLS VIDEO PLAYERS ====================

  /**
   * Scan for .hn-video-player divs and upgrade them with HLS streaming.
   * Three-tier fallback: HLS (hls.js) → native HLS (Safari) → MP4 direct.
   * Call after any markdown is rendered into the DOM.
   */
  function initVideoPlayers () {
    var players = document.querySelectorAll('.hn-video-player:not([data-initialized])')
    for (var pi = 0; pi < players.length; pi++) {
      (function (el) {
        el.setAttribute('data-initialized', '1')
        var video = el.querySelector('video')
        if (!video) return

        var streamUrl = el.dataset.stream
        var fallbackUrl = el.dataset.fallback
        var statusUrl = el.dataset.status
        var hash = el.dataset.hash

        if (!hash) return // old-style URL without hash, keep MP4 fallback

        // Check processing status first
        if (statusUrl) {
          fetch(statusUrl)
            .then(function (r) { return r.json() })
            .then(function (status) {
              if (status.status === 'ready') {
                // Set poster if available
                if (status.poster) {
                  var bp = (HnApi.getApiBase ? HnApi.getApiBase() : HnApi.basePath) || ''
                  var nb = window._hnCurrentNotebook || '_'
                  video.poster = bp + '/api/notebooks/' + nb + '/media/' + status.poster
                }

                // Upgrade to HLS
                _initHLS(video, streamUrl, fallbackUrl)

                // Add quality badge
                if (status.levels && status.levels.length > 0) {
                  var badge = document.createElement('div')
                  badge.className = 'hn-video-quality'
                  badge.textContent = status.levels.map(function (l) { return l.name }).join(' / ')
                  badge.style.cssText = 'position:absolute;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#fff;padding:2px 8px;border-radius:4px;font-size:12px;pointer-events:none'
                  el.style.position = 'relative'
                  el.appendChild(badge)
                }
              } else if (status.status === 'processing') {
                // Show processing indicator
                var indicator = document.createElement('div')
                indicator.textContent = 'Processing video...'
                indicator.style.cssText = 'position:absolute;top:8px;left:8px;background:rgba(0,0,0,0.7);color:#ff0;padding:4px 10px;border-radius:4px;font-size:12px'
                el.style.position = 'relative'
                el.appendChild(indicator)

                // Poll until ready
                _pollVideoStatus(el, video, statusUrl, streamUrl, fallbackUrl, indicator)
              }
              // status === 'failed' or unknown → keep MP4 fallback
            })
            .catch(function () {
              // Status check failed — keep MP4 fallback
            })
        }
      })(players[pi])
    }
  }

  function _initHLS (video, streamUrl, fallbackUrl) {
    if (streamUrl && typeof Hls !== 'undefined' && Hls.isSupported()) {
      var hls = new Hls({
        maxBufferLength: 30,
        maxMaxBufferLength: 60
      })
      hls.loadSource(streamUrl)
      hls.attachMedia(video)
      hls.on(Hls.Events.ERROR, function (event, data) {
        if (data.fatal) {
          // HLS failed — fall back to MP4
          video.src = fallbackUrl
        }
      })
    } else if (streamUrl && video.canPlayType('application/vnd.apple.mpegurl')) {
      // Safari native HLS
      video.src = streamUrl
    }
    // else: keep existing MP4 fallback <source> element
  }

  function _pollVideoStatus (el, video, statusUrl, streamUrl, fallbackUrl, indicator) {
    var attempts = 0
    var maxAttempts = 40
    var delay = 2000 // start at 2s
    var maxDelay = 30000 // cap at 30s

    function poll () {
      attempts++
      if (attempts > maxAttempts) {
        indicator.innerHTML = 'Processing timed out. <a href="#" style="color:var(--accent);text-decoration:underline">Retry</a>'
        indicator.querySelector('a').addEventListener('click', function (e) {
          e.preventDefault()
          attempts = 0
          delay = 2000
          indicator.textContent = 'Processing video...'
          poll()
        })
        return
      }
      fetch(statusUrl)
        .then(function (r) { return r.json() })
        .then(function (status) {
          if (status.status === 'ready') {
            indicator.remove()
            if (status.poster) {
              var bp = (HnApi.getApiBase ? HnApi.getApiBase() : HnApi.basePath) || ''
              var nb = window._hnCurrentNotebook || '_'
              video.poster = bp + '/api/notebooks/' + nb + '/media/' + status.poster
            }
            _initHLS(video, streamUrl, fallbackUrl)
          } else if (status.status === 'failed') {
            indicator.innerHTML = 'Processing failed. <a href="#" style="color:var(--accent);text-decoration:underline">Retry</a>'
            indicator.style.color = '#f66'
            indicator.querySelector('a').addEventListener('click', function (e) {
              e.preventDefault()
              attempts = 0
              delay = 2000
              indicator.textContent = 'Processing video...'
              indicator.style.color = '#ff0'
              poll()
            })
          } else {
            delay = Math.min(delay * 1.5, maxDelay)
            setTimeout(poll, delay)
          }
        })
        .catch(function () {
          delay = Math.min(delay * 1.5, maxDelay)
          setTimeout(poll, delay)
        })
    }

    setTimeout(poll, delay)
  }

  // ==================== STATE ====================

  var state = {
    notebooks: [],
    currentNotebook: null,
    notes: [],
    currentNote: null,
    editing: false,
    editingId: null, // null = new note
    canWrite: false,
    canAdmin: false,
    displayName: null
  }

  // ==================== DOM REFS ====================

  var $sidebar = document.getElementById('sidebar')
  var $sidebarToggle = document.getElementById('sidebar-toggle')
  var $breadcrumb = document.getElementById('breadcrumb')
  var $searchInput = document.getElementById('search-input')
  var $profileLink = document.getElementById('profile-link')

  var $notebookList = document.getElementById('notebook-list')

  var $btnNewNotebook = document.getElementById('btn-new-notebook')

  // Views
  var $viewHome = document.getElementById('view-home')
  var $viewNotebook = document.getElementById('view-notebook')
  var $viewNote = document.getElementById('view-note')
  var $viewEditor = document.getElementById('view-editor')
  var $viewSearch = document.getElementById('view-search')
  var $viewProfile = document.getElementById('view-profile')
  var $viewAdmin = document.getElementById('view-admin')
  var $adminContent = document.getElementById('admin-content')

  // Note view
  var $noteContent = document.getElementById('note-content')
  var $noteLinksPanel = document.getElementById('note-links-panel')

  // Editor
  var $editorTitle = document.getElementById('editor-title')
  var $editorTags = document.getElementById('editor-tags')
  var $editorContainer = document.getElementById('editor-container')
  var tuiEditor = null
  var spreadsheetInstance = null
  var calendarInstance = null

  // Notebook view
  var $notebookTitle = document.getElementById('notebook-title')
  var $notebookMeta = document.getElementById('notebook-meta')
  var $notebookNotes = document.getElementById('notebook-notes')

  // Search
  var $searchResults = document.getElementById('search-results')

  // Profile
  var $profileContent = document.getElementById('profile-content')

  // Modal
  var $modalOverlay = document.getElementById('modal-overlay')
  var $modalContent = document.getElementById('modal-content')

  // Auth (legacy)
  var $authSetup = document.getElementById('auth-setup')
  var $authTokenInput = document.getElementById('auth-token-input')
  var $authTokenSave = document.getElementById('auth-token-save')

  // ==================== ROUTER ====================

  function navigate (hash) {
    window.location.hash = hash
    // Auto-collapse sidebar on mobile after navigation
    if (window.innerWidth <= 768) {
      $sidebar.classList.add('collapsed')
    }
  }

  function parseRoute () {
    var hash = window.location.hash || '#/'
    var path = hash.substring(1) // strip #

    // Search: #/search?q=term
    if (path.startsWith('/search')) {
      var qi = path.indexOf('?q=')
      var q = qi >= 0 ? decodeURIComponent(path.substring(qi + 3)) : ''
      return { view: 'search', query: q }
    }

    // Profile: #/profile
    if (path === '/profile') {
      return { view: 'profile' }
    }

    // Admin: #/admin
    if (path === '/admin') {
      return { view: 'admin' }
    }

    // User profile: #/user/:username
    if (path.startsWith('/user/')) {
      return { view: 'user', username: decodeURIComponent(path.substring(6)) }
    }

    // Strip leading slash
    if (path.startsWith('/')) path = path.substring(1)
    if (!path) return { view: 'home' }

    var parts = path.split('/')

    // #/:notebook/:noteId/edit
    if (parts.length >= 3 && parts[2] === 'edit') {
      return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: parts[1] }
    }

    // #/:notebook/new or #/:notebook/new?type=spreadsheet|calendar
    if (parts.length >= 2 && parts[1].split('?')[0] === 'new') {
      var newPart = parts[1]
      var typeParam = null
      var tIdx = newPart.indexOf('?type=')
      if (tIdx >= 0) typeParam = newPart.substring(tIdx + 6)
      return { view: 'editor', notebook: decodeURIComponent(parts[0]), noteId: null, noteType: typeParam }
    }

    // #/:notebook/:noteId
    if (parts.length >= 2) {
      return { view: 'note', notebook: decodeURIComponent(parts[0]), noteId: parts[1] }
    }

    // #/:notebook — check for ?link= param
    var linkParam = null
    var qIdx = parts[0].indexOf('?link=')
    var nb = parts[0]
    if (qIdx >= 0) {
      linkParam = decodeURIComponent(nb.substring(qIdx + 6))
      nb = nb.substring(0, qIdx)
    }

    return { view: 'notebook', notebook: decodeURIComponent(nb), link: linkParam }
  }

  async function onRoute () {
    var route = parseRoute()

    switch (route.view) {
      case 'home':
        showView('home')
        state.currentNotebook = null
        state.currentNote = null
        state.notes = []
        updateBreadcrumb()
        renderNotebookList()
        loadRecentFeed()
        break

      case 'notebook':
        await loadNotebook(route.notebook, route.link)
        break

      case 'note':
        await loadNote(route.notebook, route.noteId)
        break

      case 'editor':
        await loadEditor(route.notebook, route.noteId, route.noteType)
        break

      case 'search':
        await doSearch(route.query)
        break

      case 'profile':
        await loadProfile()
        break

      case 'user':
        await loadUserProfile(route.username)
        break

      case 'admin':
        await loadAdminPanel()
        break
    }
  }

  // ==================== VIEW MANAGEMENT ====================

  function showView (name) {
    $viewHome.style.display = name === 'home' ? 'block' : 'none'
    $viewNotebook.style.display = name === 'notebook' ? 'block' : 'none'
    $viewNote.style.display = name === 'note' ? 'block' : 'none'
    $viewEditor.style.display = name === 'editor' ? 'block' : 'none'
    $viewSearch.style.display = name === 'search' ? 'block' : 'none'
    $viewProfile.style.display = name === 'profile' ? 'block' : 'none'
    $viewAdmin.style.display = name === 'admin' ? 'block' : 'none'
  }

  function updateBreadcrumb () {
    var parts = []
    parts.push('<a href="#/">Home</a>')

    if (state.currentNotebook) {
      parts.push('<span class="sep">/</span>')
      parts.push('<a href="#/' + encodeURIComponent(state.currentNotebook.slug) + '">' + esc(state.currentNotebook.name || state.currentNotebook.slug) + '</a>')
    }

    if (state.currentNote) {
      parts.push('<span class="sep">/</span>')
      parts.push('<span>' + esc(state.currentNote.title) + '</span>')
    }

    $breadcrumb.innerHTML = parts.join(' ')
  }

  function esc (s) {
    if (!s) return ''
    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
  }

  function updateProfileLink () {
    var username = HnIdentity.getUsername()
    var displayName = state.displayName
    if (username) {
      $profileLink.textContent = '@' + username
    } else if (displayName) {
      $profileLink.textContent = displayName
    } else {
      $profileLink.textContent = 'Set username'
    }
  }

  // ==================== SIDEBAR ====================

  async function loadNotebooks () {
    try {
      // Show only personal notebooks if authenticated
      var hasIdent = HnIdentity.hasIdentity() || HnApi.getToken()
      var data = hasIdent ? await HnApi.listMyNotebooks() : await HnApi.listNotebooks()
      state.notebooks = data.notebooks || []
      renderNotebookList()
    } catch (err) {
      if (err.status === 401) {
        // Fallback to public list
        try {
          var fallback = await HnApi.listNotebooks()
          state.notebooks = fallback.notebooks || []
          renderNotebookList()
        } catch (err2) {
          console.error('Failed to load notebooks:', err2)
        }
      } else {
        console.error('Failed to load notebooks:', err)
      }
    }
  }

  function renderNotebookList () {
    $notebookList.innerHTML = ''
    for (var i = 0; i < state.notebooks.length; i++) {
      var nb = state.notebooks[i]
      var isActive = state.currentNotebook && state.currentNotebook.slug === nb.slug

      var li = document.createElement('li')
      li.setAttribute('data-slug', nb.slug)
      li.className = 'nb-item' + (isActive ? ' expanded' : '')

      // Notebook row
      var row = document.createElement('div')
      row.className = 'nb-row' + (isActive ? ' active' : '')

      var arrow = document.createElement('span')
      arrow.className = 'nb-arrow'
      arrow.textContent = isActive ? '\u25BE' : '\u25B8'
      row.appendChild(arrow)

      var label = document.createElement('span')
      label.className = 'nb-label'
      label.textContent = nb.name || nb.slug
      if (nb.encrypted) {
        label.innerHTML += ' <span class="encrypted-badge">&#128274;</span>'
      }
      row.appendChild(label)

      var count = document.createElement('span')
      count.className = 'note-count'
      count.textContent = nb.noteCount || 0
      row.appendChild(count)

      row.addEventListener('click', (function (slug) {
        return function () { navigate('#/' + encodeURIComponent(slug)) }
      })(nb.slug))

      li.appendChild(row)

      // Nested note list (only for active notebook)
      if (isActive) {
        var noteUl = document.createElement('ul')
        noteUl.className = 'nb-notes'

        // "New note" buttons
        var newLi = document.createElement('li')
        newLi.className = 'nb-note-new'
        newLi.textContent = '+ New note'
        newLi.addEventListener('click', (function (slug) {
          return function (e) {
            e.stopPropagation()
            navigate('#/' + encodeURIComponent(slug) + '/new')
          }
        })(nb.slug))
        noteUl.appendChild(newLi)

        var newSheetLi = document.createElement('li')
        newSheetLi.className = 'nb-note-new'
        newSheetLi.textContent = '+ New spreadsheet'
        newSheetLi.addEventListener('click', (function (slug) {
          return function (e) {
            e.stopPropagation()
            navigate('#/' + encodeURIComponent(slug) + '/new?type=spreadsheet')
          }
        })(nb.slug))
        noteUl.appendChild(newSheetLi)

        var newCalLi = document.createElement('li')
        newCalLi.className = 'nb-note-new'
        newCalLi.textContent = '+ New calendar'
        newCalLi.addEventListener('click', (function (slug) {
          return function (e) {
            e.stopPropagation()
            navigate('#/' + encodeURIComponent(slug) + '/new?type=calendar')
          }
        })(nb.slug))
        noteUl.appendChild(newCalLi)

        for (var j = 0; j < state.notes.length; j++) {
          var note = state.notes[j]
          var noteLi = document.createElement('li')
          noteLi.className = 'nb-note'
          if (state.currentNote && state.currentNote.id === note.id) {
            noteLi.className += ' active'
          }
          noteLi.textContent = note.title || 'Untitled'
          noteLi.addEventListener('click', (function (slug, id) {
            return function (e) {
              e.stopPropagation()
              navigate('#/' + encodeURIComponent(slug) + '/' + id)
            }
          })(nb.slug, note.id))
          noteUl.appendChild(noteLi)
        }

        li.appendChild(noteUl)
      }

      $notebookList.appendChild(li)
    }
    // Re-apply permission visibility after re-rendering sidebar
    updateWriteUI()
  }

  async function loadNoteList (notebook) {
    try {
      var data = await HnApi.listNotes(notebook)
      state.notes = data.notes || []
      renderNotebookList()
    } catch (err) {
      if (err.status === 403 && err.data && err.data.code === 'NOTEBOOK_LOCKED') {
        await promptUnlock(notebook)
      } else {
        console.error('Failed to load notes:', err)
      }
    }
  }

  // ==================== USER PROFILE VIEW ====================

  async function loadUserProfile (username) {
    showView('home')
    state.currentNotebook = null
    state.currentNote = null
    updateBreadcrumb()

    var container = $viewHome
    container.innerHTML = '<h1>@' + escapeHtml(username) + '</h1><p class="text-muted">Loading notebooks...</p>'

    try {
      var data = await HnApi.getUserNotebooks(username)
      var notebooks = data.notebooks || []

      var html = '<h1>@' + escapeHtml(username) + '</h1>'

      if (notebooks.length === 0) {
        html += '<p class="text-muted">No public notebooks.</p>'
      } else {
        html += '<div class="recent-feed">'
        for (var i = 0; i < notebooks.length; i++) {
          var nb = notebooks[i]
          html += '<a href="#/' + encodeURIComponent(nb.slug) + '" class="feed-card">'
          html += '<div class="feed-card-header">'
          html += '<span class="feed-card-title">' + escapeHtml(nb.name || nb.slug) + '</span>'
          html += '<span class="feed-card-time">' + (nb.noteCount || 0) + ' notes</span>'
          html += '</div>'
          if (nb.description) {
            html += '<div class="feed-card-preview">' + escapeHtml(nb.description) + '</div>'
          }
          html += '</a>'
        }
        html += '</div>'
      }
      container.innerHTML = html
    } catch (err) {
      container.innerHTML = '<h1>@' + escapeHtml(username) + '</h1><p class="text-muted">Could not load notebooks.</p>'
    }
  }

  // ==================== RECENT FEED ====================

  async function loadRecentFeed () {
    var container = $viewHome
    container.innerHTML = '<h1>Recent Notes</h1><p class="text-muted" id="home-loading">Loading...</p>'
    var loadingEl = document.getElementById('home-loading')
    var loadTimer = setTimeout(function () {
      if (loadingEl) loadingEl.textContent = 'Syncing notes from P2P network, this may take a moment...'
    }, 3000)

    try {
      var data = await HnApi.recentNotes(20)
      clearTimeout(loadTimer)
      var notes = data.notes || []

      if (notes.length === 0) {
        container.innerHTML = '<h1>Recent Notes</h1><p class="text-muted">No public notes yet. Create a notebook to get started.</p>'
        return
      }

      var html = '<h1>Recent Notes</h1><div class="recent-feed">'
      for (var i = 0; i < notes.length; i++) {
        var n = notes[i]
        var href = '#/' + encodeURIComponent(n.notebook) + '/' + n.id
        var date = new Date(n.updatedAt)
        var timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
        var preview = (n.content || '').replace(/[#*`\[\]!<>]/g, '').substring(0, 200)
        if (preview.length >= 200) preview += '...'

        html += '<a href="' + href + '" class="feed-card">'
        html += '<div class="feed-card-header">'
        html += '<span class="feed-card-title">' + escapeHtml(n.title) + '</span>'
        html += '<span class="feed-card-time">' + timeStr + '</span>'
        html += '</div>'
        html += '<div class="feed-card-meta">'
        html += '<span class="feed-card-notebook">' + escapeHtml(n.notebookName) + '</span>'
        html += '<span class="feed-card-author">by ' + escapeHtml(n.ownerName || 'Anonymous') + '</span>'
        html += '</div>'
        if (preview) html += '<div class="feed-card-preview">' + escapeHtml(preview) + '</div>'
        if (n.tags && n.tags.length) {
          html += '<div class="feed-card-tags">'
          for (var t = 0; t < n.tags.length; t++) {
            html += '<span class="tag">' + escapeHtml(n.tags[t]) + '</span>'
          }
          html += '</div>'
        }
        html += '</a>'
      }
      html += '</div>'
      container.innerHTML = html
    } catch (err) {
      container.innerHTML = '<h1>Recent Notes</h1><p class="text-muted">Could not load recent notes.</p>'
    }
  }

  // ==================== NOTEBOOK VIEW ====================

  async function loadNotebook (slug, linkTitle) {
    try {
      var data = await HnApi.getNotebook(slug)
      state.currentNotebook = data.notebook || data
      state.currentNote = null
      showView('notebook')
      updateBreadcrumb()
      renderNotebookList()

      $notebookTitle.textContent = state.currentNotebook.name || slug
      var vis = state.currentNotebook.visibility || 'public'
      var meta = []
      meta.push((state.currentNotebook.noteCount || 0) + ' notes')
      if (state.currentNotebook.encrypted) meta.push('Encrypted')
      if (state.currentNotebook.description) meta.push(state.currentNotebook.description)
      $notebookMeta.innerHTML = escapeHtml(meta.join(' · '))
      if (state.canWrite) {
        var visBtn = document.createElement('button')
        visBtn.className = 'btn-sm btn-visibility'
        visBtn.textContent = vis === 'public' ? 'Public' : 'Private'
        visBtn.title = 'Toggle visibility'
        visBtn.addEventListener('click', async function () {
          var newVis = vis === 'public' ? 'private' : 'public'
          await HnApi.updateNotebook(slug, { visibility: newVis })
          state.currentNotebook.visibility = newVis
          vis = newVis
          visBtn.textContent = newVis === 'public' ? 'Public' : 'Private'
        })
        $notebookMeta.appendChild(document.createTextNode(' · '))
        $notebookMeta.appendChild(visBtn)
      }

      // Multi-writer share toggle (admin only)
      if (state.canAdmin) {
        var isShared = false
        try {
          var mwSt = await HnApi.multiwriterStatus()
          if (mwSt && mwSt.linked) {
            isShared = (mwSt.sharedSlugs || []).indexOf(slug) >= 0
            var shareBtn = document.createElement('button')
            shareBtn.className = 'btn-sm'
            shareBtn.style.cssText = 'margin-left:4px'
            shareBtn.textContent = isShared ? 'Shared' : 'Local'
            shareBtn.title = isShared ? 'Click to stop sharing via multi-writer' : 'Click to share via multi-writer'
            shareBtn.addEventListener('click', (function (currentSlug, currentlyShared) {
              return async function () {
                try {
                  await HnApi.shareNotebook(currentSlug, !currentlyShared)
                  loadNotebook(currentSlug)
                } catch (err) {
                  alert('Error: ' + (err.message || 'Failed to toggle sharing'))
                }
              }
            })(slug, isShared))
            $notebookMeta.appendChild(document.createTextNode(' · '))
            $notebookMeta.appendChild(shareBtn)
          }
        } catch (e) { /* multi-writer not available, skip */ }
      }

      $notebookNotes.innerHTML = '<p class="text-muted" id="nb-loading">Loading notes...</p>'
      var nbLoadTimer = setTimeout(function () {
        var el = document.getElementById('nb-loading')
        if (el) el.textContent = 'Syncing notes from P2P network, this may take a moment...'
      }, 3000)
      await loadNoteList(slug)
      clearTimeout(nbLoadTimer)
      renderNotebookCards(slug)

      // Handle wiki-link redirect
      if (linkTitle) {
        var match = state.notes.find(function (n) {
          return n.title && n.title.toLowerCase() === linkTitle.toLowerCase()
        })
        if (match) {
          navigate('#/' + encodeURIComponent(slug) + '/' + match.id)
        }
      }
    } catch (err) {
      if (err.status === 403 && err.data && err.data.code === 'NOTEBOOK_LOCKED') {
        state.currentNotebook = { slug: slug, name: slug }
        showView('notebook')
        updateBreadcrumb()
        renderNotebookList()
        $notebookTitle.textContent = slug
        $notebookMeta.textContent = 'Encrypted — unlock required'
        $notebookNotes.innerHTML = ''
        await promptUnlock(slug)
      } else {
        console.error('Failed to load notebook:', err)
        showView('home')
      }
    }
  }

  function renderNotebookCards (notebook) {
    $notebookNotes.innerHTML = ''
    for (var i = 0; i < state.notes.length; i++) {
      var note = state.notes[i]
      var card = document.createElement('div')
      card.className = 'note-card'

      // Hover actions (edit / delete) — only for writers
      if (state.canWrite) {
        var actions = document.createElement('div')
        actions.className = 'note-card-actions'
        var editBtn = document.createElement('button')
        editBtn.className = 'note-card-btn note-card-edit'
        editBtn.textContent = 'Edit'
        editBtn.title = 'Edit note'
        editBtn.addEventListener('click', (function (id) {
          return function (e) {
            e.stopPropagation()
            navigate('#/' + encodeURIComponent(notebook) + '/' + id + '/edit')
          }
        })(note.id))
        var delBtn = document.createElement('button')
        delBtn.className = 'note-card-btn note-card-delete'
        delBtn.textContent = 'Delete'
        delBtn.title = 'Delete note'
        delBtn.addEventListener('click', (function (id, title) {
          return function (e) {
            e.stopPropagation()
            if (!confirm('Delete "' + (title || 'Untitled') + '"? This cannot be undone.')) return
            HnApi.deleteNote(notebook, id).then(function () {
              loadNoteList(notebook).then(function () { renderNotebookCards(notebook) })
            }).catch(function (err) { alert('Error: ' + (err.message || 'Failed to delete')) })
          }
        })(note.id, note.title))
        actions.appendChild(editBtn)
        actions.appendChild(delBtn)
        card.appendChild(actions)
      }

      var h3 = document.createElement('h3')
      h3.textContent = note.title || 'Untitled'
      if (note.type && note.type !== 'markdown') {
        var typeBadge = document.createElement('span')
        typeBadge.className = 'note-type-badge'
        typeBadge.textContent = note.type
        h3.appendChild(document.createTextNode(' '))
        h3.appendChild(typeBadge)
      }
      card.appendChild(h3)

      if (note.content) {
        var snippet = document.createElement('div')
        snippet.className = 'snippet'
        if (note.type === 'spreadsheet') {
          snippet.textContent = 'Spreadsheet'
        } else if (note.type === 'calendar') {
          try {
            var cjson = JSON.parse(note.content)
            var evtCount = (cjson.events || []).length
            snippet.textContent = 'Calendar with ' + evtCount + ' event' + (evtCount !== 1 ? 's' : '')
          } catch (e) {
            snippet.textContent = 'Calendar'
          }
        } else {
          snippet.textContent = note.content.substring(0, 120) + (note.content.length > 120 ? '...' : '')
        }
        card.appendChild(snippet)
      }

      if (note.tags && note.tags.length > 0) {
        var tags = document.createElement('div')
        tags.className = 'tags'
        for (var j = 0; j < note.tags.length; j++) {
          var tag = document.createElement('span')
          tag.className = 'tag'
          tag.textContent = note.tags[j]
          tags.appendChild(tag)
        }
        card.appendChild(tags)
      }

      var meta = document.createElement('div')
      meta.className = 'meta'
      meta.textContent = new Date(note.updatedAt || note.createdAt).toLocaleDateString()
      card.appendChild(meta)

      card.addEventListener('click', (function (id) {
        return function () { navigate('#/' + encodeURIComponent(notebook) + '/' + id) }
      })(note.id))

      $notebookNotes.appendChild(card)
    }
  }

  // ==================== NOTE VIEW ====================

  async function loadNote (notebook, noteId) {
    try {
      // Show loading state immediately
      showView('note')
      $noteContent.className = 'markdown-body'
      $noteContent.innerHTML = '<p class="text-muted" id="note-loading">Loading...</p>'
      var noteLoadTimer = setTimeout(function () {
        var el = document.getElementById('note-loading')
        if (el) el.textContent = 'Syncing note from P2P network, this may take a moment...'
      }, 3000)

      // Load notebook context if needed
      if (!state.currentNotebook || state.currentNotebook.slug !== notebook) {
        var nbData = await HnApi.getNotebook(notebook)
        state.currentNotebook = nbData.notebook || nbData
        await loadNoteList(notebook)
      }

      var data = await HnApi.getNote(notebook, noteId)
      clearTimeout(noteLoadTimer)
      state.currentNote = data.note || data
      showView('note')
      updateBreadcrumb()
      renderNotebookList()

      // Render note content based on type
      var noteType = state.currentNote.type || 'markdown'
      $noteContent.className = 'markdown-body'
      $noteContent.innerHTML = '<h1>' + esc(state.currentNote.title) + '</h1>'
      if (state.currentNote.tags && state.currentNote.tags.length > 0) {
        var tagsHtml = '<div class="tags" style="margin-bottom:16px">'
        for (var i = 0; i < state.currentNote.tags.length; i++) {
          tagsHtml += '<span class="tag">' + esc(state.currentNote.tags[i]) + '</span> '
        }
        tagsHtml += '</div>'
        $noteContent.innerHTML += tagsHtml
      }

      if (noteType === 'spreadsheet') {
        var sheetContainer = document.createElement('div')
        sheetContainer.id = 'note-spreadsheet-view'
        $noteContent.appendChild(sheetContainer)
        try {
          var sheetData = JSON.parse(state.currentNote.content)
          jspreadsheet(sheetContainer, {
            data: sheetData.data || [[]],
            columns: sheetData.columns || undefined,
            editable: false,
            columnSorting: false,
            tableOverflow: true,
            tableWidth: '100%'
          })
        } catch (e) {
          sheetContainer.textContent = 'Failed to load spreadsheet data.'
        }
      } else if (noteType === 'calendar') {
        var calContainer = document.createElement('div')
        calContainer.id = 'note-calendar-view'
        $noteContent.appendChild(calContainer)
        try {
          var calData = JSON.parse(state.currentNote.content || '{}')
          var settings = calData.settings || {}
          var viewCal = new FullCalendar.Calendar(calContainer, {
            initialView: settings.defaultView || 'dayGridMonth',
            firstDay: settings.firstDay != null ? settings.firstDay : 1,
            headerToolbar: {
              left: 'prev,next today',
              center: 'title',
              right: 'dayGridMonth,timeGridWeek,timeGridDay'
            },
            events: (calData.events || []).map(function (ev) {
              return { id: ev.id, title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay, color: ev.color, extendedProps: { description: ev.description } }
            }),
            editable: false,
            height: 'auto'
          })
          viewCal.render()
        } catch (e) {
          calContainer.textContent = 'Failed to load calendar data.'
        }
      } else {
        // Markdown (default)
        // Set current notebook for markdown video URL construction
        window._hnCurrentNotebook = notebook
        $noteContent.innerHTML += window.renderMarkdown(rewriteMediaUrls(state.currentNote.content))

        // Upgrade video elements to HLS players
        initVideoPlayers()

        // Add media hover overlays for writers
        if (state.canWrite) addMediaOverlays()
      }

      $noteLinksPanel.style.display = 'none'
    } catch (err) {
      console.error('Failed to load note:', err)
      navigate('#/' + encodeURIComponent(notebook))
    }
  }

  /**
   * Add hover delete/remove overlays to images, gallery items, and videos in the note view.
   * On click, modifies the note markdown via API and re-renders.
   */
  function addMediaOverlays () {
    var notebook = state.currentNotebook && state.currentNotebook.slug
    var noteId = state.currentNote && state.currentNote.id
    if (!notebook || !noteId) return

    // Helper: save updated content and re-render
    function saveAndReload (newContent) {
      return HnApi.updateNote(notebook, noteId, { content: newContent }).then(function () {
        return loadNote(notebook, noteId)
      })
    }

    // Helper: extract the media URL path from a src attribute (strip basePath prefix)
    function srcToMarkdownUrl (src) {
      var bp = HnApi.basePath || ''
      if (bp && src.startsWith(bp)) return src.substring(bp.length)
      try { var u = new URL(src); return u.pathname.replace(bp, '') } catch (e) {}
      return src
    }

    // Helper: remove lines containing url from content, clean empty gallery blocks, save
    function removeMediaLine (url) {
      var content = state.currentNote.content
      var lines = content.split('\n')
      var found = false
      var filtered = lines.filter(function (line) {
        if (!found && line.indexOf(url) !== -1) { found = true; return false }
        return true
      })
      if (!found) return
      // Clean empty gallery blocks
      var cleaned = []
      for (var ci = 0; ci < filtered.length; ci++) {
        if (filtered[ci].trimStart() === '```gallery') {
          var nextReal = ci + 1
          while (nextReal < filtered.length && filtered[nextReal].trim() === '') nextReal++
          if (nextReal < filtered.length && filtered[nextReal].trimStart() === '```') {
            ci = nextReal; continue
          }
        }
        cleaned.push(filtered[ci])
      }
      saveAndReload(cleaned.join('\n'))
    }

    // Helper: remove a link line [text](url) or [[wikilink]]
    function removeLinkLine (url, text) {
      var content = state.currentNote.content
      // Try [text](url) pattern first
      var mdLink = '[' + text + '](' + url + ')'
      if (content.indexOf(mdLink) !== -1) {
        saveAndReload(content.replace(mdLink, ''))
        return
      }
      // Try [[wikilink]] pattern
      var wikiLink = '[[' + text + ']]'
      if (content.indexOf(wikiLink) !== -1) {
        saveAndReload(content.replace(wikiLink, ''))
        return
      }
      // Fallback: remove any line containing the url
      var lines = content.split('\n')
      var filtered = lines.filter(function (line) { return line.indexOf(url) === -1 })
      if (filtered.length !== lines.length) saveAndReload(filtered.join('\n'))
    }

    function addOverlay (el, buttons) {
      if (typeof buttons === 'string') {
        // Legacy: addOverlay(el, label, onClick)
        var lbl = buttons, cb = arguments[2]
        buttons = [{ label: lbl, className: 'media-hover-delete', onClick: cb }]
      }
      var overlay = document.createElement('div')
      overlay.className = 'media-hover-actions'
      for (var bi = 0; bi < buttons.length; bi++) {
        var btn = document.createElement('button')
        btn.className = 'media-hover-btn ' + (buttons[bi].className || 'media-hover-delete')
        btn.textContent = buttons[bi].label
        ;(function (click) {
          btn.addEventListener('click', function (e) {
            e.stopPropagation(); e.preventDefault(); click()
          })
        })(buttons[bi].onClick)
        overlay.appendChild(btn)
      }
      el.appendChild(overlay)
    }

    // Standalone images — wrap in container
    var imgs = $noteContent.querySelectorAll('.markdown-body > p > img, .markdown-body > img')
    for (var i = 0; i < imgs.length; i++) {
      var wrapper = document.createElement('div')
      wrapper.className = 'media-hover-wrap'
      imgs[i].parentNode.insertBefore(wrapper, imgs[i])
      wrapper.appendChild(imgs[i])
      ;(function (img) {
        addOverlay(wrapper, 'Delete', function () { removeMediaLine(srcToMarkdownUrl(img.src)) })
      })(imgs[i])
    }

    // Gallery containers — add toolbar for add/delete gallery
    var galleries = $noteContent.querySelectorAll('.gallery-masonry')
    for (var gmi = 0; gmi < galleries.length; gmi++) {
      ;(function (gallery, galleryIndex) {
        var toolbar = document.createElement('div')
        toolbar.className = 'gallery-toolbar'

        var addBtn = document.createElement('button')
        addBtn.className = 'media-hover-btn media-hover-edit'
        addBtn.textContent = 'Add Images'
        var delBtn = document.createElement('button')
        delBtn.className = 'media-hover-btn media-hover-delete'
        delBtn.textContent = 'Delete Gallery'
        toolbar.appendChild(addBtn)
        toolbar.appendChild(delBtn)
        gallery.appendChild(toolbar)

        // Add images to this gallery
        var addInput = document.createElement('input')
        addInput.type = 'file'
        addInput.accept = 'image/*'
        addInput.multiple = true
        addInput.style.display = 'none'
        gallery.appendChild(addInput)

        addBtn.addEventListener('click', function (e) {
          e.stopPropagation()
          addInput.click()
        })

        addInput.addEventListener('change', async function () {
          var files = addInput.files
          if (!files || files.length === 0) return
          var nb = notebook
          if (!nb) return

          var total = files.length
          showUploadProgress('Uploading 1 of ' + total + '...')
          var newLines = []
          for (var fi = 0; fi < files.length; fi++) {
            try {
              var fileNum = fi + 1
              var data = await uploadMedia(nb, files[fi], function (pct) {
                var overall = ((fi / total) + (pct / 100 / total)) * 100
                updateUploadProgress(overall, 'Uploading ' + fileNum + ' of ' + total + '...')
              })
              var name = files[fi].name.replace(/\.[^.]+$/, '')
              newLines.push('![' + name + '](' + data.url + ')')
            } catch (err) {
              alert('Failed to upload ' + files[fi].name + ': ' + err.message)
            }
          }
          hideUploadProgress()
          addInput.value = ''

          if (newLines.length === 0) return

          // Insert new image lines before the closing ``` of this gallery block
          var content = state.currentNote.content
          var lines = content.split('\n')
          var galleryIdx = 0
          var insertPos = -1
          for (var li = 0; li < lines.length; li++) {
            if (lines[li].trimStart() === '```gallery') {
              if (galleryIdx === galleryIndex) {
                // Find the closing ```
                for (var ci = li + 1; ci < lines.length; ci++) {
                  if (lines[ci].trimStart() === '```') {
                    insertPos = ci
                    break
                  }
                }
                break
              }
              galleryIdx++
            }
          }

          if (insertPos >= 0) {
            for (var ni = 0; ni < newLines.length; ni++) {
              lines.splice(insertPos + ni, 0, newLines[ni])
            }
            saveAndReload(lines.join('\n'))
          }
        })

        // Delete entire gallery
        delBtn.addEventListener('click', function (e) {
          e.stopPropagation()
          var items = gallery.querySelectorAll('.gallery-item')
          var count = items.length
          if (!confirm('Delete this gallery (' + count + ' images)? This cannot be undone.')) return

          var content = state.currentNote.content
          var lines = content.split('\n')
          var galleryIdx = 0
          var startLine = -1
          var endLine = -1
          for (var li = 0; li < lines.length; li++) {
            if (lines[li].trimStart() === '```gallery') {
              if (galleryIdx === galleryIndex) {
                startLine = li
                for (var ci = li + 1; ci < lines.length; ci++) {
                  if (lines[ci].trimStart() === '```') {
                    endLine = ci
                    break
                  }
                }
                break
              }
              galleryIdx++
            }
          }

          if (startLine >= 0 && endLine >= 0) {
            lines.splice(startLine, endLine - startLine + 1)
            saveAndReload(lines.join('\n'))
          }
        })
      })(galleries[gmi], gmi)
    }

    // Gallery items — inject overlay INSIDE (no wrapping, preserves masonry)
    var galleryItems = $noteContent.querySelectorAll('.gallery-item')
    for (var gi = 0; gi < galleryItems.length; gi++) {
      ;(function (item) {
        var gImg = item.querySelector('img')
        if (!gImg) return
        addOverlay(item, 'Remove', function () { removeMediaLine(srcToMarkdownUrl(gImg.src)) })
      })(galleryItems[gi])
    }

    // Videos — wrap in container
    var videos = $noteContent.querySelectorAll('video')
    for (var vi = 0; vi < videos.length; vi++) {
      var vWrapper = document.createElement('div')
      vWrapper.className = 'media-hover-wrap'
      videos[vi].parentNode.insertBefore(vWrapper, videos[vi])
      vWrapper.appendChild(videos[vi])
      ;(function (video) {
        var src = video.querySelector('source') ? video.querySelector('source').src : video.src || ''
        addOverlay(vWrapper, 'Delete', function () { removeMediaLine(srcToMarkdownUrl(src)) })
      })(videos[vi])
    }

    // Links — add edit/remove overlay on hover
    var links = $noteContent.querySelectorAll('.markdown-body a[href]')
    for (var li = 0; li < links.length; li++) {
      ;(function (link) {
        var linkWrap = document.createElement('span')
        linkWrap.className = 'link-hover-wrap'
        link.parentNode.insertBefore(linkWrap, link)
        linkWrap.appendChild(link)
        addOverlay(linkWrap, [
          {
            label: 'Edit',
            className: 'media-hover-edit',
            onClick: function () {
              // Close any existing link editor
              var existing = document.querySelector('.link-edit-popover')
              if (existing) existing.remove()

              var oldText = link.textContent || ''
              var oldHref = link.getAttribute('href') || ''
              var isWiki = link.classList.contains('wikilink')
              var content = state.currentNote.content

              // For auto-linked bare URLs, oldText === oldHref
              var isBareUrl = (oldText === oldHref || oldText === decodeURIComponent(oldHref))

              var pop = document.createElement('div')
              pop.className = 'link-edit-popover'
              pop.innerHTML =
                '<label>Name<input type="text" class="link-edit-name" value=""></label>' +
                (isWiki ? '' : '<label>URL<input type="text" class="link-edit-url" value=""></label>') +
                '<div class="link-edit-actions">' +
                '<button type="button" class="btn-primary link-edit-save">Save</button>' +
                '<button type="button" class="link-edit-cancel">Cancel</button>' +
                '</div>'
              linkWrap.appendChild(pop)

              var nameInput = pop.querySelector('.link-edit-name')
              var urlInput = pop.querySelector('.link-edit-url')
              nameInput.value = oldText
              if (urlInput) urlInput.value = oldHref
              nameInput.focus()

              pop.querySelector('.link-edit-cancel').addEventListener('click', function () { pop.remove() })
              pop.querySelector('.link-edit-save').addEventListener('click', function () {
                var newText = nameInput.value.trim()
                var newUrl = urlInput ? urlInput.value.trim() : null
                if (!newText) return

                // Line-level replacement to handle duplicate links correctly
                var lines = content.split('\n')
                // Find DOM position: count which occurrence of this link we are
                var allLinks = $noteContent.querySelectorAll('.markdown-body a[href]')
                var occurrenceIndex = 0
                for (var ai = 0; ai < allLinks.length; ai++) {
                  if (allLinks[ai] === link) break
                  if (isWiki && allLinks[ai].classList.contains('wikilink') && allLinks[ai].textContent === oldText) occurrenceIndex++
                  else if (!isWiki && !allLinks[ai].classList.contains('wikilink')) {
                    var aiHref = allLinks[ai].getAttribute('href') || ''
                    var aiText = allLinks[ai].textContent || ''
                    var aiBare = (aiText === aiHref || aiText === decodeURIComponent(aiHref))
                    if (isBareUrl && aiBare && aiHref === oldHref) occurrenceIndex++
                    else if (!isBareUrl && !aiBare && aiText === oldText && aiHref === oldHref) occurrenceIndex++
                  }
                }

                var needle, replacement
                if (isWiki) {
                  needle = '[[' + oldText + ']]'
                  replacement = '[[' + newText + ']]'
                } else if (isBareUrl) {
                  needle = oldHref
                  replacement = '[' + newText + '](' + (newUrl || oldHref) + ')'
                } else {
                  needle = '[' + oldText + '](' + oldHref + ')'
                  replacement = '[' + newText + '](' + (newUrl || oldHref) + ')'
                }

                var found = 0
                for (var li = 0; li < lines.length; li++) {
                  var pos = lines[li].indexOf(needle)
                  while (pos !== -1) {
                    if (found === occurrenceIndex) {
                      lines[li] = lines[li].substring(0, pos) + replacement + lines[li].substring(pos + needle.length)
                      saveAndReload(lines.join('\n'))
                      return
                    }
                    found++
                    pos = lines[li].indexOf(needle, pos + needle.length)
                  }
                }
                // Fallback: replace first occurrence
                saveAndReload(content.replace(needle, replacement))
              })
              // Save on Enter
              pop.addEventListener('keydown', function (e) {
                if (e.key === 'Enter') { e.preventDefault(); pop.querySelector('.link-edit-save').click() }
                if (e.key === 'Escape') { pop.remove() }
              })
            }
          },
          {
            label: 'Remove',
            className: 'media-hover-delete',
            onClick: function () {
              var href = link.getAttribute('href') || ''
              var text = link.textContent || ''
              removeLinkLine(href, text)
            }
          }
        ])
      })(links[li])
    }
  }

  // ==================== UPLOAD HELPERS ====================

  var uploadOverlay = null

  function showUploadProgress (label) {
    if (!uploadOverlay) {
      uploadOverlay = document.createElement('div')
      uploadOverlay.className = 'upload-progress-overlay'
      uploadOverlay.innerHTML = '<div class="upload-progress-box">' +
        '<div class="upload-progress-label"></div>' +
        '<div class="upload-progress-track"><div class="upload-progress-bar"></div></div>' +
        '<div class="upload-progress-pct">0%</div>' +
        '</div>'
      document.body.appendChild(uploadOverlay)
    }
    uploadOverlay.querySelector('.upload-progress-label').textContent = label || 'Uploading...'
    uploadOverlay.querySelector('.upload-progress-bar').style.width = '0%'
    uploadOverlay.querySelector('.upload-progress-pct').textContent = '0%'
    uploadOverlay.style.display = 'flex'
  }

  function updateUploadProgress (pct, label) {
    if (!uploadOverlay) return
    var p = Math.round(pct)
    uploadOverlay.querySelector('.upload-progress-bar').style.width = p + '%'
    uploadOverlay.querySelector('.upload-progress-pct').textContent = p + '%'
    if (label) uploadOverlay.querySelector('.upload-progress-label').textContent = label
  }

  function hideUploadProgress () {
    if (uploadOverlay) uploadOverlay.style.display = 'none'
  }

  function uploadMedia (notebookSlug, file, onProgress) {
    return new Promise(async function (resolve, reject) {
      var headers = {}
      var pubKey = HnIdentity.getPublicKey()
      if (pubKey) {
        try {
          var ch = await HnIdentity.getChallenge()
          var sig = await HnIdentity.signChallenge(ch.challenge)
          headers['X-Wirecore-PublicKey'] = pubKey
          headers['X-Wirecore-Signature'] = sig
          headers['X-Wirecore-Challenge'] = ch.challengeId
        } catch (e) {
          var token = localStorage.getItem('hn-token')
          if (token) headers['Authorization'] = 'Bearer ' + token
        }
      }

      var xhr = new XMLHttpRequest()
      xhr.open('POST', HnApi.basePath + '/api/notebooks/' + encodeURIComponent(notebookSlug) + '/media')
      xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
      for (var h in headers) xhr.setRequestHeader(h, headers[h])

      xhr.upload.onprogress = function (e) {
        if (e.lengthComputable && onProgress) {
          onProgress(e.loaded / e.total * 100)
        }
      }
      xhr.onload = function () {
        if (xhr.status >= 200 && xhr.status < 300) {
          try { resolve(JSON.parse(xhr.responseText)) }
          catch (e) { reject(new Error('Invalid response')) }
        } else {
          try {
            var err = JSON.parse(xhr.responseText)
            reject(new Error(err.error || 'Upload failed: ' + xhr.status))
          } catch (e) { reject(new Error('Upload failed: ' + xhr.status)) }
        }
      }
      xhr.onerror = function () { reject(new Error('Network error')) }
      xhr.send(file)
    })
  }

  // ==================== EDITOR ====================

  async function loadEditor (notebook, noteId, noteType) {
    // Write permission check — redirect read-only users
    if (!state.canWrite) {
      if (!await ensureIdentity()) return
      await refreshPermissions()
      if (!state.canWrite) {
        alert('You do not have write permissions.')
        if (noteId) {
          navigate('#/' + encodeURIComponent(notebook) + '/' + noteId)
        } else {
          navigate('#/' + encodeURIComponent(notebook))
        }
        return
      }
    }

    // Ensure notebook context
    if (!state.currentNotebook || state.currentNotebook.slug !== notebook) {
      try {
        var nbData = await HnApi.getNotebook(notebook)
        state.currentNotebook = nbData.notebook || nbData
        await loadNoteList(notebook)
      } catch (err) {
        console.error('Failed to load notebook for editor:', err)
        navigate('#/')
        return
      }
    }

    state.editing = true
    state.editingId = noteId

    var initialContent = ''

    if (noteId) {
      // Edit existing
      try {
        var data = await HnApi.getNote(notebook, noteId)
        var note = data.note || data
        $editorTitle.value = note.title || ''
        $editorTags.value = (note.tags || []).join(', ')
        initialContent = rewriteMediaUrls(note.content)
        state.currentNote = note
      } catch (err) {
        console.error('Failed to load note for editing:', err)
        navigate('#/' + encodeURIComponent(notebook))
        return
      }
    } else {
      // New note
      $editorTitle.value = ''
      $editorTags.value = ''
      initialContent = ''
      state.currentNote = null
    }

    // Determine note type: from existing note, route param, or default to markdown
    var editorType = (state.currentNote && state.currentNote.type) || noteType || 'markdown'

    showView('editor')
    updateBreadcrumb()
    renderNotebookList()

    // Destroy previous editor instances
    if (tuiEditor) {
      tuiEditor.destroy()
      tuiEditor = null
    }
    if (spreadsheetInstance) {
      if (spreadsheetInstance.destroy) spreadsheetInstance.destroy()
      spreadsheetInstance = null
    }
    if (calendarInstance) {
      calendarInstance.destroy()
      calendarInstance = null
    }
    $editorContainer.innerHTML = ''

    // ---- Spreadsheet editor ----
    if (editorType === 'spreadsheet') {
      var sheetEl = document.createElement('div')
      sheetEl.id = 'editor-spreadsheet'
      $editorContainer.appendChild(sheetEl)
      var sheetData = [[]]
      var sheetColumns = undefined
      if (initialContent) {
        try {
          var parsed = JSON.parse(initialContent)
          sheetData = parsed.data || [[]]
          sheetColumns = parsed.columns || undefined
        } catch (e) {}
      }
      spreadsheetInstance = jspreadsheet(sheetEl, {
        data: sheetData.length ? sheetData : [['', '', '', ''], ['', '', '', ''], ['', '', '', '']],
        columns: sheetColumns,
        minDimensions: [4, 5],
        tableOverflow: true,
        tableWidth: '100%'
      })
      return
    }

    // ---- Calendar editor ----
    if (editorType === 'calendar') {
      var calEl = document.createElement('div')
      calEl.id = 'editor-calendar'
      $editorContainer.appendChild(calEl)
      var calData = { events: [], settings: {} }
      if (initialContent) {
        try { calData = JSON.parse(initialContent) } catch (e) {}
      }
      var calSettings = calData.settings || {}

      function generateEventId () {
        return 'evt_' + Math.random().toString(36).substring(2, 10)
      }

      function showEventModal (eventData, onSave, onDelete) {
        var isNew = !eventData.id
        showModal(
          '<h2>' + (isNew ? 'New Event' : 'Edit Event') + '</h2>' +
          '<div class="form-group"><label>Title</label><input type="text" id="modal-evt-title" value="' + esc(eventData.title || '') + '"></div>' +
          '<div class="form-group"><label>Start</label><input type="text" id="modal-evt-start" value="' + esc(eventData.start || '') + '" placeholder="YYYY-MM-DD or YYYY-MM-DDTHH:mm"></div>' +
          '<div class="form-group"><label>End</label><input type="text" id="modal-evt-end" value="' + esc(eventData.end || '') + '" placeholder="YYYY-MM-DD or YYYY-MM-DDTHH:mm (optional)"></div>' +
          '<div class="form-group"><label style="display:inline;margin-right:8px">All day</label><input type="checkbox" id="modal-evt-allday"' + (eventData.allDay ? ' checked' : '') + '></div>' +
          '<div class="form-group"><label>Color</label><input type="color" id="modal-evt-color" value="' + (eventData.color || '#569cd6') + '" style="width:60px;height:32px;padding:2px;border:1px solid var(--border);border-radius:4px;cursor:pointer"></div>' +
          '<div class="form-group"><label>Description</label><textarea id="modal-evt-desc" rows="3" style="width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:8px;font-family:var(--font);font-size:0.85rem">' + esc(eventData.description || '') + '</textarea></div>' +
          '<div class="form-actions">' +
            (isNew ? '' : '<button id="modal-evt-delete" class="btn-danger" style="margin-right:auto">Delete</button>') +
            '<button id="modal-evt-cancel">Cancel</button>' +
            '<button id="modal-evt-save" class="btn-primary">Save</button>' +
          '</div>'
        )
        document.getElementById('modal-evt-cancel').addEventListener('click', hideModal)
        document.getElementById('modal-evt-save').addEventListener('click', function () {
          var title = document.getElementById('modal-evt-title').value.trim()
          if (!title) { alert('Title is required'); return }
          var startVal = document.getElementById('modal-evt-start').value.trim()
          if (!startVal || isNaN(new Date(startVal).getTime())) { alert('Valid start date is required (YYYY-MM-DD or YYYY-MM-DDTHH:mm)'); return }
          var endVal = document.getElementById('modal-evt-end').value.trim() || null
          if (endVal && isNaN(new Date(endVal).getTime())) { alert('Invalid end date format'); return }
          onSave({
            id: eventData.id || generateEventId(),
            title: title,
            start: startVal,
            end: endVal,
            allDay: document.getElementById('modal-evt-allday').checked,
            color: document.getElementById('modal-evt-color').value,
            description: document.getElementById('modal-evt-desc').value.trim()
          })
          hideModal()
        })
        if (!isNew) {
          document.getElementById('modal-evt-delete').addEventListener('click', function () {
            if (confirm('Delete this event?')) {
              onDelete()
              hideModal()
            }
          })
        }
      }

      calendarInstance = new FullCalendar.Calendar(calEl, {
        initialView: calSettings.defaultView || 'dayGridMonth',
        firstDay: calSettings.firstDay != null ? calSettings.firstDay : 1,
        headerToolbar: {
          left: 'prev,next today',
          center: 'title',
          right: 'dayGridMonth,timeGridWeek,timeGridDay'
        },
        events: (calData.events || []).map(function (ev) {
          return { id: ev.id, title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay, color: ev.color, extendedProps: { description: ev.description } }
        }),
        editable: true,
        selectable: true,
        height: 'auto',
        dateClick: function (info) {
          showEventModal({ start: info.dateStr, allDay: info.allDay }, function (evt) {
            calendarInstance.addEvent({
              id: evt.id, title: evt.title, start: evt.start, end: evt.end, allDay: evt.allDay, color: evt.color,
              extendedProps: { description: evt.description }
            })
          }, function () {})
        },
        eventClick: function (info) {
          var ev = info.event
          showEventModal({
            id: ev.id, title: ev.title,
            start: ev.startStr, end: ev.endStr,
            allDay: ev.allDay, color: ev.backgroundColor,
            description: (ev.extendedProps && ev.extendedProps.description) || ''
          }, function (updated) {
            ev.remove()
            calendarInstance.addEvent({
              id: updated.id, title: updated.title, start: updated.start, end: updated.end,
              allDay: updated.allDay, color: updated.color,
              extendedProps: { description: updated.description }
            })
          }, function () {
            ev.remove()
          })
        }
      })
      calendarInstance.render()
      return
    }

    // ---- Markdown editor (default) ----

    // Helper: insert raw markdown without WYSIWYG escaping
    // Switches to markdown mode, inserts at end, adds trailing newline for cursor escape
    function insertRawMarkdown (md) {
      if (!tuiEditor) return
      // Switch to markdown mode to insert at cursor position
      var wasWysiwyg = tuiEditor.isWysiwygMode()
      if (wasWysiwyg) tuiEditor.changeMode('markdown')
      // Insert at cursor position using replaceSelection
      var insertion = '\n' + md.replace(/^\n+/, '').replace(/\n+$/, '') + '\n'
      tuiEditor.replaceSelection(insertion)
      if (wasWysiwyg) tuiEditor.changeMode('wysiwyg')
    }

    // Custom toolbar button elements (created before editor so they can be referenced)
    var videoBtnEl = document.createElement('button')
    videoBtnEl.type = 'button'
    videoBtnEl.textContent = '\u25B6'
    videoBtnEl.title = 'Insert video'
    videoBtnEl.style.cssText = 'font-size:14px;color:#999;background:none;border:none;cursor:pointer;padding:4px 6px'

    var galleryBtnEl = document.createElement('button')
    galleryBtnEl.type = 'button'
    galleryBtnEl.textContent = '\u25A6'
    galleryBtnEl.title = 'Insert gallery'
    galleryBtnEl.style.cssText = 'font-size:14px;color:#999;background:none;border:none;cursor:pointer;padding:4px 6px'

    // Create Toast UI Editor
    tuiEditor = new toastui.Editor({
      el: $editorContainer,
      initialEditType: 'markdown',
      previewStyle: 'vertical',
      height: 'calc(100vh - 200px)',
      initialValue: initialContent,
      usageStatistics: false,
      toolbarItems: [
        ['heading', 'bold', 'italic', 'strike'],
        ['hr', 'quote'],
        ['ul', 'ol', 'task', 'indent', 'outdent'],
        ['table', 'image', 'link'],
        ['code', 'codeblock'],
        [{
          name: 'video',
          tooltip: 'Insert video',
          el: videoBtnEl
        },
        {
          name: 'gallery',
          tooltip: 'Insert gallery',
          el: galleryBtnEl
        }],
        ['scrollSync']
      ],
      hooks: {
        addImageBlobHook: function (blob, callback) {
          var nb = state.currentNotebook ? state.currentNotebook.slug : null
          if (!nb) { callback('', ''); return }
          var contentType = blob.type || 'application/octet-stream'
          var label = contentType.startsWith('video/') ? 'Uploading video...' : 'Uploading image...'
          showUploadProgress(label)
          uploadMedia(nb, blob, function (pct) { updateUploadProgress(pct) })
            .then(function (data) {
              hideUploadProgress()
              var mediaUrl = HnApi.basePath + data.url
              if (contentType.startsWith('video/')) {
                insertRawMarkdown('![video](' + mediaUrl + ')')
                callback('', '')
              } else {
                callback(mediaUrl, blob.name || 'image')
              }
            })
            .catch(function (err) {
              hideUploadProgress()
              alert('Upload failed: ' + err.message)
              callback('', '')
            })
          return false
        }
      }
    })

    // Escape code blocks (including gallery) with Enter at end or ArrowDown
    // Use event delegation on $editorContainer so it works even when .ProseMirror
    // doesn't exist yet (e.g. in markdown mode)
    $editorContainer.addEventListener('keydown', function (e) {
      if (e.key !== 'Enter' && e.key !== 'ArrowDown') return
      var pmEl = $editorContainer.querySelector('.ProseMirror')
      if (!pmEl) return
      var sel = window.getSelection()
      if (!sel || !sel.rangeCount) return
      var node = sel.anchorNode
      // Walk up to find a code block container
      var codeBlock = null
      var el = node.nodeType === 3 ? node.parentElement : node
      while (el && el !== pmEl) {
        if (el.tagName === 'PRE' || (el.tagName === 'DIV' && el.classList.contains('toastui-editor-ww-code-block'))) {
          codeBlock = el
          break
        }
        el = el.parentElement
      }
      if (!codeBlock) return

      // Check if cursor is at the end of the code block
      var range = sel.getRangeAt(0)
      var testRange = document.createRange()
      testRange.selectNodeContents(codeBlock)
      testRange.setStart(range.endContainer, range.endOffset)
      var remaining = testRange.toString()
      if (remaining.trim().length > 0) return

      // Insert a paragraph after the code block
      e.preventDefault()
      var p = document.createElement('p')
      p.innerHTML = '<br>'
      codeBlock.parentNode.insertBefore(p, codeBlock.nextSibling)
      // Move cursor into the new paragraph
      var newRange = document.createRange()
      newRange.setStart(p, 0)
      newRange.collapse(true)
      sel.removeAllRanges()
      sel.addRange(newRange)
    })

    // Register video upload toolbar command
    var videoInput = document.createElement('input')
    videoInput.type = 'file'
    videoInput.accept = 'video/*'
    videoInput.style.display = 'none'
    document.body.appendChild(videoInput)

    videoInput.addEventListener('change', function () {
      var file = videoInput.files[0]
      if (!file) return
      var nb = state.currentNotebook ? state.currentNotebook.slug : null
      if (!nb) return

      showUploadProgress('Uploading video...')
      uploadMedia(nb, file, function (pct) { updateUploadProgress(pct) })
        .then(function (data) {
          hideUploadProgress()
          insertRawMarkdown('![video](' + HnApi.basePath + data.url + ')')
        })
        .catch(function (err) {
          hideUploadProgress()
          alert('Video upload failed: ' + err.message)
        })
      videoInput.value = ''
    })

    // Gallery upload
    var galleryInput = document.createElement('input')
    galleryInput.type = 'file'
    galleryInput.accept = 'image/*'
    galleryInput.multiple = true
    galleryInput.style.display = 'none'
    document.body.appendChild(galleryInput)

    galleryInput.addEventListener('change', async function () {
      var files = galleryInput.files
      if (!files || files.length === 0) return
      var nb = state.currentNotebook ? state.currentNotebook.slug : null
      if (!nb) return

      var total = files.length
      showUploadProgress('Uploading 1 of ' + total + '...')

      var lines = ['', '```gallery']
      for (var fi = 0; fi < files.length; fi++) {
        try {
          var file = files[fi]
          var fileNum = fi + 1
          var data = await uploadMedia(nb, file, function (pct) {
            // Combine per-file progress with overall progress
            var overall = ((fi / total) + (pct / 100 / total)) * 100
            updateUploadProgress(overall, 'Uploading ' + fileNum + ' of ' + total + '...')
          })
          var name = file.name.replace(/\.[^.]+$/, '')
          lines.push('![' + name + '](' + HnApi.basePath + data.url + ')')
        } catch (err) {
          alert('Failed to upload ' + files[fi].name + ': ' + err.message)
        }
      }
      hideUploadProgress()
      lines.push('```')
      lines.push('')

      if (lines.length > 4) {
        insertRawMarkdown(lines.join('\n'))
      }
      galleryInput.value = ''
    })

    // Wire up custom toolbar buttons directly via their DOM elements
    videoBtnEl.addEventListener('click', function () {
      videoInput.click()
    })
    galleryBtnEl.addEventListener('click', function () {
      galleryInput.click()
    })

    // ---- Editor media hover overlay ----
    var editorOverlay = document.createElement('div')
    editorOverlay.className = 'editor-media-overlay'
    editorOverlay.innerHTML = '<button class="media-hover-btn media-hover-delete" type="button">Delete</button>'
    editorOverlay.style.display = 'none'
    $editorContainer.style.position = 'relative'
    $editorContainer.appendChild(editorOverlay)

    var overlayTarget = null
    var overlayHideTimer = null

    function showEditorOverlay (mediaEl) {
      if (overlayTarget === mediaEl) return
      overlayTarget = mediaEl
      clearTimeout(overlayHideTimer)
      var containerRect = $editorContainer.getBoundingClientRect()
      var elRect = mediaEl.getBoundingClientRect()
      editorOverlay.style.display = ''
      editorOverlay.style.top = (elRect.top - containerRect.top + 4) + 'px'
      editorOverlay.style.left = (elRect.right - containerRect.left - editorOverlay.offsetWidth - 4) + 'px'
    }

    function hideEditorOverlay () {
      overlayHideTimer = setTimeout(function () {
        editorOverlay.style.display = 'none'
        overlayTarget = null
      }, 200)
    }

    editorOverlay.addEventListener('mouseenter', function () {
      clearTimeout(overlayHideTimer)
    })
    editorOverlay.addEventListener('mouseleave', function () {
      hideEditorOverlay()
    })

    $editorContainer.addEventListener('mouseover', function (e) {
      var el = e.target
      // Find img or video inside ProseMirror
      if (el.tagName === 'IMG' || el.tagName === 'VIDEO') {
        var pm = el.closest('.ProseMirror')
        if (pm) showEditorOverlay(el)
      }
    })
    $editorContainer.addEventListener('mouseout', function (e) {
      var el = e.target
      if (el.tagName === 'IMG' || el.tagName === 'VIDEO') {
        hideEditorOverlay()
      }
    })

    editorOverlay.querySelector('.media-hover-delete').addEventListener('click', function (e) {
      e.preventDefault()
      e.stopPropagation()
      if (!overlayTarget || !tuiEditor) return
      var src = overlayTarget.src || ''
      if (overlayTarget.tagName === 'VIDEO') {
        var source = overlayTarget.querySelector('source')
        src = source ? source.src : overlayTarget.src || ''
      }
      // Normalize src to markdown URL
      var bp = HnApi.basePath || ''
      try { var u = new URL(src); src = u.pathname } catch (ex) {}
      if (bp && src.startsWith(bp)) src = src.substring(bp.length)

      var md = tuiEditor.getMarkdown()
      var lines = md.split('\n')
      var filtered = lines.filter(function (line) {
        return line.indexOf(src) === -1
      })
      if (filtered.length !== lines.length) {
        // Clean up empty gallery blocks
        var cleaned = []
        for (var ci = 0; ci < filtered.length; ci++) {
          if (filtered[ci].trimStart() === '```gallery') {
            var nextReal = ci + 1
            while (nextReal < filtered.length && filtered[nextReal].trim() === '') nextReal++
            if (nextReal < filtered.length && filtered[nextReal].trimStart() === '```') {
              ci = nextReal
              continue
            }
          }
          cleaned.push(filtered[ci])
        }
        tuiEditor.setMarkdown(cleaned.join('\n'))
      }
      editorOverlay.style.display = 'none'
      overlayTarget = null
    })
  }

  async function saveNote () {
    if (!await ensureIdentity()) return
    var notebook = state.currentNotebook.slug
    var title = $editorTitle.value.trim()
    var tags = $editorTags.value
      .split(',')
      .map(function (t) { return t.trim() })
      .filter(function (t) { return t.length > 0 })

    if (!title) {
      alert('Title is required')
      return
    }

    // Determine content based on editor type
    var content = ''
    var noteType = 'markdown'

    if (spreadsheetInstance) {
      noteType = 'spreadsheet'
      var sheetData = spreadsheetInstance.getData ? spreadsheetInstance.getData() : []
      var sheetHeaders = spreadsheetInstance.getHeaders ? spreadsheetInstance.getHeaders(true) : []
      content = JSON.stringify({ data: sheetData, columns: sheetHeaders.length ? sheetHeaders.map(function (h) { return { title: h } }) : undefined })
    } else if (calendarInstance) {
      noteType = 'calendar'
      var events = calendarInstance.getEvents().map(function (ev) {
        return {
          id: ev.id,
          title: ev.title,
          start: ev.startStr,
          end: ev.endStr || null,
          allDay: ev.allDay,
          color: ev.backgroundColor || null,
          description: (ev.extendedProps && ev.extendedProps.description) || ''
        }
      })
      content = JSON.stringify({ events: events, settings: { defaultView: calendarInstance.view.type, firstDay: 1 } })
    } else if (tuiEditor) {
      content = tuiEditor.getMarkdown()
    }

    try {
      if (state.editingId) {
        // Update existing note
        await HnApi.updateNote(notebook, state.editingId, { title: title, content: content, tags: tags })
        navigate('#/' + encodeURIComponent(notebook) + '/' + state.editingId)
      } else {
        // Create new note
        var data = await HnApi.createNote(notebook, { title: title, content: content, tags: tags, type: noteType })
        var note = data.note || data
        navigate('#/' + encodeURIComponent(notebook) + '/' + note.id)
      }
      await loadNotebooks()
    } catch (err) {
      console.error('Failed to save note:', err)
      alert('Error saving note: ' + (err.message || 'Unknown error'))
    }
  }

  // ==================== SEARCH ====================

  async function doSearch (query) {
    if (!query) {
      showView('home')
      return
    }

    $searchInput.value = query
    showView('search')
    $searchResults.innerHTML = '<p>Searching...</p>'

    try {
      var data = await HnApi.search(query, state.currentNotebook ? state.currentNotebook.slug : null)
      var results = data.results || []

      if (results.length === 0) {
        $searchResults.innerHTML = '<p>No results found.</p>'
        return
      }

      $searchResults.innerHTML = ''
      for (var i = 0; i < results.length; i++) {
        var r = results[i]
        var item = document.createElement('div')
        item.className = 'search-item'

        var title = document.createElement('div')
        title.className = 'title'
        title.textContent = r.title || 'Untitled'
        item.appendChild(title)

        var pathDiv = document.createElement('div')
        pathDiv.className = 'path'
        pathDiv.textContent = r.notebook + '/' + r.id
        item.appendChild(pathDiv)

        if (r.snippet) {
          var snippet = document.createElement('div')
          snippet.className = 'snippet'
          snippet.textContent = r.snippet
          item.appendChild(snippet)
        }

        item.addEventListener('click', (function (nb, id) {
          return function () { navigate('#/' + encodeURIComponent(nb) + '/' + id) }
        })(r.notebook, r.id))

        $searchResults.appendChild(item)
      }
    } catch (err) {
      console.error('Search failed:', err)
      $searchResults.innerHTML = '<p>Search error: ' + esc(err.message) + '</p>'
    }
  }

  // ==================== LINKS ====================

  async function showLinks () {
    if (!state.currentNotebook || !state.currentNote) return

    try {
      var data = await HnApi.getLinks(state.currentNotebook.slug, state.currentNote.id)
      var links = data.links || []

      var html = '<h3>Outgoing Links (' + links.length + ')</h3>'
      if (links.length === 0) {
        html += '<p style="color:var(--text-muted);font-size:0.85rem">No outgoing links</p>'
      } else {
        html += '<ul>'
        for (var i = 0; i < links.length; i++) {
          var l = links[i]
          if (l.targetId) {
            html += '<li><a href="#/' + encodeURIComponent(l.targetNotebook || state.currentNotebook.slug) + '/' + l.targetId + '">' + esc(l.title || l.raw) + '</a></li>'
          } else {
            html += '<li><span style="color:var(--text-muted)">' + esc(l.raw) + ' (unresolved)</span></li>'
          }
        }
        html += '</ul>'
      }

      $noteLinksPanel.innerHTML = html
      $noteLinksPanel.style.display = 'block'
    } catch (err) {
      console.error('Failed to load links:', err)
    }
  }

  async function showBacklinks () {
    if (!state.currentNotebook || !state.currentNote) return

    try {
      var data = await HnApi.getBacklinks(state.currentNotebook.slug, state.currentNote.id)
      var backlinks = data.backlinks || []

      var html = '<h3>Backlinks (' + backlinks.length + ')</h3>'
      if (backlinks.length === 0) {
        html += '<p style="color:var(--text-muted);font-size:0.85rem">No backlinks</p>'
      } else {
        html += '<ul>'
        for (var i = 0; i < backlinks.length; i++) {
          var b = backlinks[i]
          html += '<li><a href="#/' + encodeURIComponent(b.notebook || state.currentNotebook.slug) + '/' + b.noteId + '">' + esc(b.title || b.noteId) + '</a></li>'
        }
        html += '</ul>'
      }

      $noteLinksPanel.innerHTML = html
      $noteLinksPanel.style.display = 'block'
    } catch (err) {
      console.error('Failed to load backlinks:', err)
    }
  }

  // ==================== PROFILE / SETTINGS ====================

  async function loadProfile () {
    showView('profile')

    // Profile requires identity — generate on demand
    if (!await ensureIdentity()) {
      $profileContent.innerHTML = '<p>Identity required to view profile.</p>'
      return
    }

    $profileContent.innerHTML = '<p>Loading...</p>'

    try {
      var profile = await HnApi.getProfile()
      if (profile.theme) applyTheme(profile.theme)
      renderProfileView(profile)
    } catch (err) {
      console.error('Failed to load profile:', err)
      $profileContent.innerHTML = '<p>Failed to load profile: ' + esc(err.message) + '</p>'
    }
  }

  function renderProfileView (profile) {
    var html = ''

    // --- User Status Card ---
    var perms = []
    if (profile.permissions.admin) perms.push('admin')
    else if (profile.permissions.write) perms.push('write')
    else if (profile.permissions.read) perms.push('read')
    var permClass = profile.permissions.admin ? 'admin' : (profile.permissions.write ? 'write' : 'read')

    html += '<div class="settings-card">'
    html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">'
    html += '<div style="font-size:2rem">&#128100;</div>'
    html += '<div>'
    html += '<div style="font-size:1.2rem;font-weight:bold">' + esc(profile.name || 'Anonymous') + '</div>'
    if (profile.username) {
      html += '<div style="color:var(--text-muted)">@' + esc(profile.username) + '</div>'
    }
    html += '</div>'
    html += '<span class="perm-badge ' + permClass + '" style="margin-left:auto">' + perms[0] + '</span>'
    html += '</div>'

    // --- Username ---
    html += '<div class="settings-section">'
    html += '<div class="settings-row"><label>Username</label><div class="settings-value">'
    if (profile.username) {
      html += '<span>@' + esc(profile.username) + '</span> '
      html += '<button id="btn-change-username" class="btn-sm">Change</button>'
    } else {
      html += '<span style="color:var(--text-muted)">Not set</span> '
      html += '<button id="btn-set-username" class="btn-sm btn-primary">Set username</button>'
    }
    html += '</div></div>'

    // --- Display Name ---
    html += '<div class="settings-row"><label>Display Name</label><div class="settings-value">'
    html += '<span id="profile-display-name">' + esc(profile.name || 'Anonymous') + '</span> '
    html += '<button id="btn-edit-name" class="btn-sm">Edit</button>'
    html += '</div></div>'

    // --- Theme ---
    var currentTheme = profile.theme || localStorage.getItem('hn-theme') || 'dark'
    html += '<div class="settings-row"><label>Theme</label><div class="settings-value">'
    html += '<div class="theme-options">'
    html += '<button class="theme-option' + (currentTheme === 'light' ? ' active' : '') + '" data-theme="light">'
    html += '<div class="theme-swatch swatch-light"></div>Light</button>'
    html += '<button class="theme-option' + (currentTheme === 'dark' ? ' active' : '') + '" data-theme="dark">'
    html += '<div class="theme-swatch swatch-dark"></div>Dark</button>'
    html += '<button class="theme-option' + (currentTheme === 'dark-blue' ? ' active' : '') + '" data-theme="dark-blue">'
    html += '<div class="theme-swatch swatch-dark-blue"></div>Dark Blue</button>'
    html += '</div>'
    html += '</div></div>'

    // --- Auth Method ---
    html += '<div class="settings-row"><label>Auth Method</label><div class="settings-value">'
    html += '<span>' + esc(profile.method) + '</span>'
    html += '</div></div>'
    html += '</div>'

    // --- Identity / Keypair ---
    html += '<div class="settings-section">'
    html += '<h3>Identity</h3>'

    if (profile.identities && profile.identities.length > 0) {
      for (var i = 0; i < profile.identities.length; i++) {
        var ident = profile.identities[i]
        html += '<div class="settings-row"><label>' + esc(ident.method) + '</label><div class="settings-value">'
        html += '<code style="font-size:0.75rem;word-break:break-all">' + esc(ident.identityId) + '</code>'
        html += '</div></div>'
      }
    }

    var pubKey = HnIdentity.getPublicKey()
    if (pubKey) {
      html += '<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">'
      html += '<button id="btn-copy-pubkey" class="btn-sm">Copy Public Key</button>'
      html += '<button id="btn-copy-keypair" class="btn-sm">Copy Keypair</button>'
      html += '<button id="btn-show-keypair" class="btn-sm">Show Keypair</button>'
      html += '<button id="btn-export-keypair" class="btn-sm">Export JSON</button>'
      html += '</div>'
      html += '<div id="keypair-display" style="display:none;margin-top:12px">'
      html += '<div style="background:var(--bg-secondary, #f5f5f5);padding:12px;border-radius:6px;font-family:monospace;font-size:0.7rem;word-break:break-all">'
      html += '<div><strong>Public Key (44 chars):</strong></div>'
      html += '<div style="margin:4px 0">' + esc(pubKey) + '</div>'
      html += '<div style="margin-top:8px"><strong style="color:var(--color-error, red)">Full Keypair (132 chars) — KEEP SECRET:</strong></div>'
      html += '<div style="margin:4px 0" id="keypair-full-text">' + esc(HnIdentity.getKeypair()) + '</div>'
      html += '</div>'
      html += '<div style="margin-top:8px;padding:8px 12px;background:var(--bg-warning, #fff3cd);border:1px solid var(--border-warning, #ffc107);border-radius:6px;font-size:0.8rem">'
      html += '<strong>Warning:</strong> Your keypair is your private key. Never share the full 132-char string publicly.'
      html += '</div></div>'
    }
    html += '</div>'

    // --- Device Linking ---
    html += '<div class="settings-section">'
    html += '<h3>Link Another Device</h3>'
    html += '<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:8px">Import your keypair on another device to access your account there.</p>'
    html += '<button id="btn-import-keypair" class="btn-sm">Import Keypair</button>'
    html += '</div>'

    // --- Danger Zone ---
    html += '<div class="settings-section">'
    html += '<h3 style="color:var(--color-error, red)">Danger Zone</h3>'
    html += '<button id="btn-logout" class="btn-sm btn-danger">Clear Identity & Logout</button>'
    html += '</div>'

    html += '</div>'
    $profileContent.innerHTML = html

    // --- Bind events ---
    var $btnSetUsername = document.getElementById('btn-set-username')
    if ($btnSetUsername) $btnSetUsername.addEventListener('click', function () { showUsernameModal(null) })

    var $btnChangeUsername = document.getElementById('btn-change-username')
    if ($btnChangeUsername) $btnChangeUsername.addEventListener('click', function () { showUsernameModal(profile.username) })

    document.getElementById('btn-edit-name').addEventListener('click', function () {
      showEditNameModal(profile.name || '')
    })

    // Theme buttons
    var themeButtons = document.querySelectorAll('.theme-option')
    for (var t = 0; t < themeButtons.length; t++) {
      themeButtons[t].addEventListener('click', function () {
        var theme = this.getAttribute('data-theme')
        applyTheme(theme)
        // Update active state
        var all = document.querySelectorAll('.theme-option')
        for (var j = 0; j < all.length; j++) all[j].classList.remove('active')
        this.classList.add('active')
        // Save to server
        HnApi.updateProfile({ theme: theme }).catch(function (err) {
          console.error('Failed to save theme:', err)
        })
      })
    }

    if (document.getElementById('btn-copy-pubkey')) {
      document.getElementById('btn-copy-pubkey').addEventListener('click', function () {
        navigator.clipboard.writeText(HnIdentity.getPublicKey()).then(function () {
          alert('Public key copied!')
        })
      })
    }

    if (document.getElementById('btn-show-keypair')) {
      document.getElementById('btn-show-keypair').addEventListener('click', function () {
        var $kp = document.getElementById('keypair-display')
        $kp.style.display = $kp.style.display === 'none' ? 'block' : 'none'
      })
    }

    if (document.getElementById('btn-export-keypair')) {
      document.getElementById('btn-export-keypair').addEventListener('click', function () {
        var data = HnIdentity.exportKeypair()
        if (!data) { alert('No identity to export'); return }
        var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
        var a = document.createElement('a')
        a.href = URL.createObjectURL(blob)
        a.download = 'hypernote-identity.json'
        a.click()
      })
    }

    if (document.getElementById('btn-copy-keypair')) {
      document.getElementById('btn-copy-keypair').addEventListener('click', function () {
        var kp = HnIdentity.getKeypair()
        if (!kp) { alert('No identity to copy'); return }
        navigator.clipboard.writeText(kp).then(function () {
          alert('Keypair copied! Paste it on another device/origin to use the same identity.')
        })
      })
    }

    document.getElementById('btn-import-keypair').addEventListener('click', function () {
      showImportIdentityModal()
    })

    document.getElementById('btn-logout').addEventListener('click', function () {
      if (confirm('This will clear your identity from this browser. Make sure you have exported your keypair first! Continue?')) {
        HnIdentity.clear()
        window.location.reload()
      }
    })
  }

  function showEditNameModal (currentName) {
    showModal(
      '<h2>Edit Display Name</h2>' +
      '<div class="form-group"><label>Display Name</label>' +
        '<input type="text" id="modal-name-input" value="' + esc(currentName) + '" placeholder="Your name"></div>' +
      '<div class="form-actions">' +
        '<button id="modal-name-cancel">Cancel</button>' +
        '<button id="modal-name-save" class="btn-primary">Save</button>' +
      '</div>'
    )

    document.getElementById('modal-name-cancel').addEventListener('click', hideModal)
    document.getElementById('modal-name-save').addEventListener('click', async function () {
      var name = document.getElementById('modal-name-input').value.trim()
      if (!name) { alert('Name is required'); return }
      try {
        await HnApi.updateProfile({ name: name })
        hideModal()
        await loadProfile()
        updateProfileLink()
      } catch (err) {
        alert('Error: ' + (err.message || 'Failed to update name'))
      }
    })
  }

  // ==================== MODALS ====================

  function showModal (html) {
    $modalContent.innerHTML = html
    $modalOverlay.style.display = 'flex'
  }

  function hideModal () {
    $modalOverlay.style.display = 'none'
    $modalContent.innerHTML = ''
  }

  function showNewNotebookModal () {
    showModal(
      '<h2>New Notebook</h2>' +
      '<div class="form-group"><label>Slug (URL-safe name)</label><input type="text" id="modal-nb-slug" placeholder="my-notebook"></div>' +
      '<div class="form-group"><label>Name</label><input type="text" id="modal-nb-name" placeholder="My Notebook"></div>' +
      '<div class="form-group"><label>Description (optional)</label><input type="text" id="modal-nb-desc" placeholder="Description"></div>' +
      '<div class="form-group"><label>Visibility</label>' +
        '<select id="modal-nb-visibility">' +
          '<option value="public">Public — visible in feed and to everyone</option>' +
          '<option value="private">Private — only you can see it</option>' +
        '</select>' +
      '</div>' +
      '<div class="form-actions">' +
        '<button id="modal-nb-cancel">Cancel</button>' +
        '<button id="modal-nb-create" class="btn-primary">Create</button>' +
      '</div>'
    )

    document.getElementById('modal-nb-cancel').addEventListener('click', hideModal)
    document.getElementById('modal-nb-create').addEventListener('click', async function () {
      if (!await ensureIdentity()) return
      var slug = document.getElementById('modal-nb-slug').value.trim()
      var name = document.getElementById('modal-nb-name').value.trim()
      var desc = document.getElementById('modal-nb-desc').value.trim()
      var visibility = document.getElementById('modal-nb-visibility').value

      if (!slug) {
        alert('Slug is required')
        return
      }

      try {
        await HnApi.createNotebook({ slug: slug, name: name || slug, description: desc || undefined, visibility: visibility })
        hideModal()
        await loadNotebooks()
        navigate('#/' + encodeURIComponent(slug))
      } catch (err) {
        alert('Error: ' + (err.message || 'Failed to create notebook'))
      }
    })
  }

  // ==================== IDENTITY CHOICE & WELCOME MODALS ====================

  /**
   * First visit — no identity exists. Let user choose:
   * 1. Create new identity (generates keypair client-side)
   * 2. Import existing identity (paste keypair or upload JSON)
   * 3. Use bearer token (legacy)
   */
  function showIdentityChoiceModal () {
    showModal(
      '<h2>Welcome to Hypernote</h2>' +
      '<p style="color:var(--text-muted);margin-bottom:16px">Choose how to connect:</p>' +
      '<div style="display:flex;flex-direction:column;gap:10px">' +
        '<button id="modal-ident-create" class="btn-primary" style="padding:12px 16px;font-size:1rem">' +
          'Create new identity</button>' +
        '<button id="modal-ident-import" style="padding:12px 16px;font-size:1rem">' +
          'Import existing identity</button>' +
        '<button id="modal-ident-token" style="padding:10px 16px;font-size:0.85rem;color:var(--text-muted)">' +
          'Use bearer token</button>' +
      '</div>'
    )

    document.getElementById('modal-ident-create').addEventListener('click', async function () {
      try {
        await HnIdentity.generate()
        hideModal()
        updateProfileLink()
        await refreshPermissions()
      } catch (err) {
        alert('Error: ' + (err.message || 'Failed to generate identity'))
      }
    })

    document.getElementById('modal-ident-import').addEventListener('click', function () {
      hideModal()
      showImportIdentityModal()
    })

    document.getElementById('modal-ident-token').addEventListener('click', function () {
      hideModal()
      showAuth()
    })
  }

  /**
   * Import identity modal — shown from identity choice or profile page.
   * Accepts 132-char keypair string or JSON file export.
   */
  function showImportIdentityModal () {
    showModal(
      '<h2>Import Identity</h2>' +
      '<p style="color:var(--text-muted);font-size:0.85rem;margin-bottom:12px">' +
        'Paste your 132-character keypair or upload a JSON export to use your existing identity on this device.</p>' +
      '<div class="form-group"><label>Keypair (132 chars)</label>' +
        '<input type="text" id="modal-import-input" placeholder="Paste keypair string" autocomplete="off" style="font-family:monospace;font-size:0.8rem"></div>' +
      '<p style="text-align:center;color:var(--text-muted);margin:8px 0">or</p>' +
      '<div class="form-group">' +
        '<input type="file" id="modal-import-file" accept=".json">' +
      '</div>' +
      '<div class="form-actions">' +
        '<button id="modal-import-cancel">Back</button>' +
        '<button id="modal-import-save" class="btn-primary">Import</button>' +
      '</div>'
    )

    var $fileInput = document.getElementById('modal-import-file')
    var $textInput = document.getElementById('modal-import-input')

    $fileInput.addEventListener('change', function () {
      var file = $fileInput.files[0]
      if (!file) return
      var reader = new FileReader()
      reader.onload = function () {
        try {
          var data = JSON.parse(reader.result)
          if (data.keypair) $textInput.value = data.keypair
        } catch (err) {
          alert('Invalid JSON file')
        }
      }
      reader.readAsText(file)
    })

    document.getElementById('modal-import-cancel').addEventListener('click', function () {
      hideModal()
      // Go back to choice if no identity, otherwise just close
      if (!HnIdentity.hasIdentity()) {
        showIdentityChoiceModal()
      }
    })

    document.getElementById('modal-import-save').addEventListener('click', async function () {
      var keypair = $textInput.value.trim()
      if (!keypair || keypair.length !== 132) {
        alert('Invalid keypair \u2014 must be exactly 132 characters')
        return
      }
      try {
        HnIdentity.importKeypair(keypair)
        hideModal()
        updateProfileLink()
        await refreshPermissions()
      } catch (err) {
        alert('Error: ' + (err.message || 'Failed to import keypair'))
      }
    })
  }

  /**
   * Welcome modal — shown after identity is created/imported.
   * Prompts for optional username.
   */
  function showWelcomeModal () {
    var pubKey = HnIdentity.getPublicKey() || ''
    var truncated = pubKey.length > 16 ? pubKey.substring(0, 16) + '...' : pubKey

    showModal(
      '<h2>Identity Ready</h2>' +
      '<div class="form-group"><label>Public Key</label>' +
        '<p style="font-family:monospace;font-size:0.8rem">' + esc(truncated) + '</p></div>' +
      '<div class="form-group"><label>Choose a username (optional)</label>' +
        '<input type="text" id="modal-welcome-username" placeholder="alice" autocomplete="off">' +
        '<p id="modal-welcome-hint" class="hint" style="margin-top:4px"></p></div>' +
      '<div class="form-actions">' +
        '<button id="modal-welcome-skip">Skip for now</button>' +
        '<button id="modal-welcome-save" class="btn-primary">Get Started</button>' +
      '</div>'
    )

    var $input = document.getElementById('modal-welcome-username')
    var $hint = document.getElementById('modal-welcome-hint')
    var checkTimeout = null

    $input.addEventListener('input', function () {
      clearTimeout(checkTimeout)
      var val = $input.value.trim().toLowerCase()
      if (val.length < 3) {
        $hint.textContent = val.length > 0 ? 'At least 3 characters' : ''
        return
      }
      checkTimeout = setTimeout(async function () {
        try {
          var result = await HnApi.checkUsername(val)
          $hint.textContent = result.available ? 'Available!' : (result.reason || 'Taken')
          $hint.style.color = result.available ? 'var(--color-success, green)' : 'var(--color-error, red)'
        } catch (err) {
          $hint.textContent = ''
        }
      }, 300)
    })

    document.getElementById('modal-welcome-skip').addEventListener('click', function () {
      hideModal()
    })

    document.getElementById('modal-welcome-save').addEventListener('click', async function () {
      var username = $input.value.trim().toLowerCase()
      if (!username) {
        hideModal()
        return
      }

      try {
        await HnApi.setUsername(username)
        HnIdentity.setUsername(username)
        hideModal()
        updateProfileLink()
        await refreshPermissions()
        await loadNotebooks()
      } catch (err) {
        alert('Error: ' + (err.message || 'Failed to set username'))
      }
    })
  }

  function showUsernameModal (currentUsername) {
    var isChange = !!currentUsername
    var heading = isChange ? 'Change username' : 'Choose a username'
    var warning = isChange
      ? '<div style="background:var(--bg-warning, #fff3cd);border:1px solid var(--border-warning, #ffc107);border-radius:6px;padding:10px 14px;margin-bottom:12px;font-size:0.85rem">' +
        '<strong>Warning:</strong> Changing your username will break existing public links. ' +
        'Anyone using <code>/' + esc(currentUsername) + '/...</code> URLs will get a "not found" error.' +
        '</div>'
      : ''

    showModal(
      '<h2>' + heading + '</h2>' +
      warning +
      '<p style="color:var(--text-muted);font-size:0.85rem">Your username will be used in public URLs: <code>/username/notebook/note</code></p>' +
      '<div class="form-group"><label>Username</label>' +
        '<input type="text" id="modal-username-input" placeholder="alice" autocomplete="off" value="' + esc(currentUsername || '') + '">' +
        '<p id="modal-username-hint" class="hint" style="margin-top:4px"></p></div>' +
      '<div class="form-actions">' +
        '<button id="modal-username-cancel">Cancel</button>' +
        '<button id="modal-username-save" class="btn-primary">' + (isChange ? 'Change' : 'Save') + '</button>' +
      '</div>'
    )

    var $input = document.getElementById('modal-username-input')
    var $hint = document.getElementById('modal-username-hint')
    var checkTimeout = null

    $input.addEventListener('input', function () {
      clearTimeout(checkTimeout)
      var val = $input.value.trim().toLowerCase()
      if (val.length < 3) {
        $hint.textContent = val.length > 0 ? 'At least 3 characters' : ''
        return
      }
      checkTimeout = setTimeout(async function () {
        try {
          var result = await HnApi.checkUsername(val)
          $hint.textContent = result.available ? 'Available!' : (result.reason || 'Taken')
          $hint.style.color = result.available ? 'var(--color-success, green)' : 'var(--color-error, red)'
        } catch (err) {
          $hint.textContent = ''
        }
      }, 300)
    })

    document.getElementById('modal-username-cancel').addEventListener('click', hideModal)
    document.getElementById('modal-username-save').addEventListener('click', async function () {
      var username = $input.value.trim().toLowerCase()
      if (!username) {
        alert('Username is required')
        return
      }

      try {
        await HnApi.setUsername(username)
        HnIdentity.setUsername(username)
        hideModal()
        updateProfileLink()
        await refreshPermissions()
        await loadNotebooks()
        // Reload profile if on profile view
        if (parseRoute().view === 'profile') {
          await loadProfile()
        }
      } catch (err) {
        alert('Error: ' + (err.message || 'Failed to set username'))
      }
    })
  }

  // ==================== USER DIRECTORY ====================

  async function showUserDirectory () {
    showModal(
      '<h2>User Directory</h2>' +
      '<div id="directory-list" style="min-height:60px"><p class="text-muted">Loading...</p></div>'
    )

    try {
      var data = await HnApi.listUsers()
      var users = data.users || []
      var $list = document.getElementById('directory-list')

      if (users.length === 0) {
        $list.innerHTML = '<p class="text-muted">No users yet.</p>'
        return
      }

      var html = ''
      for (var i = 0; i < users.length; i++) {
        var u = users[i]
        html += '<a class="directory-item" href="#/user/' + esc(u.username) + '">'
        html += '<div class="directory-name">' + esc(u.name || 'Anonymous') + '</div>'
        html += '<div class="directory-username">@' + esc(u.username) + '</div>'
        html += '</a>'
      }
      $list.innerHTML = html
      // Close modal when clicking a user
      $list.addEventListener('click', function (e) {
        var link = e.target.closest('.directory-item')
        if (link) hideModal()
      })
    } catch (err) {
      var $list2 = document.getElementById('directory-list')
      if ($list2) $list2.innerHTML = '<p class="text-muted">Failed to load users.</p>'
    }
  }

  // ==================== ADMIN PANEL ====================

  async function loadAdminPanel () {
    showView('admin')
    state.currentNotebook = null
    state.currentNote = null
    updateBreadcrumb()

    $adminContent.innerHTML = '<p class="text-muted">Loading...</p>'

    try {
      var statsData = await HnApi.adminStats()
      var usersData = await HnApi.adminUsers()
      var notebooksData = await HnApi.listNotebooks()
      var users = usersData.users || []
      var notebooks = notebooksData.notebooks || []

      var html = ''

      // ==================== P2P NETWORK ====================
      var syncStatus = null
      try { syncStatus = await HnApi.get('/api/sync/status') } catch (e) {}

      html += '<h3 class="admin-section-title">P2P Network</h3>'

      // Connection status bar
      var swarmActive = syncStatus && syncStatus.swarmActive
      var peerCount = syncStatus ? syncStatus.peersConnected : 0
      var announcedSlugs = syncStatus ? (syncStatus.announced || []) : []

      html += '<div class="p2p-status-bar">'
      html += '<span class="p2p-indicator ' + (swarmActive ? 'p2p-online' : 'p2p-offline') + '"></span>'
      html += '<span>' + (swarmActive ? 'Swarm active' : 'Swarm offline') + '</span>'
      html += '<span class="p2p-peers">' + peerCount + ' peer' + (peerCount !== 1 ? 's' : '') + ' connected</span>'
      html += '</div>'

      // Site drive URL
      try {
        var siteInfo = await HnApi.get('/api/site')
        if (siteInfo.hyperUrl) {
          html += '<div class="p2p-site-row">'
          html += '<label>Site Drive:</label>'
          html += '<code class="site-url-code" title="Click to copy">' + esc(siteInfo.hyperUrl) + '</code>'
          html += '<button class="btn-sm p2p-republish" title="Republish site">Republish</button>'
          html += '</div>'
        }
      } catch (e) {}

      // Per-notebook sharing table
      html += '<div class="p2p-notebooks">'
      html += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
      html += '<th>Notebook</th><th>Drive Key</th><th>Sharing</th><th>Actions</th>'
      html += '</tr></thead><tbody>'
      for (var pi = 0; pi < notebooks.length; pi++) {
        var pnb = notebooks[pi]
        var isAnnounced = announcedSlugs.indexOf(pnb.slug) >= 0
        var driveKey = pnb.driveKey || ''
        var shortKey = driveKey ? driveKey.substring(0, 8) + '...' + driveKey.substring(56) : 'n/a'
        html += '<tr>'
        html += '<td>' + esc(pnb.name || pnb.slug) + '</td>'
        html += '<td><code class="p2p-key-code" data-key="' + esc(driveKey) + '" title="' + esc(driveKey) + '">' + shortKey + '</code></td>'
        html += '<td>'
        if (isAnnounced) {
          html += '<span class="p2p-badge p2p-badge-on">sharing</span>'
        } else {
          html += '<span class="p2p-badge p2p-badge-off">local only</span>'
        }
        html += '</td>'
        html += '<td class="admin-actions">'
        if (isAnnounced) {
          html += '<button class="btn-sm p2p-unannounce" data-slug="' + esc(pnb.slug) + '">Stop Sharing</button>'
        } else {
          html += '<button class="btn-sm btn-primary p2p-announce" data-slug="' + esc(pnb.slug) + '">Share P2P</button>'
        }
        html += '</td></tr>'
      }
      html += '</tbody></table></div>'

      // Add remote notebook form
      html += '<div class="p2p-add-remote">'
      html += '<h4>Add Remote Notebook</h4>'
      html += '<div class="p2p-add-form">'
      html += '<input type="text" id="p2p-remote-key" placeholder="Drive key (64-char hex)" class="p2p-input">'
      html += '<input type="text" id="p2p-remote-slug" placeholder="Local name (optional)" class="p2p-input p2p-input-sm">'
      html += '<button class="btn-primary btn-sm" id="p2p-add-remote-btn">Add</button>'
      html += '</div>'
      html += '</div>'
      html += '</div>'

      // ==================== MULTI-WRITER SYNC ====================
      var mwStatus = null
      try { mwStatus = await HnApi.multiwriterStatus() } catch (e) {}

      html += '<h3 class="admin-section-title">Multi-Writer Sync</h3>'
      html += '<div id="mw-section">'

      // Status display
      html += '<div id="mw-status-display">'
      if (mwStatus) {
        if (mwStatus.linked) {
          html += '<span class="p2p-badge p2p-badge-on">Linked</span> '
          html += '<span class="text-muted">' + (mwStatus.sharedNotebooks || 0) + ' shared notebook' + ((mwStatus.sharedNotebooks || 0) !== 1 ? 's' : '') + '</span>'
        } else {
          html += '<span class="p2p-badge p2p-badge-off">Not linked</span> '
          html += '<span class="text-muted">No peer instances linked yet</span>'
        }
      } else {
        html += '<span class="text-muted">Multi-writer not available</span>'
      }
      html += '</div>'

      // Actions
      html += '<div style="display:flex;gap:8px;margin-top:0.75rem">'
      html += '<button class="btn-sm btn-primary" id="mw-link-btn">Create Link Invite</button>'
      html += '<button class="btn-sm" id="mw-join-btn">Join with Invite</button>'
      html += '</div>'

      // Invite display (hidden by default)
      html += '<div id="mw-invite-display" style="display:none">'
      html += '<label class="text-muted" style="font-size:0.85rem">Share this invite with the other instance:</label>'
      html += '<input id="mw-invite-code" type="text" readonly style="width:100%;font-family:monospace;font-size:0.85em;margin:0.5rem 0" />'
      html += '<button class="btn-sm" id="mw-copy-invite">Copy</button>'
      html += '</div>'

      html += '</div>'

      // Stats cards
      html += '<div class="admin-stats">'
      html += '<div class="admin-stat"><div class="admin-stat-value">' + statsData.userCount + '</div><div class="admin-stat-label">Users</div></div>'
      html += '<div class="admin-stat"><div class="admin-stat-value">' + statsData.notebookCount + '</div><div class="admin-stat-label">Notebooks</div></div>'
      html += '<div class="admin-stat"><div class="admin-stat-value">' + statsData.noteCount + '</div><div class="admin-stat-label">Notes</div></div>'
      html += '<div class="admin-stat"><div class="admin-stat-value">' + statsData.publicNotebooks + '</div><div class="admin-stat-label">Public</div></div>'
      html += '<div class="admin-stat"><div class="admin-stat-value">' + statsData.privateNotebooks + '</div><div class="admin-stat-label">Private</div></div>'
      html += '</div>'

      // Users table
      html += '<h3 class="admin-section-title">Users</h3>'
      html += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
      html += '<th>Name</th><th>Username</th><th>Role</th><th>Joined</th><th>Actions</th>'
      html += '</tr></thead><tbody>'
      for (var i = 0; i < users.length; i++) {
        var u = users[i]
        var perms = u.globalPermissions || []
        var role = perms.includes('admin') ? 'admin' : perms.includes('write') ? 'writer' : 'reader'
        var joined = new Date(u.createdAt).toLocaleDateString()
        html += '<tr data-userid="' + u.userId + '">'
        html += '<td>' + esc(u.name || 'Anonymous') + '</td>'
        html += '<td>' + (u.username ? '@' + esc(u.username) : '<span class="text-muted">none</span>') + '</td>'
        html += '<td><span class="admin-role admin-role-' + role + '">' + role + '</span></td>'
        html += '<td>' + joined + '</td>'
        html += '<td class="admin-actions">'
        if (role !== 'admin') {
          html += '<select class="admin-role-select" data-userid="' + u.userId + '">'
          html += '<option value="" disabled selected>Change role...</option>'
          html += '<option value="read"' + (role === 'reader' ? ' disabled' : '') + '>Reader</option>'
          html += '<option value="write"' + (role === 'writer' ? ' disabled' : '') + '>Writer</option>'
          html += '<option value="admin">Admin</option>'
          html += '</select>'
          html += ' <button class="btn-sm btn-danger admin-delete-user" data-userid="' + u.userId + '" data-name="' + esc(u.name || u.username || 'this user') + '">Delete</button>'
        } else {
          html += '<span class="text-muted">—</span>'
        }
        html += '</td></tr>'
      }
      html += '</tbody></table></div>'

      // Notebooks table
      html += '<h3 class="admin-section-title">Notebooks</h3>'
      html += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
      html += '<th>Name</th><th>Slug</th><th>Owner</th><th>Visibility</th><th>Notes</th><th>Actions</th>'
      html += '</tr></thead><tbody>'
      for (var j = 0; j < notebooks.length; j++) {
        var nb = notebooks[j]
        var ownerName = 'none'
        for (var k = 0; k < users.length; k++) {
          if (users[k].userId === nb.ownerId) { ownerName = users[k].username || users[k].name || 'Unknown'; break }
        }
        var vis = nb.visibility || 'public'
        html += '<tr>'
        html += '<td><a href="#/' + encodeURIComponent(nb.slug) + '">' + esc(nb.name || nb.slug) + '</a></td>'
        html += '<td class="text-muted">' + esc(nb.slug) + '</td>'
        html += '<td>' + (nb.ownerId ? esc(ownerName) : '<span class="text-muted">unowned</span>') + '</td>'
        html += '<td><span class="admin-vis admin-vis-' + vis + '">' + vis + '</span></td>'
        html += '<td>' + (nb.noteCount || 0) + '</td>'
        html += '<td class="admin-actions">'
        html += '<select class="admin-owner-select" data-slug="' + esc(nb.slug) + '">'
        html += '<option value="" disabled selected>Transfer to...</option>'
        for (var m = 0; m < users.length; m++) {
          if (!users[m].userId) continue
          var selected = users[m].userId === nb.ownerId ? ' disabled' : ''
          html += '<option value="' + users[m].userId + '"' + selected + '>' + esc(users[m].username || users[m].name || users[m].userId.substring(0, 8)) + '</option>'
        }
        html += '</select>'
        html += '</td></tr>'
      }
      html += '</tbody></table></div>'

      $adminContent.innerHTML = html

      // Click-to-copy for site URL and drive keys
      var copyableEls = $adminContent.querySelectorAll('.site-url-code, .p2p-key-code')
      for (var ci = 0; ci < copyableEls.length; ci++) {
        copyableEls[ci].addEventListener('click', function () {
          var textToCopy = this.getAttribute('data-key') || this.textContent
          var el = this
          navigator.clipboard.writeText(textToCopy).then(function () {
            var orig = el.textContent
            el.classList.add('copied')
            el.textContent = 'Copied!'
            setTimeout(function () { el.classList.remove('copied'); el.textContent = orig }, 1200)
          })
        })
      }

      // P2P announce/unannounce handlers
      var announceButtons = $adminContent.querySelectorAll('.p2p-announce')
      for (var ai = 0; ai < announceButtons.length; ai++) {
        announceButtons[ai].addEventListener('click', async function () {
          var slug = this.getAttribute('data-slug')
          try {
            await HnApi.post('/api/notebooks/' + encodeURIComponent(slug) + '/announce')
            loadAdminPanel()
          } catch (err) { alert('Error: ' + (err.message || 'Failed to announce')) }
        })
      }
      var unannounceButtons = $adminContent.querySelectorAll('.p2p-unannounce')
      for (var ui = 0; ui < unannounceButtons.length; ui++) {
        unannounceButtons[ui].addEventListener('click', async function () {
          var slug = this.getAttribute('data-slug')
          try {
            await HnApi.post('/api/notebooks/' + encodeURIComponent(slug) + '/unannounce')
            loadAdminPanel()
          } catch (err) { alert('Error: ' + (err.message || 'Failed to unannounce')) }
        })
      }

      // Republish site button
      var republishBtn = $adminContent.querySelector('.p2p-republish')
      if (republishBtn) {
        republishBtn.addEventListener('click', async function () {
          republishBtn.disabled = true
          republishBtn.textContent = 'Publishing...'
          try {
            await HnApi.post('/api/site/publish')
            loadAdminPanel()
          } catch (err) { alert('Error: ' + (err.message || 'Failed to republish')) }
        })
      }

      // Add remote notebook handler
      var addRemoteBtn = $adminContent.querySelector('#p2p-add-remote-btn')
      if (addRemoteBtn) {
        addRemoteBtn.addEventListener('click', async function () {
          var keyInput = document.getElementById('p2p-remote-key')
          var slugInput = document.getElementById('p2p-remote-slug')
          var driveKey = (keyInput.value || '').trim()
          var slug = (slugInput.value || '').trim()
          if (!driveKey || driveKey.length !== 64) {
            alert('Please enter a valid 64-character hex drive key')
            return
          }
          try {
            var body = { driveKey: driveKey }
            if (slug) body.slug = slug
            await HnApi.post('/api/sync/add', body)
            loadAdminPanel()
          } catch (err) { alert('Error: ' + (err.message || 'Failed to add remote notebook')) }
        })
      }

      // Bind role change handlers
      var roleSelects = $adminContent.querySelectorAll('.admin-role-select')
      for (var ri = 0; ri < roleSelects.length; ri++) {
        roleSelects[ri].addEventListener('change', async function () {
          var userId = this.getAttribute('data-userid')
          var newRole = this.value
          try {
            if (newRole === 'read') {
              await HnApi.adminDemote(userId, 'read')
            } else {
              await HnApi.adminPromote(userId, newRole)
            }
            loadAdminPanel()
          } catch (err) {
            alert('Error: ' + (err.message || 'Failed to change role'))
            this.selectedIndex = 0
          }
        })
      }

      // Bind delete user handlers
      var deleteButtons = $adminContent.querySelectorAll('.admin-delete-user')
      for (var di = 0; di < deleteButtons.length; di++) {
        deleteButtons[di].addEventListener('click', async function () {
          var userId = this.getAttribute('data-userid')
          var name = this.getAttribute('data-name')
          if (!confirm('Delete user "' + name + '"? This cannot be undone.')) return
          try {
            await HnApi.adminDeleteUser(userId)
            loadAdminPanel()
          } catch (err) {
            alert('Error: ' + (err.message || 'Failed to delete user'))
          }
        })
      }

      // Bind notebook transfer handlers
      var ownerSelects = $adminContent.querySelectorAll('.admin-owner-select')
      for (var oi = 0; oi < ownerSelects.length; oi++) {
        ownerSelects[oi].addEventListener('change', async function () {
          var slug = this.getAttribute('data-slug')
          var newOwnerId = this.value
          try {
            await HnApi.adminTransferNotebook(slug, newOwnerId)
            loadAdminPanel()
          } catch (err) {
            alert('Error: ' + (err.message || 'Failed to transfer notebook'))
            this.selectedIndex = 0
          }
        })
      }

      // Multi-writer: Create Link Invite
      var mwLinkBtn = $adminContent.querySelector('#mw-link-btn')
      if (mwLinkBtn) {
        mwLinkBtn.addEventListener('click', async function () {
          mwLinkBtn.disabled = true
          mwLinkBtn.textContent = 'Creating...'
          try {
            var result = await HnApi.multiwriterLink()
            var inviteDisplay = document.getElementById('mw-invite-display')
            var inviteCode = document.getElementById('mw-invite-code')
            if (inviteDisplay && inviteCode) {
              inviteCode.value = result.invite
              inviteDisplay.style.display = 'block'
            }
          } catch (err) {
            alert('Error: ' + (err.message || 'Failed to create link invite'))
          }
          mwLinkBtn.disabled = false
          mwLinkBtn.textContent = 'Create Link Invite'
        })
      }

      // Multi-writer: Join with Invite
      var mwJoinBtn = $adminContent.querySelector('#mw-join-btn')
      if (mwJoinBtn) {
        mwJoinBtn.addEventListener('click', async function () {
          var invite = prompt('Paste the invite hex string from the other instance:')
          if (!invite || !invite.trim()) return
          mwJoinBtn.disabled = true
          mwJoinBtn.textContent = 'Joining...'
          try {
            await HnApi.multiwriterJoin(invite.trim())
            alert('Successfully joined! Instances are now linked.')
            loadAdminPanel()
          } catch (err) {
            alert('Error: ' + (err.message || 'Failed to join'))
          }
          mwJoinBtn.disabled = false
          mwJoinBtn.textContent = 'Join with Invite'
        })
      }

      // Multi-writer: Copy invite
      var mwCopyBtn = $adminContent.querySelector('#mw-copy-invite')
      if (mwCopyBtn) {
        mwCopyBtn.addEventListener('click', function () {
          var inviteCode = document.getElementById('mw-invite-code')
          if (inviteCode) {
            navigator.clipboard.writeText(inviteCode.value).then(function () {
              mwCopyBtn.textContent = 'Copied!'
              setTimeout(function () { mwCopyBtn.textContent = 'Copy' }, 1200)
            })
          }
        })
      }

    } catch (err) {
      if (err.status === 403) {
        $adminContent.innerHTML = '<p class="text-muted">You do not have admin access.</p>'
      } else {
        $adminContent.innerHTML = '<p class="text-muted">Failed to load admin panel.</p>'
        console.error('Admin panel error:', err)
      }
    }
  }

  async function confirmDeleteNote () {
    if (!await ensureIdentity()) return
    if (!state.currentNotebook || !state.currentNote) return

    var ok = confirm('Delete "' + (state.currentNote.title || 'Untitled') + '"? This cannot be undone.')
    if (!ok) return

    try {
      await HnApi.deleteNote(state.currentNotebook.slug, state.currentNote.id)
      await loadNotebooks()
      navigate('#/' + encodeURIComponent(state.currentNotebook.slug))
    } catch (err) {
      alert('Error deleting note: ' + (err.message || 'Unknown error'))
    }
  }

  async function confirmDeleteNotebook () {
    if (!await ensureIdentity()) return
    if (!state.currentNotebook) return

    var name = state.currentNotebook.name || state.currentNotebook.slug
    var noteCount = state.currentNotebook.noteCount || 0
    var msg = 'Delete notebook "' + name + '"'
    if (noteCount > 0) msg += ' and all ' + noteCount + ' notes inside it'
    msg += '? This cannot be undone.'

    if (!confirm(msg)) return

    // Double-confirm for notebooks with notes
    if (noteCount > 0) {
      if (!confirm('Are you sure? All notes in "' + name + '" will be permanently deleted.')) return
    }

    try {
      await HnApi.deleteNotebook(state.currentNotebook.slug)
      state.currentNotebook = null
      state.currentNote = null
      await loadNotebooks()
      navigate('#/')
    } catch (err) {
      alert('Error deleting notebook: ' + (err.message || 'Unknown error'))
    }
  }

  async function promptUnlock (notebook) {
    showModal(
      '<h2>Unlock Notebook</h2>' +
      '<p style="color:var(--text-muted);font-size:0.85rem">This notebook is encrypted. Enter your password to unlock.</p>' +
      '<div class="form-group"><label>Password</label><input type="password" id="modal-unlock-pw"></div>' +
      '<div class="form-actions">' +
        '<button id="modal-unlock-cancel">Cancel</button>' +
        '<button id="modal-unlock-ok" class="btn-primary">Unlock</button>' +
      '</div>'
    )

    document.getElementById('modal-unlock-cancel').addEventListener('click', hideModal)
    document.getElementById('modal-unlock-ok').addEventListener('click', async function () {
      var pw = document.getElementById('modal-unlock-pw').value
      if (!pw) return

      try {
        await HnApi.unlockNotebook(notebook, pw)
        hideModal()
        navigate('#/' + encodeURIComponent(notebook))
      } catch (err) {
        alert('Unlock failed: ' + (err.message || 'Wrong password'))
      }
    })
  }

  // ==================== AUTH ====================

  function showAuth () {
    $authSetup.style.display = 'flex'
  }

  function hideAuth () {
    $authSetup.style.display = 'none'
  }

  /**
   * Check permissions from server and update UI visibility.
   * Called on init and after identity changes.
   */
  async function refreshPermissions () {
    state.canWrite = false
    state.canAdmin = false

    if (!HnIdentity.hasIdentity() && !HnApi.getToken()) {
      updateWriteUI()
      return
    }

    try {
      var who = await HnApi.whoami()
      if (who.authenticated && who.permissions) {
        state.canWrite = !!who.permissions.write
        state.canAdmin = !!who.permissions.admin
      }
      // Sync username and display name from server
      if (who.username) {
        HnIdentity.setUsername(who.username)
      }
      // Also fetch profile for display name fallback
      try {
        var profile = await HnApi.getProfile()
        if (profile.username) HnIdentity.setUsername(profile.username)
        state.displayName = profile.name || null
      } catch (e) {}
    } catch (err) {
      // Server down or auth failed — stay read-only
    }
    updateWriteUI()
    updateProfileLink()
  }

  /**
   * Show/hide write controls based on permissions.
   */
  function updateWriteUI () {
    var show = state.canWrite ? '' : 'none'
    // Sidebar new notebook button
    $btnNewNotebook.style.display = show
    // Note toolbar write buttons
    document.getElementById('btn-edit-note').style.display = show
    document.getElementById('btn-delete-note').style.display = show
    document.getElementById('btn-delete-notebook').style.display = show
    // Sidebar "new note" links
    var newNoteLinks = document.querySelectorAll('.nb-note-new')
    for (var i = 0; i < newNoteLinks.length; i++) {
      newNoteLinks[i].style.display = show
    }
    // Admin link
    document.getElementById('admin-link').style.display = state.canAdmin ? '' : 'none'
  }

  /**
   * Ensure identity exists for write operations.
   * Following wiredove: identity is generated client-side on demand.
   * Reads are open — no auth needed.
   * Returns true if identity is ready for writes, false if not.
   */
  async function ensureIdentity () {
    // Already have a keypair
    if (HnIdentity.hasIdentity()) return true

    // Legacy bearer token
    var token = HnApi.getToken()
    if (token) return true

    // Auto-generate keypair silently
    try {
      await HnIdentity.generate()
      await refreshPermissions()
      return true
    } catch (err) {
      // Noble crypto failed (e.g. non-secure context) — show manual flow
      showIdentityChoiceModal()
      return false
    }
  }

  // ==================== EVENT HANDLERS ====================

  // Sidebar toggle
  $sidebarToggle.addEventListener('click', function () {
    $sidebar.classList.toggle('collapsed')
  })

  // Close sidebar on mobile when tapping main content area
  document.getElementById('main').addEventListener('click', function () {
    if (window.innerWidth <= 768 && !$sidebar.classList.contains('collapsed')) {
      $sidebar.classList.add('collapsed')
    }
  })

  // New notebook
  $btnNewNotebook.addEventListener('click', showNewNotebookModal)

  // Note toolbar buttons
  document.getElementById('btn-edit-note').addEventListener('click', function () {
    if (state.currentNotebook && state.currentNote) {
      navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + state.currentNote.id + '/edit')
    }
  })

  document.getElementById('btn-delete-note').addEventListener('click', confirmDeleteNote)
  document.getElementById('btn-delete-notebook').addEventListener('click', confirmDeleteNotebook)
  document.getElementById('btn-note-links').addEventListener('click', showLinks)
  document.getElementById('btn-note-backlinks').addEventListener('click', showBacklinks)

  // Editor save/cancel
  document.getElementById('btn-save-note').addEventListener('click', saveNote)
  document.getElementById('btn-cancel-edit').addEventListener('click', function () {
    if (state.currentNotebook) {
      if (state.editingId) {
        navigate('#/' + encodeURIComponent(state.currentNotebook.slug) + '/' + state.editingId)
      } else {
        navigate('#/' + encodeURIComponent(state.currentNotebook.slug))
      }
    } else {
      navigate('#/')
    }
  })

  // Search toggle (mobile)
  document.getElementById('search-toggle').addEventListener('click', function () {
    $searchInput.classList.toggle('open')
    if ($searchInput.classList.contains('open')) {
      $searchInput.focus()
    }
  })

  // User directory
  document.getElementById('directory-toggle').addEventListener('click', showUserDirectory)

  // Search
  $searchInput.addEventListener('keydown', function (e) {
    if (e.key === 'Enter') {
      var q = $searchInput.value.trim()
      if (q) {
        $searchInput.classList.remove('open')
        $searchInput.blur()
        navigate('#/search?q=' + encodeURIComponent(q))
      }
    }
  })
  $searchInput.addEventListener('blur', function () {
    // Collapse on mobile after losing focus
    if (window.innerWidth <= 480) {
      setTimeout(function () { $searchInput.classList.remove('open') }, 200)
    }
  })

  // Auth token save (legacy)
  $authTokenSave.addEventListener('click', async function () {
    var token = $authTokenInput.value.trim()
    if (!token) return

    HnApi.setToken(token)
    hideAuth()
    await loadNotebooks()
    await refreshPermissions()
    onRoute()
  })

  // Modal overlay click to close
  $modalOverlay.addEventListener('click', function (e) {
    if (e.target === $modalOverlay) {
      hideModal()
    }
  })

  // Hash change routing
  window.addEventListener('hashchange', onRoute)

  // ==================== INIT ====================

  async function init () {
    // Load content immediately — reads are open, no auth gate (wiredove model)
    hideAuth()
    // Collapse sidebar on mobile by default
    if (window.innerWidth <= 768) {
      $sidebar.classList.add('collapsed')
    }
    updateProfileLink()
    await loadNotebooks()
    await refreshPermissions()
    onRoute()
  }

  init()
})()