/**
* 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">×</button>'
if (total > 1) {
html += '<button class="lightbox-nav lightbox-prev" title="Previous">‹</button>'
html += '<button class="lightbox-nav lightbox-next" title="Next">›</button>'
html += '<div class="lightbox-counter">' + (idx + 1) + ' / ' + total + '</div>'
}
html += '<img class="lightbox-img" src="' + img.src + '" alt="' + img.alt.replace(/"/g, '"') + '">'
if (img.caption) {
html += '<div class="lightbox-caption">' + img.caption.replace(/</g, '<') + '</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
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">🔒</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('')
} 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('')
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('')
})
.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('')
} 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">👤</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()
})()