248 lines
7.4 KiB
Svelte
248 lines
7.4 KiB
Svelte
<script lang="ts">
|
|
import {type TrustedEvent} from "@welshman/util"
|
|
import Button from "@lib/components/Button.svelte"
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
import Link from "@lib/components/Link.svelte"
|
|
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
|
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
|
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
|
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
|
import Folder from "@assets/icons/folder.svg?dataurl"
|
|
import Add from "@assets/icons/add.svg?dataurl"
|
|
|
|
type BookmarkList = {
|
|
key: string
|
|
label: string
|
|
count: number
|
|
event?: TrustedEvent
|
|
}
|
|
|
|
type Props = {
|
|
lists: BookmarkList[]
|
|
selectedKey: string
|
|
totalCount: number
|
|
onOpenManager: () => void
|
|
onSelect: (key: string) => void
|
|
onRename: (key: string, label: string, nextLabel: string) => Promise<void>
|
|
onDelete: (key: string, label: string) => Promise<void>
|
|
}
|
|
|
|
const {lists, selectedKey, totalCount, onOpenManager, onSelect, onRename, onDelete}: Props =
|
|
$props()
|
|
|
|
let menuOpen = $state(false)
|
|
let menuX = $state(0)
|
|
let menuY = $state(0)
|
|
let menuListKey = $state("")
|
|
let menuListLabel = $state("")
|
|
let listDialogMode: "rename" | "delete" | undefined = $state()
|
|
let dialogListKey = $state("")
|
|
let dialogListLabel = $state("")
|
|
let nextListLabel = $state("")
|
|
let dialogPending = $state(false)
|
|
|
|
const closeMenu = () => {
|
|
menuOpen = false
|
|
}
|
|
|
|
const openListMenu = (event: MouseEvent, key: string, label: string) => {
|
|
event.preventDefault()
|
|
menuOpen = true
|
|
menuX = event.clientX
|
|
menuY = event.clientY
|
|
menuListKey = key
|
|
menuListLabel = label
|
|
}
|
|
|
|
const openMenuList = () => {
|
|
closeMenu()
|
|
onSelect(menuListKey)
|
|
}
|
|
|
|
const openRenameDialog = () => {
|
|
closeMenu()
|
|
listDialogMode = "rename"
|
|
dialogListKey = menuListKey
|
|
dialogListLabel = menuListLabel
|
|
nextListLabel = menuListLabel
|
|
}
|
|
|
|
const openDeleteDialog = () => {
|
|
closeMenu()
|
|
listDialogMode = "delete"
|
|
dialogListKey = menuListKey
|
|
dialogListLabel = menuListLabel
|
|
nextListLabel = menuListLabel
|
|
}
|
|
|
|
const closeDialog = (force = false) => {
|
|
if (!force && dialogPending) {
|
|
return
|
|
}
|
|
|
|
listDialogMode = undefined
|
|
dialogListKey = ""
|
|
dialogListLabel = ""
|
|
nextListLabel = ""
|
|
}
|
|
|
|
const submitRename = async () => {
|
|
if (listDialogMode !== "rename") {
|
|
return
|
|
}
|
|
|
|
dialogPending = true
|
|
|
|
try {
|
|
await onRename(dialogListKey, dialogListLabel, nextListLabel)
|
|
closeDialog(true)
|
|
} finally {
|
|
dialogPending = false
|
|
}
|
|
}
|
|
|
|
const submitDelete = async () => {
|
|
if (listDialogMode !== "delete") {
|
|
return
|
|
}
|
|
|
|
dialogPending = true
|
|
|
|
try {
|
|
await onDelete(dialogListKey, dialogListLabel)
|
|
closeDialog(true)
|
|
} finally {
|
|
dialogPending = false
|
|
}
|
|
}
|
|
|
|
const handleWindowKeydown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
closeMenu()
|
|
closeDialog()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onclick={closeMenu} onkeydown={handleWindowKeydown} />
|
|
|
|
<SecondaryNav class="relative">
|
|
<SecondaryNavSection>
|
|
<SecondaryNavHeader>
|
|
<span class="flex items-center gap-2 uppercase tracking-wide">
|
|
<Icon icon={Bookmark} />
|
|
Bookmarks
|
|
</span>
|
|
<span class="badge badge-sm badge-neutral">{totalCount}</span>
|
|
</SecondaryNavHeader>
|
|
<div
|
|
class="flex items-center justify-between px-1 pt-1 text-xs uppercase tracking-wide opacity-70">
|
|
<span>My Lists</span>
|
|
<Button class="btn btn-ghost btn-xs btn-square" onclick={onOpenManager}>
|
|
<Icon size={3.5} icon={Add} />
|
|
</Button>
|
|
</div>
|
|
</SecondaryNavSection>
|
|
|
|
<div class="col-2 gap-2 overflow-y-auto px-2 pb-2">
|
|
{#each lists as list (list.key)}
|
|
<div
|
|
role="button"
|
|
tabindex="-1"
|
|
oncontextmenu={event => openListMenu(event, list.key, list.label)}>
|
|
<Link href={`/bookmarks?list=${encodeURIComponent(list.key)}`}>
|
|
<div
|
|
class={`card2 card2-sm bg-alt col-2 gap-1 transition-colors hover:bg-base-100 ${selectedKey === list.key ? "bg-base-100" : ""}`}>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<p class="truncate font-medium">
|
|
<span class="inline-flex items-center gap-2">
|
|
<Icon size={4} icon={list.key.startsWith("10003:") ? Bookmark : Folder} />
|
|
{list.label}
|
|
</span>
|
|
</p>
|
|
<span class="badge badge-sm badge-neutral">{list.count}</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
{:else}
|
|
<div class="card2 card2-sm bg-alt text-sm opacity-70">No lists found yet.</div>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if menuOpen}
|
|
<div class="fixed inset-0 z-popover" role="presentation">
|
|
<div
|
|
class="menu rounded-box bg-base-100 shadow-xl"
|
|
role="menu"
|
|
style={`position: fixed; left: ${menuX}px; top: ${menuY}px; min-width: 11rem;`}>
|
|
<li>
|
|
<Button class="justify-start" onclick={openMenuList}>Open List</Button>
|
|
</li>
|
|
{#if menuListKey !== selectedKey}
|
|
<li>
|
|
<Button class="justify-start" onclick={openRenameDialog}>Rename List</Button>
|
|
</li>
|
|
<li>
|
|
<Button class="justify-start text-error" onclick={openDeleteDialog}>Delete List</Button>
|
|
</li>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if listDialogMode}
|
|
<div class="fixed inset-0 z-modal bg-black/45 p-4 md:p-6">
|
|
<button
|
|
type="button"
|
|
class="absolute inset-0"
|
|
aria-label="Close dialog"
|
|
onclick={() => closeDialog()}></button>
|
|
<div class="center relative h-full w-full">
|
|
<div
|
|
class="card2 bg-base-100 w-full max-w-md gap-4 p-5 shadow-2xl"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1">
|
|
{#if listDialogMode === "rename"}
|
|
<div class="col-2 gap-1">
|
|
<h3 class="text-lg font-semibold">Rename List</h3>
|
|
<p class="text-sm opacity-70">Choose a new name for "{dialogListLabel}".</p>
|
|
</div>
|
|
|
|
<label class="input input-bordered flex items-center gap-2">
|
|
<Icon icon={Folder} />
|
|
<input bind:value={nextListLabel} class="grow" type="text" placeholder="List name" />
|
|
</label>
|
|
|
|
<div class="flex items-center justify-end gap-2">
|
|
<Button class="btn btn-ghost" onclick={() => closeDialog()} disabled={dialogPending}
|
|
>Cancel</Button>
|
|
<Button
|
|
class="btn btn-neutral"
|
|
onclick={submitRename}
|
|
disabled={dialogPending || !nextListLabel.trim()}>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<div class="col-2 gap-1">
|
|
<h3 class="text-lg font-semibold">Delete List?</h3>
|
|
<p class="text-sm opacity-70">
|
|
This will remove "{dialogListLabel}" and cannot be undone.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-end gap-2">
|
|
<Button class="btn btn-ghost" onclick={() => closeDialog()} disabled={dialogPending}
|
|
>Cancel</Button>
|
|
<Button class="btn btn-error" onclick={submitDelete} disabled={dialogPending}
|
|
>Delete</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</SecondaryNav>
|