dragoman 0.4.11

Server for scholarly metadata in commonmeta format stored in a SQLite database.
<script>
  import { fly, fade } from 'svelte/transition'

  const STORAGE_KEY = 'dragoman-history'
  const MAX_ENTRIES = 50

  let { open = $bindable(false), tab = $bindable('history'), onadd, savedItems = [], onremovesaved, onclearsaved, locale = 'en-US' } = $props()

  let expLoading = $state(false)
  let expCopied  = $state(false)
  let expError   = $state('')

  function serializeBookmarks() {
    const items = savedItems.map(item => {
      try { return JSON.parse(item.data) } catch { return null }
    }).filter(Boolean)
    return JSON.stringify(items.length === 1 ? items[0] : items, null, 2)
  }

  async function exportBookmarks() {
    expLoading = true; expError = ''
    try {
      const content = serializeBookmarks()
      const url = URL.createObjectURL(new Blob([content], { type: 'application/json' }))
      Object.assign(document.createElement('a'), { href: url, download: 'bookmarks.json' }).click()
      URL.revokeObjectURL(url)
    } catch (e) { expError = String(e) }
    finally { expLoading = false }
  }

  async function copyBookmarks() {
    expLoading = true; expError = ''; expCopied = false
    try {
      await navigator.clipboard.writeText(serializeBookmarks())
      expCopied = true
      setTimeout(() => { expCopied = false }, 2000)
    } catch (e) { expError = String(e) }
    finally { expLoading = false }
  }

  let entries = $state([])

  $effect(() => {
    if (open) entries = readHistory()
  })

  export function pushEntry(path, title, id) {
    const history = readHistory()
    // deduplicate: drop prior entry for same path
    const filtered = history.filter(e => e.path !== path)
    const next = [{ path, title, id: id ?? path, timestamp: Date.now() }, ...filtered].slice(0, MAX_ENTRIES)
    localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
  }

  function readHistory() {
    try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') }
    catch { return [] }
  }

  function deleteEntry(timestamp) {
    entries = entries.filter(e => e.timestamp !== timestamp)
    localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
  }

  function clearHistory() {
    entries = []
    localStorage.removeItem(STORAGE_KEY)
  }

  function bookmarkLabel(item) {
    try {
      const d = JSON.parse(item.data)
      return d.title || d.name || [d.given_name, d.family_name].filter(Boolean).join(' ') || item.id
    } catch { return item.id }
  }

  const rtf = $derived(new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }))

  function relativeTime(ts) {
    const diff = Date.now() - ts
    const sec  = Math.floor(diff / 1000)
    const min  = Math.floor(sec / 60)
    const hr   = Math.floor(min / 60)
    const day  = Math.floor(hr / 24)
    if (sec < 60)  return rtf.format(-sec,  'second')
    if (min < 60)  return rtf.format(-min,  'minute')
    if (hr  < 24)  return rtf.format(-hr,   'hour')
    if (day < 30)  return rtf.format(-day,  'day')
    return new Date(ts).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' })
  }

  function dayLabel(ts) {
    const d = new Date(ts)
    const today    = new Date(); today.setHours(0,0,0,0)
    const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1)
    if (d >= today)     return rtf.format(0, 'day')
    if (d >= yesterday) return rtf.format(-1, 'day')
    return d.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric' })
  }

  const dayGroups = $derived.by(() => {
    const groups = []
    let currentLabel = null
    let flatIdx = 0
    for (const entry of entries) {
      const label = dayLabel(entry.timestamp)
      if (label !== currentLabel) {
        groups.push({ label, items: [] })
        currentLabel = label
      }
      groups[groups.length - 1].items.push({ entry, flatIdx: flatIdx++ })
    }
    return groups
  })

  function itemPath(item) {
    if (item.id?.startsWith('https://doi.org/')) return '/' + item.id.replace('https://doi.org/', '')
    if (item.id?.startsWith('https://orcid.org/')) return '/' + item.id.replace('https://orcid.org/', '')
    if (item.id?.startsWith('https://ror.org/')) return '/' + item.id.replace('https://ror.org/', '')
    return item.id
  }

  let focusedIdx = $state(-1)

  $effect(() => { void tab; focusedIdx = -1 })
  $effect(() => { void open; focusedIdx = -1 })

  $effect(() => {
    if (!open) return
    const items = tab === 'history' ? entries : savedItems
    function handleKey(e) {
      if (e.key === 'ArrowDown') {
        e.preventDefault()
        focusedIdx = Math.min(focusedIdx + 1, items.length - 1)
      } else if (e.key === 'ArrowUp') {
        e.preventDefault()
        focusedIdx = Math.max(focusedIdx - 1, 0)
      } else if (e.key === 'Enter' && focusedIdx >= 0) {
        e.preventDefault()
        const item = items[focusedIdx]
        open = false
        window.location.href = tab === 'history' ? item.path : itemPath(item)
      }
    }
    window.addEventListener('keydown', handleKey)
    return () => window.removeEventListener('keydown', handleKey)
  })

  $effect(() => {
    if (focusedIdx >= 0) {
      document.querySelector(`[data-sheet-idx="${focusedIdx}"]`)?.scrollIntoView({ block: 'nearest' })
    }
  })
</script>

