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">
|
<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 {isRelayUrl, getTagValue} from "@welshman/util"
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {preventDefault, stopPropagation} from "@lib/html"
|
import {preventDefault, stopPropagation} from "@lib/html"
|
||||||
|
|
@ -9,7 +25,7 @@
|
||||||
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
||||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
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 {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
|
||||||
import {isRoomId} from "@app/groups"
|
import {isRoomId} from "@app/groups"
|
||||||
|
|
||||||
|
|
@ -35,16 +51,6 @@
|
||||||
return undefined
|
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 = () => {
|
const onError = () => {
|
||||||
hideImage = true
|
hideImage = true
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +79,7 @@
|
||||||
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-2xl" />
|
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-2xl" />
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
{#await loadPreview()}
|
{#await loadPreview(url)}
|
||||||
<div class="flex justify-center items-center my-12 w-full">
|
<div class="flex justify-center items-center my-12 w-full">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
let muted = $state($isEventMuted(event))
|
let muted = $state($isEventMuted(event))
|
||||||
</script>
|
</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}
|
{#if muted}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex gap-2 relative">
|
<div class="flex gap-2 relative">
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<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)}>
|
onclick={() => goToEvent(event)}>
|
||||||
{formatTimestamp(event.created_at)}
|
{formatTimestamp(event.created_at)}
|
||||||
</Button>
|
</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 {load} from "@welshman/net"
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app"
|
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 Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
tip: !noTooltip && !isMobile,
|
tip: !noTooltip && !isMobile,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
onclick={onReportClick}>
|
onclick={stopPropagation(onReportClick)}>
|
||||||
<Icon icon={Danger} />
|
<Icon icon={Danger} />
|
||||||
<span>{$reports.length}</span>
|
<span>{$reports.length}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -186,7 +186,7 @@
|
||||||
"button-primary": isOwn,
|
"button-primary": isOwn,
|
||||||
"button-neutral": !isOwn,
|
"button-neutral": !isOwn,
|
||||||
})}
|
})}
|
||||||
onclick={onClick}>
|
onclick={stopPropagation(onClick)}>
|
||||||
<Reaction event={events[0]} />
|
<Reaction event={events[0]} />
|
||||||
{#if events.length > 1}
|
{#if events.length > 1}
|
||||||
<span>{events.length}</span>
|
<span>{events.length}</span>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||||
import Home from "@assets/icons/home.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 Danger from "@assets/icons/danger.svg?dataurl"
|
||||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
import Exit from "@assets/icons/logout-3.svg?dataurl"
|
||||||
|
|
@ -49,7 +50,8 @@
|
||||||
userSpaceUrls,
|
userSpaceUrls,
|
||||||
displayRoom,
|
displayRoom,
|
||||||
} from "@app/groups"
|
} 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 {deriveEventsForUrl} from "@app/repository"
|
||||||
import {deriveSpaceActionItems} from "@app/actionItems"
|
import {deriveSpaceActionItems} from "@app/actionItems"
|
||||||
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
|
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
|
||||||
|
|
@ -77,6 +79,12 @@
|
||||||
$events => new Set($events.map(e => e.kind)),
|
$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 =>
|
const roomSearch = derived(otherRooms, $otherRooms =>
|
||||||
createSearch(
|
createSearch(
|
||||||
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
|
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
|
||||||
|
|
@ -228,6 +236,11 @@
|
||||||
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
||||||
<Icon icon={UsersGroup} /> Directory
|
<Icon icon={UsersGroup} /> Directory
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
|
{#if showLibrary}
|
||||||
|
<SecondaryNavItem href={makeSpacePath(url, "library")}>
|
||||||
|
<Icon icon={GalleryWide} /> Library
|
||||||
|
</SecondaryNavItem>
|
||||||
|
{/if}
|
||||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
||||||
<SecondaryNavItem href={goalsPath}>
|
<SecondaryNavItem href={goalsPath}>
|
||||||
<Icon icon={StarFallMinimalistic} /> Goals
|
<Icon icon={StarFallMinimalistic} /> Goals
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {first, now} from "@welshman/lib"
|
import {now} from "@welshman/lib"
|
||||||
import {APP_DATA, getTagValues} from "@welshman/util"
|
import {APP_DATA, getTagValues} from "@welshman/util"
|
||||||
import type {ManagementMethod} from "@welshman/util"
|
import type {ManagementMethod} from "@welshman/util"
|
||||||
import {deriveRelay, manageRelay} from "@welshman/app"
|
import {manageRelay} from "@welshman/app"
|
||||||
import {deriveEventsForUrl} from "@app/repository"
|
import {deriveRelaySignedEvents} from "@app/repository"
|
||||||
|
|
||||||
// NIP-78 app data published by the relay's self key. Each featured entry is a
|
// 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).
|
// ["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) =>
|
export const deriveFeaturedContent = (url: string) =>
|
||||||
derived(
|
derived(
|
||||||
[deriveRelay(url), deriveEventsForUrl(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}])],
|
deriveRelaySignedEvents(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}]),
|
||||||
([$relay, $events]) => {
|
([event]) => getTagValues("content", event?.tags ?? []),
|
||||||
const self = $relay?.self || $relay?.pubkey
|
|
||||||
const event = (self && $events.find(e => e.pubkey === self)) || first($events)
|
|
||||||
|
|
||||||
return getTagValues("content", event?.tags ?? [])
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Publish the featured content list by asking the relay to sign it with its self
|
// 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 {sortEventsDesc} from "@welshman/util"
|
||||||
import type {Filter} from "@welshman/util"
|
import type {Filter} from "@welshman/util"
|
||||||
import {load} from "@welshman/net"
|
import {load} from "@welshman/net"
|
||||||
import {first} from "@welshman/lib"
|
import {filter, first, spec} from "@welshman/lib"
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
export const deriveEvent = makeDeriveEvent({
|
export const deriveEvent = makeDeriveEvent({
|
||||||
repository,
|
repository,
|
||||||
|
|
@ -41,9 +41,6 @@ export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =
|
||||||
)
|
)
|
||||||
|
|
||||||
export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =>
|
export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =>
|
||||||
derived(
|
derived([deriveRelay(url), deriveEventsForUrl(url, filters)], ([relay, events]) =>
|
||||||
[deriveRelay(url), deriveEventsForUrl(url, filters)],
|
filter(spec({pubkey: relay?.self}), events),
|
||||||
([relay, events]) => events,
|
|
||||||
// TODO: khatru doesn't support relay.self, uncomment when it's ready
|
|
||||||
// filter(spec({pubkey: relay.self}), events)
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import {decodeRelay} from "@app/relays"
|
||||||
import {loadFeedsForPubkey} from "@app/feeds"
|
import {loadFeedsForPubkey} from "@app/feeds"
|
||||||
import {RELAY_ROLE} from "@app/members"
|
import {RELAY_ROLE} from "@app/members"
|
||||||
import {FEATURED_CONTENT_D} from "@app/featured"
|
import {FEATURED_CONTENT_D} from "@app/featured"
|
||||||
|
import {BOARD, PIN} from "@app/pinboards"
|
||||||
import {hasBlossomSupport} from "@app/uploads"
|
import {hasBlossomSupport} from "@app/uploads"
|
||||||
import {LIVEKIT_PARTICIPANTS} from "@app/call"
|
import {LIVEKIT_PARTICIPANTS} from "@app/call"
|
||||||
|
|
||||||
|
|
@ -300,6 +301,7 @@ const syncSpace = (url: string) => {
|
||||||
filters: [
|
filters: [
|
||||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
||||||
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
|
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
|
||||||
|
{kinds: [BOARD, PIN]},
|
||||||
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
|
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
|
||||||
makeCommentFilter(CONTENT_KINDS, {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 lightning from "@app/lightning"
|
||||||
import * as uploads from "@app/uploads"
|
import * as uploads from "@app/uploads"
|
||||||
import * as appPolls from "@app/polls"
|
import * as appPolls from "@app/polls"
|
||||||
|
import * as pinboards from "@app/pinboards"
|
||||||
import * as reports from "@app/reports"
|
import * as reports from "@app/reports"
|
||||||
import * as relays from "@app/relays"
|
import * as relays from "@app/relays"
|
||||||
import * as settings from "@app/settings"
|
import * as settings from "@app/settings"
|
||||||
|
|
@ -116,6 +117,7 @@
|
||||||
...lightning,
|
...lightning,
|
||||||
...uploads,
|
...uploads,
|
||||||
...appPolls,
|
...appPolls,
|
||||||
|
...pinboards,
|
||||||
...reports,
|
...reports,
|
||||||
...notifications,
|
...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