Add pinboards and content library
This commit is contained in:
parent
518a312972
commit
61198f4f10
19 changed files with 1008 additions and 35 deletions
|
|
@ -1,5 +1,21 @@
|
|||
<script module lang="ts">
|
||||
import {postJson, simpleCache} from "@welshman/lib"
|
||||
import {dufflepud} from "@app/env"
|
||||
|
||||
// Cache previews by url so the same link isn't re-fetched across renders/instances.
|
||||
const loadPreview = simpleCache(async ([url]: [string]) => {
|
||||
const json = await postJson(dufflepud("link/preview"), {url})
|
||||
|
||||
if (!json?.title && !json?.image) {
|
||||
throw new Error("Failed to load link preview")
|
||||
}
|
||||
|
||||
return json
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
|
||||
import {call, ellipsize, displayUrl} from "@welshman/lib"
|
||||
import {isRelayUrl, getTagValue} from "@welshman/util"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {preventDefault, stopPropagation} from "@lib/html"
|
||||
|
|
@ -9,7 +25,7 @@
|
|||
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {dufflepud, PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
|
||||
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
|
||||
import {isRoomId} from "@app/groups"
|
||||
|
||||
|
|
@ -35,16 +51,6 @@
|
|||
return undefined
|
||||
}
|
||||
|
||||
const loadPreview = async () => {
|
||||
const json = await postJson(dufflepud("link/preview"), {url})
|
||||
|
||||
if (!json?.title && !json?.image) {
|
||||
throw new Error("Failed to load link preview")
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
hideImage = true
|
||||
}
|
||||
|
|
@ -73,7 +79,7 @@
|
|||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-2xl" />
|
||||
</button>
|
||||
{:else}
|
||||
{#await loadPreview()}
|
||||
{#await loadPreview(url)}
|
||||
<div class="flex justify-center items-center my-12 w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
let muted = $state($isEventMuted(event))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 {restProps.class}" style={restProps.style}>
|
||||
<div class="@container flex flex-col gap-2 {restProps.class}" style={restProps.style}>
|
||||
{#if muted}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2 relative">
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
{/if}
|
||||
{/if}
|
||||
<Button
|
||||
class={cx("text-sm opacity-75", {"text-xs": minimal})}
|
||||
class={cx("text-sm opacity-75 @max-sm:hidden", {"text-xs": minimal})}
|
||||
onclick={() => goToEvent(event)}>
|
||||
{formatTimestamp(event.created_at)}
|
||||
</Button>
|
||||
|
|
|
|||
120
src/app/components/Pin.svelte
Normal file
120
src/app/components/Pin.svelte
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {Address} from "@welshman/util"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Badge from "@lib/components/Badge.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import PinContentEvent from "@app/components/PinContentEvent.svelte"
|
||||
import PinEdit from "@app/components/PinEdit.svelte"
|
||||
import {deletePin, type Pin} from "@app/pinboards"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
pin: Pin
|
||||
minimal?: boolean
|
||||
showMenu?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
const {url, pin, minimal, showMenu, ...props}: Props = $props()
|
||||
|
||||
// Encode nostr references as nostr: entities (i tags are already urls) so
|
||||
// Content parses them into rich embeds rather than rendering raw ids/coords.
|
||||
const content = $derived.by(() => {
|
||||
const [type, data = ""] = pin.value
|
||||
|
||||
if (type === "e") return "nostr:" + nip19.neventEncode({id: data, relays: [url]})
|
||||
|
||||
if (type === "a") {
|
||||
const {kind, pubkey, identifier} = Address.from(data)
|
||||
|
||||
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]})
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
let menuOpen = $state(false)
|
||||
|
||||
const edit = () => {
|
||||
menuOpen = false
|
||||
pushModal(PinEdit, {url, pin})
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
menuOpen = false
|
||||
pushModal(Confirm, {
|
||||
title: "Delete Link",
|
||||
message: "Delete this link?",
|
||||
confirm: async () => {
|
||||
const error = await deletePin(url, pin.id)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Link deleted!"})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="{props.class} relative flex h-full flex-col gap-2">
|
||||
{#if showMenu}
|
||||
<div class="absolute right-2 top-2 z-feature">
|
||||
<Button
|
||||
class="button button-neutral button-sm button-square"
|
||||
aria-label="More options"
|
||||
onclick={() => (menuOpen = !menuOpen)}>
|
||||
<Icon size={4} icon={MenuDots} />
|
||||
</Button>
|
||||
{#if menuOpen}
|
||||
<Popover hideOnClick onClose={() => (menuOpen = false)}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
|
||||
<li>
|
||||
<Button onclick={edit}>
|
||||
<Icon icon={Pen} />
|
||||
Edit link
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button class="text-error" onclick={confirmDelete}>
|
||||
<Icon icon={TrashBin} />
|
||||
Delete link
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pin.title}
|
||||
<strong class="ellipsize pr-8">{pin.title}</strong>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if pin.value[0] === "i"}
|
||||
<Content event={{content, tags: []}} {url} />
|
||||
{:else}
|
||||
<PinContentEvent
|
||||
{url}
|
||||
value={pin.value[1]}
|
||||
relays={pin.value[2] ? [pin.value[2], url] : [url]} />
|
||||
{/if}
|
||||
{#if !minimal && pin.topics.length > 0}
|
||||
<div class="mt-auto flex flex-wrap gap-1">
|
||||
{#each pin.topics as topic (topic)}
|
||||
<Badge>#{topic}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
21
src/app/components/PinAdd.svelte
Normal file
21
src/app/components/PinAdd.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
|
||||
import {createPin, referenceToPin} from "@app/pinboards"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
address: string
|
||||
}
|
||||
|
||||
const {url, address}: Props = $props()
|
||||
|
||||
const submit = async ({title, topics, value}: PinFormValues) => {
|
||||
const params = referenceToPin(value)
|
||||
|
||||
if (!params) return "Please enter a valid URL or nostr link."
|
||||
|
||||
return createPin(url, {...params, boards: [address], title, topics})
|
||||
}
|
||||
</script>
|
||||
|
||||
<PinForm {url} heading="Add Link" action="Add link" successMessage="Link added!" {submit} />
|
||||
27
src/app/components/PinContentEvent.svelte
Normal file
27
src/app/components/PinContentEvent.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
import {deriveEvent} from "@app/repository"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
value: string
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
const {url, value, relays}: Props = $props()
|
||||
|
||||
const event = deriveEvent(value, relays)
|
||||
</script>
|
||||
|
||||
{#if $event}
|
||||
<Button onclick={() => goToEvent($event)}>
|
||||
<NoteItem {url} event={$event} />
|
||||
</Button>
|
||||
{:else}
|
||||
<p class="flex justify-center py-8">
|
||||
<Spinner loading>Loading event...</Spinner>
|
||||
</p>
|
||||
{/if}
|
||||
34
src/app/components/PinEdit.svelte
Normal file
34
src/app/components/PinEdit.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
|
||||
import {createPin, referenceToPin, pinToReference, type Pin} from "@app/pinboards"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
pin: Pin
|
||||
}
|
||||
|
||||
const {url, pin}: Props = $props()
|
||||
|
||||
const submit = async ({title, topics, value}: PinFormValues) => {
|
||||
const params = referenceToPin(value)
|
||||
|
||||
if (!params) return "Please enter a valid URL or nostr link."
|
||||
|
||||
// Reuse the pin's identifier and boards so its addressable event is replaced.
|
||||
return createPin(url, {
|
||||
...params,
|
||||
identifier: pin.identifier,
|
||||
boards: pin.boards,
|
||||
title,
|
||||
topics,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<PinForm
|
||||
{url}
|
||||
heading="Edit Link"
|
||||
action="Save changes"
|
||||
successMessage="Link updated!"
|
||||
values={{title: pin.title, topics: pin.topics, value: pinToReference(pin)}}
|
||||
{submit} />
|
||||
107
src/app/components/PinForm.svelte
Normal file
107
src/app/components/PinForm.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<script module lang="ts">
|
||||
export type PinFormValues = {
|
||||
title: string
|
||||
topics: string[]
|
||||
value: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
heading: string
|
||||
action: string
|
||||
successMessage: string
|
||||
values?: Partial<PinFormValues>
|
||||
submit: (values: PinFormValues) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
const {url, heading, action, successMessage, values = {}, submit}: Props = $props()
|
||||
|
||||
let title = $state(values.title ?? "")
|
||||
let topics = $state<string[]>(values.topics ?? [])
|
||||
let value = $state(values.value ?? "")
|
||||
let loading = $state(false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const onSubmit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const error = await submit({title, topics, value})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: successMessage})
|
||||
back()
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{heading}</ModalTitle>
|
||||
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Title
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input
|
||||
bind:value={title}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Optional title" />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Topics
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Link
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>A URL or nostr link (note, nevent, or naddr).</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input bind:value class="input input-bordered w-full" placeholder="URL or nevent..." />
|
||||
{/snippet}
|
||||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button class="button button-primary" onclick={onSubmit} disabled={loading || !value.trim()}>
|
||||
<Spinner {loading}>{action}</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
92
src/app/components/PinboardEdit.svelte
Normal file
92
src/app/components/PinboardEdit.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts">
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {goto} from "$app/navigation"
|
||||
import RelayName from "@app/components/RelayName.svelte"
|
||||
import {editBoard, type Board} from "@app/pinboards"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
board: Board
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
const {url, board, isNew = false}: Props = $props()
|
||||
|
||||
let title = $state(board.title)
|
||||
let description = $state(board.description)
|
||||
let loading = $state(false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const submit = async () => {
|
||||
loading = true
|
||||
|
||||
try {
|
||||
const error = await editBoard(url, {...board, title, description})
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: isNew ? "Shelf created!" : "Shelf updated!"})
|
||||
|
||||
// Navigate to a freshly created board; otherwise just close the modal.
|
||||
if (isNew) {
|
||||
goto(makeSpacePath(url, "library", board.address))
|
||||
} else {
|
||||
back()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{isNew ? "Create Shelf" : "Edit Shelf"}</ModalTitle>
|
||||
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Title
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input bind:value={title} class="input input-bordered w-full" placeholder="Shelf title" />
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
Description
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<input
|
||||
bind:value={description}
|
||||
class="input input-bordered w-full"
|
||||
placeholder="What's this shelf about?" />
|
||||
{/snippet}
|
||||
</Field>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button class="button button-primary" onclick={submit} disabled={loading || !title.trim()}>
|
||||
<Spinner {loading}>Save changes</Spinner>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
34
src/app/components/PinboardItem.svelte
Normal file
34
src/app/components/PinboardItem.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Pin from "@app/components/Pin.svelte"
|
||||
import {derivePins, type Board} from "@app/pinboards"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
board: Board
|
||||
}
|
||||
|
||||
const {url, board}: Props = $props()
|
||||
|
||||
const pins = derivePins(url, board.address)
|
||||
const detailPath = makeSpacePath(url, "library", board.address)
|
||||
</script>
|
||||
|
||||
<div class="card flex flex-col gap-2">
|
||||
<div class="flex flex-col">
|
||||
<strong class="text-lg">{board.title || "Untitled shelf"}</strong>
|
||||
{#if board.description}
|
||||
<span class="text-sm opacity-70">{board.description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each $pins.slice(0, 1) as pin (pin.id)}
|
||||
<Pin minimal {url} {pin} />
|
||||
{/each}
|
||||
<Link href={detailPath} class="button button-primary">
|
||||
<Icon icon={AltArrowRight} />
|
||||
<strong>View all content</strong>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
import {load} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import {isMobile, stopPropagation} from "@lib/html"
|
||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
tip: !noTooltip && !isMobile,
|
||||
},
|
||||
)}
|
||||
onclick={onReportClick}>
|
||||
onclick={stopPropagation(onReportClick)}>
|
||||
<Icon icon={Danger} />
|
||||
<span>{$reports.length}</span>
|
||||
</Button>
|
||||
|
|
@ -186,7 +186,7 @@
|
|||
"button-primary": isOwn,
|
||||
"button-neutral": !isOwn,
|
||||
})}
|
||||
onclick={onClick}>
|
||||
onclick={stopPropagation(onClick)}>
|
||||
<Reaction event={events[0]} />
|
||||
{#if events.length > 1}
|
||||
<span>{events.length}</span>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||
import Home from "@assets/icons/home.svg?dataurl"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||
|
|
@ -49,7 +50,8 @@
|
|||
userSpaceUrls,
|
||||
displayRoom,
|
||||
} from "@app/groups"
|
||||
import {hasNip29} from "@app/relays"
|
||||
import {hasNip29, deriveSupportedMethods} from "@app/relays"
|
||||
import {BOARD} from "@app/pinboards"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {deriveSpaceActionItems} from "@app/actionItems"
|
||||
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
|
||||
|
|
@ -77,6 +79,12 @@
|
|||
$events => new Set($events.map(e => e.kind)),
|
||||
)
|
||||
|
||||
const boardEvents = deriveEventsForUrl(url, [{kinds: [BOARD]}])
|
||||
const supportedMethods = deriveSupportedMethods(url)
|
||||
const showLibrary = $derived(
|
||||
$boardEvents.length > 0 || $supportedMethods.some(m => (m as string) === "signevent"),
|
||||
)
|
||||
|
||||
const roomSearch = derived(otherRooms, $otherRooms =>
|
||||
createSearch(
|
||||
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
|
||||
|
|
@ -228,6 +236,11 @@
|
|||
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
||||
<Icon icon={UsersGroup} /> Directory
|
||||
</SecondaryNavItem>
|
||||
{#if showLibrary}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "library")}>
|
||||
<Icon icon={GalleryWide} /> Library
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
||||
<SecondaryNavItem href={goalsPath}>
|
||||
<Icon icon={StarFallMinimalistic} /> Goals
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import {derived} from "svelte/store"
|
||||
import {first, now} from "@welshman/lib"
|
||||
import {now} from "@welshman/lib"
|
||||
import {APP_DATA, getTagValues} from "@welshman/util"
|
||||
import type {ManagementMethod} from "@welshman/util"
|
||||
import {deriveRelay, manageRelay} from "@welshman/app"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {manageRelay} from "@welshman/app"
|
||||
import {deriveRelaySignedEvents} from "@app/repository"
|
||||
|
||||
// NIP-78 app data published by the relay's self key. Each featured entry is a
|
||||
// ["content", <value>] tag (freeform text, intended to be a url or nevent).
|
||||
|
|
@ -11,13 +11,8 @@ export const FEATURED_CONTENT_D = "flotilla/featured-content"
|
|||
|
||||
export const deriveFeaturedContent = (url: string) =>
|
||||
derived(
|
||||
[deriveRelay(url), deriveEventsForUrl(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}])],
|
||||
([$relay, $events]) => {
|
||||
const self = $relay?.self || $relay?.pubkey
|
||||
const event = (self && $events.find(e => e.pubkey === self)) || first($events)
|
||||
|
||||
return getTagValues("content", event?.tags ?? [])
|
||||
},
|
||||
deriveRelaySignedEvents(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}]),
|
||||
([event]) => getTagValues("content", event?.tags ?? []),
|
||||
)
|
||||
|
||||
// Publish the featured content list by asking the relay to sign it with its self
|
||||
|
|
|
|||
213
src/app/pinboards.ts
Normal file
213
src/app/pinboards.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import {derived} from "svelte/store"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {now, randomId} from "@welshman/lib"
|
||||
import {
|
||||
Address,
|
||||
DELETE,
|
||||
asDecryptedEvent,
|
||||
fromNostrURI,
|
||||
getAddress,
|
||||
getListTags,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
readList,
|
||||
sortEventsDesc,
|
||||
} from "@welshman/util"
|
||||
import type {ManagementMethod, TrustedEvent} from "@welshman/util"
|
||||
import {manageRelay} from "@welshman/app"
|
||||
import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository"
|
||||
|
||||
export const BOARD = 30067
|
||||
|
||||
export const PIN = 39067
|
||||
|
||||
export type Board = {
|
||||
address: string
|
||||
identifier: string
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
topics: string[]
|
||||
collaborative: boolean
|
||||
}
|
||||
|
||||
export type Pin = {
|
||||
id: string
|
||||
pubkey: string
|
||||
identifier: string
|
||||
boards: string[]
|
||||
comment: string
|
||||
title: string
|
||||
topics: string[]
|
||||
// The pin's content reference: the first e/a/i tag found, e.g. ["e", <id>],
|
||||
// ["a", <coord>], or ["i", <external id>]. Empty when the pin has no reference.
|
||||
value: string[]
|
||||
}
|
||||
|
||||
export const readBoard = (event: TrustedEvent): Board => {
|
||||
const tags = getListTags(readList(asDecryptedEvent(event)))
|
||||
|
||||
return {
|
||||
address: getAddress(event),
|
||||
identifier: getTagValue("d", tags) ?? "",
|
||||
title: getTagValue("title", tags) ?? "",
|
||||
description: getTagValue("description", tags) ?? "",
|
||||
image: getTagValue("image", tags) ?? "",
|
||||
topics: getTagValues("t", tags),
|
||||
collaborative: tags.some(tag => tag[0] === "collaborative"),
|
||||
}
|
||||
}
|
||||
|
||||
export const readPin = (event: TrustedEvent): Pin => ({
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: getTagValue("d", event.tags) ?? "",
|
||||
boards: getTagValues("A", event.tags),
|
||||
comment: event.content,
|
||||
title: getTagValue("title", event.tags) ?? "",
|
||||
topics: getTagValues("t", event.tags),
|
||||
value: event.tags.find(tag => ["e", "a", "i"].includes(tag[0])) ?? [],
|
||||
})
|
||||
|
||||
export const deriveBoards = (url: string) =>
|
||||
derived(deriveEventsForUrl(url, [{kinds: [BOARD]}]), $events => $events.map(readBoard))
|
||||
|
||||
export const deriveBoard = (url: string, identifier: string) =>
|
||||
derived(deriveRelaySignedEvents(url, [{kinds: [BOARD], "#d": [identifier]}]), ([event]) =>
|
||||
event ? readBoard(event) : undefined,
|
||||
)
|
||||
|
||||
export const deriveBoardByAddress = (url: string, address: string) =>
|
||||
deriveBoard(url, Address.from(address).identifier)
|
||||
|
||||
export const derivePins = (url: string, address: string) =>
|
||||
derived(deriveRelaySignedEvents(url, [{kinds: [PIN], "#A": [address]}]), $events =>
|
||||
sortEventsDesc($events).map(readPin),
|
||||
)
|
||||
|
||||
const signAsRelay = async (url: string, template: object): Promise<string | undefined> => {
|
||||
const {error} = await manageRelay(url, {
|
||||
method: "signevent" as ManagementMethod,
|
||||
params: [template as unknown as string],
|
||||
})
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
const makeBoardTemplate = ({
|
||||
identifier,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
topics,
|
||||
collaborative,
|
||||
}: Omit<Board, "address">) => ({
|
||||
kind: BOARD,
|
||||
created_at: now(),
|
||||
content: "",
|
||||
tags: [
|
||||
["d", identifier],
|
||||
["title", title],
|
||||
...(description ? [["description", description]] : []),
|
||||
...(image ? [["image", image]] : []),
|
||||
...topics.map(topic => ["t", topic]),
|
||||
...(collaborative ? [["collaborative"]] : []),
|
||||
],
|
||||
})
|
||||
|
||||
const makePinTemplate = ({
|
||||
identifier = "",
|
||||
boards = [],
|
||||
comment = "",
|
||||
title = "",
|
||||
topics = [],
|
||||
value = [],
|
||||
}: Partial<Pin>) => ({
|
||||
kind: PIN,
|
||||
created_at: now(),
|
||||
content: comment,
|
||||
tags: [
|
||||
// Reuse the identifier when editing so the addressable event is replaced;
|
||||
// fall back to a random one for new pins.
|
||||
["d", identifier || randomId()],
|
||||
...boards.map(board => ["A", board]),
|
||||
...(value.length ? [value] : []),
|
||||
...(title ? [["title", title]] : []),
|
||||
...topics.map(topic => ["t", topic]),
|
||||
],
|
||||
})
|
||||
|
||||
const makeDeleteTemplate = (tags: string[][]) => ({
|
||||
kind: DELETE,
|
||||
created_at: now(),
|
||||
content: "",
|
||||
tags,
|
||||
})
|
||||
|
||||
export const editBoard = (url: string, board: Board) => signAsRelay(url, makeBoardTemplate(board))
|
||||
|
||||
export const createPin = (url: string, pin: Partial<Pin>) => signAsRelay(url, makePinTemplate(pin))
|
||||
|
||||
export const deletePin = (url: string, id: string) =>
|
||||
signAsRelay(
|
||||
url,
|
||||
makeDeleteTemplate([
|
||||
["e", id],
|
||||
["k", String(PIN)],
|
||||
]),
|
||||
)
|
||||
|
||||
export const deleteBoard = (url: string, address: string) =>
|
||||
signAsRelay(
|
||||
url,
|
||||
makeDeleteTemplate([
|
||||
["a", address],
|
||||
["k", String(BOARD)],
|
||||
]),
|
||||
)
|
||||
|
||||
export const pinToReference = (pin: Pin): string => {
|
||||
const [type, data = "", relay] = pin.value
|
||||
const relays = relay ? [relay] : []
|
||||
|
||||
if (type === "e") return nip19.neventEncode({id: data, relays})
|
||||
|
||||
if (type === "a") {
|
||||
const {kind, pubkey, identifier} = Address.from(data)
|
||||
|
||||
return nip19.naddrEncode({kind, pubkey, identifier, relays})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const referenceToPin = (reference: string): Partial<Pin> | undefined => {
|
||||
const trimmed = reference.trim()
|
||||
|
||||
if (!trimmed) return undefined
|
||||
|
||||
try {
|
||||
const decoded = nip19.decode(fromNostrURI(trimmed))
|
||||
|
||||
if (decoded.type === "note") return {value: ["e", decoded.data]}
|
||||
if (decoded.type === "nevent") {
|
||||
const {id, relays} = decoded.data
|
||||
|
||||
return {value: relays?.[0] ? ["e", id, relays[0]] : ["e", id]}
|
||||
}
|
||||
if (decoded.type === "naddr") {
|
||||
const {kind, pubkey, identifier, relays} = decoded.data
|
||||
const coordinate = `${kind}:${pubkey}:${identifier}`
|
||||
|
||||
return {value: relays?.[0] ? ["a", coordinate, relays[0]] : ["a", coordinate]}
|
||||
}
|
||||
} catch {
|
||||
// Not a nostr entity; fall through to external url handling.
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return {value: ["i", trimmed]}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {deriveRelay, repository, tracker} from "@welshman/app"
|
|||
import {sortEventsDesc} from "@welshman/util"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {load} from "@welshman/net"
|
||||
import {first} from "@welshman/lib"
|
||||
import {filter, first, spec} from "@welshman/lib"
|
||||
import {derived} from "svelte/store"
|
||||
export const deriveEvent = makeDeriveEvent({
|
||||
repository,
|
||||
|
|
@ -41,9 +41,6 @@ export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =
|
|||
)
|
||||
|
||||
export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =>
|
||||
derived(
|
||||
[deriveRelay(url), deriveEventsForUrl(url, filters)],
|
||||
([relay, events]) => events,
|
||||
// TODO: khatru doesn't support relay.self, uncomment when it's ready
|
||||
// filter(spec({pubkey: relay.self}), events)
|
||||
derived([deriveRelay(url), deriveEventsForUrl(url, filters)], ([relay, events]) =>
|
||||
filter(spec({pubkey: relay?.self}), events),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import {decodeRelay} from "@app/relays"
|
|||
import {loadFeedsForPubkey} from "@app/feeds"
|
||||
import {RELAY_ROLE} from "@app/members"
|
||||
import {FEATURED_CONTENT_D} from "@app/featured"
|
||||
import {BOARD, PIN} from "@app/pinboards"
|
||||
import {hasBlossomSupport} from "@app/uploads"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/call"
|
||||
|
||||
|
|
@ -300,6 +301,7 @@ const syncSpace = (url: string) => {
|
|||
filters: [
|
||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
||||
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
|
||||
{kinds: [BOARD, PIN]},
|
||||
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
|
||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||
],
|
||||
|
|
|
|||
27
src/lib/components/Masonry.svelte
Normal file
27
src/lib/components/Masonry.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import type {Snippet} from "svelte"
|
||||
|
||||
type Props = {
|
||||
items: any[]
|
||||
child: Snippet<[any]>
|
||||
getKey: (item: any) => PropertyKey
|
||||
// Minimum column width, in tailwind spacing units (1 = 0.25rem). The browser
|
||||
// fits as many columns as the container allows, so the layout stays
|
||||
// responsive without media queries.
|
||||
columnWidth?: number
|
||||
// Space between columns and between stacked items, in tailwind units.
|
||||
gap?: number
|
||||
}
|
||||
|
||||
const {items, child, getKey, columnWidth = 72, gap = 3}: Props = $props()
|
||||
|
||||
const rem = (units: number) => `${units * 0.25}rem`
|
||||
</script>
|
||||
|
||||
<div style="column-width: {rem(columnWidth)}; column-gap: {rem(gap)};">
|
||||
{#each items as item (getKey(item))}
|
||||
<div class="break-inside-avoid" style="margin-bottom: {rem(gap)};">
|
||||
{@render child(item)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
import * as lightning from "@app/lightning"
|
||||
import * as uploads from "@app/uploads"
|
||||
import * as appPolls from "@app/polls"
|
||||
import * as pinboards from "@app/pinboards"
|
||||
import * as reports from "@app/reports"
|
||||
import * as relays from "@app/relays"
|
||||
import * as settings from "@app/settings"
|
||||
|
|
@ -116,6 +117,7 @@
|
|||
...lightning,
|
||||
...uploads,
|
||||
...appPolls,
|
||||
...pinboards,
|
||||
...reports,
|
||||
...notifications,
|
||||
})
|
||||
|
|
|
|||
107
src/routes/spaces/[relay]/library/+page.svelte
Normal file
107
src/routes/spaces/[relay]/library/+page.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {get} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
import {randomId} from "@welshman/lib"
|
||||
import {deriveRelay} from "@welshman/app"
|
||||
import {load} from "@welshman/net"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Masonry from "@lib/components/Masonry.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import PinboardItem from "@app/components/PinboardItem.svelte"
|
||||
import PinboardEdit from "@app/components/PinboardEdit.svelte"
|
||||
import {decodeRelay, deriveSupportedMethods} from "@app/relays"
|
||||
import {deriveBoards, BOARD, PIN, type Board} from "@app/pinboards"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
|
||||
const boards = deriveBoards(url)
|
||||
const supportedMethods = deriveSupportedMethods(url)
|
||||
const canManage = $derived($supportedMethods.some(m => (m as string) === "signevent"))
|
||||
|
||||
let term = $state("")
|
||||
let loading = $state(true)
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const value = term.trim().toLowerCase()
|
||||
|
||||
if (!value) return $boards
|
||||
|
||||
return $boards.filter(board =>
|
||||
[board.title, board.description, ...board.topics].some(field =>
|
||||
field?.toLowerCase().includes(value),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const createBoard = () => {
|
||||
const self = get(deriveRelay(url))?.self ?? ""
|
||||
const identifier = randomId()
|
||||
const board: Board = {
|
||||
address: `${BOARD}:${self}:${identifier}`,
|
||||
identifier,
|
||||
title: "",
|
||||
description: "",
|
||||
image: "",
|
||||
topics: [],
|
||||
collaborative: false,
|
||||
}
|
||||
|
||||
pushModal(PinboardEdit, {url, board, isNew: true})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
load({relays: [url], filters: [{kinds: [BOARD, PIN]}], signal: controller.signal}).then(() => {
|
||||
loading = false
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
{#snippet leading()}
|
||||
<Icon icon={GalleryWide} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<strong>Library</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
{#if canManage}
|
||||
<Button class="button button-primary button-sm" onclick={createBoard}>
|
||||
<Icon icon={Add} />
|
||||
Create Shelf
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent class="flex flex-col gap-4 p-4">
|
||||
<div class="card flex flex-col gap-2">
|
||||
<label class="input input-sm input-group flex w-full items-center gap-2">
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
<input bind:value={term} class="min-w-0 grow" type="text" placeholder="Search library..." />
|
||||
</label>
|
||||
<Masonry items={filtered} getKey={board => board.address} columnWidth={80} gap={2}>
|
||||
{#snippet child(board)}
|
||||
<PinboardItem {url} {board} />
|
||||
{/snippet}
|
||||
</Masonry>
|
||||
{#if loading}
|
||||
<p class="flex items-center justify-center py-20">
|
||||
<Spinner loading>Loading library...</Spinner>
|
||||
</p>
|
||||
{:else if filtered.length === 0}
|
||||
<p class="flex flex-col items-center py-20 text-center">No shelves found.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</PageContent>
|
||||
176
src/routes/spaces/[relay]/library/[address]/+page.svelte
Normal file
176
src/routes/spaces/[relay]/library/[address]/+page.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import {getIdFilters} from "@welshman/util"
|
||||
import {request} from "@welshman/net"
|
||||
import {fly} from "@lib/transition"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Masonry from "@lib/components/Masonry.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import Pin from "@app/components/Pin.svelte"
|
||||
import PinboardEdit from "@app/components/PinboardEdit.svelte"
|
||||
import PinAdd from "@app/components/PinAdd.svelte"
|
||||
import {decodeRelay, deriveSupportedMethods} from "@app/relays"
|
||||
import {deriveBoardByAddress, derivePins, deleteBoard, PIN} from "@app/pinboards"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
const {relay, address} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
const url = decodeRelay(relay)
|
||||
|
||||
const board = deriveBoardByAddress(url, address)
|
||||
const pins = derivePins(url, address)
|
||||
const supportedMethods = deriveSupportedMethods(url)
|
||||
const canManage = $derived($supportedMethods.some(m => (m as string) === "signevent"))
|
||||
|
||||
let term = $state("")
|
||||
let menuOpen = $state(false)
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const value = term.trim().toLowerCase()
|
||||
|
||||
if (!value) return $pins
|
||||
|
||||
return $pins.filter(pin =>
|
||||
[pin.title, pin.comment, pin.value[0] === "i" ? pin.value[1] : "", ...pin.topics].some(
|
||||
field => field?.toLowerCase().includes(value),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const addLink = () => {
|
||||
menuOpen = false
|
||||
pushModal(PinAdd, {url, address})
|
||||
}
|
||||
|
||||
const edit = () => {
|
||||
menuOpen = false
|
||||
pushModal(PinboardEdit, {url, board: $board})
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
menuOpen = false
|
||||
pushModal(Confirm, {
|
||||
title: "Delete Shelf",
|
||||
message: `Delete "${$board?.title || "this shelf"}"?`,
|
||||
confirm: async () => {
|
||||
const error = await deleteBoard(url, address)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Shelf deleted!"})
|
||||
back()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
request({
|
||||
relays: [url],
|
||||
filters: [...getIdFilters([address]), {kinds: [PIN], "#A": [address]}],
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<SpaceBar {back}>
|
||||
{#snippet title()}
|
||||
<h1 class="ellipsize text-xl">{$board?.title || "Shelf"}</h1>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
{#if canManage}
|
||||
<div class="relative">
|
||||
<Button
|
||||
class="button button-neutral button-sm button-square"
|
||||
aria-label="More options"
|
||||
onclick={() => (menuOpen = !menuOpen)}>
|
||||
<Icon size={4} icon={MenuDots} />
|
||||
</Button>
|
||||
{#if menuOpen}
|
||||
<Popover hideOnClick onClose={() => (menuOpen = false)}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
|
||||
<li>
|
||||
<Button onclick={addLink}>
|
||||
<Icon icon={AddCircle} />
|
||||
Add link
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={edit}>
|
||||
<Icon icon={Pen} />
|
||||
Edit shelf
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button class="text-error" onclick={confirmDelete}>
|
||||
<Icon icon={TrashBin} />
|
||||
Delete shelf
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
|
||||
{#if $board}
|
||||
{#if $pins.length === 0}
|
||||
<div class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<p class="opacity-70">This shelf doesn't have any links yet.</p>
|
||||
{#if canManage}
|
||||
<Button class="button button-primary" onclick={addLink}>
|
||||
<Icon icon={AddCircle} />
|
||||
Add a link
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<label class="input input-sm input-group flex w-full items-center gap-2">
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
<input bind:value={term} class="min-w-0 grow" type="text" placeholder="Search links..." />
|
||||
</label>
|
||||
{#if filtered.length === 0}
|
||||
<p class="py-20 text-center opacity-70">No links found.</p>
|
||||
{:else}
|
||||
<Masonry items={filtered} getKey={pin => pin.id} columnWidth={60} gap={3}>
|
||||
{#snippet child(pin)}
|
||||
<Pin {url} {pin} showMenu={canManage} class="card" />
|
||||
{/snippet}
|
||||
</Masonry>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex justify-center py-20">
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading shelf...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load shelf.</p>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
</PageContent>
|
||||
Loading…
Reference in a new issue