feat: implement bookmarks page with list management and deep-link scroll targeting #196
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {displayPubkey} from "@welshman/util"
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import {displayProfileByPubkey} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
|
||||
type BookmarkItem = {
|
||||
key: string
|
||||
event: TrustedEvent
|
||||
href: string
|
||||
external: boolean
|
||||
image: string | undefined
|
||||
video: string | undefined
|
||||
contentType: "image" | "video" | "text"
|
||||
preview: string
|
||||
pollOptions: string[]
|
||||
searchable: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
item: BookmarkItem
|
||||
showAddToSaved: boolean
|
||||
onOpen: (item: BookmarkItem) => Promise<void> | void
|
||||
onCopyLink: (item: BookmarkItem) => Promise<void> | void
|
||||
onAddToSaved: (item: BookmarkItem) => Promise<void> | void
|
||||
onRemove: (item: BookmarkItem) => Promise<void> | void
|
||||
}
|
||||
|
||||
const {item, showAddToSaved, onOpen, onCopyLink, onAddToSaved, onRemove}: Props = $props()
|
||||
|
||||
let openMenu = $state(false)
|
||||
|
||||
const closeMenu = () => {
|
||||
openMenu = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMenu = !openMenu
|
||||
}
|
||||
|
||||
const openItem = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeMenu()
|
||||
await onOpen(item)
|
||||
}
|
||||
|
||||
const copyLink = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeMenu()
|
||||
await onCopyLink(item)
|
||||
}
|
||||
|
||||
const addToSaved = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeMenu()
|
||||
await onAddToSaved(item)
|
||||
}
|
||||
|
||||
const removeFromList = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeMenu()
|
||||
await onRemove(item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={closeMenu} />
|
||||
|
||||
<Link
|
||||
href={item.href}
|
||||
external={item.external}
|
||||
class="card2 bg-alt col-2 w-full gap-3 p-3 transition-colors hover:bg-base-100 md:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProfileCircle pubkey={item.event.pubkey} size={5} />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-primary">
|
||||
{displayProfileByPubkey(item.event.pubkey)}
|
||||
</p>
|
||||
<p class="truncate text-[11px] opacity-60">
|
||||
{displayPubkey(item.event.pubkey)} · {formatTimestamp(item.event.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" role="presentation">
|
||||
<Button class="btn btn-ghost btn-xs btn-square" onclick={toggleMenu}>
|
||||
<Icon size={4} icon={MenuDots} />
|
||||
</Button>
|
||||
|
||||
{#if openMenu}
|
||||
<ul
|
||||
class="menu absolute right-0 top-7 z-popover w-44 rounded-box bg-base-100 p-2 shadow-xl"
|
||||
role="menu">
|
||||
<li>
|
||||
<Button class="justify-start" onclick={openItem}>Open bookmark</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button class="justify-start" onclick={copyLink}>Copy link</Button>
|
||||
</li>
|
||||
{#if showAddToSaved}
|
||||
<li>
|
||||
<Button class="justify-start" onclick={addToSaved}>Add to Saved Items</Button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<Button class="justify-start text-error" onclick={removeFromList}
|
||||
>Remove from this list</Button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if item.image}
|
||||
<ContentLinkBlockImage
|
||||
value={{url: item.image}}
|
||||
event={item.event}
|
||||
class="max-h-[28rem] w-full rounded-lg object-contain" />
|
||||
{:else if item.video}
|
||||
<video
|
||||
src={item.video}
|
||||
class="max-h-[28rem] w-full rounded-lg object-contain"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"></video>
|
||||
{/if}
|
||||
|
||||
<NoteContentMinimal event={item.event} />
|
||||
</Link>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
||||
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
type Props = {
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {event}: Props = $props()
|
||||
|
||||
const bookmark = () => pushModal(BookmarkListPicker, {event})
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<Button onclick={bookmark}>
|
||||
<Icon size={4} icon={Bookmark} />
|
||||
Bookmark
|
||||
</Button>
|
||||
</li>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import BookmarkCard from "@app/components/BookmarkCard.svelte"
|
||||
|
||||
type BookmarkItem = {
|
||||
key: string
|
||||
event: TrustedEvent
|
||||
href: string
|
||||
external: boolean
|
||||
image: string | undefined
|
||||
video: string | undefined
|
||||
contentType: "image" | "video" | "text"
|
||||
preview: string
|
||||
pollOptions: string[]
|
||||
searchable: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
items: BookmarkItem[]
|
||||
showAddToSaved: boolean
|
||||
onOpen: (item: BookmarkItem) => Promise<void> | void
|
||||
onCopyLink: (item: BookmarkItem) => Promise<void> | void
|
||||
onAddToSaved: (item: BookmarkItem) => Promise<void> | void
|
||||
onRemove: (item: BookmarkItem) => Promise<void> | void
|
||||
}
|
||||
|
||||
const {items, showAddToSaved, onOpen, onCopyLink, onAddToSaved, onRemove}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-wrap content-start items-stretch gap-3">
|
||||
{#each items as item (item.key)}
|
||||
<BookmarkCard {item} {showAddToSaved} {onOpen} {onCopyLink} {onAddToSaved} {onRemove} />
|
||||
{:else}
|
||||
<div class="card2 bg-alt col-2 max-w-lg p-5 text-sm opacity-80">
|
||||
No items match your current filters.
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -2,7 +2,14 @@
|
||||
import {onMount} from "svelte"
|
||||
import {first, uniqBy} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {DELETE, getTagValue, getTagValues, sortEventsDesc} from "@welshman/util"
|
||||
import {
|
||||
Address,
|
||||
DELETE,
|
||||
getAddress,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
sortEventsDesc,
|
||||
} from "@welshman/util"
|
||||
import {load} from "@welshman/net"
|
||||
import {pubkey, waitForThunkError} from "@welshman/app"
|
||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
||||
@@ -18,7 +25,13 @@
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {createBookmarkList, addEventBookmark, deleteBookmarkList} from "@app/core/commands"
|
||||
import {deriveEvents, INDEXER_RELAYS, shouldIgnoreError} from "@app/core/state"
|
||||
import {
|
||||
BOOKMARKS,
|
||||
BOOKMARK_LISTS,
|
||||
deriveEvents,
|
||||
INDEXER_RELAYS,
|
||||
shouldIgnoreError,
|
||||
} from "@app/core/state"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
|
||||
|
hodlbod marked this conversation as resolved
|
||||
type Props = {
|
||||
@@ -36,10 +49,14 @@
|
||||
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
|
||||
|
hodlbod
commented
These kinds shouldn't be hard-coded These kinds shouldn't be hard-coded
hodlbod
commented
Also, no need to derive deletes, deriveEvents automatically filters out deleted stuff. Also, no need to derive deletes, deriveEvents automatically filters out deleted stuff.
|
||||
|
||||
const getListKey = (listEvent: TrustedEvent) =>
|
||||
`${listEvent.kind}:${getTagValue("d", listEvent.tags) || ""}`
|
||||
listEvent.kind === BOOKMARKS
|
||||
? new Address(BOOKMARKS, listEvent.pubkey, "").toString()
|
||||
: getAddress(listEvent)
|
||||
|
||||
const getListLabel = (listEvent: TrustedEvent) =>
|
||||
getTagValue("d", listEvent.tags) || (listEvent.kind === 10003 ? "Saved Items" : "Untitled List")
|
||||
getTagValue("title", listEvent.tags) ||
|
||||
getTagValue("d", listEvent.tags) ||
|
||||
|
hodlbod
commented
Remove the d fallback, if it doesn't have a title we should just show untitled. Remove the d fallback, if it doesn't have a title we should just show untitled.
|
||||
(listEvent.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
|
||||
|
||||
const userLists = $derived(
|
||||
sortEventsDesc($listEvents).filter(
|
||||
@@ -54,8 +71,7 @@
|
||||
)
|
||||
|
||||
const isDeletedList = (listEvent: TrustedEvent) => {
|
||||
const d = getTagValue("d", listEvent.tags) || ""
|
||||
const address = `${listEvent.kind}:${listEvent.pubkey}:${d}`
|
||||
const address = getListKey(listEvent)
|
||||
|
||||
return userDeletes.some(deleteEvent => {
|
||||
if (deleteEvent.created_at < listEvent.created_at) {
|
||||
@@ -82,13 +98,13 @@
|
||||
}),
|
||||
)
|
||||
|
hodlbod
commented
Just return the events themselves here and save the rendering stuff for elsewhere Just return the events themselves here and save the rendering stuff for elsewhere
|
||||
|
||||
const savedItems = first(mapped.filter(list => list.key === "10003:")) || {
|
||||
key: "10003:",
|
||||
const savedItems = first(mapped.filter(list => Address.from(list.key).kind === BOOKMARKS)) || {
|
||||
key: $pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "10003:",
|
||||
label: "Saved Items",
|
||||
count: 0,
|
||||
}
|
||||
|
||||
return [savedItems, ...mapped.filter(list => list.key !== "10003:")]
|
||||
return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
|
||||
})
|
||||
|
||||
let listName = $state("")
|
||||
@@ -159,7 +175,7 @@
|
||||
}
|
||||
|
||||
const deleteList = async (key: string, label: string) => {
|
||||
if (deleting || key === "10003:") {
|
||||
if (deleting || Address.from(key).kind === BOOKMARKS) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,7 +214,7 @@
|
||||
|
||||
await load({
|
||||
relays: INDEXER_RELAYS,
|
||||
filters: [{kinds: [10003, 30003, DELETE], authors: [$pubkey]}],
|
||||
filters: [{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE], authors: [$pubkey]}],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,7 +265,9 @@
|
||||
onclick={() => addToList(list.key, list.label)}
|
||||
disabled={selecting}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Icon size={4} icon={list.key === "10003:" ? Bookmark : Folder} />
|
||||
<Icon
|
||||
size={4}
|
||||
icon={Address.from(list.key).kind === BOOKMARKS ? Bookmark : Folder} />
|
||||
{list.label}
|
||||
</span>
|
||||
<span class="badge badge-sm badge-neutral">{list.count}</span>
|
||||
@@ -257,12 +275,14 @@
|
||||
{:else}
|
||||
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-2">
|
||||
<span class="min-w-0 truncate inline-flex items-center gap-2">
|
||||
<Icon size={4} icon={list.key === "10003:" ? Bookmark : Folder} />
|
||||
<Icon
|
||||
size={4}
|
||||
icon={Address.from(list.key).kind === BOOKMARKS ? Bookmark : Folder} />
|
||||
{list.label}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-sm badge-neutral">{list.count}</span>
|
||||
{#if list.key !== "10003:"}
|
||||
{#if Address.from(list.key).kind !== BOOKMARKS}
|
||||
<Button
|
||||
class="btn btn-ghost btn-xs btn-square text-error"
|
||||
onclick={() => deleteList(list.key, list.label)}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<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>
|
||||
@@ -12,6 +12,7 @@
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
@@ -51,6 +52,7 @@
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Event">
|
||||
{#snippet customActions()}
|
||||
<BookmarkEventMenuItem {event} />
|
||||
{#if event.pubkey === $pubkey}
|
||||
<li>
|
||||
<Button onclick={editEvent}>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
||||
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
@@ -64,6 +65,7 @@
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Listing">
|
||||
{#snippet customActions()}
|
||||
<BookmarkEventMenuItem {event} />
|
||||
{#if event.pubkey === $pubkey}
|
||||
<li>
|
||||
<Button onclick={editClassified}>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
@@ -34,6 +35,10 @@
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Comment" />
|
||||
<EventActions {url} {event} noun="Comment">
|
||||
{#snippet customActions()}
|
||||
<BookmarkEventMenuItem {event} />
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
import {userProfile} from "@welshman/app"
|
||||
import {onMount} from "svelte"
|
||||
import {uniqBy} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
Address,
|
||||
DELETE,
|
||||
getAddress,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
sortEventsDesc,
|
||||
} from "@welshman/util"
|
||||
import {load} from "@welshman/net"
|
||||
import {pubkey, userProfile} from "@welshman/app"
|
||||
import Letter from "@assets/icons/letter.svg?dataurl"
|
||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
@@ -8,11 +20,19 @@
|
||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import Divider from "@lib/components/Divider.svelte"
|
||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
|
||||
import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
|
||||
import {
|
||||
BOOKMARKS,
|
||||
BOOKMARK_LISTS,
|
||||
deriveEvents,
|
||||
INDEXER_RELAYS,
|
||||
loadUserBookmarkCollections,
|
||||
loadUserBookmarkList,
|
||||
userSpaceUrls,
|
||||
PLATFORM_RELAYS,
|
||||
} from "@app/core/state"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {goToChat, makeSpacePath} from "@app/util/routes"
|
||||
@@ -30,18 +50,90 @@
|
||||
const anySpaceNotifications = $derived(
|
||||
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
||||
)
|
||||
|
||||
const bookmarkListEvents = deriveEvents([{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE]}])
|
||||
let loadedBookmarkCountPubkey: string | undefined = $state()
|
||||
|
||||
const getListKey = (event: TrustedEvent) =>
|
||||
event.kind === BOOKMARKS
|
||||
? new Address(BOOKMARKS, event.pubkey, "").toString()
|
||||
: getAddress(event)
|
||||
|
||||
const isDeletedList = (event: TrustedEvent, deleteEvents: TrustedEvent[]) => {
|
||||
const address =
|
||||
event.kind === BOOKMARKS
|
||||
? new Address(BOOKMARKS, event.pubkey, "").toString()
|
||||
: `${event.kind}:${event.pubkey}:${getTagValue("d", event.tags) || ""}`
|
||||
|
||||
return deleteEvents.some(deleteEvent => {
|
||||
if (deleteEvent.created_at < event.created_at) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
getTagValues("e", deleteEvent.tags).includes(event.id) ||
|
||||
getTagValues("a", deleteEvent.tags).includes(address)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const bookmarkListCount = $derived.by(() => {
|
||||
if (!$pubkey) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const ownEvents = sortEventsDesc($bookmarkListEvents).filter(event => event.pubkey === $pubkey)
|
||||
const deleteEvents = ownEvents.filter(event => event.kind === DELETE)
|
||||
const listEvents = ownEvents.filter(
|
||||
event => event.kind === BOOKMARKS || event.kind === BOOKMARK_LISTS,
|
||||
)
|
||||
const visibleLists = uniqBy(getListKey, listEvents).filter(
|
||||
event => !isDeletedList(event, deleteEvents),
|
||||
)
|
||||
const hasSavedItems = visibleLists.some(event => event.kind === BOOKMARKS)
|
||||
|
||||
return visibleLists.length + (hasSavedItems ? 0 : 1)
|
||||
})
|
||||
|
||||
const loadBookmarkCountData = async () => {
|
||||
if (!$pubkey || loadedBookmarkCountPubkey === $pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
loadedBookmarkCountPubkey = $pubkey
|
||||
|
||||
await Promise.all([
|
||||
loadUserBookmarkList(),
|
||||
loadUserBookmarkCollections(),
|
||||
load({
|
||||
relays: INDEXER_RELAYS,
|
||||
filters: [{kinds: [DELETE], authors: [$pubkey]}],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadBookmarkCountData()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($pubkey && loadedBookmarkCountPubkey !== $pubkey) {
|
||||
void loadBookmarkCountData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
|
||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||
<PrimaryNavSpaces />
|
||||
{#if PLATFORM_RELAYS.length > 0}
|
||||
<Divider />
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
|
||||
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
|
||||
<div class="relative">
|
||||
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
|
||||
<span class="badge badge-xs badge-neutral absolute -right-2 -top-2"
|
||||
>{bookmarkListCount}</span>
|
||||
</div>
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||
{#if $userProfile?.picture}
|
||||
@@ -83,7 +175,11 @@
|
||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||
</PrimaryNavItem>
|
||||
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
|
||||
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
|
||||
<div class="relative">
|
||||
<ImageIcon alt="Bookmarks" src={Bookmark} size={8} />
|
||||
<span class="badge badge-xs badge-neutral absolute -right-2 -top-2"
|
||||
>{bookmarkListCount}</span>
|
||||
</div>
|
||||
</PrimaryNavItem>
|
||||
{#if PLATFORM_RELAYS.length !== 1}
|
||||
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeThreadPath, makeSpacePath} from "@app/util/routes"
|
||||
|
||||
@@ -41,5 +42,9 @@
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Thread" />
|
||||
<EventActions {url} {event} noun="Thread">
|
||||
{#snippet customActions()}
|
||||
<BookmarkEventMenuItem {event} />
|
||||
{/snippet}
|
||||
</EventActions>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
simpleCache,
|
||||
normalizeUrl,
|
||||
nthNe,
|
||||
randomId,
|
||||
} from "@welshman/lib"
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
import type {UploadTask} from "@welshman/editor"
|
||||
@@ -33,11 +34,11 @@ import {
|
||||
ROOMS,
|
||||
COMMENT,
|
||||
APP_DATA,
|
||||
asDecryptedEvent,
|
||||
isSignedEvent,
|
||||
makeEvent,
|
||||
normalizeRelayUrl,
|
||||
makeList,
|
||||
Address,
|
||||
addToListPublicly,
|
||||
removeFromListByPredicate,
|
||||
updateList,
|
||||
@@ -49,7 +50,6 @@ import {
|
||||
toNostrURI,
|
||||
RelayMode,
|
||||
getTagValues,
|
||||
readList,
|
||||
uploadBlob,
|
||||
canUploadBlob,
|
||||
encryptFile,
|
||||
@@ -86,10 +86,14 @@ import {
|
||||
import {compressFile} from "@lib/html"
|
||||
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
|
||||
import {
|
||||
BOOKMARKS,
|
||||
BOOKMARK_LISTS,
|
||||
SETTINGS,
|
||||
PROTECTED,
|
||||
INDEXER_RELAYS,
|
||||
DEFAULT_BLOSSOM_SERVERS,
|
||||
getBookmarkList,
|
||||
getBookmarkCollection,
|
||||
userSpaceUrls,
|
||||
userSettingsValues,
|
||||
getSetting,
|
||||
@@ -171,59 +175,51 @@ export const broadcastUserData = async (relays: string[]) => {
|
||||
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
These can be imported from @welshman/util These can be imported from @welshman/util
Ghost
commented
Done. Now these are imported from @welshman/util Done. Now these are imported from @welshman/util
|
||||
// List updates
|
||||
|
||||
const BOOKMARKS = 10003
|
||||
const BOOKMARK_LISTS = 30003
|
||||
|
||||
const parseListKey = (key: string) => {
|
||||
const [kind, ...rest] = key.split(":")
|
||||
return {
|
||||
kind: parseInt(kind),
|
||||
d: rest.join(":"),
|
||||
const getSavedItemsList = (owner = pubkey.get() || "") => {
|
||||
if (!owner) {
|
||||
return makeList({kind: BOOKMARKS})
|
||||
}
|
||||
|
||||
return getBookmarkList().get(owner) || makeList({kind: BOOKMARKS})
|
||||
}
|
||||
|
||||
const getUserBookmarkList = (key = `${BOOKMARKS}:`) => {
|
||||
const author = pubkey.get()
|
||||
const {kind, d} = parseListKey(key)
|
||||
const getBookmarkListFromAddress = (address: string) => {
|
||||
const parsed = Address.from(address)
|
||||
|
||||
if (!author) {
|
||||
return makeList({kind})
|
||||
if (parsed.kind === BOOKMARKS) {
|
||||
return getSavedItemsList(parsed.pubkey)
|
||||
}
|
||||
|
||||
const latest = first(
|
||||
repository
|
||||
.query([{kinds: [kind], authors: [author]}])
|
||||
.filter(event => getTagValue("d", event.tags) === d),
|
||||
)
|
||||
if (parsed.kind === BOOKMARK_LISTS) {
|
||||
return getBookmarkCollection().get(address)
|
||||
}
|
||||
|
||||
return latest ? readList(asDecryptedEvent(latest)) : makeList({kind})
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const createBookmarkList = async (title: string) => {
|
||||
|
hodlbod marked this conversation as resolved
hodlbod
commented
This should go in See This should go in `state` and should be as simple as:
```
export const bookmarkListsByPubkey = deriveItemsByKey({
repository,
eventToItem: event => readList(asDecryptedEvent(event)),
filters: [{kinds: [BOOKMARKS]}],
getKey: list => list.event.pubkey,
})
export const getBookmarkList = (pubkey: string) => getBookmarkList().get(pubkey)
export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList)
export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList)
```
See `@welshman/app`'s `user.ts` file for examples.
hodlbod
commented
I think part of the problem is that you're using the same function to handle two different kinds of list. You should create separate functions for 10003 vs 30003 since they work quite differently. I think part of the problem is that you're using the same function to handle two different kinds of list. You should create separate functions for 10003 vs 30003 since they work quite differently.
Ghost
commented
Done. Done.
|
||||
const d = title.trim()
|
||||
const label = title.trim()
|
||||
|
||||
if (!d) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${d}`)
|
||||
|
||||
if (existing.event?.id) {
|
||||
if (!label) {
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
`d` should be randomly generated, not a slug of the title
Ghost
commented
Fixed. d is now opaque/random, and title is stored separately. Fixed. d is now opaque/random, and title is stored separately.
|
||||
return
|
||||
}
|
||||
|
||||
const list = makeList({kind: BOOKMARK_LISTS})
|
||||
const event = await updateList(list, {publicTags: [["d", d]]}).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
||||
const event = await updateList(list, {
|
||||
publicTags: [
|
||||
["d", randomId()],
|
||||
["title", label],
|
||||
],
|
||||
}).reconcile(nip44EncryptToSelf)
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => {
|
||||
const list = getUserBookmarkList(key)
|
||||
const {d} = parseListKey(key)
|
||||
export const addEventBookmark = async (target: TrustedEvent, address?: string) => {
|
||||
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
|
||||
|
||||
if (d && !list.event?.id) {
|
||||
if (!list) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -234,20 +230,15 @@ export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}
|
||||
}
|
||||
|
||||
const event = await addToListPublicly(list, ["e", target.id]).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([
|
||||
...Router.get().FromUser().getUrls(),
|
||||
...Router.get().Event(target).limit(3).getUrls(),
|
||||
...getRelayTagValues(event.tags),
|
||||
])
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => {
|
||||
const list = getUserBookmarkList(key)
|
||||
const {d} = parseListKey(key)
|
||||
export const removeEventBookmark = async (target: TrustedEvent, address?: string) => {
|
||||
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
|
||||
|
||||
|
hodlbod marked this conversation as resolved
hodlbod
commented
The bookmark list shouldn't be published to relays based on its content, just publish it to the user's outbox The bookmark list shouldn't be published to relays based on its content, just publish it to the user's outbox
Ghost
commented
Fixed. Bookmark list updates are now published only to user outbox relays. Fixed. Bookmark list updates are now published only to user outbox relays.
|
||||
if (d && !list.event?.id) {
|
||||
if (!list) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -269,51 +260,46 @@ export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMAR
|
||||
(tag[0] === "e" && tag[1] === target.id) ||
|
||||
(targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress),
|
||||
).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([
|
||||
...INDEXER_RELAYS,
|
||||
...getRelayTagValues(event.tags),
|
||||
...Router.get().Event(target).limit(3).getUrls(),
|
||||
])
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
export const deleteBookmarkList = async (key: string) => {
|
||||
const list = getUserBookmarkList(key)
|
||||
const {kind, d} = parseListKey(key)
|
||||
export const deleteBookmarkList = async (address: string) => {
|
||||
const list = getBookmarkCollection().get(address)
|
||||
const {kind} = Address.from(address)
|
||||
|
||||
if (kind !== BOOKMARK_LISTS || !d || !list.event) {
|
||||
if (kind !== BOOKMARK_LISTS || !list?.event) {
|
||||
return
|
||||
}
|
||||
|
||||
const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)])
|
||||
const address = `${kind}:${list.event.pubkey}:${d}`
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays})
|
||||
}
|
||||
|
||||
export const renameBookmarkList = async (key: string, title: string) => {
|
||||
const list = getUserBookmarkList(key)
|
||||
const {kind, d} = parseListKey(key)
|
||||
const nextD = title.trim()
|
||||
export const renameBookmarkList = async (address: string, title: string) => {
|
||||
const list = getBookmarkCollection().get(address)
|
||||
const {kind} = Address.from(address)
|
||||
const nextTitle = title.trim()
|
||||
|
||||
if (kind !== BOOKMARK_LISTS || !d || !nextD || !list.event) {
|
||||
if (kind !== BOOKMARK_LISTS || !nextTitle || !list?.event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextD === d) {
|
||||
const currentTitle =
|
||||
getTagValue("title", list.event.tags) || getTagValue("d", list.event.tags) || ""
|
||||
|
||||
if (nextTitle === currentTitle) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${nextD}`)
|
||||
|
||||
if (existing.event?.id && existing.event.id !== list.event.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const publicTags = [["d", nextD], ...list.publicTags.filter(tag => tag[0] !== "d")]
|
||||
const publicTags = [
|
||||
["d", getTagValue("d", list.event.tags) || randomId()],
|
||||
["title", nextTitle],
|
||||
]
|
||||
const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf)
|
||||
const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)])
|
||||
const relays = Router.get().FromUser().getUrls()
|
||||
|
||||
return publishThunk({event, relays})
|
||||
}
|
||||
|
||||
@@ -161,6 +161,10 @@ export const PROTECTED = ["-"]
|
||||
|
||||
export const MESSAGE_KINDS = [MESSAGE]
|
||||
|
||||
export const BOOKMARKS = 10003
|
||||
|
||||
export const BOOKMARK_LISTS = 30003
|
||||
|
||||
export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
|
||||
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
|
||||
@@ -706,6 +710,50 @@ export const deriveOtherVoiceRooms = (url: string) =>
|
||||
|
||||
// User space/room lists
|
||||
|
||||
export const bookmarkListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
filters: [{kinds: [BOOKMARKS]}],
|
||||
getKey: list => list.event.pubkey,
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
})
|
||||
|
||||
export const getBookmarkList = getter(bookmarkListsByPubkey)
|
||||
|
||||
export const loadBookmarkList = makeLoadItem(makeOutboxLoader(BOOKMARKS), getBookmarkList)
|
||||
|
||||
export const userBookmarkList = makeUserData(bookmarkListsByPubkey, loadBookmarkList)
|
||||
|
||||
export const loadUserBookmarkList = makeUserLoader(loadBookmarkList)
|
||||
|
||||
export const bookmarkCollectionsByAddress = deriveItemsByKey({
|
||||
repository,
|
||||
filters: [{kinds: [BOOKMARK_LISTS]}],
|
||||
getKey: list => getAddress(list.event),
|
||||
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
|
||||
})
|
||||
|
||||
export const getBookmarkCollection = getter(bookmarkCollectionsByAddress)
|
||||
|
||||
export const bookmarkCollections = deriveItems(bookmarkCollectionsByAddress)
|
||||
|
||||
export const bookmarkCollectionsByPubkey = derived(bookmarkCollections, $lists =>
|
||||
groupBy(list => list.event.pubkey, $lists),
|
||||
)
|
||||
|
||||
export const getBookmarkCollections = getter(bookmarkCollectionsByPubkey)
|
||||
|
||||
export const loadBookmarkCollections = makeLoadItem(
|
||||
makeOutboxLoader(BOOKMARK_LISTS),
|
||||
getBookmarkCollections,
|
||||
)
|
||||
|
||||
export const userBookmarkCollections = makeUserData(
|
||||
bookmarkCollectionsByPubkey,
|
||||
loadBookmarkCollections,
|
||||
)
|
||||
|
||||
export const loadUserBookmarkCollections = makeUserLoader(loadBookmarkCollections)
|
||||
|
||||
export const groupListsByPubkey = deriveItemsByKey({
|
||||
repository,
|
||||
filters: [{kinds: [ROOMS]}],
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {onMount} from "svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import {page} from "$app/stores"
|
||||
import {first, formatTimestamp, uniqBy} from "@welshman/lib"
|
||||
import {first, uniqBy} from "@welshman/lib"
|
||||
import {load} from "@welshman/net"
|
||||
import {
|
||||
DELETE,
|
||||
displayPubkey,
|
||||
getIdFilters,
|
||||
getTags,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
sortEventsDesc,
|
||||
tagsFromIMeta,
|
||||
Address,
|
||||
getAddress,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {
|
||||
displayProfileByPubkey,
|
||||
loadProfile,
|
||||
pubkey,
|
||||
repository,
|
||||
tracker,
|
||||
waitForThunkError,
|
||||
} from "@welshman/app"
|
||||
import {loadProfile, pubkey, repository, tracker, waitForThunkError} from "@welshman/app"
|
||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
||||
import Folder from "@assets/icons/folder.svg?dataurl"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import SliderMinimalisticHorizontal from "@assets/icons/slider-minimalistic-horizontal.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Page from "@lib/components/Page.svelte"
|
||||
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
|
||||
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
|
||||
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
|
||||
import BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
|
||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import BookmarkGrid from "@app/components/BookmarkGrid.svelte"
|
||||
import BookmarkSidebar from "@app/components/BookmarkSidebar.svelte"
|
||||
import {
|
||||
addEventBookmark,
|
||||
deleteBookmarkList,
|
||||
@@ -47,9 +33,13 @@
|
||||
renameBookmarkList,
|
||||
} from "@app/core/commands"
|
||||
import {
|
||||
BOOKMARKS,
|
||||
BOOKMARK_LISTS,
|
||||
deriveEvents,
|
||||
IMAGE_CONTENT_TYPES,
|
||||
INDEXER_RELAYS,
|
||||
loadUserBookmarkCollections,
|
||||
loadUserBookmarkList,
|
||||
shouldIgnoreError,
|
||||
VIDEO_CONTENT_TYPES,
|
||||
} from "@app/core/state"
|
||||
@@ -73,17 +63,15 @@
|
||||
external: boolean
|
||||
image: string | undefined
|
||||
video: string | undefined
|
||||
contentType: Exclude<ContentType, "all">
|
||||
contentType: "image" | "video" | "text"
|
||||
preview: string
|
||||
pollOptions: string[]
|
||||
searchable: string
|
||||
}
|
||||
|
||||
const BOOKMARKS = 10003
|
||||
const CUSTOM_BOOKMARKS = 30003
|
||||
const MANAGED_LIST_KINDS = [BOOKMARKS, CUSTOM_BOOKMARKS]
|
||||
const savedItemsKey = $derived($pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "")
|
||||
|
||||
const listEvents = deriveEvents([{kinds: [...MANAGED_LIST_KINDS, DELETE]}])
|
||||
const listEvents = deriveEvents([{kinds: [BOOKMARKS, BOOKMARK_LISTS, DELETE]}])
|
||||
|
||||
const isImageUrl = (url: string) => /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(url)
|
||||
|
||||
@@ -129,14 +117,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const asLinkValue = (url: string) => {
|
||||
try {
|
||||
return {url: new URL(url)}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const getPollQuestion = (event: TrustedEvent) => {
|
||||
const lines = (event.content || "")
|
||||
.split("\n")
|
||||
@@ -163,7 +143,7 @@
|
||||
return clean.length > 140 ? `${clean.slice(0, 140).trim()}...` : clean
|
||||
}
|
||||
|
||||
const detectContentType = (event: TrustedEvent): Exclude<ContentType, "all"> => {
|
||||
const detectContentType = (event: TrustedEvent): "image" | "video" | "text" => {
|
||||
const media = getMediaPreview(event)
|
||||
|
||||
if (media.image) {
|
||||
@@ -177,10 +157,15 @@
|
||||
return "text"
|
||||
}
|
||||
|
||||
const getListKey = (event: TrustedEvent) => `${event.kind}:${getTagValue("d", event.tags) || ""}`
|
||||
const getListKey = (event: TrustedEvent) =>
|
||||
event.kind === BOOKMARKS
|
||||
? new Address(BOOKMARKS, event.pubkey, "").toString()
|
||||
: getAddress(event)
|
||||
|
||||
const getListLabel = (event: TrustedEvent) =>
|
||||
getTagValue("d", event.tags) || (event.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
|
||||
getTagValue("title", event.tags) ||
|
||||
getTagValue("d", event.tags) ||
|
||||
(event.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
|
||||
|
||||
const userListEvents = $derived(
|
||||
sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind !== DELETE),
|
||||
@@ -218,14 +203,14 @@
|
||||
)
|
||||
.filter(list => (list.event ? !isDeletedList(list.event) : true))
|
||||
|
||||
const savedItems = first(mapped.filter(list => list.key === "10003:")) || {
|
||||
key: "10003:",
|
||||
const savedItems = first(mapped.filter(list => list.key === savedItemsKey)) || {
|
||||
key: savedItemsKey,
|
||||
label: "Saved Items",
|
||||
count: 0,
|
||||
event: undefined,
|
||||
}
|
||||
|
||||
return [savedItems, ...mapped.filter(list => list.key !== "10003:")]
|
||||
return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
|
||||
})
|
||||
|
||||
const defaultListKey = $derived(sidebarLists[0]?.key || "")
|
||||
@@ -243,49 +228,9 @@
|
||||
let searchTerm = $state("")
|
||||
let showSearch = $state(false)
|
||||
let contentType = $state<ContentType>("all")
|
||||
let listsReady = $state(false)
|
||||
let loadedListPubkey: string | undefined = $state()
|
||||
let loadedSelectionKey: string | undefined = $state()
|
||||
let menuOpen = $state(false)
|
||||
let menuX = $state(0)
|
||||
let menuY = $state(0)
|
||||
let menuListKey = $state("")
|
||||
let menuListLabel = $state("")
|
||||
let openBookmarkMenuKey: string | undefined = $state()
|
||||
let listDialogMode: "rename" | "delete" | undefined = $state()
|
||||
let dialogListKey = $state("")
|
||||
let dialogListLabel = $state("")
|
||||
let nextListLabel = $state("")
|
||||
let listDialogPending = $state(false)
|
||||
|
||||
const closeListContextMenu = () => {
|
||||
menuOpen = false
|
||||
}
|
||||
|
||||
const closeBookmarkMenu = () => {
|
||||
openBookmarkMenuKey = undefined
|
||||
}
|
||||
|
||||
const toggleBookmarkMenu = (key: string) => {
|
||||
openBookmarkMenuKey = openBookmarkMenuKey === key ? undefined : key
|
||||
}
|
||||
|
||||
const openListDialog = (mode: "rename" | "delete", key: string, label: string) => {
|
||||
listDialogMode = mode
|
||||
dialogListKey = key
|
||||
dialogListLabel = label
|
||||
nextListLabel = label
|
||||
}
|
||||
|
||||
const closeListDialog = (force = false) => {
|
||||
if (!force && listDialogPending) {
|
||||
return
|
||||
}
|
||||
|
||||
listDialogMode = undefined
|
||||
dialogListKey = ""
|
||||
dialogListLabel = ""
|
||||
nextListLabel = ""
|
||||
}
|
||||
|
||||
const setFilterAll = () => {
|
||||
contentType = "all"
|
||||
@@ -315,8 +260,6 @@
|
||||
shouldIgnoreError(error) || error.includes("only accepts kind 10002 events")
|
||||
|
||||
const openBookmarkFromMenu = async (item: BookmarkItem) => {
|
||||
closeBookmarkMenu()
|
||||
|
||||
if (item.external) {
|
||||
window.open(item.href, "_blank", "noopener,noreferrer")
|
||||
return
|
||||
@@ -326,8 +269,6 @@
|
||||
}
|
||||
|
||||
const copyBookmarkLinkFromMenu = async (item: BookmarkItem) => {
|
||||
closeBookmarkMenu()
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.href)
|
||||
pushToast({message: "Link copied"})
|
||||
@@ -337,9 +278,11 @@
|
||||
}
|
||||
|
||||
const addBookmarkToSavedItems = async (item: BookmarkItem) => {
|
||||
closeBookmarkMenu()
|
||||
if (!savedItemsKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const thunk = await addEventBookmark(item.event, "10003:")
|
||||
const thunk = await addEventBookmark(item.event, savedItemsKey)
|
||||
|
||||
if (!thunk) {
|
||||
pushToast({message: "Already in Saved Items"})
|
||||
@@ -357,8 +300,6 @@
|
||||
}
|
||||
|
||||
const removeBookmarkFromCurrentList = async (item: BookmarkItem) => {
|
||||
closeBookmarkMenu()
|
||||
|
||||
if (!selectedKey) {
|
||||
return
|
||||
}
|
||||
@@ -383,7 +324,7 @@
|
||||
}
|
||||
|
||||
const deleteListFromSidebar = async (key: string, label: string) => {
|
||||
if (key === "10003:") {
|
||||
if (key.startsWith(`${BOOKMARKS}:`)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -404,12 +345,12 @@
|
||||
pushToast({message: `Deleted ${label}`})
|
||||
|
||||
if (selectedKey === key) {
|
||||
await goto(selectedListHref("10003:"), {replaceState: true, noScroll: true})
|
||||
await goto(selectedListHref(savedItemsKey), {replaceState: true, noScroll: true})
|
||||
}
|
||||
}
|
||||
|
||||
const renameListFromSidebar = async (key: string, label: string, nextLabel: string) => {
|
||||
if (key === "10003:") {
|
||||
if (key.startsWith(`${BOOKMARKS}:`)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -433,95 +374,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
const deleteOldThunk = await deleteBookmarkList(key)
|
||||
|
||||
if (deleteOldThunk) {
|
||||
const deleteOldError = await waitForThunkError(deleteOldThunk)
|
||||
|
||||
if (deleteOldError && !isIgnorableBookmarkListError(deleteOldError)) {
|
||||
pushToast({theme: "error", message: deleteOldError})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pushToast({message: `Renamed to ${normalized}`})
|
||||
|
||||
if (selectedKey === key) {
|
||||
await goto(selectedListHref(`${CUSTOM_BOOKMARKS}:${normalized}`), {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onListContextMenu = async (event: MouseEvent, key: string, label: string) => {
|
||||
event.preventDefault()
|
||||
menuOpen = true
|
||||
menuX = event.clientX
|
||||
menuY = event.clientY
|
||||
menuListKey = key
|
||||
menuListLabel = label
|
||||
}
|
||||
|
||||
const openListFromMenu = async () => {
|
||||
closeListContextMenu()
|
||||
await goto(selectedListHref(menuListKey), {replaceState: true, noScroll: true})
|
||||
}
|
||||
|
||||
const renameListFromMenu = async () => {
|
||||
closeListContextMenu()
|
||||
openListDialog("rename", menuListKey, menuListLabel)
|
||||
}
|
||||
|
||||
const deleteListFromMenu = async () => {
|
||||
closeListContextMenu()
|
||||
openListDialog("delete", menuListKey, menuListLabel)
|
||||
}
|
||||
|
||||
const submitRenameDialog = async () => {
|
||||
if (listDialogMode !== "rename") {
|
||||
return
|
||||
}
|
||||
|
||||
listDialogPending = true
|
||||
|
||||
try {
|
||||
await renameListFromSidebar(dialogListKey, dialogListLabel, nextListLabel)
|
||||
closeListDialog(true)
|
||||
} finally {
|
||||
listDialogPending = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitDeleteDialog = async () => {
|
||||
if (listDialogMode !== "delete") {
|
||||
return
|
||||
}
|
||||
|
||||
listDialogPending = true
|
||||
|
||||
try {
|
||||
await deleteListFromSidebar(dialogListKey, dialogListLabel)
|
||||
closeListDialog(true)
|
||||
} finally {
|
||||
listDialogPending = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleListDialogBackdropClick = () => {
|
||||
closeListDialog()
|
||||
}
|
||||
|
||||
const cancelListDialog = () => {
|
||||
closeListDialog()
|
||||
}
|
||||
|
||||
const handleWindowKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeListContextMenu()
|
||||
closeBookmarkMenu()
|
||||
closeListDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedTerm = $derived(searchTerm.trim().toLowerCase())
|
||||
@@ -565,22 +418,31 @@
|
||||
|
||||
const selectedListHref = (key: string) => `/bookmarks?list=${encodeURIComponent(key)}`
|
||||
|
||||
const listClass = (active: boolean) =>
|
||||
cx("card2 card2-sm bg-alt col-2 gap-1 transition-colors hover:bg-base-100", {
|
||||
"bg-base-100": active,
|
||||
})
|
||||
|
||||
const loadBookmarkLists = async () => {
|
||||
if (!$pubkey || loadedListPubkey === $pubkey) {
|
||||
if (!$pubkey) {
|
||||
listsReady = false
|
||||
return
|
||||
}
|
||||
|
||||
if (loadedListPubkey === $pubkey && listsReady) {
|
||||
return
|
||||
}
|
||||
|
||||
loadedListPubkey = $pubkey
|
||||
listsReady = false
|
||||
|
||||
await load({
|
||||
relays: INDEXER_RELAYS,
|
||||
filters: [{kinds: [...MANAGED_LIST_KINDS, DELETE], authors: [$pubkey]}],
|
||||
})
|
||||
try {
|
||||
await Promise.all([
|
||||
loadUserBookmarkList(),
|
||||
loadUserBookmarkCollections(),
|
||||
load({
|
||||
relays: INDEXER_RELAYS,
|
||||
filters: [{kinds: [DELETE], authors: [$pubkey]}],
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
listsReady = true
|
||||
}
|
||||
|
||||
// Ensure selected-list references are reloaded once list metadata arrives.
|
||||
loadedSelectionKey = undefined
|
||||
@@ -644,144 +506,33 @@
|
||||
$effect(() => {
|
||||
if (selectedList) {
|
||||
void loadSelectedReferences()
|
||||
} else {
|
||||
selectedEvents = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onclick={() => {
|
||||
closeListContextMenu()
|
||||
closeBookmarkMenu()
|
||||
}}
|
||||
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">{totalListCount}</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={openListManager}>
|
||||
<Icon size={3.5} icon={Add} />
|
||||
</Button>
|
||||
</div>
|
||||
</SecondaryNavSection>
|
||||
<div class="col-2 overflow-y-auto px-2 pb-2">
|
||||
{#each sidebarListsWithDisplayCount as list (list.key)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
oncontextmenu={event => onListContextMenu(event, list.key, list.label)}>
|
||||
<Link href={selectedListHref(list.key)} class={listClass(selectedKey === list.key)}>
|
||||
<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 === "10003:" ? Bookmark : Folder} />
|
||||
{list.label}
|
||||
</span>
|
||||
</p>
|
||||
<span class="badge badge-sm badge-neutral">{list.displayCount}</span>
|
||||
</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={openListFromMenu}>Open List</Button>
|
||||
</li>
|
||||
{#if menuListKey !== "10003:"}
|
||||
<li>
|
||||
<Button class="justify-start" onclick={renameListFromMenu}>Rename List</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button class="justify-start text-error" onclick={deleteListFromMenu}
|
||||
>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={handleListDialogBackdropClick}></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={cancelListDialog} disabled={listDialogPending}
|
||||
>Cancel</Button>
|
||||
<Button
|
||||
class="btn btn-neutral"
|
||||
onclick={submitRenameDialog}
|
||||
disabled={listDialogPending || !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={cancelListDialog} disabled={listDialogPending}
|
||||
>Cancel</Button>
|
||||
<Button
|
||||
class="btn btn-error"
|
||||
onclick={submitDeleteDialog}
|
||||
disabled={listDialogPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</SecondaryNav>
|
||||
<BookmarkSidebar
|
||||
lists={listsReady ? sidebarListsWithDisplayCount : []}
|
||||
{selectedKey}
|
||||
totalCount={listsReady ? totalListCount : 0}
|
||||
onOpenManager={openListManager}
|
||||
onSelect={key => goto(selectedListHref(key), {replaceState: true, noScroll: true})}
|
||||
onRename={renameListFromSidebar}
|
||||
onDelete={deleteListFromSidebar} />
|
||||
|
||||
<Page class="overflow-hidden">
|
||||
<div class="col-3 gap-3 p-3 md:p-4">
|
||||
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-2 p-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Icon size={4} icon={Bookmark} />
|
||||
<p class="truncate text-sm font-medium">{selectedList?.label || "Saved Items"}</p>
|
||||
<p class="truncate text-sm font-medium">
|
||||
{#if listsReady}
|
||||
{selectedList?.label || "Saved Items"}
|
||||
{:else}
|
||||
Loading bookmarks...
|
||||
{/if}
|
||||
</p>
|
||||
<span class="badge badge-sm badge-neutral">{filteredItems.length}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -810,6 +561,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap gap-2 px-1 text-[11px] uppercase tracking-wide opacity-70">
|
||||
<span>{filteredItems.length} items</span>
|
||||
<span>•</span>
|
||||
<span>{listsReady ? selectedList?.label || "Saved Items" : "Loading"}</span>
|
||||
</div>
|
||||
|
||||
{#if showSearch}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon icon={Magnifier} />
|
||||
@@ -817,86 +574,16 @@
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full flex-wrap content-start items-stretch gap-3">
|
||||
{#each filteredItems as item (item.key)}
|
||||
<Link
|
||||
href={item.href}
|
||||
external={item.external}
|
||||
class="card2 bg-alt col-2 w-full gap-3 p-3 transition-colors hover:bg-base-100 md:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProfileCircle pubkey={item.event.pubkey} size={5} />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-primary">
|
||||
{displayProfileByPubkey(item.event.pubkey)}
|
||||
</p>
|
||||
<p class="truncate text-[11px] opacity-60">
|
||||
{displayPubkey(item.event.pubkey)} · {formatTimestamp(item.event.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" role="presentation">
|
||||
<Button
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
onclick={() => toggleBookmarkMenu(item.key)}>
|
||||
<Icon size={4} icon={MenuDots} />
|
||||
</Button>
|
||||
{#if openBookmarkMenuKey === item.key}
|
||||
<ul
|
||||
class="menu absolute right-0 top-7 z-popover w-44 rounded-box bg-base-100 p-2 shadow-xl"
|
||||
role="menu">
|
||||
<li>
|
||||
<Button class="justify-start" onclick={() => openBookmarkFromMenu(item)}>
|
||||
Open bookmark
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button class="justify-start" onclick={() => copyBookmarkLinkFromMenu(item)}>
|
||||
Copy link
|
||||
</Button>
|
||||
</li>
|
||||
{#if selectedKey !== "10003:"}
|
||||
<li>
|
||||
<Button class="justify-start" onclick={() => addBookmarkToSavedItems(item)}>
|
||||
Add to Saved Items
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<Button
|
||||
class="justify-start text-error"
|
||||
onclick={() => removeBookmarkFromCurrentList(item)}>
|
||||
Remove from this list
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if item.image}
|
||||
{#if asLinkValue(item.image)}
|
||||
<ContentLinkBlockImage
|
||||
value={asLinkValue(item.image)}
|
||||
event={item.event}
|
||||
class="max-h-[28rem] w-full rounded-lg object-contain" />
|
||||
{/if}
|
||||
{:else if item.video}
|
||||
<video
|
||||
src={item.video}
|
||||
class="max-h-[28rem] w-full rounded-lg object-contain"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"></video>
|
||||
{/if}
|
||||
|
||||
<p class="break-words min-w-0 text-base leading-relaxed opacity-90">{item.preview}</p>
|
||||
</Link>
|
||||
{:else}
|
||||
<div class="card2 bg-alt col-2 max-w-lg p-5 text-sm opacity-80">
|
||||
No items match your current filters.
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if listsReady}
|
||||
<BookmarkGrid
|
||||
items={filteredItems}
|
||||
showAddToSaved={selectedKey !== savedItemsKey}
|
||||
onOpen={openBookmarkFromMenu}
|
||||
onCopyLink={copyBookmarkLinkFromMenu}
|
||||
onAddToSaved={addBookmarkToSavedItems}
|
||||
onRemove={removeBookmarkFromCurrentList} />
|
||||
{:else}
|
||||
<div class="card2 card2-sm bg-alt text-sm opacity-70">Loading bookmark lists...</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
Import these from @welshman/util
Done. Now these are imported from @welshman/util