defter 0.6.2

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
// Appended to htmx (vendor/) by build.rs; both run as one deferred script.

// The theme toggle. The button's label switches by CSS (dark:hidden), and an
// inline script in the page head applies the stored theme before first paint.
document.addEventListener('click', (event) => {
  if (!event.target.closest('[data-theme-toggle]')) return
  const dark = document.documentElement.classList.toggle('dark')
  localStorage.setItem('defter-dark', String(dark))
})

// The public profile's facets, which are links and <details> and work without
// any of this: open or close them all, and keep them as they were when htmx
// replaces the list of works.
function syncFacetsToggle() {
  const toggle = document.querySelector('[data-facets-toggle]')
  if (!toggle) return
  const open = [...document.querySelectorAll('details[data-facet]')].every((f) => f.open)
  toggle.textContent = open ? toggle.dataset.closeLabel : toggle.dataset.openLabel
}

document.addEventListener('click', (event) => {
  if (!event.target.closest('[data-facets-toggle]')) return
  const facets = [...document.querySelectorAll('details[data-facet]')]
  const open = !facets.every((f) => f.open)
  facets.forEach((f) => { f.open = open })
})

// `toggle` does not bubble, so this listens on the way down.
document.addEventListener('toggle', syncFacetsToggle, true)

let openFacets = new Map()
let exportChoice = null

// A citation style is only for formatted citations.
function syncExportStyle() {
  const style = document.querySelector('[data-export-style]')
  if (!style) return
  style.hidden = document.querySelector('[data-export-format]').value !== 'citation'
}

document.addEventListener('change', (event) => {
  if (event.target.matches('[data-export-format]')) syncExportStyle()
})

document.addEventListener('DOMContentLoaded', () => {
  syncFacetsToggle()
  syncExportStyle()
})

document.addEventListener('htmx:beforeSwap', () => {
  openFacets = new Map(
    [...document.querySelectorAll('details[data-facet]')].map((f) => [f.dataset.facet, f.open])
  )
  const format = document.querySelector('[data-export-format]')
  const style = document.querySelector('[data-export-style]')
  exportChoice = format && style ? [format.value, style.value] : null
})

document.addEventListener('htmx:afterSwap', () => {
  document.querySelectorAll('details[data-facet]').forEach((f) => {
    f.open = openFacets.get(f.dataset.facet) ?? f.open
  })
  const format = document.querySelector('[data-export-format]')
  if (format && exportChoice) {
    format.value = exportChoice[0]
    document.querySelector('[data-export-style]').value = exportChoice[1]
  }
  syncFacetsToggle()
  syncExportStyle()
})

// A tick on a copy button for a moment, and its label if it has one.
function showCopied(button) {
  const [copy, copied] = button.querySelectorAll('svg')
  const label = button.querySelector('span')
  const text = label?.textContent
  copy.classList.add('hidden')
  copied.classList.remove('hidden')
  if (label) label.textContent = button.dataset.copiedLabel
  setTimeout(() => {
    copy.classList.remove('hidden')
    copied.classList.add('hidden')
    if (label) label.textContent = text
  }, 2000)
}

// Copy what a URL answers. Safari only lets a page write to the clipboard during
// the click, so where it can, this hands over the answer still to come.
async function copyFrom(url) {
  const text = fetch(url).then((response) => {
    if (!response.ok) throw new Error(`${url}: ${response.status}`)
    return response.text()
  })
  if (typeof ClipboardItem !== 'undefined' && navigator.clipboard.write) {
    const blob = text.then((value) => new Blob([value], { type: 'text/plain' }))
    await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })])
  } else {
    await navigator.clipboard.writeText(await text)
  }
}

// The export, to the clipboard rather than to a file.
document.addEventListener('click', async (event) => {
  const button = event.target.closest('[data-export-copy]')
  if (!button) return
  const form = button.closest('form')
  const query = new URLSearchParams(new FormData(form))
  await copyFrom(`${form.action}?${query}`)
  showCopied(button)
})

// One work, in the format picked for exports.
document.addEventListener('click', async (event) => {
  const button = event.target.closest('[data-copy-work]')
  if (!button) return
  const form = document.querySelector('[data-export]')
  const picked = new FormData(form)
  const query = new URLSearchParams({
    key: button.dataset.copyWork,
    format: picked.get('format'),
    style: picked.get('style'),
    lang: picked.get('lang'),
  })
  await copyFrom(`${form.action}?${query}`)
  showCopied(button)
})

// Copy what a button holds.
document.addEventListener('click', async (event) => {
  const button = event.target.closest('[data-copy]')
  if (!button) return
  await navigator.clipboard.writeText(button.dataset.copy)
  showCopied(button)
})

// The language switcher: the same page in another language. The URL has
// whatever htmx has made of the list since the page was rendered.
document.addEventListener('change', (event) => {
  if (!event.target.matches('[data-language]')) return
  // Pages htmx does not change, some of them answers to a POST, say where to go.
  if (event.target.matches('[data-language-submit]')) {
    event.target.form.submit()
    return
  }
  const url = new URL(window.location.href)
  url.searchParams.set('lang', event.target.value)
  window.location.href = url.href
})