<script>
import { fly, fade } from 'svelte/transition'
import { _ } from '@sveltia/i18n'
import { decodeEntities, entityPath, bookmarkPath, bookmarkRecord, containerId } from '$lib/bib-utils.js'
import { Select } from '$lib/components/ui/select/index.js'
import { bookmarkLink } from '$lib/bookmark-link.js'
import { encode } from 'uqr'
const STORAGE_KEY = 'dragoman-history'
const MAX_ENTRIES = 50
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
let { open = $bindable(false), tab = $bindable('history'), onadd, savedItems = [], onremovesaved, onreordersaved, onclearsaved, onimportsaved, linkImport = null, onlinkimport, ondismisslinkimport, savedFull = false, locale = 'en-US' } = $props()
let expCopied = $state(false)
let expError = $state('')
let impMessage = $state('')
let importInput = $state(null)
let linkImporting = $state(false)
// Beyond version 20 (97 modules a side) a printed or projected code is too
// dense for many phone cameras; the link itself still works when copied.
const QR_MAX_VERSION = 20
// Square and large enough for every social network's image upload, and for
// a slide or a printout.
const QR_PNG_SIZE = 1080
let bkmFormat = $state('commonmeta')
const expFormats = [
{ value: 'commonmeta', label: 'Commonmeta' },
{ value: 'browser', i18n: 'sheet.format_browser' },
{ value: 'qr', i18n: 'sheet.qr_code' },
]
const expFiles = { commonmeta: 'bookmarks.json', browser: 'bookmarks.html', qr: 'bookmarks-qr.png' }
const expTypes = { commonmeta: 'application/json', browser: 'text/html', qr: 'image/png' }
function escapeHtml(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
// The Netscape bookmark file every browser imports: one folder of links to the
// dragoman pages, titled as the panel titles them.
function browserBookmarks() {
const links = savedItems.map(item => {
const href = new URL(itemPath(item), window.location.origin).href
return ` <DT><A HREF="${escapeHtml(href)}">${escapeHtml(bookmarkLabel(item))}</A>`
})
return [
'<!DOCTYPE NETSCAPE-Bookmark-file-1>',
'<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
'<TITLE>Bookmarks</TITLE>',
'<H1>Bookmarks</H1>',
'<DL><p>',
' <DT><H3>Commonmeta</H3>',
' <DL><p>',
...links,
' </DL><p>',
'</DL><p>',
'',
].join('\n')
}
function serializeBookmarks() {
if (bkmFormat === 'browser') return browserBookmarks()
const items = savedItems.map(item => bookmarkRecord(item.data)).filter(Boolean)
return JSON.stringify(items.length === 1 ? items[0] : items, null, 2)
}
// A PNG QR code of the bookmark link, drawn with the strongest error
// correction that still fits: a short list gets a code that survives print,
// glare and a projector across the room. Modules are whole pixels, centred on
// a white square, so the code stays sharp however a network rescales it.
function qrCode(link) {
const ecc = ['H', 'Q', 'M', 'L'].find(e => encode(link, { ecc: e }).version <= QR_MAX_VERSION)
if (!ecc) throw new Error(_('sheet.qr_too_large'))
const qr = encode(link, { ecc, border: 4 })
const px = Math.floor(QR_PNG_SIZE / qr.size)
const offset = Math.floor((QR_PNG_SIZE - qr.size * px) / 2)
const canvas = Object.assign(document.createElement('canvas'), { width: QR_PNG_SIZE, height: QR_PNG_SIZE })
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, QR_PNG_SIZE, QR_PNG_SIZE)
ctx.fillStyle = '#000'
qr.data.forEach((row, y) => row.forEach((dark, x) => {
if (dark) ctx.fillRect(offset + x * px, offset + y * px, px, px)
}))
return new Promise((resolve, reject) =>
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG encoding failed')), 'image/png'))
}
const linkForBookmarks = () => bookmarkLink(savedItems.map(item => item.id), window.location.origin)
async function exportBookmarks() {
expError = ''
try {
const blob = bkmFormat === 'qr'
? await qrCode(await linkForBookmarks())
: new Blob([serializeBookmarks()], { type: expTypes[bkmFormat] })
const url = URL.createObjectURL(blob)
Object.assign(document.createElement('a'), { href: url, download: expFiles[bkmFormat] }).click()
URL.revokeObjectURL(url)
} catch (e) { expError = e.message ?? String(e) }
}
// For the QR code, the link it encodes: what a slide or a chat message needs.
async function copyBookmarks() {
expError = ''; expCopied = false
try {
await navigator.clipboard.writeText(bkmFormat === 'qr' ? await linkForBookmarks() : serializeBookmarks())
expCopied = true
setTimeout(() => { expCopied = false }, 2000)
} catch (e) { expError = String(e) }
}
// What a Commonmeta export writes: one record, or an array of them, each with
// the PID URL it is bookmarked under (a container's from its identifiers) and
// a title or name. Anything else —
// CSL-JSON, DataCite, ROR, Schema.org — is refused whole rather than imported
// in part. CSL-JSON's lowercase types (`article-journal`) tell it
// apart from Commonmeta's `JournalArticle`, DataCite's camelCase keys
// (`schemaVersion`) from Commonmeta's snake_case, and ROR's `names` array from
// an organization's `name`; a person or organization carries no type at all.
function commonmetaRecords(json) {
const records = Array.isArray(json) ? json : [json]
const isRecord = r => r && typeof r === 'object' && !Array.isArray(r)
&& (typeof r.id === 'string' ? /^https?:\/\//.test(r.id) : Boolean(containerId(r)))
&& !('@context' in r)
&& (r.type === undefined || /^[A-Z]/.test(r.type))
&& !Object.keys(r).some(k => /[a-z][A-Z]/.test(k))
&& ['title', 'titles', 'name', 'given_name', 'family_name'].some(k => k in r)
if (records.length === 0 || !records.every(isRecord)) throw new Error(_('sheet.import_invalid'))
return records
}
async function importFromLink() {
linkImporting = true; expError = ''; impMessage = ''
try {
const { added, skipped, failed } = await onlinkimport()
impMessage = _('sheet.link_import_done', { values: { added, skipped, failed } })
} catch (e) { expError = String(e) }
finally { linkImporting = false }
}
async function importBookmarks(e) {
const input = e.currentTarget
const file = input.files?.[0]
// Cleared so that choosing the same file again still fires `change`.
input.value = ''
if (!file) return
expError = ''; impMessage = ''
let json
try { json = JSON.parse(await file.text()) }
catch { expError = _('sheet.import_invalid'); return }
try {
const { added, skipped } = onimportsaved(commonmetaRecords(json))
impMessage = _('sheet.import_done', { values: { added, skipped } })
} catch (err) { expError = err.message }
}
let entries = $state([])
$effect(() => {
if (open) { entries = readHistory(); impMessage = '' }
})
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 {
const cutoff = Date.now() - MAX_AGE_MS
return (JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')).filter(e => e.timestamp > cutoff)
}
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) {
const d = bookmarkRecord(item.data)
const raw = d?.title || d?.titles?.[0]?.title || d?.name
|| [d?.given_name, d?.family_name].filter(Boolean).join(' ') || item.id
return decodeEntities(raw) ?? raw
}
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
})
const itemPath = item => bookmarkPath(item.id)
const entryPath = entityPath
let focusedIdx = $state(-1)
// Drag a bookmark row onto another: it lands above or below that row,
// whichever half the pointer is over, as on the entity page's cards.
let dragIdx = $state(null)
let dropAt = $state(null)
function onRowDragStart(e, idx) {
dragIdx = idx
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', savedItems[idx].id)
}
function onRowDragOver(e, idx) {
if (dragIdx === null) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const r = e.currentTarget.getBoundingClientRect()
dropAt = { idx, before: e.clientY < r.top + r.height / 2 }
}
function onRowDrop(e) {
e.preventDefault()
if (dragIdx !== null && dropAt) {
// The index the row ends up at once it has left its old place.
let to = dropAt.idx + (dropAt.before ? 0 : 1)
if (to > dragIdx) to--
if (to !== dragIdx) onreordersaved(dragIdx, to)
}
endDrag()
}
function endDrag() { dragIdx = null; dropAt = null }
$effect(() => { void tab; focusedIdx = -1 })
$effect(() => { void open; focusedIdx = -1 })
$effect(() => {
if (!open) return
const items = tab === 'history' ? entries : savedItems
function handleKey(e) {
// Alt-↑ / Alt-↓ carries the selected bookmark with it.
if (e.altKey && tab === 'bookmarks' && onreordersaved && focusedIdx >= 0
&& (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault()
const to = focusedIdx + (e.key === 'ArrowUp' ? -1 : 1)
if (to < 0 || to >= items.length) return
onreordersaved(focusedIdx, to)
focusedIdx = to
return
}
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' ? entryPath(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-[640px] bg-background border-l border-border shadow-xl flex flex-col"
transition:fly={{ x: 640, 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'}"
>{_('sheet.tab_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'}"
>{_('sheet.tab_bookmarks')}</button>
</div>
<button
type="button"
aria-label={_('sheet.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">{_('sheet.history_empty')}</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={entryPath(entry.path)}
class="flex-1 min-w-0"
onclick={() => { open = false }}
>
<p class="text-sm font-medium truncate">{decodeEntities(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={_('sheet.add_to_saved')}
disabled={savedFull}
onclick={async () => { if (await onadd(entry)) tab = 'bookmarks' }}
class="text-muted-foreground/40 hover:text-primary transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<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={_('sheet.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>
{_('sheet.clear')}
</button>
</div>
{/if}
<!-- Bookmarks tab -->
{:else}
<div class="flex-1 overflow-y-auto">
{#if linkImport}
<div class="mx-5 mt-4 rounded-md border border-border bg-muted/50 px-4 py-3 flex items-center gap-2 flex-wrap">
<p class="flex-1 min-w-40 text-sm">{_('sheet.link_import', { values: { count: linkImport.length } })}</p>
<button
type="button"
onclick={importFromLink}
disabled={linkImporting || savedFull}
class="inline-flex items-center 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"
>{_('sheet.import')}</button>
<button
type="button"
onclick={ondismisslinkimport}
disabled={linkImporting}
class="inline-flex items-center rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50 transition-colors"
>{_('sheet.dismiss')}</button>
</div>
{/if}
{#if savedItems.length === 0}
<p class="px-5 py-8 text-sm text-muted-foreground text-center">{_('sheet.bookmarks_empty')}</p>
{:else}
{#each savedItems as item, idx (item.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-sheet-idx={idx}
draggable={Boolean(onreordersaved)}
ondragstart={e => onRowDragStart(e, idx)}
ondragover={e => onRowDragOver(e, idx)}
ondragleave={() => { if (dropAt?.idx === idx) dropAt = null }}
ondrop={onRowDrop}
ondragend={endDrag}
class="group flex items-start gap-2 px-5 py-2 hover:bg-accent transition-colors
{idx === focusedIdx ? 'bg-accent' : ''}
{onreordersaved ? 'cursor-grab active:cursor-grabbing' : ''}
{dragIdx === idx ? 'opacity-30' : ''}
border-y-2 {dropAt?.idx === idx && dropAt.before ? 'border-t-primary' : 'border-t-transparent'}
{dropAt?.idx === idx && !dropAt.before ? 'border-b-primary' : 'border-b-transparent'}"
>
{#if onreordersaved}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class="mt-1 w-3.5 h-3.5 shrink-0 text-muted-foreground/40 opacity-0 group-hover:opacity-100 transition-opacity">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
</svg>
{/if}
<a
href={itemPath(item)}
draggable="false"
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={_('sheet.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>
<div class="shrink-0 border-t border-border bg-muted/50 px-4 py-3 flex items-center gap-2 flex-wrap">
{#if savedItems.length > 0}
<Select bind:value={bkmFormat} class="h-8 text-xs py-0 w-44">
{#each expFormats as f}
<option value={f.value}>{f.i18n ? _(f.i18n) : f.label}</option>
{/each}
</Select>
<button
type="button"
onclick={exportBookmarks}
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 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>
{_('bibliography.export')}
</button>
<button
type="button"
onclick={copyBookmarks}
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 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>
{_('bibliography.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>
{_('bibliography.copy')}
{/if}
</button>
{/if}
{#if onimportsaved}
<input bind:this={importInput} type="file" accept=".json,application/json" class="hidden" onchange={importBookmarks} />
<button
type="button"
onclick={() => importInput?.click()}
disabled={savedFull}
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 disabled:cursor-not-allowed 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.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
{_('sheet.import')}
</button>
{/if}
{#if onclearsaved && savedItems.length > 0}
<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>
{_('bibliography.delete')}
</button>
{/if}
{#if expError}
<p class="w-full text-xs text-destructive">{expError}</p>
{/if}
{#if impMessage}
<p class="w-full text-xs text-muted-foreground">{impMessage}</p>
{/if}
</div>
{/if}
</div>
{/if}