{#if open}
  <!-- Backdrop -->
  <div
    role="presentation"
    class="fixed inset-0 z-40 bg-black/40"
    transition:fade={{ duration: 150 }}
    onmousedown={() => { open = false }}
  ></div>

  <!-- Panel -->
  <div
    class="fixed right-0 top-0 bottom-0 z-50 w-96 bg-background border-l border-border shadow-xl flex flex-col"
    transition:fly={{ x: 384, duration: 250 }}
  >
    <!-- Header -->
    <div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
      <div class="flex items-center gap-4">
        <button
          type="button"
          onclick={() => { tab = 'history' }}
          class="text-sm font-semibold transition-colors {tab === 'history' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}"
        >History</button>
        <button
          type="button"
          onclick={() => { tab = 'bookmarks' }}
          class="text-sm font-semibold transition-colors {tab === 'bookmarks' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}"
        >Bookmarks</button>
      </div>
      <button
        type="button"
        aria-label="Close"
        onclick={() => { open = false }}
        class="text-muted-foreground hover:text-foreground transition-colors"
      >
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4">
          <path d="M18 6 6 18M6 6l12 12"/>
        </svg>
      </button>
    </div>

    <!-- History tab -->
    {#if tab === 'history'}
      <div class="flex-1 overflow-y-auto">
        {#if dayGroups.length === 0}
          <p class="px-5 py-8 text-sm text-muted-foreground text-center">No history yet.</p>
        {:else}
          {#each dayGroups as group}
            <p class="px-5 pt-4 pb-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">{group.label}</p>
            {#each group.items as { entry, flatIdx } (entry.timestamp)}
              <div data-sheet-idx={flatIdx} class="group flex items-start gap-2 px-5 py-2 hover:bg-accent transition-colors {flatIdx === focusedIdx ? 'bg-accent' : ''}">
                <a
                  href={entry.path}
                  class="flex-1 min-w-0"
                  onclick={() => { open = false }}
                >
                  <p class="text-sm font-medium truncate">{entry.title || entry.id || entry.path}</p>
                  <p class="text-xs text-muted-foreground font-mono truncate">{entry.id || entry.path}</p>
                  <p class="text-xs text-muted-foreground/60">{relativeTime(entry.timestamp)}</p>
                </a>
                <div class="flex flex-col gap-0.5 mt-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
                  {#if onadd && !savedItems.some(s => s.id === entry.id)}
                    <button
                      type="button"
                      aria-label="Add to saved"
                      onclick={async () => { if (await onadd(entry)) tab = 'bookmarks' }}
                      class="text-muted-foreground/40 hover:text-primary transition-colors"
                    >
                      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
                        <path d="M12 4.5v15m7.5-7.5h-15"/>
                      </svg>
                    </button>
                  {/if}
                  <button
                    type="button"
                    aria-label="Remove"
                    onclick={() => deleteEntry(entry.timestamp)}
                    class="text-muted-foreground/40 hover:text-destructive transition-colors"
                  >
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
                      <path d="M18 6 6 18M6 6l12 12"/>
                    </svg>
                  </button>
                </div>
              </div>
            {/each}
          {/each}
        {/if}
      </div>
      {#if entries.length > 0}
        <div class="shrink-0 border-t border-border bg-muted/50 px-4 py-3 flex items-center">
          <button
            type="button"
            onclick={clearHistory}
            class="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:text-destructive hover:border-destructive transition-colors"
          >
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
              <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
            </svg>
            Clear
          </button>
        </div>
      {/if}

    <!-- Bookmarks tab -->
    {:else}
      <div class="flex-1 overflow-y-auto">
        {#if savedItems.length === 0}
          <p class="px-5 py-8 text-sm text-muted-foreground text-center">No saved items yet.</p>
        {:else}
          {#each savedItems as item, idx (item.id)}
            <div data-sheet-idx={idx} class="group flex items-start gap-2 px-5 py-2 hover:bg-accent transition-colors {idx === focusedIdx ? 'bg-accent' : ''}">
              <a
                href={itemPath(item)}
                class="flex-1 min-w-0"
                onclick={() => { open = false }}
              >
                <p class="text-sm font-medium truncate">{bookmarkLabel(item)}</p>
                <p class="text-xs text-muted-foreground font-mono truncate">{item.id}</p>
              </a>
              {#if onremovesaved}
                <button
                  type="button"
                  aria-label="Remove bookmark"
                  onclick={() => onremovesaved(item.id)}
                  class="mt-0.5 shrink-0 text-muted-foreground/40 hover:text-destructive transition-colors opacity-0 group-hover:opacity-100"
                >
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
                    <path d="M18 6 6 18M6 6l12 12"/>
                  </svg>
                </button>
              {/if}
            </div>
          {/each}
        {/if}
      </div>
      {#if savedItems.length > 0}
        <div class="shrink-0 border-t border-border bg-muted/50 px-4 py-3 flex items-center gap-2 flex-wrap">
          <button
            type="button"
            onclick={exportBookmarks}
            disabled={expLoading}
            class="inline-flex items-center gap-1.5 rounded-md border border-border bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
          >
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
              <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
            </svg>
            Export
          </button>
          <button
            type="button"
            onclick={copyBookmarks}
            disabled={expLoading}
            class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50 transition-colors"
          >
            {#if expCopied}
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
                <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
              </svg>
              Copied
            {:else}
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
                <path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
              </svg>
              Copy
            {/if}
          </button>
          {#if onclearsaved}
            <button
              type="button"
              onclick={onclearsaved}
              class="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:text-destructive hover:border-destructive transition-colors"
            >
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
                <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
              </svg>
              Delete
            </button>
          {/if}
          {#if expError}
            <p class="w-full text-xs text-destructive">{expError}</p>
          {/if}
        </div>
      {/if}
    {/if}
  </div>
{/if}