fix: stabilize list loading and show correct list count
This commit is contained in:
@@ -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 {onMount} from "svelte"
|
||||||
import {first, uniqBy} from "@welshman/lib"
|
import {first, uniqBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
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 {load} from "@welshman/net"
|
||||||
import {pubkey, waitForThunkError} from "@welshman/app"
|
import {pubkey, waitForThunkError} from "@welshman/app"
|
||||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
||||||
@@ -18,7 +25,13 @@
|
|||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import {createBookmarkList, addEventBookmark, deleteBookmarkList} from "@app/core/commands"
|
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"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -36,10 +49,14 @@
|
|||||||
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
|
const listEvents = deriveEvents([{kinds: [10003, 30003, DELETE]}])
|
||||||
|
|
||||||
const getListKey = (listEvent: TrustedEvent) =>
|
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) =>
|
const getListLabel = (listEvent: TrustedEvent) =>
|
||||||
getTagValue("d", listEvent.tags) || (listEvent.kind === 10003 ? "Saved Items" : "Untitled List")
|
getTagValue("title", listEvent.tags) ||
|
||||||
|
getTagValue("d", listEvent.tags) ||
|
||||||
|
(listEvent.kind === BOOKMARKS ? "Saved Items" : "Untitled List")
|
||||||
|
|
||||||
const userLists = $derived(
|
const userLists = $derived(
|
||||||
sortEventsDesc($listEvents).filter(
|
sortEventsDesc($listEvents).filter(
|
||||||
@@ -54,8 +71,7 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isDeletedList = (listEvent: TrustedEvent) => {
|
const isDeletedList = (listEvent: TrustedEvent) => {
|
||||||
const d = getTagValue("d", listEvent.tags) || ""
|
const address = getListKey(listEvent)
|
||||||
const address = `${listEvent.kind}:${listEvent.pubkey}:${d}`
|
|
||||||
|
|
||||||
return userDeletes.some(deleteEvent => {
|
return userDeletes.some(deleteEvent => {
|
||||||
if (deleteEvent.created_at < listEvent.created_at) {
|
if (deleteEvent.created_at < listEvent.created_at) {
|
||||||
@@ -82,13 +98,13 @@
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const savedItems = first(mapped.filter(list => list.key === "10003:")) || {
|
const savedItems = first(mapped.filter(list => Address.from(list.key).kind === BOOKMARKS)) || {
|
||||||
key: "10003:",
|
key: $pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "10003:",
|
||||||
label: "Saved Items",
|
label: "Saved Items",
|
||||||
count: 0,
|
count: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
return [savedItems, ...mapped.filter(list => list.key !== "10003:")]
|
return [savedItems, ...mapped.filter(list => Address.from(list.key).kind !== BOOKMARKS)]
|
||||||
})
|
})
|
||||||
|
|
||||||
let listName = $state("")
|
let listName = $state("")
|
||||||
@@ -159,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteList = async (key: string, label: string) => {
|
const deleteList = async (key: string, label: string) => {
|
||||||
if (deleting || key === "10003:") {
|
if (deleting || Address.from(key).kind === BOOKMARKS) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +214,7 @@
|
|||||||
|
|
||||||
await load({
|
await load({
|
||||||
relays: INDEXER_RELAYS,
|
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)}
|
onclick={() => addToList(list.key, list.label)}
|
||||||
disabled={selecting}>
|
disabled={selecting}>
|
||||||
<span class="inline-flex items-center gap-2">
|
<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}
|
{list.label}
|
||||||
</span>
|
</span>
|
||||||
<span class="badge badge-sm badge-neutral">{list.count}</span>
|
<span class="badge badge-sm badge-neutral">{list.count}</span>
|
||||||
@@ -257,12 +275,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-2">
|
<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">
|
<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}
|
{list.label}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge badge-sm badge-neutral">{list.count}</span>
|
<span class="badge badge-sm badge-neutral">{list.count}</span>
|
||||||
{#if list.key !== "10003:"}
|
{#if Address.from(list.key).kind !== BOOKMARKS}
|
||||||
<Button
|
<Button
|
||||||
class="btn btn-ghost btn-xs btn-square text-error"
|
class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
onclick={() => deleteList(list.key, list.label)}
|
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 EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
|
||||||
|
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<EventActions {url} {event} noun="Event">
|
<EventActions {url} {event} noun="Event">
|
||||||
{#snippet customActions()}
|
{#snippet customActions()}
|
||||||
|
<BookmarkEventMenuItem {event} />
|
||||||
{#if event.pubkey === $pubkey}
|
{#if event.pubkey === $pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={editEvent}>
|
<Button onclick={editEvent}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
|
||||||
|
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
|
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<EventActions {url} {event} noun="Listing">
|
<EventActions {url} {event} noun="Listing">
|
||||||
{#snippet customActions()}
|
{#snippet customActions()}
|
||||||
|
<BookmarkEventMenuItem {event} />
|
||||||
{#if event.pubkey === $pubkey}
|
{#if event.pubkey === $pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={editClassified}>
|
<Button onclick={editClassified}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
|
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
import {makeSpacePath} from "@app/util/routes"
|
import {makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@
|
|||||||
{#if showActivity}
|
{#if showActivity}
|
||||||
<EventActivity {url} {path} {event} />
|
<EventActivity {url} {path} {event} />
|
||||||
{/if}
|
{/if}
|
||||||
<EventActions {url} {event} noun="Comment" />
|
<EventActions {url} {event} noun="Comment">
|
||||||
|
{#snippet customActions()}
|
||||||
|
<BookmarkEventMenuItem {event} />
|
||||||
|
{/snippet}
|
||||||
|
</EventActions>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
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 Letter from "@assets/icons/letter.svg?dataurl"
|
||||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
@@ -8,11 +20,19 @@
|
|||||||
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
|
||||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
|
||||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.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 {pushModal} from "@app/util/modal"
|
||||||
import {notifications} from "@app/util/notifications"
|
import {notifications} from "@app/util/notifications"
|
||||||
import {goToChat, makeSpacePath} from "@app/util/routes"
|
import {goToChat, makeSpacePath} from "@app/util/routes"
|
||||||
@@ -30,18 +50,90 @@
|
|||||||
const anySpaceNotifications = $derived(
|
const anySpaceNotifications = $derived(
|
||||||
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
$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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 shrink-0 bg-base-200 pt-2 md:block">
|
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}>
|
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
{#if PLATFORM_RELAYS.length > 0}
|
|
||||||
<Divider />
|
|
||||||
{/if}
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
|
<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>
|
||||||
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||||
{#if $userProfile?.picture}
|
{#if $userProfile?.picture}
|
||||||
@@ -83,7 +175,11 @@
|
|||||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Bookmarks" href="/bookmarks" prefix="/bookmarks">
|
<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>
|
||||||
{#if PLATFORM_RELAYS.length !== 1}
|
{#if PLATFORM_RELAYS.length !== 1}
|
||||||
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
|
<PrimaryNavItem title="Spaces" href="/spaces" notification={anySpaceNotifications}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||||
import EventActivity from "@app/components/EventActivity.svelte"
|
import EventActivity from "@app/components/EventActivity.svelte"
|
||||||
import EventActions from "@app/components/EventActions.svelte"
|
import EventActions from "@app/components/EventActions.svelte"
|
||||||
|
import BookmarkEventMenuItem from "@app/components/BookmarkEventMenuItem.svelte"
|
||||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||||
import {makeThreadPath, makeSpacePath} from "@app/util/routes"
|
import {makeThreadPath, makeSpacePath} from "@app/util/routes"
|
||||||
|
|
||||||
@@ -41,5 +42,9 @@
|
|||||||
{#if showActivity}
|
{#if showActivity}
|
||||||
<EventActivity {url} {path} {event} />
|
<EventActivity {url} {path} {event} />
|
||||||
{/if}
|
{/if}
|
||||||
<EventActions {url} {event} noun="Thread" />
|
<EventActions {url} {event} noun="Thread">
|
||||||
|
{#snippet customActions()}
|
||||||
|
<BookmarkEventMenuItem {event} />
|
||||||
|
{/snippet}
|
||||||
|
</EventActions>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+55
-69
@@ -14,6 +14,7 @@ import {
|
|||||||
simpleCache,
|
simpleCache,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
nthNe,
|
nthNe,
|
||||||
|
randomId,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import {Nip01Signer} from "@welshman/signer"
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import type {UploadTask} from "@welshman/editor"
|
import type {UploadTask} from "@welshman/editor"
|
||||||
@@ -33,11 +34,11 @@ import {
|
|||||||
ROOMS,
|
ROOMS,
|
||||||
COMMENT,
|
COMMENT,
|
||||||
APP_DATA,
|
APP_DATA,
|
||||||
asDecryptedEvent,
|
|
||||||
isSignedEvent,
|
isSignedEvent,
|
||||||
makeEvent,
|
makeEvent,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
makeList,
|
makeList,
|
||||||
|
Address,
|
||||||
addToListPublicly,
|
addToListPublicly,
|
||||||
removeFromListByPredicate,
|
removeFromListByPredicate,
|
||||||
updateList,
|
updateList,
|
||||||
@@ -49,7 +50,6 @@ import {
|
|||||||
toNostrURI,
|
toNostrURI,
|
||||||
RelayMode,
|
RelayMode,
|
||||||
getTagValues,
|
getTagValues,
|
||||||
readList,
|
|
||||||
uploadBlob,
|
uploadBlob,
|
||||||
canUploadBlob,
|
canUploadBlob,
|
||||||
encryptFile,
|
encryptFile,
|
||||||
@@ -86,10 +86,14 @@ import {
|
|||||||
import {compressFile} from "@lib/html"
|
import {compressFile} from "@lib/html"
|
||||||
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
|
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
|
||||||
import {
|
import {
|
||||||
|
BOOKMARKS,
|
||||||
|
BOOKMARK_LISTS,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
PROTECTED,
|
PROTECTED,
|
||||||
INDEXER_RELAYS,
|
INDEXER_RELAYS,
|
||||||
DEFAULT_BLOSSOM_SERVERS,
|
DEFAULT_BLOSSOM_SERVERS,
|
||||||
|
getBookmarkList,
|
||||||
|
getBookmarkCollection,
|
||||||
userSpaceUrls,
|
userSpaceUrls,
|
||||||
userSettingsValues,
|
userSettingsValues,
|
||||||
getSetting,
|
getSetting,
|
||||||
@@ -171,59 +175,51 @@ export const broadcastUserData = async (relays: string[]) => {
|
|||||||
|
|
||||||
// List updates
|
// List updates
|
||||||
|
|
||||||
const BOOKMARKS = 10003
|
const getSavedItemsList = (owner = pubkey.get() || "") => {
|
||||||
const BOOKMARK_LISTS = 30003
|
if (!owner) {
|
||||||
|
return makeList({kind: BOOKMARKS})
|
||||||
const parseListKey = (key: string) => {
|
|
||||||
const [kind, ...rest] = key.split(":")
|
|
||||||
return {
|
|
||||||
kind: parseInt(kind),
|
|
||||||
d: rest.join(":"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getBookmarkList().get(owner) || makeList({kind: BOOKMARKS})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserBookmarkList = (key = `${BOOKMARKS}:`) => {
|
const getBookmarkListFromAddress = (address: string) => {
|
||||||
const author = pubkey.get()
|
const parsed = Address.from(address)
|
||||||
const {kind, d} = parseListKey(key)
|
|
||||||
|
|
||||||
if (!author) {
|
if (parsed.kind === BOOKMARKS) {
|
||||||
return makeList({kind})
|
return getSavedItemsList(parsed.pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = first(
|
if (parsed.kind === BOOKMARK_LISTS) {
|
||||||
repository
|
return getBookmarkCollection().get(address)
|
||||||
.query([{kinds: [kind], authors: [author]}])
|
}
|
||||||
.filter(event => getTagValue("d", event.tags) === d),
|
|
||||||
)
|
|
||||||
|
|
||||||
return latest ? readList(asDecryptedEvent(latest)) : makeList({kind})
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBookmarkList = async (title: string) => {
|
export const createBookmarkList = async (title: string) => {
|
||||||
const d = title.trim()
|
const label = title.trim()
|
||||||
|
|
||||||
if (!d) {
|
if (!label) {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${d}`)
|
|
||||||
|
|
||||||
if (existing.event?.id) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = makeList({kind: BOOKMARK_LISTS})
|
const list = makeList({kind: BOOKMARK_LISTS})
|
||||||
const event = await updateList(list, {publicTags: [["d", d]]}).reconcile(nip44EncryptToSelf)
|
const event = await updateList(list, {
|
||||||
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
|
publicTags: [
|
||||||
|
["d", randomId()],
|
||||||
|
["title", label],
|
||||||
|
],
|
||||||
|
}).reconcile(nip44EncryptToSelf)
|
||||||
|
const relays = Router.get().FromUser().getUrls()
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => {
|
export const addEventBookmark = async (target: TrustedEvent, address?: string) => {
|
||||||
const list = getUserBookmarkList(key)
|
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
|
||||||
const {d} = parseListKey(key)
|
|
||||||
|
|
||||||
if (d && !list.event?.id) {
|
if (!list) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,20 +230,15 @@ export const addEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}
|
|||||||
}
|
}
|
||||||
|
|
||||||
const event = await addToListPublicly(list, ["e", target.id]).reconcile(nip44EncryptToSelf)
|
const event = await addToListPublicly(list, ["e", target.id]).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([
|
const relays = Router.get().FromUser().getUrls()
|
||||||
...Router.get().FromUser().getUrls(),
|
|
||||||
...Router.get().Event(target).limit(3).getUrls(),
|
|
||||||
...getRelayTagValues(event.tags),
|
|
||||||
])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMARKS}:`) => {
|
export const removeEventBookmark = async (target: TrustedEvent, address?: string) => {
|
||||||
const list = getUserBookmarkList(key)
|
const list = address ? getBookmarkListFromAddress(address) : getSavedItemsList()
|
||||||
const {d} = parseListKey(key)
|
|
||||||
|
|
||||||
if (d && !list.event?.id) {
|
if (!list) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,51 +260,46 @@ export const removeEventBookmark = async (target: TrustedEvent, key = `${BOOKMAR
|
|||||||
(tag[0] === "e" && tag[1] === target.id) ||
|
(tag[0] === "e" && tag[1] === target.id) ||
|
||||||
(targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress),
|
(targetAddress !== undefined && tag[0] === "a" && tag[1] === targetAddress),
|
||||||
).reconcile(nip44EncryptToSelf)
|
).reconcile(nip44EncryptToSelf)
|
||||||
const relays = uniq([
|
const relays = Router.get().FromUser().getUrls()
|
||||||
...INDEXER_RELAYS,
|
|
||||||
...getRelayTagValues(event.tags),
|
|
||||||
...Router.get().Event(target).limit(3).getUrls(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return publishThunk({event, relays})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteBookmarkList = async (key: string) => {
|
export const deleteBookmarkList = async (address: string) => {
|
||||||
const list = getUserBookmarkList(key)
|
const list = getBookmarkCollection().get(address)
|
||||||
const {kind, d} = parseListKey(key)
|
const {kind} = Address.from(address)
|
||||||
|
|
||||||
if (kind !== BOOKMARK_LISTS || !d || !list.event) {
|
if (kind !== BOOKMARK_LISTS || !list?.event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const relays = uniq([...INDEXER_RELAYS, ...getRelayTagValues(list.event.tags)])
|
const relays = Router.get().FromUser().getUrls()
|
||||||
const address = `${kind}:${list.event.pubkey}:${d}`
|
|
||||||
|
|
||||||
return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays})
|
return publishDelete({protect: false, event: list.event, tags: [["a", address]], relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renameBookmarkList = async (key: string, title: string) => {
|
export const renameBookmarkList = async (address: string, title: string) => {
|
||||||
const list = getUserBookmarkList(key)
|
const list = getBookmarkCollection().get(address)
|
||||||
const {kind, d} = parseListKey(key)
|
const {kind} = Address.from(address)
|
||||||
const nextD = title.trim()
|
const nextTitle = title.trim()
|
||||||
|
|
||||||
if (kind !== BOOKMARK_LISTS || !d || !nextD || !list.event) {
|
if (kind !== BOOKMARK_LISTS || !nextTitle || !list?.event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextD === d) {
|
const currentTitle =
|
||||||
|
getTagValue("title", list.event.tags) || getTagValue("d", list.event.tags) || ""
|
||||||
|
|
||||||
|
if (nextTitle === currentTitle) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = getUserBookmarkList(`${BOOKMARK_LISTS}:${nextD}`)
|
const publicTags = [
|
||||||
|
["d", getTagValue("d", list.event.tags) || randomId()],
|
||||||
if (existing.event?.id && existing.event.id !== list.event.id) {
|
["title", nextTitle],
|
||||||
return
|
]
|
||||||
}
|
|
||||||
|
|
||||||
const publicTags = [["d", nextD], ...list.publicTags.filter(tag => tag[0] !== "d")]
|
|
||||||
const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf)
|
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})
|
return publishThunk({event, relays})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ export const PROTECTED = ["-"]
|
|||||||
|
|
||||||
export const MESSAGE_KINDS = [MESSAGE]
|
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 IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||||
|
|
||||||
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
|
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
|
||||||
@@ -706,6 +710,50 @@ export const deriveOtherVoiceRooms = (url: string) =>
|
|||||||
|
|
||||||
// User space/room lists
|
// 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({
|
export const groupListsByPubkey = deriveItemsByKey({
|
||||||
repository,
|
repository,
|
||||||
filters: [{kinds: [ROOMS]}],
|
filters: [{kinds: [ROOMS]}],
|
||||||
|
|||||||
@@ -1,45 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {first, formatTimestamp, uniqBy} from "@welshman/lib"
|
import {first, uniqBy} from "@welshman/lib"
|
||||||
import {load} from "@welshman/net"
|
import {load} from "@welshman/net"
|
||||||
import {
|
import {
|
||||||
DELETE,
|
DELETE,
|
||||||
displayPubkey,
|
|
||||||
getIdFilters,
|
getIdFilters,
|
||||||
getTags,
|
getTags,
|
||||||
getTagValue,
|
getTagValue,
|
||||||
getTagValues,
|
getTagValues,
|
||||||
sortEventsDesc,
|
sortEventsDesc,
|
||||||
tagsFromIMeta,
|
tagsFromIMeta,
|
||||||
|
Address,
|
||||||
|
getAddress,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {
|
import {loadProfile, pubkey, repository, tracker, waitForThunkError} from "@welshman/app"
|
||||||
displayProfileByPubkey,
|
|
||||||
loadProfile,
|
|
||||||
pubkey,
|
|
||||||
repository,
|
|
||||||
tracker,
|
|
||||||
waitForThunkError,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
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 Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import SliderMinimalisticHorizontal from "@assets/icons/slider-minimalistic-horizontal.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 Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Link from "@lib/components/Link.svelte"
|
|
||||||
import Page from "@lib/components/Page.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 BookmarkListPicker from "@app/components/BookmarkListPicker.svelte"
|
||||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
import BookmarkGrid from "@app/components/BookmarkGrid.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import BookmarkSidebar from "@app/components/BookmarkSidebar.svelte"
|
||||||
import {
|
import {
|
||||||
addEventBookmark,
|
addEventBookmark,
|
||||||
deleteBookmarkList,
|
deleteBookmarkList,
|
||||||
@@ -47,9 +33,13 @@
|
|||||||
renameBookmarkList,
|
renameBookmarkList,
|
||||||
} from "@app/core/commands"
|
} from "@app/core/commands"
|
||||||
import {
|
import {
|
||||||
|
BOOKMARKS,
|
||||||
|
BOOKMARK_LISTS,
|
||||||
deriveEvents,
|
deriveEvents,
|
||||||
IMAGE_CONTENT_TYPES,
|
IMAGE_CONTENT_TYPES,
|
||||||
INDEXER_RELAYS,
|
INDEXER_RELAYS,
|
||||||
|
loadUserBookmarkCollections,
|
||||||
|
loadUserBookmarkList,
|
||||||
shouldIgnoreError,
|
shouldIgnoreError,
|
||||||
VIDEO_CONTENT_TYPES,
|
VIDEO_CONTENT_TYPES,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
@@ -73,17 +63,15 @@
|
|||||||
external: boolean
|
external: boolean
|
||||||
image: string | undefined
|
image: string | undefined
|
||||||
video: string | undefined
|
video: string | undefined
|
||||||
contentType: Exclude<ContentType, "all">
|
contentType: "image" | "video" | "text"
|
||||||
preview: string
|
preview: string
|
||||||
pollOptions: string[]
|
pollOptions: string[]
|
||||||
searchable: string
|
searchable: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const BOOKMARKS = 10003
|
const savedItemsKey = $derived($pubkey ? new Address(BOOKMARKS, $pubkey, "").toString() : "")
|
||||||
const CUSTOM_BOOKMARKS = 30003
|
|
||||||
const MANAGED_LIST_KINDS = [BOOKMARKS, CUSTOM_BOOKMARKS]
|
|
||||||
|
|
||||||
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)
|
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 getPollQuestion = (event: TrustedEvent) => {
|
||||||
const lines = (event.content || "")
|
const lines = (event.content || "")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -163,7 +143,7 @@
|
|||||||
return clean.length > 140 ? `${clean.slice(0, 140).trim()}...` : clean
|
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)
|
const media = getMediaPreview(event)
|
||||||
|
|
||||||
if (media.image) {
|
if (media.image) {
|
||||||
@@ -177,10 +157,15 @@
|
|||||||
return "text"
|
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) =>
|
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(
|
const userListEvents = $derived(
|
||||||
sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind !== DELETE),
|
sortEventsDesc($listEvents).filter(event => event.pubkey === $pubkey && event.kind !== DELETE),
|
||||||
@@ -218,14 +203,14 @@
|
|||||||
)
|
)
|
||||||
.filter(list => (list.event ? !isDeletedList(list.event) : true))
|
.filter(list => (list.event ? !isDeletedList(list.event) : true))
|
||||||
|
|
||||||
const savedItems = first(mapped.filter(list => list.key === "10003:")) || {
|
const savedItems = first(mapped.filter(list => list.key === savedItemsKey)) || {
|
||||||
key: "10003:",
|
key: savedItemsKey,
|
||||||
label: "Saved Items",
|
label: "Saved Items",
|
||||||
count: 0,
|
count: 0,
|
||||||
event: undefined,
|
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 || "")
|
const defaultListKey = $derived(sidebarLists[0]?.key || "")
|
||||||
@@ -243,49 +228,9 @@
|
|||||||
let searchTerm = $state("")
|
let searchTerm = $state("")
|
||||||
let showSearch = $state(false)
|
let showSearch = $state(false)
|
||||||
let contentType = $state<ContentType>("all")
|
let contentType = $state<ContentType>("all")
|
||||||
|
let listsReady = $state(false)
|
||||||
let loadedListPubkey: string | undefined = $state()
|
let loadedListPubkey: string | undefined = $state()
|
||||||
let loadedSelectionKey: 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 = () => {
|
const setFilterAll = () => {
|
||||||
contentType = "all"
|
contentType = "all"
|
||||||
@@ -315,8 +260,6 @@
|
|||||||
shouldIgnoreError(error) || error.includes("only accepts kind 10002 events")
|
shouldIgnoreError(error) || error.includes("only accepts kind 10002 events")
|
||||||
|
|
||||||
const openBookmarkFromMenu = async (item: BookmarkItem) => {
|
const openBookmarkFromMenu = async (item: BookmarkItem) => {
|
||||||
closeBookmarkMenu()
|
|
||||||
|
|
||||||
if (item.external) {
|
if (item.external) {
|
||||||
window.open(item.href, "_blank", "noopener,noreferrer")
|
window.open(item.href, "_blank", "noopener,noreferrer")
|
||||||
return
|
return
|
||||||
@@ -326,8 +269,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyBookmarkLinkFromMenu = async (item: BookmarkItem) => {
|
const copyBookmarkLinkFromMenu = async (item: BookmarkItem) => {
|
||||||
closeBookmarkMenu()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(item.href)
|
await navigator.clipboard.writeText(item.href)
|
||||||
pushToast({message: "Link copied"})
|
pushToast({message: "Link copied"})
|
||||||
@@ -337,9 +278,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addBookmarkToSavedItems = async (item: BookmarkItem) => {
|
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) {
|
if (!thunk) {
|
||||||
pushToast({message: "Already in Saved Items"})
|
pushToast({message: "Already in Saved Items"})
|
||||||
@@ -357,8 +300,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeBookmarkFromCurrentList = async (item: BookmarkItem) => {
|
const removeBookmarkFromCurrentList = async (item: BookmarkItem) => {
|
||||||
closeBookmarkMenu()
|
|
||||||
|
|
||||||
if (!selectedKey) {
|
if (!selectedKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -383,7 +324,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteListFromSidebar = async (key: string, label: string) => {
|
const deleteListFromSidebar = async (key: string, label: string) => {
|
||||||
if (key === "10003:") {
|
if (key.startsWith(`${BOOKMARKS}:`)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,12 +345,12 @@
|
|||||||
pushToast({message: `Deleted ${label}`})
|
pushToast({message: `Deleted ${label}`})
|
||||||
|
|
||||||
if (selectedKey === key) {
|
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) => {
|
const renameListFromSidebar = async (key: string, label: string, nextLabel: string) => {
|
||||||
if (key === "10003:") {
|
if (key.startsWith(`${BOOKMARKS}:`)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,95 +374,7 @@
|
|||||||
return
|
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}`})
|
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())
|
const normalizedTerm = $derived(searchTerm.trim().toLowerCase())
|
||||||
@@ -565,22 +418,31 @@
|
|||||||
|
|
||||||
const selectedListHref = (key: string) => `/bookmarks?list=${encodeURIComponent(key)}`
|
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 () => {
|
const loadBookmarkLists = async () => {
|
||||||
if (!$pubkey || loadedListPubkey === $pubkey) {
|
if (!$pubkey) {
|
||||||
|
listsReady = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedListPubkey === $pubkey && listsReady) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedListPubkey = $pubkey
|
loadedListPubkey = $pubkey
|
||||||
|
listsReady = false
|
||||||
|
|
||||||
await load({
|
try {
|
||||||
relays: INDEXER_RELAYS,
|
await Promise.all([
|
||||||
filters: [{kinds: [...MANAGED_LIST_KINDS, DELETE], authors: [$pubkey]}],
|
loadUserBookmarkList(),
|
||||||
})
|
loadUserBookmarkCollections(),
|
||||||
|
load({
|
||||||
|
relays: INDEXER_RELAYS,
|
||||||
|
filters: [{kinds: [DELETE], authors: [$pubkey]}],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
listsReady = true
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure selected-list references are reloaded once list metadata arrives.
|
// Ensure selected-list references are reloaded once list metadata arrives.
|
||||||
loadedSelectionKey = undefined
|
loadedSelectionKey = undefined
|
||||||
@@ -644,144 +506,33 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedList) {
|
if (selectedList) {
|
||||||
void loadSelectedReferences()
|
void loadSelectedReferences()
|
||||||
|
} else {
|
||||||
|
selectedEvents = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<BookmarkSidebar
|
||||||
onclick={() => {
|
lists={listsReady ? sidebarListsWithDisplayCount : []}
|
||||||
closeListContextMenu()
|
{selectedKey}
|
||||||
closeBookmarkMenu()
|
totalCount={listsReady ? totalListCount : 0}
|
||||||
}}
|
onOpenManager={openListManager}
|
||||||
onkeydown={handleWindowKeydown} />
|
onSelect={key => goto(selectedListHref(key), {replaceState: true, noScroll: true})}
|
||||||
|
onRename={renameListFromSidebar}
|
||||||
<SecondaryNav class="relative">
|
onDelete={deleteListFromSidebar} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Page class="overflow-hidden">
|
<Page class="overflow-hidden">
|
||||||
<div class="col-3 gap-3 p-3 md:p-4">
|
<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="card2 card2-sm bg-alt flex items-center justify-between gap-2 p-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<Icon size={4} icon={Bookmark} />
|
<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>
|
<span class="badge badge-sm badge-neutral">{filteredItems.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -810,6 +561,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{#if showSearch}
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
<Icon icon={Magnifier} />
|
<Icon icon={Magnifier} />
|
||||||
@@ -817,86 +574,16 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex w-full flex-wrap content-start items-stretch gap-3">
|
{#if listsReady}
|
||||||
{#each filteredItems as item (item.key)}
|
<BookmarkGrid
|
||||||
<Link
|
items={filteredItems}
|
||||||
href={item.href}
|
showAddToSaved={selectedKey !== savedItemsKey}
|
||||||
external={item.external}
|
onOpen={openBookmarkFromMenu}
|
||||||
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)]">
|
onCopyLink={copyBookmarkLinkFromMenu}
|
||||||
<div class="flex items-start justify-between gap-2">
|
onAddToSaved={addBookmarkToSavedItems}
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
onRemove={removeBookmarkFromCurrentList} />
|
||||||
<ProfileCircle pubkey={item.event.pubkey} size={5} />
|
{:else}
|
||||||
<div class="min-w-0">
|
<div class="card2 card2-sm bg-alt text-sm opacity-70">Loading bookmark lists...</div>
|
||||||
<p class="truncate text-sm font-semibold text-primary">
|
{/if}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
Reference in New Issue
Block a user